/// import { useEffect, useMemo, useState } from "react"; import { hydrateIsland } from "@/client/hydrate"; import { Button, Card, EmptyState, ErrorBanner, Input } from "@/client/components/ui"; import { Copy, FolderGit2, KeyRound, Trash2 } from "lucide-react"; import { REPOSITORIES_CHANGED_EVENT, type RepositoriesChangedDetail, } from "@/client/islands/repositories"; // Mirror of `PatGrantLevel` from `@/worker/db/d1/schema/patNamespaceGrants`. // The island purposely re-declares wire-shape types in its own file (see // also `TokensIslandSummary`) so the SSR boundary stays one-way. type Level = "pull" | "push"; export type TokensIslandSummary = { id: string; name: string; prefix: string; createdAt: number; expiresAt?: number | null; revokedAt?: number | null; lastUsedAt?: number | null; namespaceGrants: Array<{ namespaceSlug: string; level: Level }>; repoGrants: Array<{ namespaceSlug: string; repoSlug: string; level: Level; }>; }; // SSR-boundary mirror of `AccountRepository`. Re-declared here for the // same reason as `TokensIslandSummary` above: keep the SSR boundary // one-way so a page-side type rename doesn't ripple into the bundle. export type IslandRepository = { id: string; slug: string; namespaceSlug: string; visibility: "public" | "private"; updatedAt: number; }; export type TokensIslandProps = { primaryNamespaceSlug?: string; repositories: IslandRepository[]; tokens: TokensIslandSummary[]; }; // Tagged-union request shape mirroring the server contract. `scope` and // `level` are both required on the wire. type CreatePayload = | { scope: "namespace"; name: string; namespaceSlug: string; level: Level; } | { scope: "repo"; name: string; namespaceSlug: string; repoSlug: string; level: Level; }; type Scope = CreatePayload["scope"]; function describeLevel(level: Level): string { return level === "push" ? "pull + push" : "pull"; } function formatDate(epochMs: number): string { return new Date(epochMs).toISOString().slice(0, 10); } function formatGrants(token: TokensIslandSummary): string { if (token.namespaceGrants.length > 0) { return token.namespaceGrants .map((grant) => `@${grant.namespaceSlug} (${describeLevel(grant.level)})`) .join(", "); } return token.repoGrants .map((grant) => `${grant.namespaceSlug}/${grant.repoSlug} (${describeLevel(grant.level)})`) .join(", "); } export function TokensIsland({ primaryNamespaceSlug, repositories: initialRepositories, tokens: initialTokens, }: TokensIslandProps) { const [tokens, setTokens] = useState(initialTokens); const [repositories, setRepositories] = useState(initialRepositories); const [scope, setScope] = useState("namespace"); const [name, setName] = useState(""); const [namespaceSlug, setNamespaceSlug] = useState(primaryNamespaceSlug ?? ""); const [repoSlug, setRepoSlug] = useState(""); const [level, setLevel] = useState("pull"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [revealedToken, setRevealedToken] = useState(null); const [copied, setCopied] = useState(false); // SSR-side `namespaceSlug` is already canonicalized lowercase; the // namespace input is user-typed and can carry stray case/whitespace. const filteredRepos = useMemo(() => { const target = namespaceSlug.trim().toLowerCase(); if (target.length === 0) return []; return repositories.filter((repo) => repo.namespaceSlug === target); }, [repositories, namespaceSlug]); const canSubmit = useMemo(() => { if (submitting) return false; if (name.trim().length === 0) return false; if (namespaceSlug.trim().length === 0) return false; if (scope === "repo" && !filteredRepos.some((repo) => repo.slug === repoSlug)) return false; return true; }, [submitting, name, namespaceSlug, scope, repoSlug, filteredRepos]); useEffect(() => { if (!revealedToken) return; setCopied(false); }, [revealedToken]); // Subscribe to repository creates/visibility changes from the // `repositories` island so the repo selector here updates without a // page reload. The event detail mirrors the SSR shape. useEffect(() => { if (typeof window === "undefined") return; const handler = (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail || !Array.isArray(detail.repositories)) return; setRepositories(detail.repositories); }; window.addEventListener(REPOSITORIES_CHANGED_EVENT, handler); return () => window.removeEventListener(REPOSITORIES_CHANGED_EVENT, handler); }, []); useEffect(() => { if (scope !== "repo") return; if (repoSlug.length === 0) return; if (filteredRepos.some((repo) => repo.slug === repoSlug)) return; setRepoSlug(""); }, [scope, repoSlug, filteredRepos]); async function refresh() { try { const res = await fetch("/auth/api/tokens", { credentials: "same-origin" }); if (!res.ok) return; const body = (await res.json()) as { tokens: TokensIslandSummary[] }; setTokens(body.tokens); } catch { // Best-effort; the next mutation will retry. } } async function handleCreate(payload: CreatePayload) { setSubmitting(true); setError(null); try { const res = await fetch("/auth/api/tokens", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; setError(body.error ?? "Could not create token"); return; } const body = (await res.json()) as { plaintext: string }; setRevealedToken(body.plaintext); setName(""); setRepoSlug(""); setLevel("pull"); await refresh(); } catch (err) { setError(String(err)); } finally { setSubmitting(false); } } async function handleRevoke(id: string) { if (!confirm("Revoke this token? This cannot be undone.")) return; try { const res = await fetch(`/auth/api/tokens/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "same-origin", }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; setError(body.error ?? "Could not revoke token"); return; } await refresh(); } catch (err) { setError(String(err)); } } const activeTokens = tokens.filter((t) => !t.revokedAt); return (
{error ? {error} : null} {revealedToken ? ( { navigator.clipboard?.writeText(revealedToken).then( () => setCopied(true), () => setCopied(false) ); }} onDismiss={() => setRevealedToken(null)} /> ) : (
{ event.preventDefault(); if (!canSubmit) return; const payload: CreatePayload = scope === "namespace" ? { scope: "namespace", name: name.trim(), namespaceSlug: namespaceSlug.trim(), level, } : { scope: "repo", name: name.trim(), namespaceSlug: namespaceSlug.trim(), repoSlug: repoSlug.trim(), level, }; void handleCreate(payload); }} >

