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:
@@ -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<()> {
|
||||
|
||||
@@ -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
@@ -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) = ¶ms.msg {
|
||||
let flash = if let Some(key) = ¶ms.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) = ¶ms.msg {
|
||||
format!(r#"<div class="msg-ok">{}</div>"#, esc(msg))
|
||||
} else if let Some(err) = ¶ms.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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user