255 lines
8.9 KiB
Vue
255 lines
8.9 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<div class="page-title">
|
|
<h2>API Keys</h2>
|
|
<span class="subtitle">Long-lived tokens for non-interactive service access — scoped by permission mask</span>
|
|
</div>
|
|
<NychButton @click="showCreate = true" label="+ New Key" />
|
|
</div>
|
|
|
|
<div class="table-card">
|
|
<div class="table-card-header">
|
|
<span class="count">{{ keys.length }} {{ keys.length === 1 ? 'key' : 'keys' }}</span>
|
|
</div>
|
|
<div v-if="loading" class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
<span>Loading…</span>
|
|
</div>
|
|
<template v-else-if="keys.length"><div class="table-scroll"><table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Prefix</th>
|
|
<th>Role</th>
|
|
<th>Mask</th>
|
|
<th>Created</th>
|
|
<th>Expires</th>
|
|
<th>Last used</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="k in keys" :key="k.id">
|
|
<td class="name-cell">{{ k.name }}</td>
|
|
<td><code class="prefix-cell">mrc_{{ k.key_prefix }}…</code></td>
|
|
<td><span class="role-badge" :class="roleBadgeClass(k.permissions_mask)">{{ roleName(k.permissions_mask) }}</span></td>
|
|
<td><code>{{ k.permissions_mask }}</code></td>
|
|
<td class="date-cell">{{ fmtDate(k.created_at) }}</td>
|
|
<td class="date-cell">{{ k.expires_at ? fmtDate(k.expires_at) : '—' }}</td>
|
|
<td class="date-cell">{{ k.last_used_at ? fmtDate(k.last_used_at) : 'Never' }}</td>
|
|
<td class="actions-cell">
|
|
<NychButton size="small" severity="danger" @click="revoke(k.id, k.name)" label="Revoke" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table></div></template>
|
|
<div class="empty-state" v-else>
|
|
<span class="empty-icon">⚿</span>
|
|
<span class="empty-label">No API keys</span>
|
|
<span class="empty-hint">Create a key to allow service-to-service access without user credentials.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create dialog -->
|
|
<NychDialog v-model:visible="showCreate" header="New API Key" :modal="true" :draggable="false" style="width: min(560px, 95vw)">
|
|
<form @submit.prevent="submitCreate" class="dialog-form">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<NychInputText v-model="form.name" placeholder="e.g. CI pipeline, data importer" fluid />
|
|
</div>
|
|
<div class="field">
|
|
<label>Role</label>
|
|
<NychSelect v-model="form.permissions_mask" :options="ROLES" optionLabel="label" optionValue="value" placeholder="Select a role" fluid />
|
|
<p class="hint">{{ ROLES.find(r => r.value === form.permissions_mask)?.description ?? '' }}</p>
|
|
</div>
|
|
<div class="field">
|
|
<label>Expires <span class="optional">(optional — leave blank for no expiry)</span></label>
|
|
<NychInputText v-model="form.expires_at" type="datetime-local" fluid />
|
|
</div>
|
|
<NychButton type="submit" :disabled="!form.name || !form.permissions_mask" fluid label="Generate Key" />
|
|
</form>
|
|
</NychDialog>
|
|
|
|
<!-- Key reveal dialog — shown once after creation -->
|
|
<NychDialog v-model:visible="showReveal" header="API Key Created" :modal="true" :draggable="false" :closable="false" style="width: min(600px, 95vw)">
|
|
<div class="reveal-body">
|
|
<NychMessage severity="warn" class="reveal-warning">
|
|
Copy this key now — it will <strong>not</strong> be shown again.
|
|
</NychMessage>
|
|
<div class="key-display">
|
|
<code class="key-text">{{ newKey }}</code>
|
|
<button type="button" class="copy-btn" :class="{ copied }" @click="copyKey" title="Copy to clipboard">
|
|
{{ copied ? '✓' : '⎘' }}
|
|
</button>
|
|
</div>
|
|
<NychButton fluid @click="closeReveal" label="I've saved the key" />
|
|
</div>
|
|
</NychDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
|
|
const auth = useAuthStore()
|
|
const keys = ref<any[]>([])
|
|
const showCreate = ref(false)
|
|
const showReveal = ref(false)
|
|
const newKey = ref('')
|
|
const copied = ref(false)
|
|
const form = ref({ name: '', permissions_mask: '', expires_at: '' })
|
|
const loading = ref(false)
|
|
|
|
const ROLES = [
|
|
{ label: 'Viewer', value: '1', description: 'Read-only access to API data' },
|
|
{ label: 'Editor', value: '7', description: 'Read, write, and delete via API' },
|
|
{ label: 'Query Admin', value: '15', description: 'Editor + manage SQL query registry' },
|
|
{ label: 'Ops Admin', value: '23', description: 'Editor + manage query cache' },
|
|
{ label: 'Admin', value: '31', description: 'All above combined' },
|
|
{ label: 'Super Admin', value: '63', description: 'Full access including users, permissions, and blacklist' },
|
|
]
|
|
|
|
function roleName(mask: string) {
|
|
return ROLES.find(r => r.value === mask)?.label ?? 'Custom'
|
|
}
|
|
|
|
function roleBadgeClass(mask: string) {
|
|
const map: Record<string, string> = {
|
|
'1': 'role-viewer', '7': 'role-editor',
|
|
'15': 'role-query', '23': 'role-ops',
|
|
'31': 'role-admin', '63': 'role-super',
|
|
}
|
|
return map[mask] ?? 'role-custom'
|
|
}
|
|
|
|
function fmtDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const res = await fetch('/api/admin/api-keys', { headers: auth.authHeaders() })
|
|
keys.value = await res.json()
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function submitCreate() {
|
|
const body: any = {
|
|
name: form.value.name,
|
|
permissions_mask: form.value.permissions_mask,
|
|
}
|
|
if (form.value.expires_at) {
|
|
body.expires_at = new Date(form.value.expires_at).toISOString()
|
|
}
|
|
const res = await fetch('/api/admin/api-keys', {
|
|
method: 'POST',
|
|
headers: { ...auth.authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
const data = await res.json()
|
|
showCreate.value = false
|
|
form.value = { name: '', permissions_mask: '', expires_at: '' }
|
|
newKey.value = data.key
|
|
copied.value = false
|
|
showReveal.value = true
|
|
load()
|
|
}
|
|
|
|
async function revoke(id: number, name: string) {
|
|
if (!confirm(`Revoke key "${name}"? Any services using it will lose access immediately.`)) return
|
|
await fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE', headers: auth.authHeaders() })
|
|
load()
|
|
}
|
|
|
|
async function copyKey() {
|
|
await navigator.clipboard.writeText(newKey.value)
|
|
copied.value = true
|
|
setTimeout(() => { copied.value = false }, 2000)
|
|
}
|
|
|
|
function closeReveal() {
|
|
showReveal.value = false
|
|
newKey.value = ''
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.name-cell { font-weight: 600; color: var(--text-high); }
|
|
.prefix-cell { font-size: 0.8rem; color: var(--text-muted); }
|
|
.date-cell { color: var(--text-muted); font-size: 0.82rem; font-family: var(--font-mono); }
|
|
|
|
.role-badge {
|
|
display: inline-block;
|
|
padding: 0.2rem 0.65rem;
|
|
border-radius: 20px;
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}
|
|
.role-viewer { background: var(--info-subtle); color: var(--info); border: 1px solid var(--info-border); }
|
|
.role-editor { background: var(--success-subtle); color: var(--success); border: 1px solid var(--success-border); }
|
|
.role-query { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
|
.role-ops { background: var(--warn-subtle); color: var(--warn); border: 1px solid var(--warn-border); }
|
|
.role-admin { background: color-mix(in srgb, var(--primary) 12%, var(--surface-1)); color: var(--primary); border: 1px solid color-mix(in srgb, var(--primary) 35%, var(--border)); }
|
|
.role-super { background: var(--primary); color: var(--primary-fg); border: 1px solid var(--primary); }
|
|
.role-custom { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
|
|
|
/* Key reveal */
|
|
.reveal-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
padding-top: 0.25rem;
|
|
}
|
|
|
|
.reveal-warning {
|
|
width: 100%;
|
|
}
|
|
|
|
.key-display {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
.key-text {
|
|
flex: 1;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--text-high);
|
|
word-break: break-all;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.copy-btn {
|
|
flex-shrink: 0;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: 5px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface-1);
|
|
color: var(--text-muted);
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.copy-btn:hover { border-color: var(--primary); color: var(--primary); }
|
|
.copy-btn.copied { border-color: var(--success); color: var(--success); background: var(--success-subtle); }
|
|
</style>
|