Skip to content

g1t/apps/web/app/routes/settings/tokens.tsx

430 lines16,519 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules1import { identity, repos } from "../../lib/services.server";
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step2import { Form, Link, redirect, useSearchParams } from "react-router";
3
4import { presetScopes, type AccessToken } from "@g1t/contracts";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas5
6import type { Route } from "./+types/tokens";
7import { page } from "../../lib/meta";
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step8import {
Merge branch 'worktree-agent-a985c01f43538af01'9 SubmitButton,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step10 ButtonLink,
11 ErrorText,
12 Field,
13 Input,
14 TimeAgo,
15} from "../../components/ui";
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules16import { Badge } from "../../components/ui/badge";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas17import { DeleteButton } from "../../components/account-settings";
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules18import { FineGrainedForm, type OwnerChoice } from "../../components/fine-grained-form";
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step19import {
20 AccessSummary,
21 ExpiryField,
22 ScopeChecklist,
23} from "../../components/token-scopes";
24import {
25 describeExpiry,
26 expiryTtl,
27 grantFromForm,
28} from "../../lib/token-scopes";
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules29import { fineGrainedFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas30import { assertSameOrigin, requireUser } from "../../lib/session.server";
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules31import { cn } from "../../lib/cn";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas32
33export function meta(args: Route.MetaArgs) {
34 return page(args, { title: "Access tokens · Settings · g1t" });
35}
36
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules37type Tab = "fine-grained" | "classic";
38
39function tabOf(value: string | null): Tab {
40 return value === "classic" ? "classic" : "fine-grained";
41}
42
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas43export async function loader({ request, context }: Route.LoaderArgs) {
44 const user = requireUser(context, request);
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules45 const memberships = user.workspaces ?? [];
46 // Each workspace you belong to, with its rules for tokens and the
47 // repositories you can choose from.
48 const [tokens, owners] = await Promise.all([
49 identity.listAccessTokens(user),
50 Promise.all(
51 memberships.map(async (membership): Promise<OwnerChoice> => {
52 const [policy, listed] = await Promise.all([
53 identity.getTokenPolicy(membership.slug, user),
54 repos.list(user, { namespace: membership.slug }).catch(() => []),
55 ]);
56 return {
57 slug: membership.slug,
58 owner: membership.role === "owner",
59 policy: policy.ok ? policy.value : null,
60 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`).sort(),
61 };
62 }),
63 ),
64 ]);
65 return { user, tokens, owners };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas66}
67
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules68type ActionResult = {
69 newToken: string | null;
70 created: AccessToken | null;
71 error: string | null;
72 editing: string | null;
73};
74
75const failed = (error: string, editing: string | null = null): ActionResult => ({ newToken: null, created: null, error, editing });
76
77export async function action({ request, context }: Route.ActionArgs): Promise<ActionResult | null> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas78 assertSameOrigin(request);
79 const user = requireUser(context, request);
80 const form = await request.formData();
81 switch (form.get("intent")) {
82 case "add-token": {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step83 const grant = grantFromForm(form);
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules84 if (!grant.ok) return failed(grant.error);
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step85 const created = await identity.createAccessToken(
86 user,
87 String(form.get("label") ?? ""),
88 expiryTtl(form.get("expires")),
89 { ...grant.value, listed: true },
90 );
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules91 return { newToken: created.token, created: created.info, error: null, editing: null };
92 }
93 case "add-fine-grained": {
94 const input = fineGrainedFromForm(form);
95 if (!input.ok) return failed(input.error);
96 const created = await identity.createFineGrainedToken(user, input.value);
97 if (!created.ok) return failed(created.error.message);
98 return { newToken: created.value.token, created: created.value.info, error: null, editing: null };
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step99 }
100 case "update-token": {
101 const id = String(form.get("id") ?? "");
102 const grant = grantFromForm(form);
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules103 if (!grant.ok) return failed(grant.error, id);
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step104 const updated = await identity.updateAccessToken(user, id, grant.value);
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules105 if (!updated.ok) return failed(updated.error.message, id);
106 throw redirect("/settings/tokens?tab=classic");
107 }
108 case "update-fine-grained": {
109 const id = String(form.get("id") ?? "");
110 const input = fineGrainedFromForm(form, { editing: true });
111 if (!input.ok) return failed(input.error, id);
112 const { name, description, repositorySelection, repositories, permissions } = input.value;
113 const updated = await identity.updateFineGrainedToken(user, id, {
114 name: name || undefined,
115 description: description ?? "",
116 repositorySelection,
117 repositories,
118 permissions,
119 });
120 if (!updated.ok) return failed(updated.error.message, id);
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step121 throw redirect("/settings/tokens");
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas122 }
123 case "delete-token":
124 await identity.removeAccessToken(user, String(form.get("id") ?? ""));
125 return null;
126 }
127 return null;
128}
129
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules130export default function TokenSettings({ loaderData, actionData }: Route.ComponentProps) {
131 const { user, tokens, owners } = loaderData;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step132 const [params] = useSearchParams();
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules133 const created = actionData?.created ?? null;
134 const tab: Tab = created ? (created.kind === "fine_grained" ? "fine-grained" : "classic") : tabOf(params.get("tab"));
135 const fine = tokens.filter((token) => token.kind === "fine_grained");
136 const classic = tokens.filter((token) => token.kind !== "fine_grained");
137 const workspaces = (user.workspaces ?? []).map((membership) => membership.slug);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas138 return (
139 <section id="tokens" className="scroll-mt-20">
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules140 <nav aria-label="Kinds of token" className="mb-5 flex gap-1 border-b border-line">
141 {(
142 [
143 ["fine-grained", "Fine-grained tokens", fine.length],
144 ["classic", "Tokens (classic)", classic.length],
145 ] as const
146 ).map(([id, label, count]) => (
147 <Link
148 key={id}
149 to={id === "classic" ? "?tab=classic" : "?"}
150 preventScrollReset
151 aria-current={tab === id ? "page" : undefined}
152 className={cn(
153 "-mb-px flex items-center gap-2 border-b-2 px-3 pb-2.5 text-sm whitespace-nowrap transition-colors",
154 tab === id ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
155 )}
156 >
157 {label}
158 {count > 0 && <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">{count}</span>}
159 </Link>
160 ))}
161 </nav>
162
163 {actionData?.newToken && created && (
164 <div className="mb-5 rounded-md border border-accent/40 bg-surface p-4">
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step165 <p className="text-sm">
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules166 <span className="font-medium">{created.name}</span> is ready. Copy it now. It will not be shown again.
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step167 </p>
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules168 <pre className="mt-2 font-mono text-sm break-all whitespace-pre-wrap text-accent">{actionData.newToken}</pre>
169 {created.kind === "fine_grained" ? <FineGrainedSummary token={created} /> : <AccessSummary holder={created} className="mt-3" />}
170 <p className="mt-1.5 text-xs text-faint">{describeExpiry(created.expiresAt)}</p>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas171 </div>
172 )}
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules173
174 {tab === "fine-grained" ? (
175 <FineGrainedTokens tokens={fine} owners={owners} actionData={actionData ?? null} />
176 ) : (
177 <ClassicTokens tokens={classic} workspaces={workspaces} actionData={actionData ?? null} />
178 )}
179 </section>
180 );
181}
182
183/** A fine-grained token's reach, permissions and status, under its name. */
184function FineGrainedSummary({ token }: { token: AccessToken }) {
185 const badge = statusBadge(token.fineGrained?.status);
186 return (
187 <div className="mt-1.5 space-y-1.5">
188 <p className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
189 <Badge tone="accent">Fine-grained</Badge>
190 {badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
191 <span>{reachSummary(token)}</span>
192 </p>
193 <div className="flex flex-wrap gap-1.5">
194 {permissionChips(token.fineGrained?.permissions).map((chip) => (
195 <span key={chip} className="rounded border border-line px-1.5 py-px text-[0.6875rem] text-muted">
196 {chip}
197 </span>
198 ))}
199 </div>
200 {token.fineGrained?.repositorySelection === "selected" && token.fineGrained.repositories.length > 0 && (
201 <p className="truncate font-mono text-[0.6875rem] text-faint">{token.fineGrained.repositories.join(", ")}</p>
202 )}
203 {token.fineGrained?.reviewReason && (
204 <p className="text-xs text-faint">Owner's note: {token.fineGrained.reviewReason}</p>
205 )}
206 </div>
207 );
208}
209
210function Meta({ token }: { token: AccessToken }) {
211 const expiry = describeExpiry(token.expiresAt);
212 return (
213 <p className="text-xs text-faint">
214 Created <TimeAgo at={token.createdAt} /> ·{" "}
215 {token.lastUsedAt ? (
216 <>
217 last used <TimeAgo at={token.lastUsedAt} />
218 </>
219 ) : (
220 "never used"
221 )}{" "}
222 · <span className={expiry === "Expired" ? "text-danger" : undefined}>{expiry}</span>
223 </p>
224 );
225}
226
227function FineGrainedTokens({
228 tokens,
229 owners,
230 actionData,
231}: {
232 tokens: AccessToken[];
233 owners: OwnerChoice[];
234 actionData: ActionResult | null;
235}) {
236 const [params] = useSearchParams();
237 const editing = actionData?.editing ?? params.get("edit");
238 const created = actionData?.created;
239 return (
240 <>
241 <p className="text-sm text-muted">
242 A fine-grained token reaches one resource owner (one workspace, or your own account), only the repositories
243 you choose, and only with the permissions you give it. It always expires.
244 </p>
245 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
246 {tokens.map((token) => (
247 <li key={token.id} className="px-4 py-3">
248 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
249 <div className="min-w-0 grow basis-60">
250 <p className="truncate text-sm font-medium">{token.name}</p>
251 {token.description && <p className="truncate text-xs text-muted">{token.description}</p>}
252 <Meta token={token} />
253 <FineGrainedSummary token={token} />
254 </div>
255 <div className="ml-auto flex shrink-0 items-center gap-2">
256 {editing !== token.id && (
257 <ButtonLink variant="quiet" to={`?edit=${token.id}`} preventScrollReset>
258 Edit
259 </ButtonLink>
260 )}
261 <DeleteButton intent="delete-token" id={token.id} />
262 </div>
263 </div>
264 {editing === token.id && (
265 <Form method="post" className="mt-4 space-y-5 border-t border-line pt-4">
266 <input type="hidden" name="intent" value="update-fine-grained" />
267 <input type="hidden" name="id" value={token.id} />
268 <p className="text-sm text-muted">
269 The token stays the same; what it reaches changes from its next request. A workspace that approves
270 tokens asks again when you widen it.
271 </p>
272 <FineGrainedForm owners={owners} editing={token} />
273 <ErrorText>{actionData?.editing === token.id ? actionData.error : null}</ErrorText>
274 <div className="flex gap-2">
275 <SubmitButton pending="Saving…" match={{ intent: "update-fine-grained", id: token.id }}>
276 Save
277 </SubmitButton>
278 <ButtonLink variant="quiet" to="." preventScrollReset>
279 Cancel
280 </ButtonLink>
281 </div>
282 </Form>
283 )}
284 </li>
285 ))}
286 </ul>
287 {!editing && (
288 <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5">
289 <input type="hidden" name="intent" value="add-fine-grained" />
290 <h2 className="font-medium">New fine-grained token</h2>
291 <FineGrainedForm owners={owners} />
292 {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>}
293 <SubmitButton pending="Generating…" match={{ intent: "add-fine-grained" }}>
294 Generate token
295 </SubmitButton>
296 </Form>
297 )}
298 </>
299 );
300}
301
302function ClassicTokens({
303 tokens,
304 workspaces,
305 actionData,
306}: {
307 tokens: AccessToken[];
308 workspaces: string[];
309 actionData: ActionResult | null;
310}) {
311 const [params] = useSearchParams();
312 const editing = actionData?.editing ?? params.get("edit");
313 const created = actionData?.created;
314 return (
315 <>
316 <p className="text-sm text-muted">
317 A classic token reaches every workspace and repository you can, and its scopes say what it may do there. A
318 workspace can keep classic tokens out.
319 {workspaces.length > 0 && (
320 <>
321 {" "}
322 For CI and integrations that work for a team, use a workspace's own tokens instead:{" "}
323 {workspaces.map((slug, i) => (
324 <span key={slug}>
325 {i > 0 && ", "}
326 <Link to={`/${slug}/-/tokens`} className="font-mono text-fg underline underline-offset-4">
327 {slug}
328 </Link>
329 </span>
330 ))}
331 .
332 </>
333 )}
334 </p>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas335 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
336 {tokens.map((token) => (
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules337 <ClassicRow
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step338 key={token.id}
339 token={token}
340 editing={editing === token.id}
341 error={actionData?.editing === token.id ? actionData.error : null}
342 />
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas343 ))}
344 </ul>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step345
346 {!editing && (
Merge branch 'worktree-agent-a985c01f43538af01'347 // Keyed on the token just made, so the fields start over for the next.
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules348 <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5">
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step349 <input type="hidden" name="intent" value="add-token" />
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules350 <h2 className="font-medium">New classic token</h2>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step351 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
352 <Field label="Name" hint="Name it after what will use it.">
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules353 <Input name="label" maxLength={100} placeholder="laptop" required />
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step354 </Field>
355 <ExpiryField />
356 </div>
357 <ScopeChecklist initial={presetScopes("agent")} />
358 {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>}
Merge branch 'worktree-agent-a985c01f43538af01'359 <SubmitButton pending="Creating…" match={{ intent: "add-token" }}>
360 Create token
361 </SubmitButton>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step362 </Form>
363 )}
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules364 </>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step365 );
366}
367
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules368function ClassicRow({
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step369 token,
370 editing,
371 error,
372}: {
373 token: AccessToken;
374 editing: boolean;
375 error: string | null | undefined;
376}) {
377 const legacy = token.legacy && token.scopes === null;
378 return (
379 <li className="px-4 py-3">
380 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
381 <div className="min-w-0 grow basis-60">
382 <p className="truncate text-sm font-medium">{token.name}</p>
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules383 <Meta token={token} />
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step384 <AccessSummary holder={token} />
385 {legacy && (
386 <p className="mt-1.5 text-xs text-warn">
387 Made before tokens had scopes, so it can do everything you can.
388 Narrow it to what it needs.
389 </p>
390 )}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas391 </div>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step392 <div className="ml-auto flex shrink-0 items-center gap-2">
393 {!editing && (
394 <ButtonLink
395 variant="quiet"
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules396 to={`?tab=classic&edit=${token.id}`}
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step397 preventScrollReset
398 >
399 {legacy ? "Narrow this token" : "Edit access"}
400 </ButtonLink>
401 )}
402 <DeleteButton intent="delete-token" id={token.id} />
403 </div>
404 </div>
405 {editing && (
406 <Form
407 method="post"
408 className="mt-4 space-y-5 border-t border-line pt-4"
409 >
410 <input type="hidden" name="intent" value="update-token" />
411 <input type="hidden" name="id" value={token.id} />
412 <p className="text-sm text-muted">
413 The token stays the same; only what it may do changes, from its next
414 request.
415 </p>
416 <ScopeChecklist initial={token.scopes} />
417 <ErrorText>{error}</ErrorText>
418 <div className="flex gap-2">
Merge branch 'worktree-agent-a985c01f43538af01'419 <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}>
420 Save access
421 </SubmitButton>
Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules422 <ButtonLink variant="quiet" to="?tab=classic" preventScrollReset>
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step423 Cancel
424 </ButtonLink>
425 </div>
426 </Form>
427 )}
428 </li>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas429 );
430}

This file's history is long; its oldest lines are credited to the oldest commit read.