Skip to content
294 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.

Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules1import { Info, Search, TriangleAlert } from "lucide-react";
2import { useMemo, useState } from "react";
3
4import {
5 PERMISSION_GROUPS,
6 permissionLevels,
7 permissionScopes,
8 type FineGrainedPermission,
9 type PermissionAccess,
10 type RepositorySelection,
11} from "@g1t/contracts";
12import type { AccessToken, TokenPolicy } from "@g1t/contracts";
13
14import { cn } from "../lib/cn";
15import {
16 DEFAULT_FINE_GRAINED_DAYS,
17 accessLabel,
18 describeDays,
19 expiryChoices,
20 permissionsFor,
21 policyNote,
22} from "../lib/fine-grained";
23import { Badge } from "./ui/badge";
24import { Hint } from "./ui/hint";
25import { CONTROL } from "./ui/input";
26import { Field, Input, Textarea } from "./ui";
27
28// The form for a fine-grained personal access token: its resource owner,
29// when it expires, which repositories it reaches, and a level for each
30// permission. Every control is a plain form field (lib/fine-grained.ts reads
31// them back), so it posts the same without JavaScript; the script hides
32// what does not apply to the chosen resource owner.
33
34/** A workspace you can aim a token at, with its rules. */
35export type OwnerChoice = {
36 slug: string;
37 owner: boolean;
38 policy: TokenPolicy | null;
39 /** Its repositories you can see, as `owner/name`. */
40 repos: string[];
41};
42
43function PermissionRow({
44 permission,
45 value,
46 onChange,
47}: {
48 permission: FineGrainedPermission;
49 value: PermissionAccess;
50 onChange: (value: PermissionAccess) => void;
51}) {
52 const fixed = permission.name === "metadata";
53 const levels: PermissionAccess[] = fixed ? ["read"] : ["none", ...permissionLevels(permission)];
54 const scopes = permissionScopes(permission, value);
55 const id = `perm-${permission.name}`;
56 return (
57 <div className="flex flex-col gap-2 py-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
58 <div className="min-w-0">
59 <label htmlFor={id} className="flex items-center gap-1.5 text-sm font-medium text-fg">
60 {permission.label}
61 {fixed && <Badge>Mandatory</Badge>}
62 </label>
63 <p className="mt-0.5 text-xs leading-snug text-faint">{permission.about}</p>
64 {scopes.length > 0 && (
65 <p className="mt-1 font-mono text-[0.6875rem] text-muted">{scopes.join(" ")}</p>
66 )}
67 </div>
68 <select
69 id={id}
70 name={`perm.${permission.name}`}
71 value={value}
72 disabled={fixed}
73 onChange={(event) => onChange(event.target.value as PermissionAccess)}
74 className={cn(CONTROL, "w-full shrink-0 sm:w-44", value !== "none" && "border-accent/50 text-fg")}
75 >
76 {levels.map((level) => (
77 <option key={level} value={level}>
78 {accessLabel(level, permission)}
79 </option>
80 ))}
81 </select>
82 {fixed && <input type="hidden" name={`perm.${permission.name}`} value="read" />}
83 </div>
84 );
85}
86
87export function FineGrainedForm({
88 owners,
89 editing,
90}: {
91 owners: OwnerChoice[];
92 /** The token being changed: its resource owner and expiry stay. */
93 editing?: AccessToken;
94}) {
95 const details = editing?.fineGrained ?? null;
96 const [owner, setOwner] = useState<string>(editing ? (details?.workspace ?? "") : (owners[0]?.slug ?? ""));
97 const chosen = owners.find((choice) => choice.slug === owner) ?? null;
98 const workspace = owner !== "";
99 const [selection, setSelection] = useState<RepositorySelection>(details?.repositorySelection ?? "all");
100 const [picked, setPicked] = useState<string[]>(details?.repositories ?? []);
101 const [filter, setFilter] = useState("");
102 const [levels, setLevels] = useState<Record<string, PermissionAccess>>(() => {
103 const start: Record<string, PermissionAccess> = { metadata: "read" };
104 for (const [name, access] of Object.entries(details?.permissions ?? {})) if (access) start[name] = access;
105 return start;
106 });
107 const choices = expiryChoices(chosen?.policy);
108 const [days, setDays] = useState<number>(choices.includes(DEFAULT_FINE_GRAINED_DAYS) ? DEFAULT_FINE_GRAINED_DAYS : choices[choices.length - 1]);
109 const note = workspace ? policyNote(owner, chosen?.policy, chosen?.owner ?? false) : null;
110 const blocked = workspace && chosen?.policy?.allowFineGrained === false;
111 const shownRepos = useMemo(() => {
112 const all = chosen?.repos ?? [];
113 const query = filter.trim().toLowerCase();
114 return query ? all.filter((repo) => repo.toLowerCase().includes(query)) : all;
115 }, [chosen, filter]);
116 const permissions = permissionsFor(workspace);
117 const given = Object.entries(levels).filter(([name, access]) => access !== "none" && name !== "metadata" && permissions.some((p) => p.name === name)).length;
118
119 return (
120 <div className="space-y-6">
121 <div className="grid gap-4 sm:grid-cols-2">
122 <Field label="Token name" hint="Name it after what will use it.">
123 <Input name="name" maxLength={100} placeholder="release bot" defaultValue={editing?.name} required={!editing} />
124 </Field>
125 {editing ? (
126 <div className="flex flex-col gap-1.5">
127 <span className="text-sm font-medium text-muted">Resource owner</span>
128 <p className="py-1.5 font-mono text-sm">{details?.workspace ?? "Your account"}</p>
129 <input type="hidden" name="owner" value={owner} />
130 </div>
131 ) : (
132 <div className="flex flex-col gap-1.5">
133 <label htmlFor="token-owner" className="text-sm font-medium text-muted">
134 Resource owner
135 </label>
136 <select id="token-owner" name="owner" value={owner} onChange={(event) => setOwner(event.target.value)} className={CONTROL}>
137 {owners.map((choice) => (
138 <option key={choice.slug} value={choice.slug} disabled={choice.policy?.allowFineGrained === false}>
139 {choice.slug}
140 {choice.policy?.allowFineGrained === false ? " (does not allow fine-grained tokens)" : ""}
141 </option>
142 ))}
143 <option value="">Your account</option>
144 </select>
145 </div>
146 )}
147 </div>
148 <Field label="Description" hint="Optional. What it is for, for whoever reviews it.">
149 <Textarea name="description" rows={2} maxLength={500} defaultValue={editing?.description ?? ""} />
150 </Field>
151
152 {!editing && (
153 <div className="flex flex-col gap-1.5 sm:max-w-xs">
154 <label htmlFor="token-expires" className="text-sm font-medium text-muted">
155 Expiration
156 </label>
157 <select id="token-expires" name="expires" value={days} onChange={(event) => setDays(Number(event.target.value))} className={CONTROL}>
158 {choices.map((choice) => (
159 <option key={choice} value={choice}>
160 {describeDays(choice)}
161 </option>
162 ))}
163 </select>
164 <p className="text-xs text-faint">
165 {chosen?.policy?.maxLifetimeDays
166 ? `${owner} allows at most ${describeDays(chosen.policy.maxLifetimeDays)}.`
167 : "Fine-grained tokens always expire, within a year."}
168 </p>
169 </div>
170 )}
171
172 {note && (
173 <p className={cn("flex items-start gap-2 rounded-md border px-3 py-2 text-xs", blocked ? "border-danger/40 bg-danger/5 text-danger" : "border-warn/40 bg-warn/5 text-warn")}>
174 {blocked ? <TriangleAlert size={14} className="mt-px shrink-0" /> : <Info size={14} className="mt-px shrink-0" />}
175 {note}
176 </p>
177 )}
178
179 {workspace ? (
180 <fieldset className="space-y-2">
181 <legend className="text-sm font-medium text-fg">Repository access</legend>
182 <div className="grid gap-2 sm:grid-cols-3">
183 {(
184 [
185 ["public", "Public repositories", "Read-only, across g1t."],
186 ["all", "All repositories", `Every repository of ${owner}, ones made later too.`],
187 ["selected", "Only select repositories", "Up to 50, chosen below."],
188 ] as const
189 ).map(([value, label, about]) => (
190 <label
191 key={value}
192 className={cn(
193 "flex cursor-pointer items-start gap-2.5 rounded-md border p-3 transition-colors",
194 selection === value ? "border-accent/50 bg-accent/5" : "border-line hover:border-line-strong",
195 )}
196 >
197 <input
198 type="radio"
199 name="repository_selection"
200 value={value}
201 checked={selection === value}
202 onChange={() => setSelection(value)}
203 className="mt-0.5 accent-accent"
204 />
205 <span className="min-w-0">
206 <span className="block text-sm text-fg">{label}</span>
207 <span className="block text-xs text-faint">{about}</span>
208 </span>
209 </label>
210 ))}
211 </div>
212 {selection === "selected" && (
213 <div className="rounded-md border border-line">
214 <div className="flex items-center gap-2 border-b border-line px-3 py-2">
215 <Search size={14} className="shrink-0 text-faint" />
216 <input
217 type="search"
218 value={filter}
219 onChange={(event) => setFilter(event.target.value)}
220 placeholder={`Find a repository of ${owner}`}
221 aria-label="Find a repository"
222 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
223 />
224 <span className="shrink-0 text-xs text-faint">{picked.length} chosen</span>
225 </div>
226 <div className="max-h-56 overflow-y-auto px-3 py-1.5">
227 {shownRepos.length === 0 && <p className="py-2 text-xs text-faint">No repositories match.</p>}
228 {shownRepos.map((repo) => (
229 <label key={repo} className="flex cursor-pointer items-center gap-2.5 py-1 text-sm">
230 <input
231 type="checkbox"
232 name="repo"
233 value={repo}
234 checked={picked.includes(repo)}
235 onChange={(event) =>
236 setPicked((now) => (event.target.checked ? [...now, repo] : now.filter((name) => name !== repo)))
237 }
238 className="size-4 accent-accent"
239 />
240 <span className="truncate font-mono text-[0.8125rem]">{repo}</span>
241 </label>
242 ))}
243 </div>
244 </div>
245 )}
246 </fieldset>
247 ) : (
248 <p className="rounded-md border border-line px-3 py-2 text-xs text-muted">
249 With your account as its resource owner, a token reads public repositories and does what its account
250 permissions allow. To reach a workspace's repositories, choose the workspace.
251 </p>
252 )}
253
254 <div className="space-y-4">
255 <div className="flex items-baseline justify-between gap-4">
256 <h3 className="text-sm font-medium text-fg">Permissions</h3>
257 <span className="text-xs text-faint">{given === 1 ? "1 permission" : `${given} permissions`}</span>
258 </div>
259 {PERMISSION_GROUPS.map((group) => {
260 const rows = permissions.filter((permission) => permission.group === group.group);
261 if (rows.length === 0) return null;
262 return (
263 <section key={group.group} aria-labelledby={`group-${group.group}`} className="rounded-md border border-line">
264 <header className="border-b border-line px-3 py-2 sm:px-4">
265 <h4 id={`group-${group.group}`} className="text-xs font-medium text-muted">
266 {group.label}
267 </h4>
268 <p className="text-xs text-faint">{group.about}</p>
269 </header>
270 <div className="divide-y divide-line px-3 sm:px-4">
271 {rows.map((permission) => (
272 <PermissionRow
273 key={permission.name}
274 permission={permission}
275 value={levels[permission.name] ?? "none"}
276 onChange={(value) => setLevels((now) => ({ ...now, [permission.name]: value }))}
277 />
278 ))}
279 </div>
280 </section>
281 );
282 })}
283 {levels.workflows === "write" && (
284 <Hint label="Workflows run with the repository's secrets: a token that can change them can reach those secrets.">
285 <p className="flex items-start gap-1.5 text-xs text-warn">
286 <TriangleAlert size={13} className="mt-px shrink-0" />
287 Workflows: write lets this token add and change workflow files.
288 </p>
289 </Hint>
290 )}
291 </div>
292 </div>
293 );
294}