feat: personal API tokens
Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of what they do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
58a3e70a63
commit
2b88a52bcb
17 files changed
+4358
-98
No files matched your search
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { api, ApiError, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
const done = () => revalidatePath("/account");
|
||||
@@ -71,3 +71,34 @@ export async function startAgeCheck(_: ActionState, form: FormData): Promise<Act
|
||||
if (url) redirect(url);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** What making a token answers: the token itself, shown once. */
|
||||
export type TokenState = { error?: string; token?: string; name?: string } | null;
|
||||
|
||||
/** Makes an API token. `grants` is JSON: [{permission, scope}]. */
|
||||
export async function createToken(_: TokenState, form: FormData): Promise<TokenState> {
|
||||
let grants: { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } }[];
|
||||
try {
|
||||
grants = JSON.parse(field(form, "grants") || "[]");
|
||||
} catch {
|
||||
return { error: "Choose what it may do." };
|
||||
}
|
||||
if (!Array.isArray(grants) || grants.length === 0) return { error: "Choose what it may do." };
|
||||
try {
|
||||
const made = unwrap(await api.account.tokens.post({ name: field(form, "name"), days: Number(field(form, "days")), grants }));
|
||||
done();
|
||||
return { token: made.token, name: made.info.name };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { error: e.message };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes an API token. */
|
||||
export async function revokeToken(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return attempt(async () => {
|
||||
unwrap(await api.account.tokens({ id: field(form, "id") }).delete());
|
||||
done();
|
||||
return "Revoked.";
|
||||
});
|
||||
}
|
||||
@@ -11,13 +11,18 @@ import { linkAccount, signOutComputers } from "./actions";
|
||||
import { AgeCard } from "./age-card";
|
||||
import { ChallengeCard } from "./challenge-card";
|
||||
import { LinkCard } from "./link-card";
|
||||
import { TokensCard } from "./tokens-card";
|
||||
|
||||
export const metadata: Metadata = { title: "Account" };
|
||||
|
||||
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
|
||||
const me = await getMe();
|
||||
if (!me) redirect("/signin?next=/account");
|
||||
const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]);
|
||||
const [account, age, tokens] = await Promise.all([
|
||||
api.account.get().then(unwrap),
|
||||
api.age.get().then(unwrap),
|
||||
api.account.tokens.get().then(unwrap),
|
||||
]);
|
||||
const { person, links, challenges, computers } = account;
|
||||
const params = await searchParams;
|
||||
const welcome = params.welcome !== undefined;
|
||||
@@ -116,6 +121,8 @@ export default async function Account({ searchParams }: { searchParams: Promise<
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TokensCard data={tokens} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
// Making an API token: a name, how long it lasts, and what it may do where (some of the person's own permissions).
|
||||
import { useActionState, useState } from "react";
|
||||
import { ErrorText } from "@/components/forms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import type { TokenScope } from "@/lib/api";
|
||||
import { createToken } from "./actions";
|
||||
|
||||
const keyOf = (s: TokenScope["scope"]) => (s.type === "hub" ? "hub" : `${s.type}:${s.id}`);
|
||||
const kindOf = { hub: "Everything", catalog: "Catalog", product: "Product" } as const;
|
||||
|
||||
type Row = { scope: string; permissions: string[] };
|
||||
|
||||
export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: number }) {
|
||||
const [state, formAction, pending] = useActionState(createToken, null);
|
||||
const [rows, setRows] = useState<Row[]>([{ scope: keyOf(scopes[0]!.scope), permissions: [] }]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const byKey = new Map(scopes.map((s) => [keyOf(s.scope), s]));
|
||||
const grants = rows.flatMap((r) => r.permissions.map((permission) => ({ permission, scope: byKey.get(r.scope)!.scope })));
|
||||
const set = (i: number, row: Row) => setRows(rows.map((r, j) => (j === i ? row : r)));
|
||||
const unused = scopes.find((s) => !rows.some((r) => r.scope === keyOf(s.scope)));
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4">
|
||||
<input type="hidden" name="grants" value={JSON.stringify(grants)} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input name="name" required maxLength={64} placeholder="Name, e.g. Upload script" aria-label="Name" className="max-w-xs" />
|
||||
<NativeSelect name="days" defaultValue="90" aria-label="Lasts" className="w-36">
|
||||
{[7, 30, 90, maxDays].map((d) => (
|
||||
<NativeSelectOption key={d} value={d}>
|
||||
{d === 365 ? "1 year" : `${d} days`}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
{rows.map((row, i) => {
|
||||
const scope = byKey.get(row.scope)!;
|
||||
return (
|
||||
<fieldset key={i} className="space-y-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<NativeSelect
|
||||
value={row.scope}
|
||||
onChange={(e) => set(i, { scope: e.target.value, permissions: [] })}
|
||||
aria-label="Where"
|
||||
className="min-w-56"
|
||||
>
|
||||
{scopes.map((s) => {
|
||||
const k = keyOf(s.scope);
|
||||
if (k !== row.scope && rows.some((r) => r.scope === k)) return null;
|
||||
return (
|
||||
<NativeSelectOption key={k} value={k}>
|
||||
{s.scope.type === "hub" ? "Everything on T5DE" : `${kindOf[s.scope.type]}: ${s.name}`}
|
||||
</NativeSelectOption>
|
||||
);
|
||||
})}
|
||||
</NativeSelect>
|
||||
{rows.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{scope.permissions.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 font-mono text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.permissions.includes(p)}
|
||||
onChange={(e) =>
|
||||
set(i, { ...row, permissions: e.target.checked ? [...row.permissions, p] : row.permissions.filter((x) => x !== p) })
|
||||
}
|
||||
/>
|
||||
{p}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unused && (
|
||||
<Button type="button" variant="outline" onClick={() => setRows([...rows, { scope: keyOf(unused.scope), permissions: [] }])}>
|
||||
Add another place
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={pending || grants.length === 0}>
|
||||
{pending ? "Making…" : "Make token"}
|
||||
</Button>
|
||||
</div>
|
||||
{state?.error && <ErrorText>{state.error}</ErrorText>}
|
||||
{state?.token && (
|
||||
<div role="status" className="space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p>
|
||||
Your token <strong>{state.name}</strong>. Copy it now: it won't be shown again.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 rounded bg-muted px-2 py-1 font-mono text-xs break-all">{state.token}</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigator.clipboard.writeText(state.token!).then(() => setCopied(true))}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Send it as <code className="font-mono text-xs">Authorization: Bearer <token></code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// API tokens on the account page: the person's tokens, and making one.
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { TokenEntry, TokenList } from "@/lib/api";
|
||||
import { revokeToken } from "./actions";
|
||||
import { TokenForm } from "./token-form";
|
||||
|
||||
export function TokensCard({ data }: { data: TokenList }) {
|
||||
const active = data.tokens.filter((t) => t.status === "active");
|
||||
const past = data.tokens.filter((t) => t.status !== "active").slice(0, 10);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Tokens</CardTitle>
|
||||
<CardDescription>
|
||||
For your own scripts. A token can do only what you give it, and never more than you can do yourself.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{active.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{active.map((t) => (
|
||||
<TokenRow key={t.id} token={t} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.scopes.length > 0 ? (
|
||||
<TokenForm scopes={data.scopes} maxDays={data.maxDays} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tokens act on what you manage: your products or a catalog's team. You don't manage anything yet.
|
||||
</p>
|
||||
)}
|
||||
{past.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Revoked and expired</summary>
|
||||
<ul className="mt-2 divide-y">
|
||||
{past.map((t) => (
|
||||
<TokenRow key={t.id} token={t} />
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({ token: t }: { token: TokenEntry }) {
|
||||
return (
|
||||
<li className="space-y-1 py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.status !== "active" && <Tag tone={t.status === "revoked" ? "danger" : "neutral"}>{t.status}</Tag>}
|
||||
<span className="flex-1" />
|
||||
{t.status === "active" && (
|
||||
<ActionForm action={revokeToken} fields={{ id: t.id }}>
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…" confirm={`Revoke ${t.name}? Scripts using it stop working.`}>
|
||||
Revoke
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.grants.map((g, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ", "}
|
||||
<span className="font-mono">{g.permission}</span> on {g.scope.type === "hub" ? "everything" : g.name}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Made <DateTime iso={t.createdAt} dateOnly />
|
||||
{" · "}
|
||||
{t.status === "revoked" && t.revokedAt ? (
|
||||
<>
|
||||
revoked <DateTime iso={t.revokedAt} dateOnly />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t.status === "expired" ? "expired" : "expires"} <DateTime iso={t.expiresAt} dateOnly />
|
||||
</>
|
||||
)}
|
||||
{" · "}
|
||||
{t.lastUsedAt ? (
|
||||
<>
|
||||
last used <DateTime iso={t.lastUsedAt} />
|
||||
</>
|
||||
) : (
|
||||
"never used"
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user