Files
t5de-hub/apps/web/app/account/tokens-card.tsx
T
dhkatzandClaude Opus 5.5 2b88a52bcb
Pull request / title (pull_request) Successful in 0s
CI / test (pull_request) Successful in 1m9s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
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]>
2026-11-05 14:58:58 -08:00

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&apos;s team. You don&apos;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>
);
}