Mercury/ui/src/views/admin/ApiKeys.vue
Matthew L McPeak cf7effad5c
Some checks failed
ci / build-ui (push) Successful in 13s
ci / test (push) Failing after 1m44s
ci / publish (push) Has been skipped
Inital Commit
2026-06-18 19:21:45 -04:00

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>