From 73eb34d7223106cc6226cf2e425fea04190950cd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 23 Mar 2026 21:15:24 -0400 Subject: [PATCH] feat: add API Keys tab to Settings UI Create/revoke API keys with read/write scope selector. One-time key reveal with copy button. Keys table showing prefix, scope badge, last used. Inline revoke confirmation. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/SettingsView.vue | 179 +++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 987d26b..4bdd65e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -32,7 +32,7 @@ const appVersion = ref('dev'); const restoreFileInput = ref(null); // Migrate stored "admin" → "config"; unknown tabs fall back to "general" -const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]); +const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]); const _stored = localStorage.getItem("settings_tab") ?? "general"; const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general"); watch(activeTab, (v) => { @@ -41,8 +41,59 @@ watch(activeTab, (v) => { if (v === "logs" && authStore.isAdmin) loadLogsPanel(); if (v === "groups" && authStore.isAdmin) loadGroupsPanel(); if (v === "briefing") loadBriefingTab(); + if (v === "apikeys") fetchApiKeys(); }); +// API Keys +const apiKeys = ref>([]); +const newKeyName = ref(''); +const newKeyScope = ref<'read' | 'write'>('write'); +const newKeyValue = ref(''); +const apiKeyCopied = ref(false); +const creatingApiKey = ref(false); +const revokeConfirmId = ref(null); + +async function fetchApiKeys() { + const res = await fetch('/api/api-keys', { credentials: 'include' }); + if (res.ok) { + const data = await res.json(); + apiKeys.value = data.api_keys; + } +} + +async function createApiKey() { + if (!newKeyName.value) return; + creatingApiKey.value = true; + try { + const res = await fetch('/api/api-keys', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }), + }); + if (res.ok) { + const data = await res.json(); + newKeyValue.value = data.key; + newKeyName.value = ''; + await fetchApiKeys(); + } + } finally { + creatingApiKey.value = false; + } +} + +async function revokeApiKey(id: number) { + await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' }); + revokeConfirmId.value = null; + await fetchApiKeys(); +} + +async function copyApiKey() { + await navigator.clipboard.writeText(newKeyValue.value); + apiKeyCopied.value = true; + setTimeout(() => { apiKeyCopied.value = false; }, 2000); +} + // Groups management const groups = ref([]); const groupsLoading = ref(false); @@ -871,12 +922,12 @@ function formatUserDate(iso: string): string { + +
+
+

API Keys

+

+ API keys let external tools (like the Fable MCP server) access your data without a browser session. + Write-scoped keys can create and update content; read-only keys can only query. +

+ + +
+ +
+ + +
+ +
+ + +
+

Copy this key now — it will not be shown again.

+
+ {{ newKeyValue }} + +
+ +
+ + + + + + + + + + + + + + + +
NameScopePrefixLast Used
{{ key.name }}{{ key.scope }}{{ key.key_prefix }}…{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }} + + + + + Sure? + + + +
+

No API keys yet.

+
+
+
@@ -2877,4 +2987,67 @@ function formatUserDate(iso: string): string { flex-wrap: wrap; margin-top: 0.5rem; } + +/* API Keys tab */ +.api-key-create-form { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 1.5rem; +} +.api-key-scope-select { + display: flex; + gap: 1rem; +} +.api-key-scope-select label { + display: flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; +} +.api-key-reveal { + background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); + border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); + border-radius: var(--radius-md); + padding: 1rem; + margin-bottom: 1.5rem; +} +.api-key-value-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.5rem; +} +.api-key-value { + flex: 1; + background: var(--color-surface-2, var(--color-surface)); + padding: 0.4rem 0.6rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + word-break: break-all; +} +.api-keys-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} +.api-keys-table th, .api-keys-table td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--color-border); + font-size: 0.9rem; +} +.api-keys-table th { font-weight: 600; opacity: 0.7; } +.scope-badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 9999px; + font-size: 0.78rem; + font-weight: 600; +} +.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; } +.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; } +.settings-empty { opacity: 0.5; margin-top: 1rem; } +.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }