feat: Manage API tokens from settings

A card in the Access group beside Members and single sign-on rather than a
new nav entry — /settings/instance was folded back in for exactly this
reason. The plaintext is shown once in a well block and never again.

Tokens outside a newly tightened lifetime policy are flagged rather than
broken, because the policy governs issuance, not existing credentials.
This commit is contained in:
2026-08-12 14:58:57 +00:00
parent 3b4c87a292
commit 182752d9ab
3 changed files with 442 additions and 1 deletions
+14
View File
@@ -10,6 +10,7 @@ import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
import { MembersCard } from "@/components/settings/MembersCard";
import { ApiTokensCard } from "@/components/settings/ApiTokensCard";
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
const numberInputClass =
@@ -144,6 +145,7 @@ export default function SettingsPage() {
const [thresholdMinutes, setThresholdMinutes] = useState(5);
const [logRetentionDays, setLogRetentionDays] = useState(30);
const [offlineChannelIds, setOfflineChannelIds] = useState<string[]>([]);
const [apiTokenMaxDays, setApiTokenMaxDays] = useState(0);
const toast = useToast();
useEffect(() => {
@@ -151,6 +153,7 @@ export default function SettingsPage() {
setThresholdMinutes(settings.alerts.offline_threshold_minutes || 5);
setLogRetentionDays(settings.workflow_log_retention_days ?? 30);
setOfflineChannelIds(settings.alerts.offline_channel_ids ?? []);
setApiTokenMaxDays(settings.api_token_max_days ?? 0);
}, [settings]);
// The one place the in-progress form is turned into a payload. Both the
@@ -163,6 +166,7 @@ export default function SettingsPage() {
offline_channel_ids: offlineChannelIds,
},
workflow_log_retention_days: logRetentionDays,
api_token_max_days: apiTokenMaxDays,
};
}
@@ -220,6 +224,7 @@ export default function SettingsPage() {
<div className="space-y-10">
<Group label="Access">
<MembersCard />
<ApiTokensCard />
<AuthProvidersCard
localLoginEnabled={settings?.local_login_enabled ?? true}
onLocalLoginChange={(v) => {
@@ -291,6 +296,15 @@ export default function SettingsPage() {
<Field label="Log retention (days)" hint="0 = keep forever. Applies to per-run step output logs.">
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
</Field>
<div className="mt-6">
<Field
label="Maximum API token lifetime (days)"
hint="0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only."
>
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
</Field>
</div>
</SectionCard>
</div>
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
const ROLES: Role[] = ["owner", "admin", "member"];
const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
{ label: "30 days", days: 30 },
{ label: "60 days", days: 60 },
{ label: "90 days", days: 90 },
{ label: "365 days", days: 365 },
{ label: "Never", days: null },
];
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** The token a pending revoke refers to, carried so the dialog and the
* confirmation message name a token rather than a token_id. */
type PendingRevoke = { id: string; name: string };
function TokenIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
);
}
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
function rolesAtOrBelow(role: Role): Role[] {
const idx = ROLES.indexOf(role);
return idx === -1 ? ROLES : ROLES.slice(idx);
}
/** Renders a token's expiry, plus a policy note when the cap has tightened
* since the token was issued. The policy is not applied retroactively, so an
* outside-policy token is a prompt to rotate, not a failure of any kind. */
function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
const outsidePolicy = capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > Date.now() + capDays * 24 * 60 * 60 * 1000);
if (!token.expires_at) {
return (
<div>
<span className="text-text-secondary"> never</span>
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy rotate when convenient</p>}
</div>
);
}
const expiresAt = new Date(token.expires_at);
const expired = expiresAt.getTime() <= Date.now();
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
return (
<div>
<span className={expired ? "text-danger" : soon ? "text-warning" : "text-text-secondary"}>
{expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()}
</span>
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy rotate when convenient</p>}
</div>
);
}
export function ApiTokensCard() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
const [showAll, setShowAll] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [revoking, setRevoking] = useState<PendingRevoke | null>(null);
const [name, setName] = useState("");
const [role, setRole] = useState<Role>("member");
const [scopes, setScopes] = useState<string[]>([]);
const [expiryDays, setExpiryDays] = useState<number | null>(30);
const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null);
const [copied, setCopied] = useState(false);
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings, enabled: isAdmin });
const capDays = settings?.api_token_max_days ?? 0;
const {
data: tokensData,
isLoading,
error,
} = useQuery({ queryKey: ["api-tokens", showAll], queryFn: () => api.listApiTokens(showAll) });
const tokens = tokensData?.tokens;
const { data: scopesData } = useQuery({ queryKey: ["token-scopes"], queryFn: api.listTokenScopes, enabled: createOpen });
const availableScopes = scopesData?.scopes ?? [];
const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0])));
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
function resetForm() {
setName("");
setRole("member");
setScopes([]);
setExpiryDays(30);
setResult(null);
setCopied(false);
}
// Once the policy is known, default to the shortest option the policy still
// allows rather than a value the submit is about to be refused for.
useEffect(() => {
if (!createOpen) return;
const valid = EXPIRY_OPTIONS.find((o) => !(capDays > 0 && (o.days === null || o.days > capDays)));
if (valid) setExpiryDays(valid.days);
}, [createOpen, capDays]);
const {
mutate: createToken,
isPending: creating,
error: createError,
reset: resetCreateError,
} = useMutation({
mutationFn: () => api.createApiToken({ name, role, scopes, expires_in_days: expiryDays ?? undefined }),
onSuccess: (res) => {
setResult(res);
},
});
const {
mutate: revokeToken,
isPending: isRevoking,
error: revokeError,
reset: resetRevoke,
} = useMutation({
mutationFn: (t: PendingRevoke) => api.revokeApiToken(t.id),
onSuccess: (_data, t) => {
invalidate();
toast.success(`Revoked ${t.name}.`);
setRevoking(null);
},
});
function toggleScope(s: string) {
setScopes((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
}
function closeCreate() {
// The plaintext is gone once this closes, so only invalidate having
// shown it — closing before a result exists is a plain cancel.
if (result) invalidate();
setCreateOpen(false);
resetCreateError();
resetForm();
}
async function copyToken() {
if (!result) return;
await navigator.clipboard.writeText(result.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
return (
<SectionCard
title="API tokens"
description="Scoped, personal tokens for scripts and CI to call the REST API without a browser session."
icon={<TokenIcon />}
actions={
<div className="flex items-center gap-3">
{isAdmin && (
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={showAll}
onChange={(e) => setShowAll(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
All tokens
</label>
)}
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
Create token
</Button>
</div>
}
>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{friendlyMessage(error)}</p>
) : !tokens || tokens.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No API tokens yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
{showAll && <Th>Owner</Th>}
<Th>Role</Th>
<Th>Scopes</Th>
<Th>Last used</Th>
<Th>Expires</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{tokens.map((t) => (
<Tr key={t.token_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{t.name}</span>
<div className="font-mono text-xs text-text-secondary">{t.hint}</div>
</Td>
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
<Td label="Role">
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
</Td>
<Td label="Scopes">
<div className="flex flex-wrap gap-1">
{t.scopes.map((s) => (
<Badge key={s} variant="neutral">
{s}
</Badge>
))}
</div>
</Td>
<Td label="Last used" className="text-text-secondary">
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
</Td>
<Td label="Expires">
<ExpiryCell token={t} capDays={capDays} />
</Td>
<Td label="Actions" className="text-right">
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
<ConfirmDialog
open={revoking !== null}
title="Revoke token"
confirmLabel="Revoke token"
loading={isRevoking}
error={revokeError ? friendlyMessage(revokeError) : null}
onClose={() => {
resetRevoke();
setRevoking(null);
}}
onConfirm={() => revoking && revokeToken(revoking)}
body={
<p>
<span className="text-text-primary">{revoking?.name}</span> stops authenticating immediately. Any script or CI job using it
will start failing on its next call.
</p>
}
/>
<Modal open={createOpen} title={result ? "Token created" : "Create API token"} onClose={closeCreate}>
{result ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This is the only time this token will be shown. Store it now.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={copyToken}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button type="button" variant="primary" onClick={closeCreate}>
Done
</Button>
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
createToken();
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this token, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
<Field label="Scopes" hint="What this token may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
const writeScope = `${r}:write`;
return (
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
<span className="text-sm capitalize text-text-primary">{r}</span>
<div className="flex gap-3">
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(readScope)}
onChange={() => toggleScope(readScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(writeScope)}
onChange={() => toggleScope(writeScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
write
</label>
</div>
</div>
);
})}
</div>
</Field>
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
className={inputClass}
>
{EXPIRY_OPTIONS.map((o) => {
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
return (
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
{o.label}
</option>
);
})}
</select>
</Field>
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeCreate}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create token
</Button>
</div>
</form>
)}
</Modal>
</SectionCard>
);
}
+39 -1
View File
@@ -197,8 +197,22 @@ export interface Settings {
secrets: SecretsSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
}
export type ApiToken = {
token_id: string;
name: string;
hint: string;
role: Role;
scopes: string[];
expires_at?: string | null;
created_at: string;
last_used_at?: string | null;
user_id: string;
user_email?: string;
};
export interface SecretGroupSummary {
group: string;
key_count: number;
@@ -683,7 +697,12 @@ export const api = {
return request<Settings>("/settings");
},
saveSettings(settings: { alerts: AlertSettings; workflow_log_retention_days?: number | null; local_login_enabled?: boolean }): Promise<{ saved: boolean }> {
saveSettings(settings: {
alerts: AlertSettings;
workflow_log_retention_days?: number | null;
local_login_enabled?: boolean;
api_token_max_days?: number | null;
}): Promise<{ saved: boolean }> {
return request<{ saved: boolean }>("/settings", {
method: "PUT",
body: JSON.stringify(settings),
@@ -694,6 +713,25 @@ export const api = {
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
},
listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> {
return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`);
},
listTokenScopes(): Promise<{ scopes: string[] }> {
return request<{ scopes: string[] }>("/tokens/scopes");
},
createApiToken(body: { name: string; role: Role; scopes: string[]; expires_in_days?: number | null }): Promise<{ token: string; record: ApiToken }> {
return request<{ token: string; record: ApiToken }>("/tokens", {
method: "POST",
body: JSON.stringify(body),
});
},
revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> {
return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" });
},
listSecretGroups(): Promise<SecretGroupSummary[]> {
return request<SecretGroupSummary[]>("/secrets");
},