feat(dashboard): profile page with API-key list, mint, and revoke

/admin/profile is the per-principal page available to every
authenticated user (owner, admin, member). Shows the caller's
identity (username, role chip, email, id) plus a full API-key
list/mint/revoke surface.

Minting reveals the raw token exactly once in a yellow-bordered
panel with a Copy button and an "I've saved it" acknowledgement
gate before the Done button enables, matching the spec's one-shot
secret-display pattern.

Live mirrors the backend bound-key guard: picking an app from the
binding dropdown drops any instance:* scopes from the selection and
greys out their checkboxes with a tooltip, so submit never hits a
422 on that case.

Also surfaces a one-shot info banner when /admin/users redirects a
member here with ?denied=users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-27 08:02:40 +02:00
parent bd2258499e
commit f16ff22a5a

View File

@@ -0,0 +1,760 @@
<!--
/admin/profile — every authenticated principal lands here for their
own identity + API-key management. No role gating: a member can mint
keys for the apps they belong to just like an admin can. Users-admin
actions live under /admin/users.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import {
api,
ApiError,
ALL_SCOPES,
isInstanceScope,
type ApiKeyDto,
type App,
type MintApiKeyResponse,
type Scope
} from '$lib/api';
import { currentUser } from '$lib/auth';
import RoleChip from '$lib/RoleChip.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
const me = $derived($currentUser);
let keys = $state<ApiKeyDto[]>([]);
let apps = $state<App[]>([]);
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
let loadError = $state<string | null>(null);
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
// Surface the cross-page "access denied" notice when /users bounces
// a member back here. One-shot — clears as soon as the user
// navigates away or dismisses.
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
let mintOpen = $state(false);
let mintForm = $state<{
name: string;
scopes: Set<Scope>;
app_id: string | '';
expires_at: string;
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
let mintPending = $state(false);
let mintError = $state<string | null>(null);
let reveal = $state<MintApiKeyResponse | null>(null);
let revealAck = $state(false);
let copyState = $state<'idle' | 'copied'>('idle');
let revokeTarget = $state<ApiKeyDto | null>(null);
let revokePending = $state(false);
const NAME_MAX = 64;
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
const boundToApp = $derived(mintForm.app_id !== '');
const canSubmit = $derived(
mintForm.name.trim().length > 0 &&
mintForm.name.trim().length <= NAME_MAX &&
mintForm.scopes.size > 0 &&
!mintPending
);
onMount(async () => {
await Promise.all([refreshKeys(), loadApps()]);
});
async function refreshKeys() {
try {
keys = await api.apiKeys.list();
loadError = null;
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
}
}
async function loadApps() {
try {
apps = await api.apps.list();
} catch {
// Non-fatal: the form falls back to "no app options" and the
// list shows the bare UUID in the binding column.
apps = [];
}
}
function flash(kind: 'error' | 'info', message: string) {
banner = { kind, message };
setTimeout(() => {
if (banner?.message === message) banner = null;
}, 6000);
}
function openMint() {
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
mintError = null;
mintOpen = true;
}
function cancelMint() {
mintOpen = false;
mintError = null;
}
function toggleScope(s: Scope) {
const next = new Set(mintForm.scopes);
if (next.has(s)) next.delete(s);
else next.add(s);
mintForm = { ...mintForm, scopes: next };
}
// When the user binds the key to an app, instance:* scopes are
// mutually exclusive — drop them from the selection so submit
// doesn't 422.
$effect(() => {
if (!boundToApp) return;
const filtered = new Set<Scope>();
let dropped = false;
for (const s of mintForm.scopes) {
if (scopeIsInstance(s)) dropped = true;
else filtered.add(s);
}
if (dropped) {
mintForm = { ...mintForm, scopes: filtered };
}
});
async function submitMint(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
mintPending = true;
mintError = null;
try {
const r = await api.apiKeys.mint({
name: mintForm.name.trim(),
scopes: Array.from(mintForm.scopes),
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
expires_at: mintForm.expires_at === ''
? null
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
});
reveal = r;
revealAck = false;
copyState = 'idle';
mintOpen = false;
await refreshKeys();
} catch (e) {
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
} finally {
mintPending = false;
}
}
async function copyToken() {
if (!reveal) return;
try {
await navigator.clipboard.writeText(reveal.raw_token);
copyState = 'copied';
setTimeout(() => (copyState = 'idle'), 2000);
} catch {
flash('error', 'Clipboard write failed — select and copy manually.');
}
}
function dismissReveal() {
reveal = null;
revealAck = false;
}
function openRevoke(key: ApiKeyDto) {
revokeTarget = key;
}
async function confirmRevoke() {
if (!revokeTarget) return;
revokePending = true;
const target = revokeTarget;
try {
await api.apiKeys.revoke(target.id);
revokeTarget = null;
keys = keys.filter((k) => k.id !== target.id);
flash('info', `Revoked "${target.name}".`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
} finally {
revokePending = false;
}
}
function appLabel(app_id: string | null): string {
if (!app_id) return 'Instance-wide';
const a = appBySlug.get(app_id);
return a ? a.slug : app_id.slice(0, 8) + '…';
}
function shortDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toISOString().slice(0, 10);
}
function relative(iso: string | null): string {
if (!iso) return 'Never';
const then = new Date(iso).getTime();
const sec = Math.round((Date.now() - then) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}d ago`;
return shortDate(iso);
}
</script>
{#if me}
<section class="identity">
<div class="identity-head">
<h1>{me.username}</h1>
<RoleChip role={me.instance_role} />
</div>
<dl class="identity-meta">
<div>
<dt>Email</dt>
<dd>{me.email ?? 'No email set'}</dd>
</div>
<div>
<dt>User ID</dt>
<dd class="mono">{me.id}</dd>
</div>
</dl>
</section>
{/if}
{#if deniedFromUsers}
<div class="banner banner-info">
You don't have access to the Users page. Ask an admin if you need to manage users.
</div>
{/if}
{#if banner}
<div class="banner banner-{banner.kind}">{banner.message}</div>
{/if}
<section class="keys-section">
<header class="section-head">
<h2>API keys</h2>
{#if !mintOpen && !reveal}
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
{/if}
</header>
{#if reveal}
<div class="reveal">
<h3>Save this token now — it will never be shown again.</h3>
<p class="reveal-sub">
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
you lose it, mint a new one.
</p>
<div class="token-row">
<code class="token">{reveal.raw_token}</code>
<button type="button" class="ghost" onclick={copyToken}>
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
</button>
</div>
<label class="ack">
<input type="checkbox" bind:checked={revealAck} />
<span>I've saved this token somewhere safe.</span>
</label>
<div class="reveal-actions">
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
Done
</button>
</div>
</div>
{/if}
{#if mintOpen}
<form class="mint" onsubmit={submitMint}>
<div class="form-row">
<label class="field">
<span>Name</span>
<input
type="text"
bind:value={mintForm.name}
maxlength={NAME_MAX}
autocomplete="off"
placeholder="e.g. ci-deploy"
required
/>
<small>1{NAME_MAX} chars. Only you see it.</small>
</label>
<label class="field">
<span>Binding</span>
<select bind:value={mintForm.app_id}>
<option value="">Instance-wide</option>
{#each apps as a (a.id)}
<option value={a.id}>{a.slug} ({a.name})</option>
{/each}
</select>
<small>Pick an app to scope this key, or leave instance-wide.</small>
</label>
<label class="field">
<span>Expires</span>
<input type="date" bind:value={mintForm.expires_at} />
<small>Leave blank for no expiry.</small>
</label>
</div>
<fieldset class="scopes">
<legend>Scopes</legend>
<div class="scope-grid">
{#each ALL_SCOPES as scope (scope)}
{@const instanceScope = scopeIsInstance(scope)}
{@const disabled = boundToApp && instanceScope}
<label
class="scope-chip"
class:disabled
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
>
<input
type="checkbox"
checked={mintForm.scopes.has(scope)}
disabled={disabled || mintPending}
onchange={() => toggleScope(scope)}
/>
<span class="scope-name">{scope}</span>
</label>
{/each}
</div>
<small class="scope-hint">
{mintForm.scopes.size === 0
? 'Pick at least one scope.'
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
</small>
</fieldset>
{#if mintError}
<div class="error">{mintError}</div>
{/if}
<div class="form-actions">
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
<button type="submit" class="primary" disabled={!canSubmit}>
{mintPending ? 'Minting…' : 'Mint key'}
</button>
</div>
</form>
{/if}
{#if loadError}
<div class="error">
{loadError}
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
</div>
{:else if keys.length === 0 && !reveal && !mintOpen}
<p class="empty">
No API keys yet. Mint one to authenticate the CLI or external integrations.
</p>
{:else if keys.length > 0}
<div class="table">
<div class="row head-row">
<div>Name</div>
<div>Prefix</div>
<div>Scopes</div>
<div>Binding</div>
<div>Created</div>
<div>Last used</div>
<div>Expires</div>
<div class="actions-col"></div>
</div>
{#each keys as key (key.id)}
<div class="row">
<div class="name-cell">{key.name}</div>
<div class="mono prefix">{key.prefix}</div>
<div class="scopes-cell">
{#each key.scopes as s (s)}
<span class="scope-pill">{s}</span>
{/each}
</div>
<div>{appLabel(key.app_id)}</div>
<div>{shortDate(key.created_at)}</div>
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
<div class="actions-col">
<button
type="button"
class="danger-link"
onclick={() => openRevoke(key)}
aria-label="Revoke {key.name}"
>
Revoke
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
{#if revokeTarget}
<ConfirmModal
title="Revoke API key?"
variant="danger"
confirmLabel="Revoke"
busy={revokePending}
busyLabel="Revoking…"
onConfirm={confirmRevoke}
onCancel={() => (revokeTarget = null)}
>
<p>
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
effect immediately. Any CLI or integration using it will start returning <code>401</code>
on the next request.
</p>
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
</ConfirmModal>
{/if}
<style>
.identity {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.identity-head {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.identity h1 {
margin: 0;
font-size: 1.25rem;
color: #e2e8f0;
}
.identity-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 0.75rem 1.5rem;
margin: 0;
}
.identity-meta div {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.identity-meta dt {
color: #64748b;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.identity-meta dd {
margin: 0;
color: #cbd5e1;
font-size: 0.9rem;
}
.banner {
padding: 0.55rem 0.85rem;
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
.banner-error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
}
.banner-info {
background: #0c2a36;
border: 1px solid #155e75;
color: #a5f3fc;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.section-head h2 {
margin: 0;
font-size: 1.05rem;
color: #e2e8f0;
}
.reveal {
background: #0b1220;
border: 1px solid #ca8a04;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.reveal h3 {
margin: 0;
font-size: 0.95rem;
color: #fbbf24;
}
.reveal-sub {
margin: 0;
color: #cbd5e1;
font-size: 0.85rem;
line-height: 1.4;
}
.token-row {
display: flex;
align-items: stretch;
gap: 0.5rem;
}
.token {
flex: 1;
background: #020617;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.6rem 0.75rem;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
overflow-x: auto;
white-space: nowrap;
}
.ack {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #cbd5e1;
cursor: pointer;
}
.reveal-actions {
display: flex;
justify-content: flex-end;
}
.mint {
background: #0b1220;
border: 1px solid #1e293b;
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: #cbd5e1;
}
.field input,
.field select {
background: #0f172a;
color: #e2e8f0;
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #38bdf8;
}
.field small {
color: #64748b;
font-size: 0.72rem;
}
.scopes {
border: 1px solid #1e293b;
border-radius: 0.375rem;
padding: 0.75rem 0.85rem;
color: #cbd5e1;
}
.scopes legend {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
padding: 0 0.4rem;
}
.scope-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.4rem 0.75rem;
margin-top: 0.25rem;
}
.scope-chip {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
cursor: pointer;
}
.scope-chip.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.scope-hint {
display: block;
margin-top: 0.55rem;
font-size: 0.75rem;
color: #64748b;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.error {
background: #450a0a;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.55rem 0.8rem;
border-radius: 0.375rem;
font-size: 0.85rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.25rem;
}
.retry {
background: transparent;
border: 1px solid #b91c1c;
color: #fecaca;
padding: 0.2rem 0.55rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.empty {
color: #64748b;
text-align: center;
padding: 2.5rem 0;
border: 1px dashed #1e293b;
border-radius: 0.5rem;
}
.table {
display: flex;
flex-direction: column;
border: 1px solid #1e293b;
border-radius: 0.5rem;
background: #0b1220;
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1rem;
border-bottom: 1px solid #1e293b;
font-size: 0.85rem;
}
.row:last-child {
border-bottom: none;
}
.head-row {
color: #94a3b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #0f172a;
}
.name-cell {
color: #e2e8f0;
font-weight: 500;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.prefix {
color: #94a3b8;
}
.scopes-cell {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.scope-pill {
background: #1e293b;
color: #cbd5e1;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.actions-col {
display: flex;
justify-content: flex-end;
}
.danger-link {
background: transparent;
color: #fca5a5;
border: none;
font-size: 0.8rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
}
.danger-link:hover {
background: #450a0a;
color: #fecaca;
}
button.primary {
background: #38bdf8;
color: #0b1220;
border: none;
padding: 0.5rem 0.9rem;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
font-size: 0.85rem;
}
button.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.ghost {
background: transparent;
color: #94a3b8;
border: 1px solid #334155;
padding: 0.45rem 0.85rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
}
button.ghost:hover {
background: #1e293b;
color: #e2e8f0;
}
.muted {
color: #94a3b8;
}
</style>