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 <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ const appVersion = ref('dev');
|
|||||||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// 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 _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||||||
watch(activeTab, (v) => {
|
watch(activeTab, (v) => {
|
||||||
@@ -41,8 +41,59 @@ watch(activeTab, (v) => {
|
|||||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
||||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
||||||
if (v === "briefing") loadBriefingTab();
|
if (v === "briefing") loadBriefingTab();
|
||||||
|
if (v === "apikeys") fetchApiKeys();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
|
||||||
|
const newKeyName = ref('');
|
||||||
|
const newKeyScope = ref<'read' | 'write'>('write');
|
||||||
|
const newKeyValue = ref('');
|
||||||
|
const apiKeyCopied = ref(false);
|
||||||
|
const creatingApiKey = ref(false);
|
||||||
|
const revokeConfirmId = ref<number | null>(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
|
// Groups management
|
||||||
const groups = ref<GroupEntry[]>([]);
|
const groups = ref<GroupEntry[]>([]);
|
||||||
const groupsLoading = ref(false);
|
const groupsLoading = ref(false);
|
||||||
@@ -871,12 +922,12 @@ function formatUserDate(iso: string): string {
|
|||||||
<div class="sidebar-group">
|
<div class="sidebar-group">
|
||||||
<div class="sidebar-group-label">User</div>
|
<div class="sidebar-group-label">User</div>
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
|
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
>
|
>
|
||||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||||
@@ -1458,6 +1509,65 @@ function formatUserDate(iso: string): string {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── API Keys ── -->
|
||||||
|
<div v-show="activeTab === 'apikeys'" class="settings-grid">
|
||||||
|
<section class="settings-section full-width">
|
||||||
|
<h2>API Keys</h2>
|
||||||
|
<p class="settings-description">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Create form -->
|
||||||
|
<div class="api-key-create-form">
|
||||||
|
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" class="settings-input" />
|
||||||
|
<div class="api-key-scope-select">
|
||||||
|
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
|
||||||
|
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
|
||||||
|
</div>
|
||||||
|
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
|
||||||
|
{{ creatingApiKey ? 'Generating…' : 'Generate Key' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One-time key reveal -->
|
||||||
|
<div v-if="newKeyValue" class="api-key-reveal">
|
||||||
|
<p><strong>Copy this key now — it will not be shown again.</strong></p>
|
||||||
|
<div class="api-key-value-row">
|
||||||
|
<code class="api-key-value">{{ newKeyValue }}</code>
|
||||||
|
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
|
||||||
|
</div>
|
||||||
|
<button @click="newKeyValue = ''" class="btn btn-secondary" style="margin-top: 0.5rem;">Done</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keys table -->
|
||||||
|
<table v-if="apiKeys.length > 0" class="api-keys-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="key in apiKeys" :key="key.id">
|
||||||
|
<td>{{ key.name }}</td>
|
||||||
|
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
||||||
|
<td><code>{{ key.key_prefix }}…</code></td>
|
||||||
|
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="revokeConfirmId !== key.id">
|
||||||
|
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Sure?
|
||||||
|
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
|
||||||
|
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Admin ── -->
|
<!-- ── Admin ── -->
|
||||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||||
|
|
||||||
@@ -2877,4 +2987,67 @@ function formatUserDate(iso: string): string {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.5rem;
|
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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user