From bad4cdef77e1329880ec11496fc9ddf4e7062ada Mon Sep 17 00:00:00 2001 From: enki Date: Tue, 19 May 2026 19:56:01 -0700 Subject: [PATCH] add API key management to Settings page - list_api_keys and delete_api_key DB functions - API keys section at top of Settings: table of existing keys (masked), generate new key with label, revoke button per key - New key displayed once in a green copy box after generation with a clipboard copy button; not shown again after leaving the page --- src/db/queries.rs | 36 +++++++++++++ src/ui/mod.rs | 8 +++ src/ui/settings.rs | 123 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/db/queries.rs b/src/db/queries.rs index 679e86f..c7fa420 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -396,6 +396,42 @@ pub async fn create_api_key(pool: &SqlitePool, key: &str, label: &str) -> anyhow Ok(()) } +pub struct ApiKeyRow { + pub key: String, + pub label: String, + pub created_at: i64, + pub last_used: Option, +} + +impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for ApiKeyRow { + fn from_row(row: &sqlx::sqlite::SqliteRow) -> sqlx::Result { + use sqlx::Row; + Ok(ApiKeyRow { + key: row.try_get("key")?, + label: row.try_get("label")?, + created_at: row.try_get("created_at")?, + last_used: row.try_get("last_used")?, + }) + } +} + +pub async fn list_api_keys(pool: &SqlitePool) -> anyhow::Result> { + let rows = sqlx::query_as::<_, ApiKeyRow>( + "SELECT key, label, created_at, last_used FROM api_keys ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn delete_api_key(pool: &SqlitePool, key: &str) -> anyhow::Result<()> { + sqlx::query("DELETE FROM api_keys WHERE key = ?") + .bind(key) + .execute(pool) + .await?; + Ok(()) +} + // ─── Relays ─────────────────────────────────────────────────────────────────── pub async fn upsert_relay(pool: &SqlitePool, url: &str) -> anyhow::Result<()> { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 34c5d10..5897300 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -31,6 +31,8 @@ pub fn router() -> Router { .route("/ui/settings/curation", post(settings::save_curation)) .route("/ui/settings/ingest", post(settings::save_ingest)) .route("/ui/settings/tmdb", post(settings::save_tmdb)) + .route("/ui/settings/apikeys/create", post(settings::create_api_key)) + .route("/ui/settings/apikeys/delete", post(settings::delete_api_key)) .route("/torrent/{info_hash}", get(torrent_blob_handler)) } @@ -309,4 +311,10 @@ form.ifrm { display: inline; } .setting-row { display: flex; flex-direction: column; gap: .25rem; margin-bottom: .9rem; } .setting-label { font-size: 12px; font-weight: 600; color: var(--text); } .setting-hint { display: block; font-size: 11px; font-weight: 400; color: var(--muted); margin-top: .1rem; } +.new-key-box { background: #052e1c; border: 1px solid #0a4a2b; border-radius: 8px; + padding: .9rem 1.1rem; margin-bottom: 1.25rem; } +.new-key-title { font-weight: 600; color: var(--success); margin-bottom: .2rem; } +.new-key-hint { font-size: 11px; color: var(--muted); margin-bottom: .6rem; } +.new-key-row { display: flex; gap: .5rem; align-items: center; } +.new-key-input { flex: 1; font-size: 12px; background: var(--bg); border-color: #0a4a2b; } "#; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index d2c675f..231ea1a 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -11,6 +11,8 @@ use super::{page, err_page, esc}; pub struct PageParams { pub msg: Option, pub err: Option, + pub newkey: Option, + pub keylabel: Option, } pub async fn handler( @@ -26,6 +28,7 @@ pub async fn handler( async fn render(state: &AppState, params: &PageParams) -> anyhow::Result> { let cfg = &state.cfg; let has_overrides = db::settings_count(&state.pool).await > 0; + let api_keys = db::list_api_keys(&state.pool).await.unwrap_or_default(); let restart_banner = if has_overrides { r#""#, + label = esc(label), + key = esc(key), + ) + } else if let Some(msg) = ¶ms.msg { format!(r#"
{}
"#, esc(msg)) } else if let Some(err) = ¶ms.err { format!(r#"
{}
"#, esc(err)) @@ -204,16 +221,80 @@ async fn render(state: &AppState, params: &PageParams) -> anyhow::ResultNo API keys yet — create one below".into() + } else { + api_keys.iter().map(|k| { + let masked = format!("{}••••••••••••••••••••••••", &k.key[..8]); + let last = k.last_used + .map(|ts| super::fmt_ts_ago(ts)) + .unwrap_or_else(|| "never".into()); + let created = super::fmt_ts_ago(k.created_at); + format!( + r#" + {label} + {masked} + {created} + {last} + +
+ + +
+ +"#, + label = esc(&k.label), + key = esc(&k.key), + ) + }).collect() + }; + + let apikeys_section = format!( + r#"
+
API Keys — for Sonarr / Radarr / Lidarr
+
+ + + {key_rows} +
LabelKeyCreatedLast used
+
+
+
+ + +
+
+ Paste the generated key into Sonarr/Radarr → Indexers → Torznab → API Key. +
+
+
"# + ); + let js = r#""#; let body = format!( r#"

Settings

{flash} {restart_banner} +
+
API Keys
+ {apikeys_section} +
Network
{network_section} @@ -351,3 +432,43 @@ pub async fn save_tmdb( let _ = db::set_setting(&state.pool, "tmdb.api_key", &form.api_key).await; Redirect::to("/ui/settings?msg=TMDB+settings+saved") } + +#[derive(Deserialize)] +pub struct ApiKeyCreateForm { + pub label: String, +} + +pub async fn create_api_key( + State(state): State, + Form(form): Form, +) -> Redirect { + let label = form.label.trim().to_string(); + if label.is_empty() { + return Redirect::to("/ui/settings?err=Label+required"); + } + let key = generate_key(); + if let Err(e) = db::create_api_key(&state.pool, &key, &label).await { + return Redirect::to(&format!("/ui/settings?err={e}")); + } + let encoded_label = urlencoding::encode(&label).into_owned(); + Redirect::to(&format!("/ui/settings?newkey={key}&keylabel={encoded_label}")) +} + +#[derive(Deserialize)] +pub struct ApiKeyDeleteForm { + pub key: String, +} + +pub async fn delete_api_key( + State(state): State, + Form(form): Form, +) -> Redirect { + let _ = db::delete_api_key(&state.pool, &form.key).await; + Redirect::to("/ui/settings?msg=Key+revoked") +} + +fn generate_key() -> String { + use rand::Rng; + let bytes: [u8; 32] = rand::thread_rng().gen(); + hex::encode(bytes) +}