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);
|
||||
|
||||
// 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<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
|
||||
const groups = ref<GroupEntry[]>([]);
|
||||
const groupsLoading = ref(false);
|
||||
@@ -871,12 +922,12 @@ function formatUserDate(iso: string): string {
|
||||
<div class="sidebar-group">
|
||||
<div class="sidebar-group-label">User</div>
|
||||
<button
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
|
||||
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
|
||||
:key="tab"
|
||||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||||
@@ -1458,6 +1509,65 @@ function formatUserDate(iso: string): string {
|
||||
|
||||
</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 ── -->
|
||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user