diff options
Diffstat (limited to 'web/ui/src/routes/dashboard/keys')
| -rw-r--r-- | web/ui/src/routes/dashboard/keys/+page.server.ts | 68 | ||||
| -rw-r--r-- | web/ui/src/routes/dashboard/keys/+page.svelte | 220 |
2 files changed, 288 insertions, 0 deletions
diff --git a/web/ui/src/routes/dashboard/keys/+page.server.ts b/web/ui/src/routes/dashboard/keys/+page.server.ts new file mode 100644 index 0000000..5491283 --- /dev/null +++ b/web/ui/src/routes/dashboard/keys/+page.server.ts @@ -0,0 +1,68 @@ +import { fail, type Actions } from '@sveltejs/kit'; + +import { + createKey, + listKeys, + revokeKey, + countActiveKeys +} from '$lib/server/keys'; +import { PLANS } from '$lib/plans'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const account = locals.account!; + const keys = listKeys(account.id); + return { + keys, + activeCount: countActiveKeys(account.id), + plan: PLANS[account.plan] + }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const account = locals.account!; + const form = await request.formData(); + const name = ((form.get('name') ?? '') as string).trim(); + const scopes = form.getAll('scopes').map((s) => s.toString()); + + if (!name) { + return fail(400, { error: 'Give the key a name so you can recognize it later.' }); + } + + const plan = PLANS[account.plan]; + const active = countActiveKeys(account.id); + if (Number.isFinite(plan.maxKeys) && active >= plan.maxKeys) { + return fail(403, { + error: `Your ${plan.name} plan allows ${plan.maxKeys} active key${ + plan.maxKeys === 1 ? '' : 's' + }. Revoke one or upgrade your plan first.` + }); + } + + const created = createKey({ + accountId: account.id, + name, + scopes + }); + + return { + created: { + id: created.id, + plaintext: created.plaintext, + name: created.name + } + }; + }, + + revoke: async ({ request, locals }) => { + const account = locals.account!; + const form = await request.formData(); + const id = (form.get('id') ?? '').toString(); + if (!id) return fail(400, { error: 'Missing key id.' }); + + const ok = revokeKey(account.id, id); + if (!ok) return fail(404, { error: 'Key not found.' }); + return { revokedId: id }; + } +}; diff --git a/web/ui/src/routes/dashboard/keys/+page.svelte b/web/ui/src/routes/dashboard/keys/+page.svelte new file mode 100644 index 0000000..b0a066f --- /dev/null +++ b/web/ui/src/routes/dashboard/keys/+page.svelte @@ -0,0 +1,220 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { DATASETS } from '$lib/datasets'; + import { maskKey, scopeSummary } from '$lib/keys'; + import { pushToast } from '$lib/stores/toasts'; + import type { PageData, ActionData } from './$types'; + + let { data, form }: { data: PageData; form: ActionData } = $props(); + + let showCreate = $state(false); + let name = $state(''); + let selected = $state<Set<string>>(new Set()); + let allScopes = $state(true); + + function toggleChip(slug: string) { + if (allScopes) { + allScopes = false; + selected = new Set([slug]); + return; + } + const next = new Set(selected); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + if (next.size === 0) { + allScopes = true; + selected = new Set(); + } else { + selected = next; + } + } + + function selectAll() { + allScopes = true; + selected = new Set(); + } + + function fmtDate(ts: number): string { + return new Date(ts).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + } + + async function copy(text: string) { + try { + await navigator.clipboard.writeText(text); + pushToast('Copied to clipboard', 'success'); + } catch { + pushToast("Couldn't copy โ select it manually", 'error'); + } + } + + // When a new key is returned in `form`, show toast + scroll to top. + $effect(() => { + if (form && 'created' in form && form.created) { + pushToast(`Key "${form.created.name}" created`, 'success'); + showCreate = false; + name = ''; + selected = new Set(); + allScopes = true; + } else if (form && 'revokedId' in form && form.revokedId) { + pushToast('Key revoked', 'success'); + } else if (form && 'error' in form && form.error) { + pushToast(form.error, 'error'); + } + }); +</script> + +<p class="section-marker">ยง 01 · api keys</p> +<div class="row-between mb-24"> + <div> + <h1 class="page-title">Your API keys.</h1> + <p class="page-subtitle"> + {data.activeCount} active, {data.keys.length - data.activeCount} revoked. Your + {data.plan.name} plan allows + {Number.isFinite(data.plan.maxKeys) ? data.plan.maxKeys : 'unlimited'} + active key{data.plan.maxKeys === 1 ? '' : 's'}. + </p> + </div> + {#if !showCreate} + <button class="btn btn-primary" onclick={() => (showCreate = true)}> + + New key + </button> + {/if} +</div> + +{#if form && 'created' in form && form.created} + <div class="key-reveal"> + <div class="key-reveal-head"> + <span class="badge">New key</span> + <strong>Copy this key now โ it won't be shown again.</strong> + </div> + <div class="key-reveal-value"> + <span style="flex: 1;">{form.created.plaintext}</span> + <button class="btn btn-sm btn-ghost" onclick={() => copy(form.created!.plaintext)}> + Copy + </button> + </div> + <p class="key-reveal-warn"> + Store it somewhere safe. Once you leave this page we only keep the hash. + </p> + </div> +{/if} + +{#if showCreate} + <form + method="POST" + action="?/create" + class="card card-accent mb-24" + use:enhance + > + <div class="card-head"> + <h2 class="card-title">New key</h2> + <button type="button" class="btn btn-sm btn-ghost" onclick={() => (showCreate = false)}> + Cancel + </button> + </div> + + <div class="field"> + <label class="field-label" for="name">name</label> + <input + id="name" + name="name" + class="input" + placeholder="e.g. production ingest" + bind:value={name} + required + /> + <p class="help">Just for you โ how you'll recognize this key in the list.</p> + </div> + + <div class="field"> + <span class="field-label">dataset scope</span> + <div class="chip-grid"> + <button + type="button" + class="chip chip-all {allScopes ? 'active' : ''}" + onclick={selectAll} + > + all datasets + </button> + {#each DATASETS as slug} + <button + type="button" + class="chip {selected.has(slug) ? 'active' : ''}" + onclick={() => toggleChip(slug)} + > + {slug} + </button> + {/each} + </div> + <p class="help"> + {#if allScopes} + This key will work against every dataset in the catalog. + {:else} + This key will only work against the {selected.size} selected dataset{selected.size === 1 ? '' : 's'}. + {/if} + </p> + </div> + + {#if allScopes} + <input type="hidden" name="scopes" value="*" /> + {:else} + {#each [...selected] as slug} + <input type="hidden" name="scopes" value={slug} /> + {/each} + {/if} + + <div class="row mt-16"> + <button type="submit" class="btn btn-accent">Create key</button> + <button type="button" class="btn btn-ghost" onclick={() => (showCreate = false)}> + Cancel + </button> + </div> + </form> +{/if} + +{#if data.keys.length === 0} + <div class="empty-state"> + <p class="empty-title">No keys yet.</p> + <p>Click "+ New key" to create your first one.</p> + </div> +{:else} + {#each data.keys as key (key.id)} + <div class="card"> + <div class="key-row"> + <div class="key-row-main"> + <div class="key-name"> + {key.name} + {#if key.active} + <span class="badge badge-success">Active</span> + {:else} + <span class="badge badge-muted">Revoked</span> + {/if} + </div> + <div class="key-mask">{maskKey(key.key_prefix)}</div> + <div class="scope-summary"> + scope: <code>{scopeSummary(key.scopes)}</code> + </div> + <div class="key-meta"> + <span><span class="meta-label">created</span> {fmtDate(key.created_at)}</span> + {#if key.last_used_at} + <span><span class="meta-label">last used</span> {fmtDate(key.last_used_at)}</span> + {:else} + <span><span class="meta-label">last used</span> never</span> + {/if} + </div> + </div> + + {#if key.active} + <form method="POST" action="?/revoke" use:enhance> + <input type="hidden" name="id" value={key.id} /> + <button class="btn btn-sm btn-danger" type="submit">Revoke</button> + </form> + {/if} + </div> + </div> + {/each} +{/if} |
