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.

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