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

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