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]]
|
||||
name = "mangalord"
|
||||
version = "0.125.1"
|
||||
version = "0.126.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.125.1"
|
||||
version = "0.126.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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
|
||||
|
||||
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",
|
||||
"version": "0.125.2",
|
||||
"version": "0.126.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
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