aboutsummaryrefslogtreecommitdiff
path: root/web/ui/src/routes/dashboard/keys/+page.svelte
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/+page.svelte
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 '')
-rw-r--r--web/ui/src/routes/dashboard/keys/+page.svelte220
1 files changed, 220 insertions, 0 deletions
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}