| 1 | import { Info, Search, ShieldAlert, TriangleAlert } from "lucide-react"; |
| 2 | import { useMemo, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { |
| 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"; |
| 14 | import type { AccessToken, RepositorySelection, TokenPolicy } from "@g1t/contracts"; |
| 15 | |
| 16 | import { cn } from "../lib/cn"; |
| 17 | import { |
| 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"; |
| 31 | import { Checkbox } from "./ui/checkbox"; |
| 32 | import { Hint } from "./ui/hint"; |
| 33 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| 34 | import { SelectField } from "./ui/select"; |
| 35 | import { 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. */ |
| 44 | export 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 | |
| 53 | function 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. */ |
| 93 | function 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 | |
| 168 | function 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 | |
| 184 | export 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 | } |