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:
94
frontend/e2e/profile-tokens.spec.ts
Normal file
94
frontend/e2e/profile-tokens.spec.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user