Create token

setName(event.target.value)} placeholder="ci-deploy" helperText="A label to recognise this token in the list." required /> setNamespaceSlug(event.target.value)} placeholder={primaryNamespaceSlug ?? "rachel"} helperText="Defaults to your handle." required /> {scope === "repo" ? ( filteredRepos.length === 0 ? (
)}

{tokens.length === 0 ? "Your tokens" : `Active tokens · ${activeTokens.length}`}

{tokens.length === 0 ? (

// no tokens yet, create one above

) : (
    {tokens.map((token) => ( ))}
)}
); } type RevealPanelProps = { token: string; copied: boolean; onCopy: () => void; onDismiss: () => void; }; // `-webkit-text-security: disc` masks the plaintext while keeping it // selectable; hover/focus-within reveals it for visual verification. function RevealPanel({ token, copied, onCopy, onDismiss }: RevealPanelProps) { return (

One time only · copy now

{token}
); } type ScopeSegmentedProps = { scope: Scope; onChange: (next: Scope) => void; }; function ScopeSegmented({ scope, onChange }: ScopeSegmentedProps) { const baseHalf = "flex flex-1 flex-col items-start gap-1 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/40"; const activeHalf = "bg-accent-500/10 text-accent-600 dark:text-accent-400"; const inactiveHalf = "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-700 dark:hover:bg-zinc-800/40 dark:hover:text-zinc-300"; return (
); } type PermissionLevelTilesProps = { level: Level; onChange: (next: Level) => void; }; // Single-select control over the grant level. Push includes pull by // construction, so the UI cannot express an inconsistent "push without // pull" state. function PermissionLevelTiles({ level, onChange }: PermissionLevelTilesProps) { const tile = "group relative flex cursor-pointer flex-col gap-1 rounded-xl border border-zinc-300 p-4 transition-colors hover:border-zinc-400 has-[:checked]:border-accent-500 has-[:checked]:bg-accent-500/5 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent-500/40 dark:border-zinc-800/60 dark:hover:border-zinc-700"; return (
Permission level
); } type RepoTilesProps = { repositories: IslandRepository[]; selectedSlug: string; onChange: (next: string) => void; }; function RepoTiles({ repositories, selectedSlug, onChange }: RepoTilesProps) { const tile = "group relative flex cursor-pointer items-center justify-between gap-3 rounded-xl border border-zinc-300 px-4 py-3 transition-colors hover:border-zinc-400 has-[:checked]:border-accent-500 has-[:checked]:bg-accent-500/5 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent-500/40 dark:border-zinc-800/60 dark:hover:border-zinc-700"; return (
Repository {repositories.map((repo) => ( ))}
); } type TokenRowProps = { token: TokensIslandSummary; onRevoke: (id: string) => void; }; function TokenRow({ token, onRevoke }: TokenRowProps) { const grants = formatGrants(token); const revoked = Boolean(token.revokedAt); const metaParts = [token.prefix, `created ${formatDate(token.createdAt)}`]; if (revoked && token.revokedAt) metaParts.push(`revoked ${formatDate(token.revokedAt)}`); if (grants) metaParts.push(grants); return (
  • {token.name} {metaParts.join(" · ")}
    {revoked ? null : ( )}
  • ); } export function initTokensIsland() { hydrateIsland("tokens", TokensIsland); }