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:
MechaCat02
2026-07-11 14:55:24 +02:00
parent 32d0a7e13b
commit 1ed1a134ea
13 changed files with 561 additions and 8 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.125.1"
version = "0.126.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.125.1"
version = "0.126.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -38,7 +38,7 @@ pub fn routes() -> Router<AppState> {
"/auth/me/preferences",
get(get_preferences).patch(update_preferences),
)
.route("/auth/tokens", post(create_token))
.route("/auth/tokens", get(list_tokens).post(create_token))
.route("/auth/tokens/:id", delete(delete_token))
}
@@ -299,6 +299,22 @@ async fn update_preferences(
Ok(Json(saved))
}
/// `GET /auth/tokens` — the caller's bot tokens (newest first). The raw bearer
/// is only ever shown once at creation, so this list carries just the metadata
/// (name, created/last-used, expiry); `token_hash` is `#[serde(skip)]`.
#[derive(Debug, Serialize)]
struct TokenListResponse {
items: Vec<ApiToken>,
}
async fn list_tokens(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> AppResult<Json<TokenListResponse>> {
let items = repo::api_token::list_for_user(&state.db, user.id).await?;
Ok(Json(TokenListResponse { items }))
}
async fn create_token(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,

View File

@@ -32,6 +32,23 @@ pub async fn create(
Ok(row)
}
/// The caller's tokens, newest first. `token_hash` is `#[serde(skip)]` on the
/// domain type, so returning the full row never leaks the secret.
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<ApiToken>> {
let rows = sqlx::query_as::<_, ApiToken>(
r#"
SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
let row = sqlx::query_as::<_, ApiToken>(
r#"

View File

@@ -541,6 +541,62 @@ async fn create_and_use_bot_token(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::OK);
}
#[sqlx::test(migrations = "./migrations")]
async fn list_tokens_returns_callers_tokens_scoped_and_without_hash(pool: PgPool) {
let h = common::harness(pool);
let (_, cookie) = common::register_user(&h.app).await;
// Mint two tokens for this user, one with an expiry.
for body in [
json!({ "name": "no-expiry" }),
json!({ "name": "expiring", "expires_in_days": 30 }),
] {
let resp = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
body,
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
}
// A second user's token must NOT appear in the first user's list.
let (_, other) = common::register_user(&h.app).await;
let _ = h
.app
.clone()
.oneshot(common::post_json_with_cookie(
"/api/v1/auth/tokens",
json!({ "name": "someone-elses" }),
&other,
))
.await
.unwrap();
let resp = h
.app
.oneshot(common::get_with_cookie("/api/v1/auth/tokens", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = common::body_json(resp).await;
let items = body["items"].as_array().unwrap();
assert_eq!(items.len(), 2, "only the caller's two tokens");
let names: Vec<&str> = items.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"no-expiry") && names.contains(&"expiring"));
// Raw secret / hash must never appear, but expiry metadata must.
for t in items {
assert!(t.get("token_hash").is_none(), "token_hash must be absent");
assert!(t.get("bearer").is_none(), "raw bearer only shown at creation");
assert!(t.get("expires_at").is_some(), "expiry metadata present");
}
}
#[sqlx::test(migrations = "./migrations")]
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
// A token minted with expires_in_days is still active before its

View File

@@ -0,0 +1,94 @@
import { test, expect, type Page } from './fixtures';
// E2E for the bot API token management page: list existing tokens, create one
// (the raw bearer is shown once), and revoke one.
const existing = {
id: 't1111111-1111-1111-1111-111111111111',
user_id: 'u1',
name: 'existing-bot',
created_at: '2026-01-01T00:00:00Z',
last_used_at: null,
expires_at: null
};
type Captured = { created: Record<string, unknown> | null; deleted: string | null };
async function mockTokens(page: Page, cap: Captured) {
await page.route('**/api/v1/auth/config', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
})
);
await page.route('**/api/v1/auth/me', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
})
})
);
await page.route('**/api/v1/auth/me/preferences', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
);
await page.route('**/api/v1/me/bookmarks*', (r) =>
r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
})
);
// Specific id-scoped DELETE first (last-match-wins ordering).
await page.route('**/api/v1/auth/tokens/*', async (r) => {
cap.deleted = r.request().url().split('/').pop() ?? null;
await r.fulfill({ status: 204, body: '' });
});
await page.route('**/api/v1/auth/tokens', async (r) => {
if (r.request().method() === 'POST') {
cap.created = r.request().postDataJSON();
await r.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({
id: 't2',
user_id: 'u1',
name: cap.created?.name,
created_at: '2026-03-01T00:00:00Z',
last_used_at: null,
expires_at: null,
bearer: 'secret-raw-bearer-xyz'
})
});
return;
}
await r.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [existing] })
});
});
}
test('lists, creates (shows the bearer once), and revokes tokens', async ({ page }) => {
const cap: Captured = { created: null, deleted: null };
await mockTokens(page, cap);
await page.goto('/profile/tokens');
// Existing token is listed.
await expect(page.getByTestId(`token-row-${existing.id}`)).toContainText('existing-bot');
// Create a token — the raw bearer is revealed exactly once.
await page.getByTestId('token-name').fill('new-bot');
await page.getByRole('button', { name: 'Create token' }).click();
await expect(page.getByTestId('token-fresh')).toContainText('secret-raw-bearer-xyz');
await expect.poll(() => cap.created).toEqual({ name: 'new-bot' });
// Revoke the existing token (confirm dialog accepted).
page.on('dialog', (d) => d.accept());
await page.getByTestId(`token-revoke-${existing.id}`).click();
await expect.poll(() => cap.deleted).toBe(existing.id);
});

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.125.2",
"version": "0.126.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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();

View File

@@ -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)
});
}

View File

@@ -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 },

View File

@@ -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>

View 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 &lt;token&gt;</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>

View 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;