Skip to content
431 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
239 return (
240 <div className="space-y-8">
241 <section className="space-y-4">
242 <Field label="Token name" hint="Name it after what will use it.">
243 <Input name="name" maxLength={100} placeholder="release bot" defaultValue={editing?.name} required={!editing} />
244 </Field>
245 <Field label="Description" hint="Optional. What it is for, for whoever reviews it.">
246 <Textarea name="description" rows={2} maxLength={500} defaultValue={editing?.description ?? ""} />
247 </Field>
248 {!editing && (
249 <div className="flex flex-col gap-1.5 sm:max-w-xs">
250 <label htmlFor="token-expires" className="text-sm font-medium text-muted">
251 Expiration
252 </label>
253 <SelectField id="token-expires" name="expires" value={expiresShown} onValueChange={setExpires} options={expiryOptions} />
254 {expiresShown === "never" ? (
255 <p className="flex items-start gap-1.5 text-xs text-warn">
256 <TriangleAlert size={13} className="mt-px shrink-0" />
257 It works until someone deletes it. Prefer an expiration.
258 </p>
259 ) : (
260 !choices.never && <p className="text-xs text-faint">The workspaces it reaches allow at most {describeDays(choices.days[choices.days.length - 1]!)}.</p>
261 )}
262 </div>
263 )}
264 </section>
265
266 <section className="space-y-4">
267 <div>
268 <h3 className="text-sm font-medium text-fg">Where it reaches</h3>
269 <p className="mt-0.5 text-xs text-faint">
270 {workspaceOwned
271 ? `It acts as ${slug}, in ${slug} only, and keeps working when the person who made it leaves.`
272 : "It never reaches more than you can; this narrows it further."}
273 </p>
274 </div>
275 {!workspaceOwned &&
276 (editing ? (
277 <p className="rounded-md border border-line px-3 py-2 text-sm">
278 {reach === ALL_WORKSPACES
279 ? "All your workspaces"
280 : reach === NO_WORKSPACE
281 ? "No workspace: your account and public repositories"
282 : <span className="font-mono">{reach}</span>}
283 <input type="hidden" name="workspace" value={reach} />
284 </p>
285 ) : (
286 <div className="flex flex-col gap-1.5">
287 <label htmlFor="token-workspace" className="text-sm font-medium text-muted">
288 Workspaces
289 </label>
290 <SelectField
291 id="token-workspace"
292 name="workspace"
293 value={reach}
294 onValueChange={setReach}
295 options={[
296 { value: ALL_WORKSPACES, label: "All your workspaces", description: "Every workspace you belong to, now and later." },
297 ...workspaces.map((choice) => ({
298 value: choice.slug,
299 label: choice.slug,
300 disabled: choice.policy?.allowTokensForThisWorkspace === false,
301 description: choice.policy?.allowTokensForThisWorkspace === false ? "Does not allow tokens made for it" : "This workspace only",
302 })),
303 { value: NO_WORKSPACE, label: "No workspace", description: "Your account and public repositories only." },
304 ]}
305 />
306 </div>
307 ))}
308 {keptOut.length > 0 && (
309 <Note tone="warn">
310 {keptOut.join(", ")} {keptOut.length === 1 ? "keeps" : "keep"} out tokens made for all of a member's workspaces: make a token for{" "}
311 {keptOut.length === 1 ? "it" : "each"} alone to reach {keptOut.length === 1 ? "it" : "them"}.
312 </Note>
313 )}
314 {note && <Note tone={blocked ? "danger" : "warn"}>{note}</Note>}
315 {one && (
316 <RepositoryChoice
317 slug={one}
318 repos={workspaceOwned ? repos : (chosen?.repos ?? editing?.repositories ?? [])}
319 selection={selection}
320 onSelection={setSelection}
321 picked={picked}
322 onPicked={setPicked}
323 allowPublic={!workspaceOwned}
324 />
325 )}
326 </section>
327
328 <section className="space-y-4">
329 <div className="flex flex-wrap items-center gap-1.5">
330 <h3 className="mr-1 text-sm font-medium text-fg">Permissions</h3>
331 {PRESETS.filter((option) => option.id !== "full").map((option) => (
332 <Hint key={option.id} label={option.description}>
333 <Button
334 type="button"
335 onClick={() => choosePreset(option.id)}
336 variant="outline"
337 size="inline"
338 className="rounded-full px-2.5 py-0.5 text-xs text-muted"
339 >
340 {option.label}
341 </Button>
342 </Hint>
343 ))}
344 <Button
345 type="button"
346 onClick={() => setLevels({})}
347 variant="outline"
348 size="inline"
349 className="rounded-full px-2.5 py-0.5 text-xs text-muted font-normal"
350 >
351 Clear
352 </Button>
353 <span className="ml-auto text-xs text-faint">{given === 1 ? "1 permission" : `${given} permissions`}</span>
354 </div>
355 {groups.map((group) => (
356 <section key={group.group} aria-labelledby={`group-${group.group}`} className="rounded-md border border-line">
357 <header className="border-b border-line px-3 py-2 sm:px-4">
358 <h4 id={`group-${group.group}`} className="text-xs font-medium text-muted">
359 {group.label}
360 </h4>
361 <p className="text-xs text-faint">{group.about}</p>
362 </header>
363 <div className="divide-y divide-line px-3 sm:px-4">
364 {group.resources.map(({ resource, label }) => (
365 <PermissionRow
366 key={resource}
367 resource={resource}
368 label={label}
369 value={levels[resource] ?? "none"}
370 onChange={(value) =>
371 setLevels((now) => {
372 const next = { ...now };
373 if (value === "none") delete next[resource];
374 else next[resource] = value;
375 return next;
376 })
377 }
378 />
379 ))}
380 </div>
381 </section>
382 ))}
383 {levels.workflow_files === "write" && (
384 <Note tone="warn">
385 Workflow files: write lets this token add and change workflow files, which run with the repository's secrets.
386 </Note>
387 )}
388 {workspaceOwned && levels.repo === "admin" && (
389 <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
390 <ShieldAlert size={14} className="mt-px shrink-0" />
391 Repositories: admin makes this token an admin of {slug}'s repositories: it can rename, archive and delete them
392 and change who has access, as far as its other permissions allow. Without it, it has Write, as a member does.
393 </p>
394 )}
395 </section>
396
397 {!workspaceOwned && (
398 <section className="space-y-3">
399 <div>
400 <h3 className="text-sm font-medium text-fg">Website</h3>
401 <p className="mt-0.5 text-xs text-faint">For automation that drives a browser, such as end-to-end tests.</p>
402 </div>
403 <CheckboxOption
404 id="token-website"
405 name="website"
406 value="on"
407 checked={website}
408 onCheckedChange={(on) => setWebsite(on === true)}
409 className="rounded-md border border-line px-3 py-2.5"
410 labelClassName="font-medium"
411 label="Use the website as you"
412 description={
413 <>
414 Sent as <code className="font-mono">Authorization: Bearer</code> on each request, it signs g1t.sh in as you
415 without a password or a two-factor code. Tokens, two-factor authentication, your password, email addresses,
416 keys, deleting your account or a workspace, giving a workspace away and payment methods still need you to sign
417 in.
418 </>
419 }
420 />
421 {website && (
422 <Note tone="warn">
423 The website does not hold this token to its permissions above: treat it as able to do anything you can in
424 the workspaces it reaches. Keep it as safe as your password, and give it an expiration.
425 </Note>
426 )}
427 </section>
428 )}
429 </div>
430 );
431}