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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.125.1"
|
version = "0.126.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.125.1"
|
version = "0.126.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
"/auth/me/preferences",
|
"/auth/me/preferences",
|
||||||
get(get_preferences).patch(update_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))
|
.route("/auth/tokens/:id", delete(delete_token))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +299,22 @@ async fn update_preferences(
|
|||||||
Ok(Json(saved))
|
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(
|
async fn create_token(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ pub async fn create(
|
|||||||
Ok(row)
|
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>> {
|
pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult<Option<ApiToken>> {
|
||||||
let row = sqlx::query_as::<_, ApiToken>(
|
let row = sqlx::query_as::<_, ApiToken>(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -541,6 +541,62 @@ async fn create_and_use_bot_token(pool: PgPool) {
|
|||||||
assert_eq!(resp.status(), StatusCode::OK);
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
|
async fn bot_token_with_future_expiry_authenticates(pool: PgPool) {
|
||||||
// A token minted with expires_in_days is still active before its
|
// A token minted with expires_in_days is still active before its
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.125.2",
|
"version": "0.126.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
changePassword,
|
changePassword,
|
||||||
createToken,
|
createToken,
|
||||||
deleteToken,
|
deleteToken,
|
||||||
|
listTokens,
|
||||||
getAuthConfig
|
getAuthConfig
|
||||||
} from './auth';
|
} from './auth';
|
||||||
|
|
||||||
@@ -170,6 +171,50 @@ describe('auth api client', () => {
|
|||||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
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 () => {
|
it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
|
fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
|
||||||
const cfg = await getAuthConfig();
|
const cfg = await getAuthConfig();
|
||||||
|
|||||||
@@ -92,15 +92,29 @@ export type ApiToken = {
|
|||||||
name: string;
|
name: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_used_at: string | null;
|
last_used_at: string | null;
|
||||||
|
/** When the token stops authenticating; `null` = never expires. */
|
||||||
|
expires_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreatedToken = ApiToken & { bearer: string };
|
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', {
|
return request<CreatedToken>('/v1/auth/tokens', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
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 User from '@lucide/svelte/icons/user';
|
||||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||||
import KeyRound from '@lucide/svelte/icons/key-round';
|
import KeyRound from '@lucide/svelte/icons/key-round';
|
||||||
|
import Terminal from '@lucide/svelte/icons/terminal';
|
||||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||||
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
import FolderOpen from '@lucide/svelte/icons/folder-open';
|
||||||
import Tag from '@lucide/svelte/icons/tag';
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
{ href: '/profile', label: 'Overview', icon: User, testid: 'tab-overview', guestVisible: true },
|
{ 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/preferences', label: 'Preferences', icon: SlidersHorizontal, testid: 'tab-preferences', guestVisible: true },
|
||||||
{ href: '/profile/account', label: 'Account', icon: KeyRound, testid: 'tab-account', guestVisible: false },
|
{ 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/bookmarks', label: 'Bookmarks', icon: Bookmark, testid: 'tab-bookmarks', guestVisible: false },
|
||||||
{ href: '/profile/collections', label: 'Collections', icon: FolderOpen, testid: 'tab-collections', 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 },
|
{ href: '/profile/page-tags', label: 'Page tags', icon: Tag, testid: 'tab-page-tags', guestVisible: false },
|
||||||
|
|||||||
@@ -224,8 +224,8 @@
|
|||||||
<h2>Change password</h2>
|
<h2>Change password</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Changing your password signs out every other device using this account.
|
Changing your password signs out every other device using this account.
|
||||||
Bot API tokens keep working — revoke them individually from the bot-token
|
Bot API tokens keep working — revoke them individually from the
|
||||||
list if you want to invalidate them too.
|
<a href="/profile/tokens">API tokens</a> page if you want to invalidate them too.
|
||||||
</p>
|
</p>
|
||||||
{@render passwordForm()}
|
{@render passwordForm()}
|
||||||
</section>
|
</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