Skip to content
392 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 } 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";
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 );
217
218 // The rules of the workspaces it would reach decide how long it may last.
219 const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : [];
220 const choices = expiryChoices(reached.map((choice) => choice?.policy ?? null));
221 const [expires, setExpires] = useState<string>(
222 choices.days.includes(DEFAULT_EXPIRY_DAYS) ? String(DEFAULT_EXPIRY_DAYS) : String(choices.days[choices.days.length - 1]),
223 );
224 const expiryOptions = [
225 ...choices.days.map((days) => ({ value: String(days), label: describeDays(days) })),
226 ...(choices.never ? [{ value: "never", label: "No expiration" }] : []),
227 ];
228 const expiresShown = expiryOptions.some((option) => option.value === expires) ? expires : expiryOptions[expiryOptions.length - 1]!.value;
229
230 const groups = permissionGroups(workspaceOwned);
231 const given = groups.flatMap((group) => group.resources).filter(({ resource }) => levels[resource]).length;
232 const note = one && !workspaceOwned ? policyNote(one, chosen?.policy, chosen?.owner ?? false) : null;
233 const blocked = Boolean(one && !workspaceOwned && chosen?.policy && !chosen.policy.allowTokensForThisWorkspace);
234 const keptOut = !workspaceOwned && reach === ALL_WORKSPACES ? keptOutOfAll(workspaces) : [];
235 const choosePreset = (id: PresetId) => setLevels(permissionsOf(presetScopes(id)));
236
237 return (
238 <div className="space-y-8">
239 <section className="space-y-4">
240 <Field label="Token name" hint="Name it after what will use it.">
241 <Input name="name" maxLength={100} placeholder="release bot" defaultValue={editing?.name} required={!editing} />
242 </Field>
243 <Field label="Description" hint="Optional. What it is for, for whoever reviews it.">
244 <Textarea name="description" rows={2} maxLength={500} defaultValue={editing?.description ?? ""} />
245 </Field>
246 {!editing && (
247 <div className="flex flex-col gap-1.5 sm:max-w-xs">
248 <label htmlFor="token-expires" className="text-sm font-medium text-muted">
249 Expiration
250 </label>
251 <SelectField id="token-expires" name="expires" value={expiresShown} onValueChange={setExpires} options={expiryOptions} />
252 {expiresShown === "never" ? (
253 <p className="flex items-start gap-1.5 text-xs text-warn">
254 <TriangleAlert size={13} className="mt-px shrink-0" />
255 It works until someone deletes it. Prefer an expiration.
256 </p>
257 ) : (
258 !choices.never && <p className="text-xs text-faint">The workspaces it reaches allow at most {describeDays(choices.days[choices.days.length - 1]!)}.</p>
259 )}
260 </div>
261 )}
262 </section>
263
264 <section className="space-y-4">
265 <div>
266 <h3 className="text-sm font-medium text-fg">Where it reaches</h3>
267 <p className="mt-0.5 text-xs text-faint">
268 {workspaceOwned
269 ? `It acts as ${slug}, in ${slug} only, and keeps working when the person who made it leaves.`
270 : "It never reaches more than you can; this narrows it further."}
271 </p>
272 </div>
273 {!workspaceOwned &&
274 (editing ? (
275 <p className="rounded-md border border-line px-3 py-2 text-sm">
276 {reach === ALL_WORKSPACES
277 ? "All your workspaces"
278 : reach === NO_WORKSPACE
279 ? "No workspace: your account and public repositories"
280 : <span className="font-mono">{reach}</span>}
281 <input type="hidden" name="workspace" value={reach} />
282 </p>
283 ) : (
284 <div className="flex flex-col gap-1.5">
285 <label htmlFor="token-workspace" className="text-sm font-medium text-muted">
286 Workspaces
287 </label>
288 <SelectField
289 id="token-workspace"
290 name="workspace"
291 value={reach}
292 onValueChange={setReach}
293 options={[
294 { value: ALL_WORKSPACES, label: "All your workspaces", description: "Every workspace you belong to, now and later." },
295 ...workspaces.map((choice) => ({
296 value: choice.slug,
297 label: choice.slug,
298 disabled: choice.policy?.allowTokensForThisWorkspace === false,
299 description: choice.policy?.allowTokensForThisWorkspace === false ? "Does not allow tokens made for it" : "This workspace only",
300 })),
301 { value: NO_WORKSPACE, label: "No workspace", description: "Your account and public repositories only." },
302 ]}
303 />
304 </div>
305 ))}
306 {keptOut.length > 0 && (
307 <Note tone="warn">
308 {keptOut.join(", ")} {keptOut.length === 1 ? "keeps" : "keep"} out tokens made for all of a member's workspaces: make a token for{" "}
309 {keptOut.length === 1 ? "it" : "each"} alone to reach {keptOut.length === 1 ? "it" : "them"}.
310 </Note>
311 )}
312 {note && <Note tone={blocked ? "danger" : "warn"}>{note}</Note>}
313 {one && (
314 <RepositoryChoice
315 slug={one}
316 repos={workspaceOwned ? repos : (chosen?.repos ?? editing?.repositories ?? [])}
317 selection={selection}
318 onSelection={setSelection}
319 picked={picked}
320 onPicked={setPicked}
321 allowPublic={!workspaceOwned}
322 />
323 )}
324 </section>
325
326 <section className="space-y-4">
327 <div className="flex flex-wrap items-center gap-1.5">
328 <h3 className="mr-1 text-sm font-medium text-fg">Permissions</h3>
329 {PRESETS.filter((option) => option.id !== "full").map((option) => (
330 <Hint key={option.id} label={option.description}>
331 <button
332 type="button"
333 onClick={() => choosePreset(option.id)}
334 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"
335 >
336 {option.label}
337 </button>
338 </Hint>
339 ))}
340 <button
341 type="button"
342 onClick={() => setLevels({})}
343 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"
344 >
345 Clear
346 </button>
347 <span className="ml-auto text-xs text-faint">{given === 1 ? "1 permission" : `${given} permissions`}</span>
348 </div>
349 {groups.map((group) => (
350 <section key={group.group} aria-labelledby={`group-${group.group}`} className="rounded-md border border-line">
351 <header className="border-b border-line px-3 py-2 sm:px-4">
352 <h4 id={`group-${group.group}`} className="text-xs font-medium text-muted">
353 {group.label}
354 </h4>
355 <p className="text-xs text-faint">{group.about}</p>
356 </header>
357 <div className="divide-y divide-line px-3 sm:px-4">
358 {group.resources.map(({ resource, label }) => (
359 <PermissionRow
360 key={resource}
361 resource={resource}
362 label={label}
363 value={levels[resource] ?? "none"}
364 onChange={(value) =>
365 setLevels((now) => {
366 const next = { ...now };
367 if (value === "none") delete next[resource];
368 else next[resource] = value;
369 return next;
370 })
371 }
372 />
373 ))}
374 </div>
375 </section>
376 ))}
377 {levels.workflow_files === "write" && (
378 <Note tone="warn">
379 Workflow files: write lets this token add and change workflow files, which run with the repository's secrets.
380 </Note>
381 )}
382 {workspaceOwned && levels.repo === "admin" && (
383 <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger">
384 <ShieldAlert size={14} className="mt-px shrink-0" />
385 Repositories: admin makes this token an admin of {slug}'s repositories: it can rename, archive and delete them
386 and change who has access, as far as its other permissions allow. Without it, it has Write, as a member does.
387 </p>
388 )}
389 </section>
390 </div>
391 );
392}