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]>
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
// 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>
|
|
);
|
|
}
|