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
This commit is contained in:
2026-05-19 19:56:01 -07:00
parent 3d0b71c30b
commit bad4cdef77
3 changed files with 166 additions and 1 deletions
+36
View File
@@ -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<i64>,
}
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for ApiKeyRow {
fn from_row(row: &sqlx::sqlite::SqliteRow) -> sqlx::Result<Self> {
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<Vec<ApiKeyRow>> {
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<()> {
+8
View File
@@ -31,6 +31,8 @@ pub fn router() -> Router<AppState> {
.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; }
"#;
+122 -1
View File
@@ -11,6 +11,8 @@ use super::{page, err_page, esc};
pub struct PageParams {
pub msg: Option<String>,
pub err: Option<String>,
pub newkey: Option<String>,
pub keylabel: Option<String>,
}
pub async fn handler(
@@ -26,6 +28,7 @@ pub async fn handler(
async fn render(state: &AppState, params: &PageParams) -> anyhow::Result<Html<String>> {
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#"<div class="banner-restart">
@@ -36,7 +39,21 @@ async fn render(state: &AppState, params: &PageParams) -> anyhow::Result<Html<St
""
};
let flash = if let Some(msg) = &params.msg {
let flash = if let Some(key) = &params.newkey {
let label = params.keylabel.as_deref().unwrap_or("new key");
format!(
r#"<div class="new-key-box">
<div class="new-key-title">New API key created — <strong>{label}</strong></div>
<div class="new-key-hint">Copy it now. It will not be shown again.</div>
<div class="new-key-row">
<input id="newkey-val" type="text" value="{key}" readonly class="mono-in new-key-input">
<button onclick="copyKey()" class="btn-accent">Copy</button>
</div>
</div>"#,
label = esc(label),
key = esc(key),
)
} else if let Some(msg) = &params.msg {
format!(r#"<div class="msg-ok">{}</div>"#, esc(msg))
} else if let Some(err) = &params.err {
format!(r#"<div class="msg-err">{}</div>"#, esc(err))
@@ -204,16 +221,80 @@ async fn render(state: &AppState, params: &PageParams) -> anyhow::Result<Html<St
api_key = esc(&cfg.tmdb.api_key),
);
// ── API Keys ──────────────────────────────────────────────────────────────
let key_rows: String = if api_keys.is_empty() {
"<tr><td colspan='4' class='empty'>No API keys yet — create one below</td></tr>".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#"<tr>
<td><strong>{label}</strong></td>
<td class="mono">{masked}</td>
<td class="mono">{created}</td>
<td class="mono">{last}</td>
<td>
<form class="ifrm" method="POST" action="/ui/settings/apikeys/delete"
onsubmit="return confirm('Delete key {label}?')">
<input type="hidden" name="key" value="{key}">
<button type="submit" class="btn-danger">Revoke</button>
</form>
</td>
</tr>"#,
label = esc(&k.label),
key = esc(&k.key),
)
}).collect()
};
let apikeys_section = format!(
r#"<div class="card">
<div class="card-title">API Keys — for Sonarr / Radarr / Lidarr</div>
<div class="table-wrap" style="margin-bottom:.9rem">
<table>
<thead><tr><th>Label</th><th>Key</th><th>Created</th><th>Last used</th><th></th></tr></thead>
<tbody>{key_rows}</tbody>
</table>
</div>
<form method="POST" action="/ui/settings/apikeys/create">
<div class="form-row">
<input type="text" name="label" placeholder="sonarr" style="width:180px" required>
<button type="submit" class="btn-accent">Generate key</button>
</div>
<div style="margin-top:.4rem;font-size:11px;color:var(--muted)">
Paste the generated key into Sonarr/Radarr → Indexers → Torznab → API Key.
</div>
</form>
</div>"#
);
let js = r#"<script>
function toggleSocks5(mode) {
document.getElementById('socks5-url').style.display = mode === 'socks5' ? '' : 'none';
}
function copyKey() {
const el = document.getElementById('newkey-val');
el.select();
navigator.clipboard.writeText(el.value).then(() => {
const btn = el.nextElementSibling;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
});
}
</script>"#;
let body = format!(
r#"<h1>Settings</h1>
{flash}
{restart_banner}
<div class="section">
<div class="section-title">API Keys</div>
{apikeys_section}
</div>
<div class="section">
<div class="section-title">Network</div>
{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<AppState>,
Form(form): Form<ApiKeyCreateForm>,
) -> 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<AppState>,
Form(form): Form<ApiKeyDeleteForm>,
) -> 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)
}