SPB Git

Repository Public MIT

page.tsx 7.6 KB 97 lines tsx

1import { ELEVATED_SCOPES, PUBLISHER_SCOPES, SCOPES, type Scope } from "@skills/registry-core";2import { ActionForm, Field } from "@/components/action-form";3import { CodeBlock } from "@/components/code";4import { date, relative } from "@/lib/format";5import { requireSession } from "@/lib/session";6import { hasRecentReauth, listTokens, listWritableNamespaces, publicToken } from "@/server/auth";7import { createTokenAction, revokeTokenAction, rotateTokenAction } from "../actions";89export const metadata = { title: "API tokens" };1011export default async function Page() {12  const { ctx, principal, isAdmin } = await requireSession();13  const [tokens, namespaces] = await Promise.all([listTokens(ctx), listWritableNamespaces(ctx, principal.user)]);14  const fresh = hasRecentReauth(principal);15  const scopes = (Object.keys(SCOPES) as Scope[]).filter((s) => isAdmin || !ELEVATED_SCOPES.includes(s));16  return (17    <div>18      <h1 className="text-2xl font-semibold tracking-tight">API tokens</h1>19      <p className="mt-2 max-w-2xl text-sm text-muted">20        Tokens let the CLI, Claude Code and CI publish on your behalf. Each one is limited to the scopes and namespaces you choose, expires, and is stored only as a keyed hash: the secret is shown once and cannot be recovered.21      </p>2223      <div className="mt-6 overflow-x-auto rounded-lg border border-line">24        <table className="w-full text-left text-sm">25          <thead className="border-b border-line text-xs text-faint">26            <tr><th scope="col" className="px-3 py-2 font-medium">Name</th><th scope="col" className="px-3 py-2 font-medium">Scopes · namespaces</th><th scope="col" className="px-3 py-2 font-medium">Expires</th><th scope="col" className="px-3 py-2 font-medium">Last used</th><th scope="col" className="px-3 py-2 font-medium">Status</th><th scope="col" className="px-3 py-2 font-medium"><span className="sr-only">Actions</span></th></tr>27          </thead>28          <tbody className="divide-y divide-line">29            {tokens.map(publicToken).map((t) => (30              <tr key={t.id} className="align-top">31                <td className="px-3 py-3"><span className="font-medium">{t.name}</span><span className="block font-mono text-[11px] text-faint">skr_{t.prefix}_…{t.elevated ? " · elevated" : ""}</span></td>32                <td className="max-w-xs px-3 py-3 font-mono text-[11px] text-muted">{t.scopes.join(" ")}<span className="block text-faint">{t.namespaces.length ? t.namespaces.map((n) => `@${n}`).join(" ") : "no namespace"}{t.ipAllowlist.length ? ` · IP ${t.ipAllowlist.join(", ")}` : ""}</span></td>33                <td className="whitespace-nowrap px-3 py-3 text-muted">{date(t.expiresAt)}</td>34                <td className="whitespace-nowrap px-3 py-3 text-muted">{t.lastUsedAt ? relative(t.lastUsedAt) : "never"}</td>35                <td className={`px-3 py-3 ${t.status === "active" ? "text-ok" : "text-faint"}`}>{t.status}{t.revokedReason ? <span className="block text-[11px] text-faint">{t.revokedReason}</span> : null}</td>36                <td className="px-3 py-3">37                  {t.status === "active" && (38                    <div className="flex flex-col gap-2">39                      <ActionForm action={rotateTokenAction} submit="Rotate" className="[&_button]:min-h-8 [&_button]:px-2.5 [&_button]:text-xs [&>div]:mt-0" confirm="Rotate this token? The current secret stops working immediately.">40                        <input type="hidden" name="id" value={t.id} />41                        {!fresh && <input name="password" type="password" placeholder="Password" aria-label="Password to confirm rotation" autoComplete="current-password" className="input mb-2 min-h-8 text-xs" />}42                      </ActionForm>43                      <ActionForm action={revokeTokenAction} submit="Revoke" danger className="[&_button]:min-h-8 [&_button]:px-2.5 [&_button]:text-xs [&>div]:mt-0" confirm="Revoke this token? Anything using it will stop working.">44                        <input type="hidden" name="id" value={t.id} />45                      </ActionForm>46                    </div>47                  )}48                </td>49              </tr>50            ))}51            {tokens.length === 0 && <tr><td colSpan={6} className="px-3 py-6 text-center text-muted">No tokens yet.</td></tr>}52          </tbody>53        </table>54      </div>5556      <section className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">57        <div>58          <h2 className="mb-3 text-base font-semibold">Create a token</h2>59          <ActionForm action={createTokenAction} submit="Create token" className="card space-y-5 p-6">60            <Field label="Name" name="name" required minLength={2} maxLength={63} defaultValue="claude-code-publisher" pattern="[A-Za-z0-9][A-Za-z0-9 ._-]+" hint="Describes where the token lives, e.g. claude-code-publisher or ci-release." />61            <fieldset>62              <legend className="label">Scopes</legend>63              <div className="grid gap-2">64                {scopes.map((sc) => (65                  <label key={sc} className="flex items-start gap-2 text-sm">66                    <input type="checkbox" name="scopes" value={sc} defaultChecked={PUBLISHER_SCOPES.includes(sc)} className="mt-1" />67                    <span><code className="font-mono text-[12px] text-text">{sc}</code><span className="block text-xs text-muted">{SCOPES[sc]}</span></span>68                  </label>69                ))}70              </div>71            </fieldset>72            <fieldset>73              <legend className="label">Allowed namespaces</legend>74              {namespaces.length ? (75                <div className="flex flex-wrap gap-4">{namespaces.map((n) => <label key={n} className="flex items-center gap-2 text-sm"><input type="checkbox" name="namespaces" value={n} defaultChecked={namespaces.length === 1} /> <code className="font-mono">@{n}</code></label>)}</div>76              ) : <p className="text-sm text-muted">You own no namespace; only read, token and audit scopes are usable.</p>}77            </fieldset>78            <div className="grid gap-4 sm:grid-cols-2">79              <Field label="Expires in" name="days"><select id="f-days" name="days" defaultValue="90" className="input"><option value="1">1 day</option><option value="7">7 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></Field>80              <Field label="IP allowlist (optional)" name="ip" placeholder="203.0.113.0/24" hint="IPv4 addresses or CIDR ranges, comma-separated." autoComplete="off" />81            </div>82            <p className="text-xs text-muted">Elevated scopes (admin:*) are limited to 24 hours regardless of the expiry chosen above.</p>83            {!fresh && <Field label="Password" name="password" type="password" required autoComplete="current-password" hint="Creating a token requires recent authentication." />}84          </ActionForm>85        </div>86        <aside className="space-y-4 text-sm text-muted">87          <h2 className="text-base font-semibold text-text">Store it safely</h2>88          <p>Hand the token straight to the CLI, which saves it in the macOS Keychain (or your OS credential manager):</p>89          <CodeBlock prompt code="skills login" />90          <p>Paste the token at the hidden prompt. Never put it in a repository, a <code className="font-mono">CLAUDE.md</code>, an MCP tool description, a prompt or a chat. Tokens with administrative scopes must never be used from a browser.</p>91          <p>Lost or exposed? Revoke it here; it stops working immediately and the attempt to reuse it is logged.</p>92        </aside>93      </section>94    </div>95  );96}97
Files main
Menu

Theme