Skip to content
File

Blob: src/client/islands/tokens.tsx

typescript532 lines
1/// <reference lib="dom" />
2 
3import { useEffect, useMemo, useState } from "react";
4 
5import { hydrateIsland } from "@/client/hydrate";
6import { Button, Card, EmptyState, ErrorBanner, Input } from "@/client/components/ui";
7import { Copy, FolderGit2, KeyRound, Trash2 } from "lucide-react";
8import {
9 REPOSITORIES_CHANGED_EVENT,
10 type RepositoriesChangedDetail,
11} from "@/client/islands/repositories";
12 
13// Mirror of `PatGrantLevel` from `@/worker/db/d1/schema/patNamespaceGrants`.
14// The island purposely re-declares wire-shape types in its own file (see
15// also `TokensIslandSummary`) so the SSR boundary stays one-way.
16type Level = "pull" | "push";
17 
18export type TokensIslandSummary = {
19 id: string;
20 name: string;
21 prefix: string;
22 createdAt: number;
23 expiresAt?: number | null;
24 revokedAt?: number | null;
25 lastUsedAt?: number | null;
26 namespaceGrants: Array<{ namespaceSlug: string; level: Level }>;
27 repoGrants: Array<{
28 namespaceSlug: string;
29 repoSlug: string;
30 level: Level;
31 }>;
32};
33 
34// SSR-boundary mirror of `AccountRepository`. Re-declared here for the
35// same reason as `TokensIslandSummary` above: keep the SSR boundary
36// one-way so a page-side type rename doesn't ripple into the bundle.
37export type IslandRepository = {
38 id: string;
39 slug: string;
40 namespaceSlug: string;
41 visibility: "public" | "private";
42 updatedAt: number;
43};
44 
45export type TokensIslandProps = {
46 primaryNamespaceSlug?: string;
47 repositories: IslandRepository[];
48 tokens: TokensIslandSummary[];
49};
50 
51// Tagged-union request shape mirroring the server contract. `scope` and
52// `level` are both required on the wire.
53type CreatePayload =
54 | {
55 scope: "namespace";
56 name: string;
57 namespaceSlug: string;
58 level: Level;
59 }
60 | {
61 scope: "repo";
62 name: string;
63 namespaceSlug: string;
64 repoSlug: string;
65 level: Level;
66 };
67 
68type Scope = CreatePayload["scope"];
69 
70function describeLevel(level: Level): string {
71 return level === "push" ? "pull + push" : "pull";
72}
73 
74function formatDate(epochMs: number): string {
75 return new Date(epochMs).toISOString().slice(0, 10);
76}
77 
78function formatGrants(token: TokensIslandSummary): string {
79 if (token.namespaceGrants.length > 0) {
80 return token.namespaceGrants
81 .map((grant) => `@${grant.namespaceSlug} (${describeLevel(grant.level)})`)
82 .join(", ");
83 }
84 return token.repoGrants
85 .map((grant) => `${grant.namespaceSlug}/${grant.repoSlug} (${describeLevel(grant.level)})`)
86 .join(", ");
87}
88 
89export function TokensIsland({
90 primaryNamespaceSlug,
91 repositories: initialRepositories,
92 tokens: initialTokens,
93}: TokensIslandProps) {
94 const [tokens, setTokens] = useState(initialTokens);
95 const [repositories, setRepositories] = useState(initialRepositories);
96 const [scope, setScope] = useState<Scope>("namespace");
97 const [name, setName] = useState("");
98 const [namespaceSlug, setNamespaceSlug] = useState(primaryNamespaceSlug ?? "");
99 const [repoSlug, setRepoSlug] = useState("");
100 const [level, setLevel] = useState<Level>("pull");
101 const [submitting, setSubmitting] = useState(false);
102 const [error, setError] = useState<string | null>(null);
103 const [revealedToken, setRevealedToken] = useState<string | null>(null);
104 const [copied, setCopied] = useState(false);
105 
106 // SSR-side `namespaceSlug` is already canonicalized lowercase; the
107 // namespace input is user-typed and can carry stray case/whitespace.
108 const filteredRepos = useMemo(() => {
109 const target = namespaceSlug.trim().toLowerCase();
110 if (target.length === 0) return [];
111 return repositories.filter((repo) => repo.namespaceSlug === target);
112 }, [repositories, namespaceSlug]);
113 
114 const canSubmit = useMemo(() => {
115 if (submitting) return false;
116 if (name.trim().length === 0) return false;
117 if (namespaceSlug.trim().length === 0) return false;
118 if (scope === "repo" && !filteredRepos.some((repo) => repo.slug === repoSlug)) return false;
119 return true;
120 }, [submitting, name, namespaceSlug, scope, repoSlug, filteredRepos]);
121 
122 useEffect(() => {
123 if (!revealedToken) return;
124 setCopied(false);
125 }, [revealedToken]);
126 
127 // Subscribe to repository creates/visibility changes from the
128 // `repositories` island so the repo selector here updates without a
129 // page reload. The event detail mirrors the SSR shape.
130 useEffect(() => {
131 if (typeof window === "undefined") return;
132 const handler = (event: Event) => {
133 const detail = (event as CustomEvent<RepositoriesChangedDetail>).detail;
134 if (!detail || !Array.isArray(detail.repositories)) return;
135 setRepositories(detail.repositories);
136 };
137 window.addEventListener(REPOSITORIES_CHANGED_EVENT, handler);
138 return () => window.removeEventListener(REPOSITORIES_CHANGED_EVENT, handler);
139 }, []);
140 
141 useEffect(() => {
142 if (scope !== "repo") return;
143 if (repoSlug.length === 0) return;
144 if (filteredRepos.some((repo) => repo.slug === repoSlug)) return;
145 setRepoSlug("");
146 }, [scope, repoSlug, filteredRepos]);
147 
148 async function refresh() {
149 try {
150 const res = await fetch("/auth/api/tokens", { credentials: "same-origin" });
151 if (!res.ok) return;
152 const body = (await res.json()) as { tokens: TokensIslandSummary[] };
153 setTokens(body.tokens);
154 } catch {
155 // Best-effort; the next mutation will retry.
156 }
157 }
158 
159 async function handleCreate(payload: CreatePayload) {
160 setSubmitting(true);
161 setError(null);
162 try {
163 const res = await fetch("/auth/api/tokens", {
164 method: "POST",
165 credentials: "same-origin",
166 headers: { "Content-Type": "application/json" },
167 body: JSON.stringify(payload),
168 });
169 if (!res.ok) {
170 const body = (await res.json().catch(() => ({}))) as { error?: string };
171 setError(body.error ?? "Could not create token");
172 return;
173 }
174 const body = (await res.json()) as { plaintext: string };
175 setRevealedToken(body.plaintext);
176 setName("");
177 setRepoSlug("");
178 setLevel("pull");
179 await refresh();
180 } catch (err) {
181 setError(String(err));
182 } finally {
183 setSubmitting(false);
184 }
185 }
186 
187 async function handleRevoke(id: string) {
188 if (!confirm("Revoke this token? This cannot be undone.")) return;
189 try {
190 const res = await fetch(`/auth/api/tokens/${encodeURIComponent(id)}`, {
191 method: "DELETE",
192 credentials: "same-origin",
193 });
194 if (!res.ok) {
195 const body = (await res.json().catch(() => ({}))) as { error?: string };
196 setError(body.error ?? "Could not revoke token");
197 return;
198 }
199 await refresh();
200 } catch (err) {
201 setError(String(err));
202 }
203 }
204 
205 const activeTokens = tokens.filter((t) => !t.revokedAt);
206 
207 return (
208 <div className="grid gap-8">
209 {error ? <ErrorBanner>{error}</ErrorBanner> : null}
210
211 {revealedToken ? (
212 <RevealPanel
213 token={revealedToken}
214 copied={copied}
215 onCopy={() => {
216 navigator.clipboard?.writeText(revealedToken).then(
217 () => setCopied(true),
218 () => setCopied(false)
219 );
220 }}
221 onDismiss={() => setRevealedToken(null)}
222 />
223 ) : (
224 <Card>
225 <form
226 className="grid gap-5"
227 onSubmit={(event) => {
228 event.preventDefault();
229 if (!canSubmit) return;
230 const payload: CreatePayload =
231 scope === "namespace"
232 ? {
233 scope: "namespace",
234 name: name.trim(),
235 namespaceSlug: namespaceSlug.trim(),
236 level,
237 }
238 : {
239 scope: "repo",
240 name: name.trim(),
241 namespaceSlug: namespaceSlug.trim(),
242 repoSlug: repoSlug.trim(),
243 level,
244 };
245 void handleCreate(payload);
246 }}
247 >
248 <h3 className="m-0 text-xs font-semibold uppercase tracking-wider text-zinc-500">
249 Create token
250 </h3>
251
252 <ScopeSegmented scope={scope} onChange={setScope} />
253
254 <Input
255 label="Name"
256 type="text"
257 value={name}
258 onChange={(event) => setName(event.target.value)}
259 placeholder="ci-deploy"
260 helperText="A label to recognise this token in the list."
261 required
262 />
263 <Input
264 label="Namespace"
265 type="text"
266 value={namespaceSlug}
267 onChange={(event) => setNamespaceSlug(event.target.value)}
268 placeholder={primaryNamespaceSlug ?? "rachel"}
269 helperText="Defaults to your handle."
270 required
271 />
272 {scope === "repo" ? (
273 filteredRepos.length === 0 ? (
274 <EmptyState
275 icon={<FolderGit2 className="h-5 w-5 text-zinc-400" aria-hidden="true" />}
276 title={`No repositories under @${namespaceSlug.trim() || primaryNamespaceSlug || "namespace"}`}
277 detail="Repo-scoped tokens become available once a repository exists under this namespace. Switch to namespace scope, or create a repo first."
278 />
279 ) : (
280 <RepoTiles
281 repositories={filteredRepos}
282 selectedSlug={repoSlug}
283 onChange={setRepoSlug}
284 />
285 )
286 ) : null}
287
288 <PermissionLevelTiles level={level} onChange={setLevel} />
289
290 <div className="flex justify-end">
291 <Button type="submit" variant="primary" size="md" disabled={!canSubmit}>
292 <KeyRound className="h-4 w-4" aria-hidden="true" />
293 Generate token
294 </Button>
295 </div>
296 </form>
297 </Card>
298 )}
299
300 <section className="grid gap-3">
301 <h3 className="m-0 text-xs font-semibold uppercase tracking-wider text-zinc-500">
302 {tokens.length === 0 ? "Your tokens" : `Active tokens · ${activeTokens.length}`}
303 </h3>
304 {tokens.length === 0 ? (
305 <p className="m-0 font-mono text-xs text-zinc-500">
306 <span className="select-none">// </span>no tokens yet, create one above
307 </p>
308 ) : (
309 <Card>
310 <ul className="-mx-5 divide-y divide-zinc-200 sm:-mx-6 dark:divide-zinc-800/60">
311 {tokens.map((token) => (
312 <TokenRow key={token.id} token={token} onRevoke={handleRevoke} />
313 ))}
314 </ul>
315 </Card>
316 )}
317 </section>
318 </div>
319 );
320}
321 
322type RevealPanelProps = {
323 token: string;
324 copied: boolean;
325 onCopy: () => void;
326 onDismiss: () => void;
327};
328 
329// `-webkit-text-security: disc` masks the plaintext while keeping it
330// selectable; hover/focus-within reveals it for visual verification.
331function RevealPanel({ token, copied, onCopy, onDismiss }: RevealPanelProps) {
332 return (
333 <Card variant="accent">
334 <div className="flex flex-col gap-5">
335 <p className="m-0 animate-pulse-soft font-mono text-[10px] font-semibold uppercase tracking-[0.2em] text-accent-600 dark:text-accent-400">
336 One time only · copy now
337 </p>
338 <code className="block break-all py-2 text-center font-mono text-base text-zinc-900 select-all [-webkit-text-security:disc] hover:[-webkit-text-security:none] focus-within:[-webkit-text-security:none] sm:text-lg dark:text-zinc-100">
339 {token}
340 </code>
341 <div className="flex items-center justify-between gap-3">
342 <Button type="button" variant="primary" size="md" onClick={onCopy}>
343 <Copy className="h-4 w-4" aria-hidden="true" />
344 {copied ? "Copied" : "Copy token"}
345 </Button>
346 <Button type="button" variant="ghost" size="sm" onClick={onDismiss}>
347 Done
348 </Button>
349 </div>
350 </div>
351 </Card>
352 );
353}
354 
355type ScopeSegmentedProps = {
356 scope: Scope;
357 onChange: (next: Scope) => void;
358};
359 
360function ScopeSegmented({ scope, onChange }: ScopeSegmentedProps) {
361 const baseHalf =
362 "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";
363 const activeHalf = "bg-accent-500/10 text-accent-600 dark:text-accent-400";
364 const inactiveHalf =
365 "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-700 dark:hover:bg-zinc-800/40 dark:hover:text-zinc-300";
366 return (
367 <div
368 className="flex w-full flex-col overflow-hidden rounded-xl border border-zinc-300 sm:flex-row dark:border-zinc-800/60"
369 role="radiogroup"
370 aria-label="Token scope"
371 >
372 <button
373 type="button"
374 className={`${baseHalf} ${scope === "namespace" ? activeHalf : inactiveHalf}`}
375 onClick={() => onChange("namespace")}
376 role="radio"
377 aria-checked={scope === "namespace"}
378 >
379 <span className="font-mono text-[11px] font-semibold uppercase tracking-widest">
380 Namespace
381 </span>
382 <span className="font-mono text-[11px] text-zinc-500">@rachel</span>
383 </button>
384 <button
385 type="button"
386 className={`${baseHalf} border-t border-zinc-300 sm:border-t-0 sm:border-l ${scope === "repo" ? activeHalf : inactiveHalf} dark:border-zinc-800/60`}
387 onClick={() => onChange("repo")}
388 role="radio"
389 aria-checked={scope === "repo"}
390 >
391 <span className="font-mono text-[11px] font-semibold uppercase tracking-widest">Repo</span>
392 <span className="font-mono text-[11px] text-zinc-500">rachel/site</span>
393 </button>
394 </div>
395 );
396}
397 
398type PermissionLevelTilesProps = {
399 level: Level;
400 onChange: (next: Level) => void;
401};
402 
403// Single-select control over the grant level. Push includes pull by
404// construction, so the UI cannot express an inconsistent "push without
405// pull" state.
406function PermissionLevelTiles({ level, onChange }: PermissionLevelTilesProps) {
407 const tile =
408 "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";
409 return (
410 <fieldset className="m-0 grid gap-3 border-0 p-0 sm:grid-cols-2">
411 <legend className="sr-only">Permission level</legend>
412 <label className={tile}>
413 <input
414 type="radio"
415 name="pat-level"
416 value="pull"
417 className="sr-only"
418 checked={level === "pull"}
419 onChange={() => onChange("pull")}
420 />
421 <span className="font-mono text-[11px] font-semibold uppercase tracking-widest text-zinc-700 group-has-[:checked]:text-accent-600 dark:text-zinc-300 dark:group-has-[:checked]:text-accent-400">
422 Pull only
423 </span>
424 <span className="text-sm text-zinc-600 dark:text-zinc-400">Clone and fetch refs.</span>
425 </label>
426 <label className={tile}>
427 <input
428 type="radio"
429 name="pat-level"
430 value="push"
431 className="sr-only"
432 checked={level === "push"}
433 onChange={() => onChange("push")}
434 />
435 <span className="font-mono text-[11px] font-semibold uppercase tracking-widest text-zinc-700 group-has-[:checked]:text-accent-600 dark:text-zinc-300 dark:group-has-[:checked]:text-accent-400">
436 Pull + push
437 </span>
438 <span className="text-sm text-zinc-600 dark:text-zinc-400">
439 Clone, fetch, and push commits.
440 </span>
441 </label>
442 </fieldset>
443 );
444}
445 
446type RepoTilesProps = {
447 repositories: IslandRepository[];
448 selectedSlug: string;
449 onChange: (next: string) => void;
450};
451 
452function RepoTiles({ repositories, selectedSlug, onChange }: RepoTilesProps) {
453 const tile =
454 "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";
455 return (
456 <fieldset className="m-0 grid max-h-60 gap-2 overflow-y-auto border-0 p-0">
457 <legend className="sr-only">Repository</legend>
458 {repositories.map((repo) => (
459 <label key={repo.id} className={tile}>
460 <input
461 type="radio"
462 name="pat-repo"
463 value={repo.slug}
464 className="sr-only"
465 checked={selectedSlug === repo.slug}
466 onChange={() => onChange(repo.slug)}
467 />
468 <span className="min-w-0 truncate font-mono text-sm text-zinc-800 group-has-[:checked]:text-accent-600 dark:text-zinc-200 dark:group-has-[:checked]:text-accent-400">
469 {repo.slug}
470 </span>
471 <span
472 className={`shrink-0 font-mono text-[10px] uppercase tracking-widest ${
473 repo.visibility === "private"
474 ? "text-amber-700 dark:text-amber-400"
475 : "text-emerald-700 dark:text-emerald-400"
476 }`}
477 >
478 {repo.visibility}
479 </span>
480 </label>
481 ))}
482 </fieldset>
483 );
484}
485 
486type TokenRowProps = {
487 token: TokensIslandSummary;
488 onRevoke: (id: string) => void;
489};
490 
491function TokenRow({ token, onRevoke }: TokenRowProps) {
492 const grants = formatGrants(token);
493 const revoked = Boolean(token.revokedAt);
494 const metaParts = [token.prefix, `created ${formatDate(token.createdAt)}`];
495 if (revoked && token.revokedAt) metaParts.push(`revoked ${formatDate(token.revokedAt)}`);
496 if (grants) metaParts.push(grants);
497 return (
498 <li className="group flex flex-col items-start justify-between gap-3 px-5 py-3.5 sm:flex-row sm:items-center sm:gap-4 sm:px-6">
499 <div className="flex w-full min-w-0 flex-col gap-0.5">
500 <span
501 className={`text-sm font-medium ${
502 revoked
503 ? "text-zinc-500 line-through dark:text-zinc-500"
504 : "text-zinc-900 dark:text-zinc-100"
505 }`}
506 >
507 {token.name}
508 </span>
509 <span className="font-mono text-xs break-words text-zinc-500 sm:truncate">
510 {metaParts.join(" · ")}
511 </span>
512 </div>
513 {revoked ? null : (
514 <Button
515 type="button"
516 variant="danger"
517 size="sm"
518 onClick={() => void onRevoke(token.id)}
519 className="self-end transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
520 >
521 <Trash2 className="h-4 w-4" aria-hidden="true" />
522 Revoke
523 </Button>
524 )}
525 </li>
526 );
527}
528 
529export function initTokensIsland() {
530 hydrateIsland("tokens", TokensIsland);
531}