diff --git a/backend/Cargo.lock b/backend/Cargo.lock index abd821a..6de44c6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.125.1" +version = "0.126.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 26e9647..5596c51 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.125.1" +version = "0.126.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 22fef2c..6e6ea8b 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -38,7 +38,7 @@ pub fn routes() -> Router { "/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, +} + +async fn list_tokens( + State(state): State, + CurrentUser(user): CurrentUser, +) -> AppResult> { + let items = repo::api_token::list_for_user(&state.db, user.id).await?; + Ok(Json(TokenListResponse { items })) +} + async fn create_token( State(state): State, CurrentUser(user): CurrentUser, diff --git a/backend/src/repo/api_token.rs b/backend/src/repo/api_token.rs index d86e018..c54542b 100644 --- a/backend/src/repo/api_token.rs +++ b/backend/src/repo/api_token.rs @@ -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> { + 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> { let row = sqlx::query_as::<_, ApiToken>( r#" diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index 05e927e..c54cbb9 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -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 diff --git a/frontend/e2e/profile-tokens.spec.ts b/frontend/e2e/profile-tokens.spec.ts new file mode 100644 index 0000000..cc1c046 --- /dev/null +++ b/frontend/e2e/profile-tokens.spec.ts @@ -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 | 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); +}); diff --git a/frontend/package.json b/frontend/package.json index ba45026..69c04d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.125.2", + "version": "0.126.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/auth.test.ts b/frontend/src/lib/api/auth.test.ts index 3a40766..4b5f2f8 100644 --- a/frontend/src/lib/api/auth.test.ts +++ b/frontend/src/lib/api/auth.test.ts @@ -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(); diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 9d42871..a89d5ba 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -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 { +/** 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 { + const res = await request<{ items: ApiToken[] }>('/v1/auth/tokens'); + return res.items; +} + +export async function createToken( + name: string, + expiresInDays?: number +): Promise { + const payload: { name: string; expires_in_days?: number } = { name }; + if (expiresInDays !== undefined) payload.expires_in_days = expiresInDays; return request('/v1/auth/tokens', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name }) + body: JSON.stringify(payload) }); } diff --git a/frontend/src/routes/profile/+layout.svelte b/frontend/src/routes/profile/+layout.svelte index 48dc122..6d33e27 100644 --- a/frontend/src/routes/profile/+layout.svelte +++ b/frontend/src/routes/profile/+layout.svelte @@ -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 }, diff --git a/frontend/src/routes/profile/account/+page.svelte b/frontend/src/routes/profile/account/+page.svelte index dae522c..c76033d 100644 --- a/frontend/src/routes/profile/account/+page.svelte +++ b/frontend/src/routes/profile/account/+page.svelte @@ -224,8 +224,8 @@

Change password

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 + API tokens page if you want to invalidate them too.

{@render passwordForm()} diff --git a/frontend/src/routes/profile/tokens/+page.svelte b/frontend/src/routes/profile/tokens/+page.svelte new file mode 100644 index 0000000..26b19ea --- /dev/null +++ b/frontend/src/routes/profile/tokens/+page.svelte @@ -0,0 +1,306 @@ + + +
+

Bot API tokens

+

+ Bot tokens authenticate scripts against the same HTTP API the site uses. + Send one as Authorization: Bearer <token>. The token is + shown only once, right after you create it — store it somewhere safe. +

+ + {#if !session.user} +

Sign in to manage your API tokens.

+ {:else} +
+ + + +
+ + {#if freshBearer} +
+

Copy your new token now — you won't see it again:

+
+ {freshBearer} + +
+
+ {/if} + + {#if error} +

{error}

+ {/if} + + {#if loading} +

Loading…

+ {:else if tokens.length === 0} +

You have no API tokens yet.

+ {:else} +
    + {#each tokens as t (t.id)} +
  • +
    + {t.name} + + Created {fmt(t.created_at)} · Last used {fmt(t.last_used_at)} · + {t.expires_at ? `Expires ${fmt(t.expires_at)}` : 'No expiry'} + +
    + +
  • + {/each} +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/profile/tokens/+page.ts b/frontend/src/routes/profile/tokens/+page.ts new file mode 100644 index 0000000..ed70fef --- /dev/null +++ b/frontend/src/routes/profile/tokens/+page.ts @@ -0,0 +1,3 @@ +// Client-only: tokens are per-user and fetched on mount, matching the other +// authenticated profile subroutes. +export const ssr = false;