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]>
117 lines
5.2 KiB
TypeScript
117 lines
5.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|