feat: bot API token management page
The account page pointed users at a "bot-token list" that didn't exist: there was no GET endpoint, no UI route, and the client type lacked expiry. Add GET /v1/auth/tokens (caller-scoped, token_hash never serialised), extend the auth client with listTokens/expires_at and createToken expiry, and add a /profile/tokens page to list, create (revealing the raw bearer once), and revoke tokens. Link the account-page copy to it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import User from '@lucide/svelte/icons/user';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||
import Terminal from '@lucide/svelte/icons/terminal';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||
import Tag from '@lucide/svelte/icons/tag';
|
||||
@@ -26,6 +27,7 @@
|
||||
{ href: '/profile', label: 'Overview', icon: User, testid: 'tab-overview', guestVisible: true },
|
||||
{ href: '/profile/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
|
||||
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
|
||||
{ href: '/profile/tokens', label: 'API tokens', icon: Terminal, testid: 'tab-tokens', guestVisible: false },
|
||||
{ href: '/profile/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
|
||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', guestVisible: false },
|
||||
{ href: '/profile/page-tags', label: 'Page tags', icon: Tag, testid: 'tab-page-tags', guestVisible: false },
|
||||
|
||||
@@ -224,8 +224,8 @@
|
||||
<h2>Change password</h2>
|
||||
<p class="hint">
|
||||
Changing your password signs out every other device using this account.
|
||||
Bot API tokens keep working — revoke them individually from the bot-token
|
||||
list if you want to invalidate them too.
|
||||
Bot API tokens keep working — revoke them individually from the
|
||||
<a href="/profile/tokens">API tokens</a> page if you want to invalidate them too.
|
||||
</p>
|
||||
{@render passwordForm()}
|
||||
</section>
|
||||
|
||||
306
frontend/src/routes/profile/tokens/+page.svelte
Normal file
306
frontend/src/routes/profile/tokens/+page.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { listTokens, createToken, deleteToken, type ApiToken } from '$lib/api/auth';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let tokens = $state<ApiToken[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Create form.
|
||||
let newName = $state('');
|
||||
let newExpiry = $state<string>(''); // '' = never; otherwise days as a string
|
||||
let creating = $state(false);
|
||||
// The raw bearer, shown once right after creation.
|
||||
let freshBearer = $state<string | null>(null);
|
||||
|
||||
let busyId = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tokens = await listTokens();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not load tokens.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load once the session resolves. Guests get the sign-in prompt; a
|
||||
// signed-in user's tokens are fetched exactly once.
|
||||
let didLoad = $state(false);
|
||||
$effect(() => {
|
||||
if (!session.loaded) return;
|
||||
if (session.user && !didLoad) {
|
||||
didLoad = true;
|
||||
load();
|
||||
} else if (!session.user) {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function onCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
freshBearer = null;
|
||||
try {
|
||||
const days = newExpiry ? Number(newExpiry) : undefined;
|
||||
const created = await createToken(name, days);
|
||||
freshBearer = created.bearer;
|
||||
newName = '';
|
||||
newExpiry = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not create token.';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(t: ApiToken) {
|
||||
if (!confirm(`Revoke the token "${t.name}"? Any bot using it will stop working.`)) return;
|
||||
busyId = t.id;
|
||||
try {
|
||||
await deleteToken(t.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Could not revoke token.';
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBearer() {
|
||||
if (!freshBearer) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(freshBearer);
|
||||
toast.success('Token copied to clipboard.');
|
||||
} catch {
|
||||
toast.error('Copy failed — select and copy it manually.');
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(d: string | null): string {
|
||||
return d ? new Date(d).toLocaleDateString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card" data-testid="tokens-page">
|
||||
<h2>Bot API tokens</h2>
|
||||
<p class="hint">
|
||||
Bot tokens authenticate scripts against the same HTTP API the site uses.
|
||||
Send one as <code>Authorization: Bearer <token></code>. The token is
|
||||
shown only once, right after you create it — store it somewhere safe.
|
||||
</p>
|
||||
|
||||
{#if !session.user}
|
||||
<p class="empty" data-testid="tokens-guest">Sign in to manage your API tokens.</p>
|
||||
{:else}
|
||||
<form class="create" onsubmit={onCreate} data-testid="token-create-form">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
maxlength="64"
|
||||
placeholder="e.g. ci-bot"
|
||||
data-testid="token-name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Expires</span>
|
||||
<select bind:value={newExpiry} data-testid="token-expiry">
|
||||
<option value="">Never</option>
|
||||
<option value="30">In 30 days</option>
|
||||
<option value="90">In 90 days</option>
|
||||
<option value="365">In 1 year</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="primary" disabled={!newName.trim() || creating}>
|
||||
{creating ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if freshBearer}
|
||||
<div class="fresh" data-testid="token-fresh">
|
||||
<p>Copy your new token now — you won't see it again:</p>
|
||||
<div class="fresh-row">
|
||||
<code class="bearer">{freshBearer}</code>
|
||||
<button type="button" onclick={copyBearer} aria-label="Copy token">
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error" data-testid="tokens-error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">Loading…</p>
|
||||
{:else if tokens.length === 0}
|
||||
<p class="empty" data-testid="tokens-empty">You have no API tokens yet.</p>
|
||||
{:else}
|
||||
<ul class="token-list" data-testid="token-list">
|
||||
{#each tokens as t (t.id)}
|
||||
<li class="token" data-testid={`token-row-${t.id}`}>
|
||||
<div class="meta">
|
||||
<span class="name">{t.name}</span>
|
||||
<span class="sub">
|
||||
Created {fmt(t.created_at)} · Last used {fmt(t.last_used_at)} ·
|
||||
{t.expires_at ? `Expires ${fmt(t.expires_at)}` : 'No expiry'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
disabled={busyId === t.id}
|
||||
onclick={() => onRevoke(t)}
|
||||
aria-label={`Revoke ${t.name}`}
|
||||
data-testid={`token-revoke-${t.id}`}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
<span>Revoke</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
margin: 0 0 var(--space-3);
|
||||
}
|
||||
code {
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.12));
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.create {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.create label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.create input,
|
||||
.create select {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast, #fff);
|
||||
border-color: transparent;
|
||||
}
|
||||
.danger {
|
||||
color: var(--danger, #dc2626);
|
||||
border-color: var(--danger, #dc2626);
|
||||
}
|
||||
.fresh {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.fresh p {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.fresh-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.bearer {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.token-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
.sub {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
}
|
||||
</style>
|
||||
3
frontend/src/routes/profile/tokens/+page.ts
Normal file
3
frontend/src/routes/profile/tokens/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Client-only: tokens are per-user and fetched on mount, matching the other
|
||||
// authenticated profile subroutes.
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user