aboutsummaryrefslogtreecommitdiff
path: root/web/ui/src/routes/dashboard/keys
diff options
context:
space:
mode:
authorbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
committerbenj <benj@rse8.com>2026-04-10 11:13:34 +0800
commit493746b14c1251a45b061d2e3edd9160c929d2b9 (patch)
tree1607cceb94c1aac1a17a01bb5c0d71b97342e892 /web/ui/src/routes/dashboard/keys
parentc041641634650c31e03c70dcad132fd94cb08e63 (diff)
downloadtidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.gz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.bz2
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.lz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.xz
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.tar.zst
tidyindex-493746b14c1251a45b061d2e3edd9160c929d2b9.zip
a basic ui and landing web interface for tidyindex.com
Diffstat (limited to 'web/ui/src/routes/dashboard/keys')
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.server.ts68
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.svelte220
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 &nbsp;&middot;&nbsp; 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}