Skip to content
445 linesCodeBlameRaw
1import { 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";
31import { Checkbox, CheckboxOption } from "./ui/checkbox";
32import { Hint } from "./ui/hint";
33import { RadioGroup, RadioOption } from "./ui/radio-group";
34import { SelectField } from "./ui/select";
35import { Field, Input, Textarea } from "./ui";
36import { Button } from "./ui/button";
37
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 );
218 const [website, setWebsite] = useState<boolean>(editing?.website ?? false);
219
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)));
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);
241
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}>
336 <Button
337 type="button"
338 onClick={() => choosePreset(option.id)}
339 variant="outline"
340 size="inline"
341 className="rounded-full px-2.5 py-0.5 text-xs text-muted"
342 >
343 {option.label}
344 </Button>
345 </Hint>
346 ))}
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>
358 <Button
359 type="button"
360 onClick={() => setLevels({})}
361 variant="outline"
362 size="inline"
363 className="rounded-full px-2.5 py-0.5 text-xs text-muted font-normal"
364 >
365 Clear
366 </Button>
367 <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>
410
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 )}
443 </div>
444 );
445}