| 1 | import { Info, Search, ShieldAlert, TriangleAlert } from "lucide-react"; |
| 2 | import { useMemo, useState, type ReactNode } from "react"; |
| 3 | |
| 4 | import { PRESETS, levelsOf, permissionsOf, presetScopes, type Permissions, type PresetId, type ScopeLevel, type ScopeResource } from "@g1t/contracts/scopes"; |
| 5 | import type { AccessToken, RepositorySelection, TokenPolicy } from "@g1t/contracts"; |
| 6 | |
| 7 | import { cn } from "../lib/cn"; |
| 8 | import { |
| 9 | ALL_WORKSPACES, |
| 10 | DEFAULT_EXPIRY_DAYS, |
| 11 | NO_WORKSPACE, |
| 12 | describeDays, |
| 13 | expiryChoices, |
| 14 | isDangerousLevel, |
| 15 | keptOutOfAll, |
| 16 | levelAbout, |
| 17 | levelLabel, |
| 18 | permissionGroups, |
| 19 | policyNote, |
| 20 | tokenPermissions, |
| 21 | } from "../lib/access-tokens"; |
| 22 | import { Checkbox, CheckboxOption } from "./ui/checkbox"; |
| 23 | import { Hint } from "./ui/hint"; |
| 24 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| 25 | import { SelectField } from "./ui/select"; |
| 26 | import { Field, Input, Textarea } from "./ui"; |
| 27 | import { Button } from "./ui/button"; |
| 28 | |
| 29 | // The one form for an access token, a person's or a workspace's: its name, |
| 30 | // when it expires, where it reaches (the workspaces and repositories it is |
| 31 | // made for) and its permissions, a level for each resource. Every control |
| 32 | // is a form field (lib/access-tokens.ts reads them back), so it posts the |
| 33 | // same without JavaScript; the script hides what does not apply. |
| 34 | |
| 35 | /** A workspace a personal token can be made for, with its rules and repositories. */ |
| 36 | export type WorkspaceChoice = { |
| 37 | slug: string; |
| 38 | /** Whether you are an owner there: your tokens never wait for approval. */ |
| 39 | owner: boolean; |
| 40 | policy: TokenPolicy | null; |
| 41 | /** Its repositories you can see, as `owner/name`. */ |
| 42 | repos: string[]; |
| 43 | }; |
| 44 | |
| 45 | function PermissionRow({ |
| 46 | resource, |
| 47 | label, |
| 48 | value, |
| 49 | onChange, |
| 50 | }: { |
| 51 | resource: ScopeResource; |
| 52 | label: string; |
| 53 | value: ScopeLevel | "none"; |
| 54 | onChange: (value: ScopeLevel | "none") => void; |
| 55 | }) { |
| 56 | const id = `perm-${resource}`; |
| 57 | const about = levelAbout(resource, value); |
| 58 | const levels: (ScopeLevel | "none")[] = ["none", ...levelsOf(resource)]; |
| 59 | return ( |
| 60 | <div className="flex flex-col gap-2 py-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-6"> |
| 61 | <div className="min-w-0"> |
| 62 | <label htmlFor={id} className="text-sm font-medium text-fg"> |
| 63 | {label} |
| 64 | </label> |
| 65 | <p className={cn("mt-0.5 text-xs leading-snug", isDangerousLevel(value) ? "text-danger" : "text-faint")}>{about ?? "No access."}</p> |
| 66 | </div> |
| 67 | <SelectField |
| 68 | id={id} |
| 69 | name={`perm.${resource}`} |
| 70 | value={value} |
| 71 | onValueChange={(next) => onChange(next as ScopeLevel | "none")} |
| 72 | className={cn("w-full shrink-0 sm:w-48", value !== "none" && (isDangerousLevel(value) ? "border-danger/50" : "border-accent/50"))} |
| 73 | options={levels.map((level) => ({ value: level, label: levelLabel(resource, level) }))} |
| 74 | /> |
| 75 | </div> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | /** The repositories a token reaches in one workspace: all, the ones picked, or none of the private ones. */ |
| 80 | function RepositoryChoice({ |
| 81 | slug, |
| 82 | repos, |
| 83 | selection, |
| 84 | onSelection, |
| 85 | picked, |
| 86 | onPicked, |
| 87 | allowPublic, |
| 88 | }: { |
| 89 | slug: string; |
| 90 | repos: string[]; |
| 91 | selection: RepositorySelection; |
| 92 | onSelection: (value: RepositorySelection) => void; |
| 93 | picked: string[]; |
| 94 | onPicked: (value: string[]) => void; |
| 95 | allowPublic: boolean; |
| 96 | }) { |
| 97 | const [filter, setFilter] = useState(""); |
| 98 | const shown = useMemo(() => { |
| 99 | const query = filter.trim().toLowerCase(); |
| 100 | return query ? repos.filter((repo) => repo.toLowerCase().includes(query)) : repos; |
| 101 | }, [repos, filter]); |
| 102 | return ( |
| 103 | <fieldset className="space-y-3"> |
| 104 | <legend className="text-sm font-medium text-fg">Repository access</legend> |
| 105 | <RadioGroup name="repository_selection" value={selection} onValueChange={(value) => onSelection(value as RepositorySelection)} className="gap-2.5"> |
| 106 | <RadioOption value="all" label="All repositories" description={`Every repository of ${slug}, ones made later too.`} /> |
| 107 | <RadioOption value="selected" label="Only select repositories" description="Up to 50, chosen below." /> |
| 108 | {allowPublic && ( |
| 109 | <RadioOption |
| 110 | value="public" |
| 111 | label="No private repositories" |
| 112 | description={`Public repositories, read-only, and ${slug}'s own settings its permissions allow.`} |
| 113 | /> |
| 114 | )} |
| 115 | </RadioGroup> |
| 116 | {selection === "selected" && ( |
| 117 | <div className="rounded-md border border-line"> |
| 118 | <div className="flex items-center gap-2 border-b border-line px-3 py-2"> |
| 119 | <Search size={14} className="shrink-0 text-faint" /> |
| 120 | <input |
| 121 | type="search" |
| 122 | value={filter} |
| 123 | onChange={(event) => setFilter(event.target.value)} |
| 124 | placeholder={`Find a repository of ${slug}`} |
| 125 | aria-label="Find a repository" |
| 126 | className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" |
| 127 | /> |
| 128 | <span className="shrink-0 text-xs text-faint">{picked.length} chosen</span> |
| 129 | </div> |
| 130 | <div className="max-h-56 overflow-y-auto px-3 py-1.5"> |
| 131 | {shown.length === 0 && <p className="py-2 text-xs text-faint">No repositories match.</p>} |
| 132 | {shown.map((repo) => ( |
| 133 | <label key={repo} className="flex cursor-pointer items-center gap-2.5 py-1 text-sm"> |
| 134 | <Checkbox |
| 135 | name="repo" |
| 136 | value={repo} |
| 137 | checked={picked.includes(repo)} |
| 138 | onCheckedChange={(on) => onPicked(on === true ? [...picked, repo] : picked.filter((name) => name !== repo))} |
| 139 | /> |
| 140 | <span className="truncate font-mono text-[0.8125rem]">{repo}</span> |
| 141 | </label> |
| 142 | ))} |
| 143 | </div> |
| 144 | </div> |
| 145 | )} |
| 146 | </fieldset> |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | function Note({ tone, children }: { tone: "warn" | "danger" | "info"; children: ReactNode }) { |
| 151 | return ( |
| 152 | <p |
| 153 | className={cn( |
| 154 | "flex items-start gap-2 rounded-md border px-3 py-2 text-xs", |
| 155 | tone === "danger" && "border-danger/40 bg-danger/5 text-danger", |
| 156 | tone === "warn" && "border-warn/40 bg-warn/5 text-warn", |
| 157 | tone === "info" && "border-line text-muted", |
| 158 | )} |
| 159 | > |
| 160 | {tone === "info" ? <Info size={14} className="mt-px shrink-0" /> : <TriangleAlert size={14} className="mt-px shrink-0" />} |
| 161 | <span>{children}</span> |
| 162 | </p> |
| 163 | ); |
| 164 | } |
| 165 | |
| 166 | export function TokenForm({ |
| 167 | workspaceOwned = false, |
| 168 | slug, |
| 169 | workspaces = [], |
| 170 | repos = [], |
| 171 | editing, |
| 172 | preset = "read_only", |
| 173 | }: { |
| 174 | /** A workspace's own token, made by an owner: it reaches that workspace. */ |
| 175 | workspaceOwned?: boolean; |
| 176 | /** For a workspace's own token: the workspace. */ |
| 177 | slug?: string; |
| 178 | /** For a personal token: the workspaces you belong to. */ |
| 179 | workspaces?: WorkspaceChoice[]; |
| 180 | /** For a workspace's own token: the workspace's repositories. */ |
| 181 | repos?: string[]; |
| 182 | /** The token being changed: its reach's workspace and expiry stay. */ |
| 183 | editing?: AccessToken; |
| 184 | /** What a new token's permissions start as. */ |
| 185 | preset?: PresetId; |
| 186 | }) { |
| 187 | // Where it reaches. A personal token: every workspace (*), none (-), or one. |
| 188 | const initialReach = editing ? (editing.workspace ?? (editing.repositorySelection === "public" ? NO_WORKSPACE : ALL_WORKSPACES)) : ALL_WORKSPACES; |
| 189 | const [reach, setReach] = useState<string>(initialReach); |
| 190 | const one = workspaceOwned ? (slug ?? "") : reach !== ALL_WORKSPACES && reach !== NO_WORKSPACE ? reach : null; |
| 191 | const chosen = workspaces.find((choice) => choice.slug === one) ?? null; |
| 192 | const [selection, setSelection] = useState<RepositorySelection>(editing?.repositorySelection ?? "all"); |
| 193 | const [picked, setPicked] = useState<string[]>(editing?.repositories ?? []); |
| 194 | const [levels, setLevels] = useState<Permissions>(() => (editing ? tokenPermissions(editing) : permissionsOf(presetScopes(preset) ?? null))); |
| 195 | const [website, setWebsite] = useState<boolean>(editing?.website ?? false); |
| 196 | |
| 197 | // The rules of the workspaces it would reach decide how long it may last. |
| 198 | const reached = workspaceOwned ? [] : one ? [chosen] : reach === ALL_WORKSPACES ? workspaces : []; |
| 199 | const choices = expiryChoices(reached.map((choice) => choice?.policy ?? null)); |
| 200 | const [expires, setExpires] = useState<string>( |
| 201 | choices.days.includes(DEFAULT_EXPIRY_DAYS) ? String(DEFAULT_EXPIRY_DAYS) : String(choices.days[choices.days.length - 1]), |
| 202 | ); |
| 203 | const expiryOptions = [ |
| 204 | ...choices.days.map((days) => ({ value: String(days), label: describeDays(days) })), |
| 205 | ...(choices.never ? [{ value: "never", label: "No expiration" }] : []), |
| 206 | ]; |
| 207 | const expiresShown = expiryOptions.some((option) => option.value === expires) ? expires : expiryOptions[expiryOptions.length - 1]!.value; |
| 208 | |
| 209 | const groups = permissionGroups(workspaceOwned); |
| 210 | const given = groups.flatMap((group) => group.resources).filter(({ resource }) => levels[resource]).length; |
| 211 | const note = one && !workspaceOwned ? policyNote(one, chosen?.policy, chosen?.owner ?? false) : null; |
| 212 | const blocked = Boolean(one && !workspaceOwned && chosen?.policy && !chosen.policy.allowTokensForThisWorkspace); |
| 213 | const keptOut = !workspaceOwned && reach === ALL_WORKSPACES ? keptOutOfAll(workspaces) : []; |
| 214 | const choosePreset = (id: PresetId) => setLevels(permissionsOf(presetScopes(id))); |
| 215 | // Every permission this kind of token can have, each at its highest level. |
| 216 | const chooseAll = () => |
| 217 | setLevels(Object.fromEntries(groups.flatMap((group) => group.resources.map((row) => [row.resource, levelsOf(row.resource).at(-1)!]))) as typeof levels); |
| 218 | |
| 219 | return ( |
| 220 | <div className="space-y-8"> |
| 221 | <section className="space-y-4"> |
| 222 | <Field label="Token name" hint="Name it after what will use it."> |
| 223 | <Input name="name" maxLength={100} placeholder="release bot" defaultValue={editing?.name} required={!editing} /> |
| 224 | </Field> |
| 225 | <Field label="Description" hint="Optional. What it is for, for whoever reviews it."> |
| 226 | <Textarea name="description" rows={2} maxLength={500} defaultValue={editing?.description ?? ""} /> |
| 227 | </Field> |
| 228 | {!editing && ( |
| 229 | <div className="flex flex-col gap-1.5 sm:max-w-xs"> |
| 230 | <label htmlFor="token-expires" className="text-sm font-medium text-muted"> |
| 231 | Expiration |
| 232 | </label> |
| 233 | <SelectField id="token-expires" name="expires" value={expiresShown} onValueChange={setExpires} options={expiryOptions} /> |
| 234 | {expiresShown === "never" ? ( |
| 235 | <p className="flex items-start gap-1.5 text-xs text-warn"> |
| 236 | <TriangleAlert size={13} className="mt-px shrink-0" /> |
| 237 | It works until someone deletes it. Prefer an expiration. |
| 238 | </p> |
| 239 | ) : ( |
| 240 | !choices.never && ( |
| 241 | <p className="text-xs text-faint">The workspaces it reaches allow at most {describeDays(choices.days[choices.days.length - 1]!)}.</p> |
| 242 | ) |
| 243 | )} |
| 244 | </div> |
| 245 | )} |
| 246 | </section> |
| 247 | |
| 248 | <section className="space-y-4"> |
| 249 | <div> |
| 250 | <h3 className="text-sm font-medium text-fg">Where it reaches</h3> |
| 251 | <p className="mt-0.5 text-xs text-faint"> |
| 252 | {workspaceOwned |
| 253 | ? `It acts as ${slug}, in ${slug} only, and keeps working when the person who made it leaves.` |
| 254 | : "It never reaches more than you can; this narrows it further."} |
| 255 | </p> |
| 256 | </div> |
| 257 | {!workspaceOwned && |
| 258 | (editing ? ( |
| 259 | <p className="rounded-md border border-line px-3 py-2 text-sm"> |
| 260 | {reach === ALL_WORKSPACES ? ( |
| 261 | "All your workspaces" |
| 262 | ) : reach === NO_WORKSPACE ? ( |
| 263 | "No workspace: your account and public repositories" |
| 264 | ) : ( |
| 265 | <span className="font-mono">{reach}</span> |
| 266 | )} |
| 267 | <input type="hidden" name="workspace" value={reach} /> |
| 268 | </p> |
| 269 | ) : ( |
| 270 | <div className="flex flex-col gap-1.5"> |
| 271 | <label htmlFor="token-workspace" className="text-sm font-medium text-muted"> |
| 272 | Workspaces |
| 273 | </label> |
| 274 | <SelectField |
| 275 | id="token-workspace" |
| 276 | name="workspace" |
| 277 | value={reach} |
| 278 | onValueChange={setReach} |
| 279 | options={[ |
| 280 | { value: ALL_WORKSPACES, label: "All your workspaces", description: "Every workspace you belong to, now and later." }, |
| 281 | ...workspaces.map((choice) => ({ |
| 282 | value: choice.slug, |
| 283 | label: choice.slug, |
| 284 | disabled: choice.policy?.allowTokensForThisWorkspace === false, |
| 285 | description: choice.policy?.allowTokensForThisWorkspace === false ? "Does not allow tokens made for it" : "This workspace only", |
| 286 | })), |
| 287 | { value: NO_WORKSPACE, label: "No workspace", description: "Your account and public repositories only." }, |
| 288 | ]} |
| 289 | /> |
| 290 | </div> |
| 291 | ))} |
| 292 | {keptOut.length > 0 && ( |
| 293 | <Note tone="warn"> |
| 294 | {keptOut.join(", ")} {keptOut.length === 1 ? "keeps" : "keep"} out tokens made for all of a member's workspaces: make a token for{" "} |
| 295 | {keptOut.length === 1 ? "it" : "each"} alone to reach {keptOut.length === 1 ? "it" : "them"}. |
| 296 | </Note> |
| 297 | )} |
| 298 | {note && <Note tone={blocked ? "danger" : "warn"}>{note}</Note>} |
| 299 | {one && ( |
| 300 | <RepositoryChoice |
| 301 | slug={one} |
| 302 | repos={workspaceOwned ? repos : (chosen?.repos ?? editing?.repositories ?? [])} |
| 303 | selection={selection} |
| 304 | onSelection={setSelection} |
| 305 | picked={picked} |
| 306 | onPicked={setPicked} |
| 307 | allowPublic={!workspaceOwned} |
| 308 | /> |
| 309 | )} |
| 310 | </section> |
| 311 | |
| 312 | <section className="space-y-4"> |
| 313 | <div className="flex flex-wrap items-center gap-1.5"> |
| 314 | <h3 className="mr-1 text-sm font-medium text-fg">Permissions</h3> |
| 315 | {PRESETS.filter((option) => option.id !== "full").map((option) => ( |
| 316 | <Hint key={option.id} label={option.description}> |
| 317 | <Button |
| 318 | type="button" |
| 319 | onClick={() => choosePreset(option.id)} |
| 320 | variant="outline" |
| 321 | size="inline" |
| 322 | className="rounded-full px-2.5 py-0.5 text-xs text-muted" |
| 323 | > |
| 324 | {option.label} |
| 325 | </Button> |
| 326 | </Hint> |
| 327 | ))} |
| 328 | <Hint label="Every permission, each at its highest level. A permission g1t adds later is added to the token by editing it."> |
| 329 | <Button type="button" onClick={chooseAll} variant="outline" size="inline" className="rounded-full px-2.5 py-0.5 text-xs text-muted"> |
| 330 | All |
| 331 | </Button> |
| 332 | </Hint> |
| 333 | <Button |
| 334 | type="button" |
| 335 | onClick={() => setLevels({})} |
| 336 | variant="outline" |
| 337 | size="inline" |
| 338 | className="rounded-full px-2.5 py-0.5 text-xs text-muted font-normal" |
| 339 | > |
| 340 | Clear |
| 341 | </Button> |
| 342 | <span className="ml-auto text-xs text-faint">{given === 1 ? "1 permission" : `${given} permissions`}</span> |
| 343 | </div> |
| 344 | {groups.map((group) => ( |
| 345 | <section key={group.group} aria-labelledby={`group-${group.group}`} className="rounded-md border border-line"> |
| 346 | <header className="border-b border-line px-3 py-2 sm:px-4"> |
| 347 | <h4 id={`group-${group.group}`} className="text-xs font-medium text-muted"> |
| 348 | {group.label} |
| 349 | </h4> |
| 350 | <p className="text-xs text-faint">{group.about}</p> |
| 351 | </header> |
| 352 | <div className="divide-y divide-line px-3 sm:px-4"> |
| 353 | {group.resources.map(({ resource, label }) => ( |
| 354 | <PermissionRow |
| 355 | key={resource} |
| 356 | resource={resource} |
| 357 | label={label} |
| 358 | value={levels[resource] ?? "none"} |
| 359 | onChange={(value) => |
| 360 | setLevels((now) => { |
| 361 | const next = { ...now }; |
| 362 | if (value === "none") delete next[resource]; |
| 363 | else next[resource] = value; |
| 364 | return next; |
| 365 | }) |
| 366 | } |
| 367 | /> |
| 368 | ))} |
| 369 | </div> |
| 370 | </section> |
| 371 | ))} |
| 372 | {levels.workflow_files === "write" && ( |
| 373 | <Note tone="warn">Workflow files: write lets this token add and change workflow files, which run with the repository's secrets.</Note> |
| 374 | )} |
| 375 | {workspaceOwned && levels.repo === "admin" && ( |
| 376 | <p className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger/5 px-3 py-2 text-xs text-danger"> |
| 377 | <ShieldAlert size={14} className="mt-px shrink-0" /> |
| 378 | Repositories: admin makes this token an admin of {slug}'s repositories: it can rename, archive and delete them and change who has access, as far as |
| 379 | its other permissions allow. Without it, it has Write, as a member does. |
| 380 | </p> |
| 381 | )} |
| 382 | </section> |
| 383 | |
| 384 | {!workspaceOwned && ( |
| 385 | <section className="space-y-3"> |
| 386 | <div> |
| 387 | <h3 className="text-sm font-medium text-fg">Website</h3> |
| 388 | <p className="mt-0.5 text-xs text-faint">For automation that drives a browser, such as end-to-end tests.</p> |
| 389 | </div> |
| 390 | <CheckboxOption |
| 391 | id="token-website" |
| 392 | name="website" |
| 393 | value="on" |
| 394 | checked={website} |
| 395 | onCheckedChange={(on) => setWebsite(on === true)} |
| 396 | className="rounded-md border border-line px-3 py-2.5" |
| 397 | labelClassName="font-medium" |
| 398 | label="Use the website as you" |
| 399 | description={ |
| 400 | <> |
| 401 | Sent as <code className="font-mono">Authorization: Bearer</code> on each request, it signs g1t.sh in as you without a password or a two-factor |
| 402 | code. Tokens, two-factor authentication, your password, email addresses, keys, deleting your account or a workspace, giving a workspace away and |
| 403 | payment methods still need you to sign in. |
| 404 | </> |
| 405 | } |
| 406 | /> |
| 407 | {website && ( |
| 408 | <Note tone="warn"> |
| 409 | The website does not hold this token to its permissions above: treat it as able to do anything you can in the workspaces it reaches. Keep it as |
| 410 | safe as your password, and give it an expiration. |
| 411 | </Note> |
| 412 | )} |
| 413 | </section> |
| 414 | )} |
| 415 | </div> |
| 416 | ); |
| 417 | } |