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

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