Files
PiCloud/dashboard/src/routes/profile/+page.svelte
MechaCat02 fc35d59236 fix(dashboard): show pic_ prefix on API-key rows
The backend's ApiKeyDto.prefix is just the 8-char public head
(e.g. "PKXPCPH3"); the actual token the user pastes into their
CLI is "pic_PKXPCPH3…". Display the full visible identifier so
operators can match a row against the token in their notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:27:55 +02:00

761 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
/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">pic_{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>