File
Blob: src/client/islands/tokens.tsx
| 1 | /// <reference lib="dom" /> |
| 2 | |
| 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | |
| 5 | import { hydrateIsland } from "@/client/hydrate"; |
| 6 | import { Button, Card, EmptyState, ErrorBanner, Input } from "@/client/components/ui"; |
| 7 | import { Copy, FolderGit2, KeyRound, Trash2 } from "lucide-react"; |
| 8 | import { |
| 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. |
| 16 | type Level = "pull" | "push"; |
| 17 | |
| 18 | export 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. |
| 37 | export type IslandRepository = { |
| 38 | id: string; |
| 39 | slug: string; |
| 40 | namespaceSlug: string; |
| 41 | visibility: "public" | "private"; |
| 42 | updatedAt: number; |
| 43 | }; |
| 44 | |
| 45 | export 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. |
| 53 | type 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 | |
| 68 | type Scope = CreatePayload["scope"]; |
| 69 | |
| 70 | function describeLevel(level: Level): string { |
| 71 | return level === "push" ? "pull + push" : "pull"; |
| 72 | } |
| 73 | |
| 74 | function formatDate(epochMs: number): string { |
| 75 | return new Date(epochMs).toISOString().slice(0, 10); |
| 76 | } |
| 77 | |
| 78 | function 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 | |
| 89 | export 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 | |
| 322 | type 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. |
| 331 | function 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 | |
| 355 | type ScopeSegmentedProps = { |
| 356 | scope: Scope; |
| 357 | onChange: (next: Scope) => void; |
| 358 | }; |
| 359 | |
| 360 | function 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 | |
| 398 | type 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. |
| 406 | function 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 | |
| 446 | type RepoTilesProps = { |
| 447 | repositories: IslandRepository[]; |
| 448 | selectedSlug: string; |
| 449 | onChange: (next: string) => void; |
| 450 | }; |
| 451 | |
| 452 | function 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 | |
| 486 | type TokenRowProps = { |
| 487 | token: TokensIslandSummary; |
| 488 | onRevoke: (id: string) => void; |
| 489 | }; |
| 490 | |
| 491 | function 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 | |
| 529 | export function initTokensIsland() { |
| 530 | hydrateIsland("tokens", TokensIsland); |
| 531 | } |