Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
445 linesCodeBlameRaw

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.

One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1import { Info, Search, ShieldAlert, TriangleAlert } from "lucide-react";
2import { useMemo, useState, type ReactNode } from "react";
3
4import {
5 PRESETS,
6 levelsOf,
7 permissionsOf,
8 presetScopes,
9 type Permissions,
10 type PresetId,
11 type ScopeLevel,
12 type ScopeResource,
13} from "@g1t/contracts/scopes";
14import type { AccessToken, RepositorySelection, TokenPolicy } from "@g1t/contracts";
15
16import { cn } from "../lib/cn";
17import {
18 ALL_WORKSPACES,
19 DEFAULT_EXPIRY_DAYS,
20 NO_WORKSPACE,
21 describeDays,
22 expiryChoices,
23 isDangerousLevel,
24 keptOutOfAll,
25 levelAbout,
26 levelLabel,
27 permissionGroups,
28 policyNote,
29 tokenPermissions,
30} from "../lib/access-tokens";
Merge main into Artifacts Phase 231import { Checkbox, CheckboxOption } from "./ui/checkbox";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers32import { Hint } from "./ui/hint";
33import { RadioGroup, RadioOption } from "./ui/radio-group";
34import { SelectField } from "./ui/select";
35import { Field, Input, Textarea } from "./ui";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.36import { Button } from "./ui/button";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers37
38// The one form for an access token, a person's or a workspace's: its name,
39// when it expires, where it reaches (the workspaces and repositories it is
40// made for) and its permissions, a level for each resource. Every control
41// is a form field (lib/access-tokens.ts reads them back), so it posts the
42// same without JavaScript; the script hides what does not apply.
43
44/** A workspace a personal token can be made for, with its rules and repositories. */
45export type WorkspaceChoice = {
46 slug: string;
47 /** Whether you are an owner there: your tokens never wait for approval. */
48 owner: boolean;
49 policy: TokenPolicy | null;
50 /** Its repositories you can see, as `owner/name`. */
51 repos: string[];
52};
53
54function PermissionRow({
55 resource,
56 label,
57 value,
58 onChange,
59}: {
60 resource: ScopeResource;
61 label: string;
62 value: ScopeLevel | "none";
63 onChange: (value: ScopeLevel | "none") => void;
64}) {
65 const id = `perm-${resource}`;
66 const about = levelAbout(resource, value);
67 const levels: (ScopeLevel | "none")[] = ["none", ...levelsOf(resource)];
68 return (
69 <div className="flex flex-col gap-2 py-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
70 <div className="min-w-0">
71 <label htmlFor={id} className="text-sm font-medium text-fg">
72 {label}
73 </label>
74 <p className={cn("mt-0.5 text-xs leading-snug", isDangerousLevel(value) ? "text-danger" : "text-faint")}>
75 {about ?? "No access."}
76 </p>
77 </div>
78 <SelectField
79 id={id}
80 name={`perm.${resource}`}
81 value={value}
82 onValueChange={(next) => onChange(next as ScopeLevel | "none")}
83 className={cn(
84 "w-full shrink-0 sm:w-48",
85 value !== "none" && (isDangerousLevel(value) ? "border-danger/50" : "border-accent/50"),
86 )}
87 options={levels.map((level) => ({ value: level, label: levelLabel(resource, level) }))}
88 />
89 </div>
90 );
91}
92
93/** The repositories a token reaches in one workspace: all, the ones picked, or none of the private ones. */
94function RepositoryChoice({
95 slug,
96 repos,
97 selection,
98 onSelection,
99 picked,
100 onPicked,
101 allowPublic,
102}: {
103 slug: string;
104 repos: string[];
105 selection: RepositorySelection;
106 onSelection: (value: RepositorySelection) => void;
107 picked: string[];
108 onPicked: (value: string[]) => void;
109 allowPublic: boolean;
110}) {
111 const [filter, setFilter] = useState("");
112 const shown = useMemo(() => {
113 const query = filter.trim().toLowerCase();
114 return query ? repos.filter((repo) => repo.toLowerCase().includes(query)) : repos;
115 }, [repos, filter]);
116 return (
117 <fieldset className="space-y-3">
118 <legend className="text-sm font-medium text-fg">Repository access</legend>
119 <RadioGroup
120 name="repository_selection"
121 value={selection}
122 onValueChange={(value) => onSelection(value as RepositorySelection)}
123 className="gap-2.5"
124 >
125 <RadioOption value="all" label="All repositories" description={`Every repository of ${slug}, ones made later too.`} />
126 <RadioOption value="selected" label="Only select repositories" description="Up to 50, chosen below." />
127 {allowPublic && (
128 <RadioOption
129 value="public"
130 label="No private repositories"
131 description={`Public repositories, read-only, and ${slug}'s own settings its permissions allow.`}
132 />
133 )}
134 </RadioGroup>
135 {selection === "selected" && (
136 <div className="rounded-md border border-line">
137 <div className="flex items-center gap-2 border-b border-line px-3 py-2">
138 <Search size={14} className="shrink-0 text-faint" />
139 <input
140 type="search"
141 value={filter}
142 onChange={(event) => setFilter(event.target.value)}
143 placeholder={`Find a repository of ${slug}`}
144 aria-label="Find a repository"
145 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
146 />
147 <span className="shrink-0 text-xs text-faint">{picked.length} chosen</span>
148 </div>
149 <div className="max-h-56 overflow-y-auto px-3 py-1.5">
150 {shown.length === 0 && <p className="py-2 text-xs text-faint">No repositories match.</p>}
151 {shown.map((repo) => (
152 <label key={repo} className="flex cursor-pointer items-center gap-2.5 py-1 text-sm">
153 <Checkbox
154 name="repo"
155 value={repo}
156 checked={picked.includes(repo)}
157 onCheckedChange={(on) => onPicked(on === true ? [...picked, repo] : picked.filter((name) => name !== repo))}
158 />
159 <span className="truncate font-mono text-[0.8125rem]">{repo}</span>
160 </label>
161 ))}
162 </div>
163 </div>
164 )}
165 </fieldset>
166 );
167}
168
169function Note({ tone, children }: { tone: "warn" | "danger" | "info"; children: ReactNode }) {
170 return (
171 <p
172 className={cn(
173 "flex items-start gap-2 rounded-md border px-3 py-2 text-xs",
174 tone === "danger" && "border-danger/40 bg-danger/5 text-danger",
175 tone === "warn" && "border-warn/40 bg-warn/5 text-warn",
176 tone === "info" && "border-line text-muted",
177 )}
178 >
179 {tone === "info" ? <Info size={14} className="mt-px shrink-0" /> : <TriangleAlert size={14} className="mt-px shrink-0" />}
180 <span>{children}</span>
181 </p>
182 );
183}
184
185export function TokenForm({
186 workspaceOwned = false,
187 slug,
188 workspaces = [],
189 repos = [],
190 editing,
191 preset = "read_only",
192}: {
193 /** A workspace's own token, made by an owner: it reaches that workspace. */
194 workspaceOwned?: boolean;
195 /** For a workspace's own token: the workspace. */
196 slug?: string;
197 /** For a personal token: the workspaces you belong to. */
198 workspaces?: WorkspaceChoice[];
199 /** For a workspace's own token: the workspace's repositories. */
200 repos?: string[];
201 /** The token being changed: its reach's workspace and expiry stay. */
202 editing?: AccessToken;
203 /** What a new token's permissions start as. */
204 preset?: PresetId;
205}) {
206 // Where it reaches. A personal token: every workspace (*), none (-), or one.
207 const initialReach = editing
208 ? editing.workspace ?? (editing.repositorySelection === "public" ? NO_WORKSPACE : ALL_WORKSPACES)
209 : ALL_WORKSPACES;
210 const [reach, setReach] = useState<string>(initialReach);
211 const one = workspaceOwned ? (slug ?? "") : reach !== ALL_WORKSPACES && reach !== NO_WORKSPACE ? reach : null;
212 const chosen = workspaces.find((choice) => choice.slug === one) ?? null;
213 const [selection, setSelection] = useState<RepositorySelection>(editing?.repositorySelection ?? "all");
214 const [picked, setPicked] = useState<string[]>(editing?.repositories ?? []);
215 const [levels, setLevels] = useState<Permissions>(() =>
216 editing ? tokenPermissions(editing) : permissionsOf(presetScopes(preset) ?? null),
217 );
Merge main into Artifacts Phase 2218 const [website, setWebsite] = useState<boolean>(editing?.website ?? false);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers219
220 // The rules of the workspaces it would reach decide how long it may last.
221 const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : [];
222 const choices = expiryChoices(reached.map((choice) => choice?.policy ?? null));
223 const [expires, setExpires] = useState<string>(
224 choices.days.includes(DEFAULT_EXPIRY_DAYS) ? String(DEFAULT_EXPIRY_DAYS) : String(choices.days[choices.days.length - 1]),
225 );
226 const expiryOptions = [
227 ...choices.days.map((days) => ({ value: String(days), label: describeDays(days) })),
228 ...(choices.never ? [{ value: "never", label: "No expiration" }] : []),
229 ];
230 const expiresShown = expiryOptions.some((option) => option.value === expires) ? expires : expiryOptions[expiryOptions.length - 1]!.value;
231
232 const groups = permissionGroups(workspaceOwned);
233 const given = groups.flatMap((group) => group.resources).filter(({ resource }) => levels[resource]).length;
234 const note = one && !workspaceOwned ? policyNote(one, chosen?.policy, chosen?.owner ?? false) : null;
235 const blocked = Boolean(one && !workspaceOwned && chosen?.policy && !chosen.policy.allowTokensForThisWorkspace);
236 const keptOut = !workspaceOwned && reach === ALL_WORKSPACES ? keptOutOfAll(workspaces) : [];
237 const choosePreset = (id: PresetId) => setLevels(permissionsOf(presetScopes(id)));
An access token can be regenerated: a new secret, its name, permissions, repositories and reach kept, and the old secret refused at once. Its page (yours under Settings, a workspace's under its settings for owners) has Regenerate with a new expiry, within the workspace's rules on lifetime, and shows the secret once; identity's regenerate_token logs it to the security log or the workspace's audit log, and refuses a revoked token. The token form's quick permissions have All, every resource at its highest level. A project's Observability settings no longer list the token g1t gives its apps on g1t.page: it is g1t's, and the page says apps there need none.238 // Every permission this kind of token can have, each at its highest level.
239 const chooseAll = () =>
240 setLevels(Object.fromEntries(groups.flatMap((group) => group.resources.map((row) => [row.resource, levelsOf(row.resource).at(-1)!]))) as typeof levels);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers241
242 return (
243 <div className="space-y-8">
244 <section className="space-y-4">
245 <Field label="Token name" hint="Name it after what will use it.">
246 <Input name="name" maxLength={100} placeholder="release bot" defaultValue={editing?.name} required={!editing} />
247 </Field>
248 <Field label="Description" hint="Optional. What it is for, for whoever reviews it.">
249 <Textarea name="description" rows={2} maxLength={500} defaultValue={editing?.description ?? ""} />
250 </Field>
251 {!editing && (
252 <div className="flex flex-col gap-1.5 sm:max-w-xs">
253 <label htmlFor="token-expires" className="text-sm font-medium text-muted">
254 Expiration
255 </label>
256 <SelectField id="token-expires" name="expires" value={expiresShown} onValueChange={setExpires} options={expiryOptions} />
257 {expiresShown === "never" ? (
258 <p className="flex items-start gap-1.5 text-xs text-warn">
259 <TriangleAlert size={13} className="mt-px shrink-0" />
260 It works until someone deletes it. Prefer an expiration.
261 </p>
262 ) : (
263 !choices.never && <p className="text-xs text-faint">The workspaces it reaches allow at most {describeDays(choices.days[choices.days.length - 1]!)}.</p>
264 )}
265 </div>
266 )}
267 </section>
268
269 <section className="space-y-4">
270 <div>
271 <h3 className="text-sm font-medium text-fg">Where it reaches</h3>
272 <p className="mt-0.5 text-xs text-faint">
273 {workspaceOwned
274 ? `It acts as ${slug}, in ${slug} only, and keeps working when the person who made it leaves.`
275 : "It never reaches more than you can; this narrows it further."}
276 </p>
277 </div>
278 {!workspaceOwned &&
279 (editing ? (
280 <p className="rounded-md border border-line px-3 py-2 text-sm">
281 {reach === ALL_WORKSPACES
282 ? "All your workspaces"
283 : reach === NO_WORKSPACE
284 ? "No workspace: your account and public repositories"
285 : <span className="font-mono">{reach}</span>}
286 <input type="hidden" name="workspace" value={reach} />
287 </p>
288 ) : (
289 <div className="flex flex-col gap-1.5">
290 <label htmlFor="token-workspace" className="text-sm font-medium text-muted">
291 Workspaces
292 </label>
293 <SelectField
294 id="token-workspace"
295 name="workspace"
296 value={reach}
297 onValueChange={setReach}
298 options={[
299 { value: ALL_WORKSPACES, label: "All your workspaces", description: "Every workspace you belong to, now and later." },
300 ...workspaces.map((choice) => ({
301 value: choice.slug,
302 label: choice.slug,
303 disabled: choice.policy?.allowTokensForThisWorkspace === false,
304 description: choice.policy?.allowTokensForThisWorkspace === false ? "Does not allow tokens made for it" : "This workspace only",
305 })),
306 { value: NO_WORKSPACE, label: "No workspace", description: "Your account and public repositories only." },
307 ]}
308 />
309 </div>
310 ))}
311 {keptOut.length > 0 && (
312 <Note tone="warn">
313 {keptOut.join(", ")} {keptOut.length === 1 ? "keeps" : "keep"} out tokens made for all of a member's workspaces: make a token for{" "}
314 {keptOut.length === 1 ? "it" : "each"} alone to reach {keptOut.length === 1 ? "it" : "them"}.
315 </Note>
316 )}
317 {note && <Note tone={blocked ? "danger" : "warn"}>{note}</Note>}
318 {one && (
319 <RepositoryChoice
320 slug={one}
321 repos={workspaceOwned ? repos : (chosen?.repos ?? editing?.repositories ?? [])}
322 selection={selection}
323 onSelection={setSelection}
324 picked={picked}
325 onPicked={setPicked}
326 allowPublic={!workspaceOwned}
327 />
328 )}
329 </section>
330
331 <section className="space-y-4">
332 <div className="flex flex-wrap items-center gap-1.5">
333 <h3 className="mr-1 text-sm font-medium text-fg">Permissions</h3>
334 {PRESETS.filter((option) => option.id !== "full").map((option) => (
335 <Hint key={option.id} label={option.description}>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.336 <Button
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers337 type="button"
338 onClick={() => choosePreset(option.id)}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.339 variant="outline"
340 size="inline"
341 className="rounded-full px-2.5 py-0.5 text-xs text-muted"
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers342 >
343 {option.label}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.344 </Button>
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers345 </Hint>
346 ))}
An access token can be regenerated: a new secret, its name, permissions, repositories and reach kept, and the old secret refused at once. Its page (yours under Settings, a workspace's under its settings for owners) has Regenerate with a new expiry, within the workspace's rules on lifetime, and shows the secret once; identity's regenerate_token logs it to the security log or the workspace's audit log, and refuses a revoked token. The token form's quick permissions have All, every resource at its highest level. A project's Observability settings no longer list the token g1t gives its apps on g1t.page: it is g1t's, and the page says apps there need none.347 <Hint label="Every permission, each at its highest level. A permission g1t adds later is added to the token by editing it.">
348 <Button
349 type="button"
350 onClick={chooseAll}
351 variant="outline"
352 size="inline"
353 className="rounded-full px-2.5 py-0.5 text-xs text-muted"
354 >
355 All
356 </Button>
357 </Hint>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.358 <Button
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers359 type="button"
360 onClick={() => setLevels({})}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.361 variant="outline"
362 size="inline"
363 className="rounded-full px-2.5 py-0.5 text-xs text-muted font-normal"
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers364 >
365 Clear
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.366 </Button>
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers367 <span className="ml-auto text-xs text-faint">{given === 1 ? "1 permission" : `${given} permissions`}</span>
368 </div>
369 {groups.map((group) => (
370 <section key={group.group} aria-labelledby={`group-${group.group}`} className="rounded-md border border-line">
371 <header className="border-b border-line px-3 py-2 sm:px-4">
372 <h4 id={`group-${group.group}`} className="text-xs font-medium text-muted">
373 {group.label}
374 </h4>
375 <p className="text-xs text-faint">{group.about}</p>
376 </header>
377 <div className="divide-y divide-line px-3 sm:px-4">
378 {group.resources.map(({ resource, label }) => (
379 <PermissionRow
380 key={resource}
381 resource={resource}
382 label={label}
383 value={levels[resource] ?? "none"}
384 onChange={(value) =>
385 setLevels((now) => {
386 const next = { ...now };
387 if (value === "none") delete next[resource];
388 else next[resource] = value;
389 return next;
390 })
391 }
392 />
393 ))}
394 </div>
395 </section>
396 ))}
397 {levels.workflow_files === "write" && (
398 <Note tone="warn">
399 Workflow files: write lets this token add and change workflow files, which run with the repository's secrets.
400 </Note>
401 )}
402 {workspaceOwned && levels.repo === "admin" && (
403 <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
404 <ShieldAlert size={14} className="mt-px shrink-0" />
405 Repositories: admin makes this token an admin of {slug}'s repositories: it can rename, archive and delete them
406 and change who has access, as far as its other permissions allow. Without it, it has Write, as a member does.
407 </p>
408 )}
409 </section>
Merge main into Artifacts Phase 2410
411 {!workspaceOwned && (
412 <section className="space-y-3">
413 <div>
414 <h3 className="text-sm font-medium text-fg">Website</h3>
415 <p className="mt-0.5 text-xs text-faint">For automation that drives a browser, such as end-to-end tests.</p>
416 </div>
417 <CheckboxOption
418 id="token-website"
419 name="website"
420 value="on"
421 checked={website}
422 onCheckedChange={(on) => setWebsite(on === true)}
423 className="rounded-md border border-line px-3 py-2.5"
424 labelClassName="font-medium"
425 label="Use the website as you"
426 description={
427 <>
428 Sent as <code className="font-mono">Authorization: Bearer</code> on each request, it signs g1t.sh in as you
429 without a password or a two-factor code. Tokens, two-factor authentication, your password, email addresses,
430 keys, deleting your account or a workspace, giving a workspace away and payment methods still need you to sign
431 in.
432 </>
433 }
434 />
435 {website && (
436 <Note tone="warn">
437 The website does not hold this token to its permissions above: treat it as able to do anything you can in
438 the workspaces it reaches. Keep it as safe as your password, and give it an expiration.
439 </Note>
440 )}
441 </section>
442 )}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers443 </div>
444 );
445}

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