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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user