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:
@@ -15,6 +15,7 @@ import {
|
||||
changePassword,
|
||||
createToken,
|
||||
deleteToken,
|
||||
listTokens,
|
||||
getAuthConfig
|
||||
} from './auth';
|
||||
|
||||
@@ -170,6 +171,50 @@ describe('auth api client', () => {
|
||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
||||
});
|
||||
|
||||
it('createToken forwards expires_in_days when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok(
|
||||
{
|
||||
id: 't2',
|
||||
user_id: 'user-1',
|
||||
name: 'expiring',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: '2026-02-01T00:00:00Z',
|
||||
bearer: 'raw'
|
||||
},
|
||||
201
|
||||
)
|
||||
);
|
||||
await createToken('expiring', 30);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: 'expiring', expires_in_days: 30 });
|
||||
});
|
||||
|
||||
it('listTokens GETs /v1/auth/tokens and unwraps items', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
ok({
|
||||
items: [
|
||||
{
|
||||
id: 't1',
|
||||
user_id: 'user-1',
|
||||
name: 'ci-bot',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
last_used_at: null,
|
||||
expires_at: null
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
const tokens = await listTokens();
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].name).toBe('ci-bot');
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(init?.method ?? 'GET').toBe('GET');
|
||||
});
|
||||
|
||||
it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
|
||||
const cfg = await getAuthConfig();
|
||||
|
||||
@@ -92,15 +92,29 @@ export type ApiToken = {
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
/** When the token stops authenticating; `null` = never expires. */
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
export type CreatedToken = ApiToken & { bearer: string };
|
||||
|
||||
export async function createToken(name: string): Promise<CreatedToken> {
|
||||
/** The caller's bot tokens, newest first (metadata only — the raw bearer is
|
||||
* shown once at creation and never returned again). */
|
||||
export async function listTokens(): Promise<ApiToken[]> {
|
||||
const res = await request<{ items: ApiToken[] }>('/v1/auth/tokens');
|
||||
return res.items;
|
||||
}
|
||||
|
||||
export async function createToken(
|
||||
name: string,
|
||||
expiresInDays?: number
|
||||
): Promise<CreatedToken> {
|
||||
const payload: { name: string; expires_in_days?: number } = { name };
|
||||
if (expiresInDays !== undefined) payload.expires_in_days = expiresInDays;
|
||||
return request<CreatedToken>('/v1/auth/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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