diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index 4946831..4bbdc6f 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -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([]); + 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() {
+ { @@ -291,6 +296,15 @@ export default function SettingsPage() { setLogRetentionDays(Number(e.target.value))} className={numberInputClass} /> + +
+ + setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} /> + +
diff --git a/web/components/settings/ApiTokensCard.tsx b/web/components/settings/ApiTokensCard.tsx new file mode 100644 index 0000000..b6e814c --- /dev/null +++ b/web/components/settings/ApiTokensCard.tsx @@ -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 ( + + + + ); +} + +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 ( +
+ — never + {outsidePolicy &&

outside the current policy — rotate when convenient

} +
+ ); + } + + const expiresAt = new Date(token.expires_at); + const expired = expiresAt.getTime() <= Date.now(); + const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS; + + return ( +
+ + {expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()} + + {outsidePolicy &&

outside the current policy — rotate when convenient

} +
+ ); +} + +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(null); + + const [name, setName] = useState(""); + const [role, setRole] = useState("member"); + const [scopes, setScopes] = useState([]); + const [expiryDays, setExpiryDays] = useState(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 ( + } + actions={ +
+ {isAdmin && ( + + )} + +
+ } + > + {isLoading ? ( +
+
+
+ ) : error ? ( +

{friendlyMessage(error)}

+ ) : !tokens || tokens.length === 0 ? ( +

No API tokens yet.

+ ) : ( + + + + + {showAll && } + + + + + + + + + {tokens.map((t) => ( + + + {showAll && } + + + + + + + ))} + +
NameOwnerRoleScopesLast usedExpiresActions
+ {t.name} +
{t.hint}…
+
{t.user_email ?? t.user_id} + {t.role} + +
+ {t.scopes.map((s) => ( + + {s} + + ))} +
+
+ {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"} + + + + +
+ )} + + { + resetRevoke(); + setRevoking(null); + }} + onConfirm={() => revoking && revokeToken(revoking)} + body={ +

+ {revoking?.name} stops authenticating immediately. Any script or CI job using it + will start failing on its next call. +

+ } + /> + + + {result ? ( +
+

This is the only time this token will be shown. Store it now.

+
+ {result.token} +
+
+ + +
+
+ ) : ( +
{ + e.preventDefault(); + createToken(); + }} + className="space-y-4" + > + + setName(e.target.value)} className={inputClass} /> + + + + + + + +
+ {resources.map((r) => { + const readScope = `${r}:read`; + const writeScope = `${r}:write`; + return ( +
+ {r} +
+ + +
+
+ ); + })} +
+
+ + 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."} + > + + + + {createError &&
{friendlyMessage(createError)}
} + +
+ + +
+
+ )} +
+ + ); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index bf6c105..8f93b31 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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"); }, - 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 { return request("/secrets"); },