Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules
13 files+1325−1200/13 viewed
| 1 | + | import { Info, Search, TriangleAlert } from "lucide-react"; | |
| 2 | + | import { useMemo, useState } from "react"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | PERMISSION_GROUPS, | |
| 6 | + | permissionLevels, | |
| 7 | + | permissionScopes, | |
| 8 | + | type FineGrainedPermission, | |
| 9 | + | type PermissionAccess, | |
| 10 | + | type RepositorySelection, | |
| 11 | + | } from "@g1t/contracts"; | |
| 12 | + | import type { AccessToken, TokenPolicy } from "@g1t/contracts"; | |
| 13 | + | ||
| 14 | + | import { cn } from "../lib/cn"; | |
| 15 | + | import { | |
| 16 | + | DEFAULT_FINE_GRAINED_DAYS, | |
| 17 | + | accessLabel, | |
| 18 | + | describeDays, | |
| 19 | + | expiryChoices, | |
| 20 | + | permissionsFor, | |
| 21 | + | policyNote, | |
| 22 | + | } from "../lib/fine-grained"; | |
| 23 | + | import { Badge } from "./ui/badge"; | |
| 24 | + | import { Hint } from "./ui/hint"; | |
| 25 | + | import { CONTROL } from "./ui/input"; | |
| 26 | + | import { 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. */ | |
| 35 | + | export 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 | + | ||
| 43 | + | function 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 | + | ||
| 87 | + | export 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 | + | } |
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 866 | 866 | <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 867 | 867 | Access tokens | |
| 868 | 868 | </SidebarLink> | |
| 869 | + | {owner && ( | |
| 870 | + | <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}> | |
| 871 | + | Personal access tokens | |
| 872 | + | </SidebarLink> | |
| 873 | + | )} | |
| 869 | 874 | <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />}> | |
| 870 | 875 | Rules | |
| 871 | 876 | </SidebarLink> | |
| ⋯ | |||
| 1401 | 1406 | projects: "Projects", | |
| 1402 | 1407 | teams: "Teams", | |
| 1403 | 1408 | tokens: "Access tokens", | |
| 1409 | + | "personal-access-tokens": "Personal access tokens", | |
| 1404 | 1410 | usage: "Usage", | |
| 1405 | 1411 | gateway: "AI Gateway", | |
| 1406 | 1412 | billing: "Billing and plans", | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { PERMISSIONS } from "@g1t/contracts/fine-grained"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | accessLabel, | |
| 8 | + | expiryChoices, | |
| 9 | + | fineGrainedFromForm, | |
| 10 | + | lifetimeFromForm, | |
| 11 | + | permissionChips, | |
| 12 | + | permissionsFor, | |
| 13 | + | policyNote, | |
| 14 | + | reachSummary, | |
| 15 | + | statusBadge, | |
| 16 | + | } from "./fine-grained.ts"; | |
| 17 | + | ||
| 18 | + | function form(fields: Record<string, string | string[]>) { | |
| 19 | + | return { | |
| 20 | + | get: (name: string) => { | |
| 21 | + | const value = fields[name]; | |
| 22 | + | return Array.isArray(value) ? (value[0] ?? null) : (value ?? null); | |
| 23 | + | }, | |
| 24 | + | getAll: (name: string) => { | |
| 25 | + | const value = fields[name]; | |
| 26 | + | return value === undefined ? [] : Array.isArray(value) ? value : [value]; | |
| 27 | + | }, | |
| 28 | + | }; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | const policy = { allowClassic: true, allowFineGrained: true, requireApproval: true, maxLifetimeDays: null, forbidNoExpiry: false }; | |
| 32 | + | ||
| 33 | + | test("a workspace's token reads its form into permissions, always with metadata", () => { | |
| 34 | + | const parsed = fineGrainedFromForm( | |
| 35 | + | form({ | |
| 36 | + | name: "release bot", | |
| 37 | + | owner: "Acme", | |
| 38 | + | expires: "30", | |
| 39 | + | repository_selection: "selected", | |
| 40 | + | repo: ["web", "acme/api", "web"], | |
| 41 | + | "perm.contents": "write", | |
| 42 | + | "perm.issues": "none", | |
| 43 | + | "perm.workflows": "write", | |
| 44 | + | // Not for a workspace: left out. | |
| 45 | + | "perm.email_addresses": "write", | |
| 46 | + | }), | |
| 47 | + | ); | |
| 48 | + | assert.ok(parsed.ok); | |
| 49 | + | assert.equal(parsed.value.workspace, "acme"); | |
| 50 | + | assert.equal(parsed.value.ttlSeconds, 30 * 86_400); | |
| 51 | + | assert.deepEqual(parsed.value.repositories, ["web", "acme/api"]); | |
| 52 | + | assert.deepEqual(parsed.value.permissions, { contents: "write", workflows: "write", metadata: "read" }); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | test("your own account takes only account permissions and no repositories", () => { | |
| 56 | + | const parsed = fineGrainedFromForm(form({ name: "inbox", owner: "", expires: "7", "perm.notifications": "write", "perm.contents": "write" })); | |
| 57 | + | assert.ok(parsed.ok); | |
| 58 | + | assert.equal(parsed.value.workspace, null); | |
| 59 | + | assert.equal(parsed.value.repositorySelection, "public"); | |
| 60 | + | assert.deepEqual(parsed.value.permissions, { notifications: "write" }); | |
| 61 | + | assert.equal(fineGrainedFromForm(form({ name: "x", owner: "", expires: "7" })).ok, false, "at least one permission"); | |
| 62 | + | }); | |
| 63 | + | ||
| 64 | + | test("mistakes are named", () => { | |
| 65 | + | assert.match((fineGrainedFromForm(form({ owner: "acme" })) as { error: string }).error, /Name/); | |
| 66 | + | assert.match((fineGrainedFromForm(form({ name: "x", owner: "acme", expires: "400" })) as { error: string }).error, /366/); | |
| 67 | + | assert.match((fineGrainedFromForm(form({ name: "x", owner: "acme", expires: "30", repository_selection: "selected" })) as { error: string }).error, /repository/); | |
| 68 | + | assert.match((fineGrainedFromForm(form({ name: "x", owner: "acme", expires: "30", "perm.workflows": "read" })) as { error: string }).error, /Workflows/); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("the form offers each group to the right resource owner", () => { | |
| 72 | + | assert.ok(permissionsFor(true).every((permission) => permission.group !== "account")); | |
| 73 | + | assert.ok(permissionsFor(false).every((permission) => permission.group === "account")); | |
| 74 | + | assert.equal(permissionsFor(true).length + permissionsFor(false).length, PERMISSIONS.length); | |
| 75 | + | assert.equal(accessLabel("write", PERMISSIONS.find((p) => p.name === "workflows")), "Write"); | |
| 76 | + | assert.equal(accessLabel("write", PERMISSIONS.find((p) => p.name === "contents")), "Read and write"); | |
| 77 | + | }); | |
| 78 | + | ||
| 79 | + | test("lifetimes follow the workspace's rules", () => { | |
| 80 | + | assert.deepEqual(expiryChoices(null), [7, 30, 60, 90, 180, 366]); | |
| 81 | + | assert.deepEqual(expiryChoices({ ...policy, maxLifetimeDays: 90 }), [7, 30, 60, 90]); | |
| 82 | + | assert.deepEqual(expiryChoices({ ...policy, maxLifetimeDays: 45 }), [7, 30, 45]); | |
| 83 | + | assert.deepEqual(lifetimeFromForm(""), { ok: true, value: null }); | |
| 84 | + | assert.deepEqual(lifetimeFromForm("90"), { ok: true, value: 90 }); | |
| 85 | + | assert.equal(lifetimeFromForm("0").ok, false); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("lists read a fine-grained token in a line", () => { | |
| 89 | + | const token = { | |
| 90 | + | id: "tok_1", name: "ci", createdAt: "", lastUsedAt: null, createdBy: null, scopes: [], legacy: false, expiresAt: null, | |
| 91 | + | fineGrained: { workspace: "acme", repositorySelection: "selected" as const, repositories: ["acme/web", "acme/api"], permissions: { metadata: "read" as const, issues: "read" as const, contents: "write" as const }, status: "pending" as const }, | |
| 92 | + | }; | |
| 93 | + | assert.equal(reachSummary(token), "acme · 2 repositories"); | |
| 94 | + | assert.deepEqual(permissionChips(token.fineGrained.permissions), ["Contents: write", "Issues: read"]); | |
| 95 | + | assert.deepEqual(statusBadge("pending"), { label: "Pending approval", tone: "warn" }); | |
| 96 | + | assert.equal(statusBadge("active"), null); | |
| 97 | + | assert.match(policyNote("acme", policy, false) ?? "", /must approve/); | |
| 98 | + | assert.equal(policyNote("acme", policy, true), null, "owners' own tokens never wait"); | |
| 99 | + | assert.match(policyNote("acme", { ...policy, allowFineGrained: false }, true) ?? "", /does not allow/); | |
| 100 | + | }); |
| 1 | + | /** | |
| 2 | + | * Fine-grained personal access tokens on the site: what the form posts, | |
| 3 | + | * read back into what identity takes, and the words lists use for them. | |
| 4 | + | * | |
| 5 | + | * The form posts `owner` (empty for your own account, or a workspace's | |
| 6 | + | * slug), `expires` (days), `repository_selection`, one `repo` per chosen | |
| 7 | + | * repository, and `perm.<name>` for each permission's level. Every field is | |
| 8 | + | * a plain form field, so it posts the same with or without JavaScript. | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | FINE_GRAINED_MAX_LIFETIME_DAYS, | |
| 13 | + | PERMISSIONS, | |
| 14 | + | findPermission, | |
| 15 | + | permissionLevels, | |
| 16 | + | type FineGrainedPermission, | |
| 17 | + | type PermissionAccess, | |
| 18 | + | type RepositorySelection, | |
| 19 | + | } from "@g1t/contracts/fine-grained"; | |
| 20 | + | import type { AccessToken, FineGrainedTokenInput, TokenPolicy, TokenStatus } from "@g1t/contracts"; | |
| 21 | + | ||
| 22 | + | import type { FormLike, Parsed } from "./token-scopes"; | |
| 23 | + | ||
| 24 | + | /** Lifetimes the form offers, in days; the longest is the most a fine-grained token may last. */ | |
| 25 | + | export const FINE_GRAINED_EXPIRY_DAYS = [7, 30, 60, 90, 180, FINE_GRAINED_MAX_LIFETIME_DAYS] as const; | |
| 26 | + | ||
| 27 | + | export const DEFAULT_FINE_GRAINED_DAYS = 30; | |
| 28 | + | ||
| 29 | + | /** The lifetimes a workspace's rules leave, longest last; all of them for your own account. */ | |
| 30 | + | export function expiryChoices(policy: TokenPolicy | null | undefined): number[] { | |
| 31 | + | const most = Math.min(policy?.maxLifetimeDays ?? FINE_GRAINED_MAX_LIFETIME_DAYS, FINE_GRAINED_MAX_LIFETIME_DAYS); | |
| 32 | + | const choices: number[] = FINE_GRAINED_EXPIRY_DAYS.filter((days) => days <= most); | |
| 33 | + | if (!choices.includes(most)) choices.push(most); | |
| 34 | + | return choices; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | export function describeDays(days: number): string { | |
| 38 | + | if (days === FINE_GRAINED_MAX_LIFETIME_DAYS) return "1 year"; | |
| 39 | + | return `${days} day${days === 1 ? "" : "s"}`; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | const SELECTIONS: RepositorySelection[] = ["all", "selected", "public"]; | |
| 43 | + | ||
| 44 | + | /** The permissions a resource owner can be given: a workspace's repository and workspace ones, or your account's. */ | |
| 45 | + | export function permissionsFor(workspace: boolean): FineGrainedPermission[] { | |
| 46 | + | return PERMISSIONS.filter((permission) => (workspace ? permission.group !== "account" : permission.group === "account")); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** A permission's level as the form shows it. */ | |
| 50 | + | export function accessLabel(access: PermissionAccess, permission?: FineGrainedPermission): string { | |
| 51 | + | switch (access) { | |
| 52 | + | case "none": | |
| 53 | + | return "No access"; | |
| 54 | + | case "read": | |
| 55 | + | return "Read-only"; | |
| 56 | + | case "write": | |
| 57 | + | return permission && permission.read.length === 0 ? "Write" : "Read and write"; | |
| 58 | + | case "admin": | |
| 59 | + | return "Admin"; | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /** | |
| 64 | + | * What the form posts, ready for identity, or what is wrong with it. In | |
| 65 | + | * `editing`, the resource owner and expiry are the token's own and are not | |
| 66 | + | * read. | |
| 67 | + | */ | |
| 68 | + | export function fineGrainedFromForm(form: FormLike, options: { editing?: boolean } = {}): Parsed<FineGrainedTokenInput> { | |
| 69 | + | const name = String(form.get("name") ?? "").trim(); | |
| 70 | + | if (!options.editing && !name) return { ok: false, error: "Name the token after what will use it." }; | |
| 71 | + | const owner = String(form.get("owner") ?? "").trim().toLowerCase(); | |
| 72 | + | const workspace = owner === "" ? null : owner; | |
| 73 | + | const days = Number(form.get("expires") ?? DEFAULT_FINE_GRAINED_DAYS); | |
| 74 | + | if (!options.editing && (!Number.isInteger(days) || days < 1 || days > FINE_GRAINED_MAX_LIFETIME_DAYS)) { | |
| 75 | + | return { ok: false, error: `Choose when it expires: at most ${FINE_GRAINED_MAX_LIFETIME_DAYS} days.` }; | |
| 76 | + | } | |
| 77 | + | const selectionText = String(form.get("repository_selection") ?? (workspace ? "all" : "public")); | |
| 78 | + | const repositorySelection = SELECTIONS.includes(selectionText as RepositorySelection) ? (selectionText as RepositorySelection) : "all"; | |
| 79 | + | const repositories = [...new Set(form.getAll("repo").map((repo) => String(repo).trim()).filter(Boolean))]; | |
| 80 | + | if (workspace && repositorySelection === "selected" && repositories.length === 0) { | |
| 81 | + | return { ok: false, error: "Choose at least one repository, or all repositories." }; | |
| 82 | + | } | |
| 83 | + | const permissions: Record<string, PermissionAccess> = {}; | |
| 84 | + | for (const permission of permissionsFor(workspace !== null)) { | |
| 85 | + | const level = String(form.get(`perm.${permission.name}`) ?? "none") as PermissionAccess; | |
| 86 | + | if (level === "none") continue; | |
| 87 | + | if (!permissionLevels(permission).includes(level)) { | |
| 88 | + | return { ok: false, error: `${permission.label} cannot be ${accessLabel(level).toLowerCase()}.` }; | |
| 89 | + | } | |
| 90 | + | permissions[permission.name] = level; | |
| 91 | + | } | |
| 92 | + | // A workspace's token always reads its repositories' metadata. | |
| 93 | + | if (workspace) permissions.metadata = "read"; | |
| 94 | + | else if (Object.keys(permissions).length === 0) return { ok: false, error: "Give the token at least one permission." }; | |
| 95 | + | return { | |
| 96 | + | ok: true, | |
| 97 | + | value: { | |
| 98 | + | name, | |
| 99 | + | description: String(form.get("description") ?? "").trim() || null, | |
| 100 | + | ttlSeconds: days * 86_400, | |
| 101 | + | workspace, | |
| 102 | + | repositorySelection: workspace ? repositorySelection : "public", | |
| 103 | + | repositories: repositorySelection === "selected" ? repositories : [], | |
| 104 | + | permissions, | |
| 105 | + | }, | |
| 106 | + | }; | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /** How a fine-grained token's reach reads in a list: "acme · 2 repositories". */ | |
| 110 | + | export function reachSummary(token: AccessToken): string { | |
| 111 | + | const details = token.fineGrained; | |
| 112 | + | if (!details) return ""; | |
| 113 | + | if (!details.workspace) return "Your account · public repositories, read-only"; | |
| 114 | + | switch (details.repositorySelection) { | |
| 115 | + | case "all": | |
| 116 | + | return `${details.workspace} · all repositories`; | |
| 117 | + | case "public": | |
| 118 | + | return `${details.workspace} · public repositories, read-only`; | |
| 119 | + | case "selected": { | |
| 120 | + | const count = details.repositories.length; | |
| 121 | + | return `${details.workspace} · ${count === 1 ? "1 repository" : `${count} repositories`}`; | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /** A token's permissions as short chips: "contents: write", metadata left out. */ | |
| 127 | + | export function permissionChips(permissions: Partial<Record<string, PermissionAccess>> | undefined): string[] { | |
| 128 | + | return Object.entries(permissions ?? {}) | |
| 129 | + | .filter(([name, access]) => name !== "metadata" && access && access !== "none") | |
| 130 | + | .sort(([a], [b]) => PERMISSIONS.findIndex((p) => p.name === a) - PERMISSIONS.findIndex((p) => p.name === b)) | |
| 131 | + | .map(([name, access]) => `${findPermission(name)?.label ?? name}: ${access === "write" ? "write" : access}`); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /** A status as a badge's words and tone; null for an active token. */ | |
| 135 | + | export function statusBadge(status: TokenStatus | undefined): { label: string; tone: "warn" | "danger" } | null { | |
| 136 | + | switch (status) { | |
| 137 | + | case "pending": | |
| 138 | + | return { label: "Pending approval", tone: "warn" }; | |
| 139 | + | case "denied": | |
| 140 | + | return { label: "Denied", tone: "danger" }; | |
| 141 | + | case "revoked": | |
| 142 | + | return { label: "Revoked", tone: "danger" }; | |
| 143 | + | default: | |
| 144 | + | return null; | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** What the workspace's rules say about a fine-grained token aimed at it, for the form. */ | |
| 149 | + | export function policyNote(slug: string, policy: TokenPolicy | null | undefined, owner: boolean): string | null { | |
| 150 | + | if (!policy) return null; | |
| 151 | + | if (!policy.allowFineGrained) return `${slug} does not allow fine-grained tokens.`; | |
| 152 | + | if (policy.requireApproval && !owner) return `An owner of ${slug} must approve this token before it reaches the workspace. Until then it reads public repositories only.`; | |
| 153 | + | return null; | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /** Days a policy's lifetime field holds: a number, or null for no limit. */ | |
| 157 | + | export function lifetimeFromForm(value: unknown): Parsed<number | null> { | |
| 158 | + | const text = String(value ?? "").trim(); | |
| 159 | + | if (text === "" || text === "none") return { ok: true, value: null }; | |
| 160 | + | const days = Number(text); | |
| 161 | + | if (!Number.isInteger(days) || days < 1 || days > 3650) return { ok: false, error: "The longest lifetime is a whole number of days, 1 to 3650, or none." }; | |
| 162 | + | return { ok: true, value: days }; | |
| 163 | + | } |
| 81 | 81 | route("settings", "routes/workspace/team/settings.tsx"), | |
| 82 | 82 | ]), | |
| 83 | 83 | route("-/tokens", "routes/workspace/tokens.tsx"), | |
| 84 | + | // Its rules for members' personal access tokens, and approving them. | |
| 85 | + | route("-/personal-access-tokens", "routes/workspace/personal-access-tokens.tsx"), | |
| 84 | 86 | route("-/usage", "routes/workspace/usage.tsx"), | |
| 85 | 87 | route("-/gateway", "routes/workspace/gateway.tsx"), | |
| 86 | 88 | route("-/billing", "routes/workspace/billing.tsx"), |
| 1 | − | import { identity } from "../../lib/services.server"; | |
| 1 | + | import { identity, repos } from "../../lib/services.server"; | |
| 2 | 2 | import { Form, Link, redirect, useSearchParams } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { presetScopes, type AccessToken } from "@g1t/contracts"; | |
| ⋯ | |||
| 13 | 13 | Input, | |
| 14 | 14 | TimeAgo, | |
| 15 | 15 | } from "../../components/ui"; | |
| 16 | + | import { Badge } from "../../components/ui/badge"; | |
| 16 | 17 | import { DeleteButton } from "../../components/account-settings"; | |
| 18 | + | import { FineGrainedForm, type OwnerChoice } from "../../components/fine-grained-form"; | |
| 17 | 19 | import { | |
| 18 | 20 | AccessSummary, | |
| 19 | 21 | ExpiryField, | |
| ⋯ | |||
| 24 | 26 | expiryTtl, | |
| 25 | 27 | grantFromForm, | |
| 26 | 28 | } from "../../lib/token-scopes"; | |
| 29 | + | import { fineGrainedFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained"; | |
| 27 | 30 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 31 | + | import { cn } from "../../lib/cn"; | |
| 28 | 32 | ||
| 29 | 33 | export function meta(args: Route.MetaArgs) { | |
| 30 | 34 | return page(args, { title: "Access tokens · Settings · g1t" }); | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 37 | + | type Tab = "fine-grained" | "classic"; | |
| 38 | + | ||
| 39 | + | function tabOf(value: string | null): Tab { | |
| 40 | + | return value === "classic" ? "classic" : "fine-grained"; | |
| 41 | + | } | |
| 42 | + | ||
| 33 | 43 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 34 | 44 | const user = requireUser(context, request); | |
| 35 | − | return { user, tokens: await identity.listAccessTokens(user) }; | |
| 45 | + | const memberships = user.workspaces ?? []; | |
| 46 | + | // Each workspace you belong to, with its rules for tokens and the | |
| 47 | + | // repositories you can choose from. | |
| 48 | + | const [tokens, owners] = await Promise.all([ | |
| 49 | + | identity.listAccessTokens(user), | |
| 50 | + | Promise.all( | |
| 51 | + | memberships.map(async (membership): Promise<OwnerChoice> => { | |
| 52 | + | const [policy, listed] = await Promise.all([ | |
| 53 | + | identity.getTokenPolicy(membership.slug, user), | |
| 54 | + | repos.list(user, { namespace: membership.slug }).catch(() => []), | |
| 55 | + | ]); | |
| 56 | + | return { | |
| 57 | + | slug: membership.slug, | |
| 58 | + | owner: membership.role === "owner", | |
| 59 | + | policy: policy.ok ? policy.value : null, | |
| 60 | + | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`).sort(), | |
| 61 | + | }; | |
| 62 | + | }), | |
| 63 | + | ), | |
| 64 | + | ]); | |
| 65 | + | return { user, tokens, owners }; | |
| 36 | 66 | } | |
| 37 | 67 | ||
| 38 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 68 | + | type ActionResult = { | |
| 69 | + | newToken: string | null; | |
| 70 | + | created: AccessToken | null; | |
| 71 | + | error: string | null; | |
| 72 | + | editing: string | null; | |
| 73 | + | }; | |
| 74 | + | ||
| 75 | + | const failed = (error: string, editing: string | null = null): ActionResult => ({ newToken: null, created: null, error, editing }); | |
| 76 | + | ||
| 77 | + | export async function action({ request, context }: Route.ActionArgs): Promise<ActionResult | null> { | |
| 39 | 78 | assertSameOrigin(request); | |
| 40 | 79 | const user = requireUser(context, request); | |
| 41 | 80 | const form = await request.formData(); | |
| 42 | 81 | switch (form.get("intent")) { | |
| 43 | 82 | case "add-token": { | |
| 44 | 83 | const grant = grantFromForm(form); | |
| 45 | − | if (!grant.ok) | |
| 46 | − | return { | |
| 47 | − | newToken: null, | |
| 48 | − | created: null, | |
| 49 | − | error: grant.error, | |
| 50 | − | editing: null, | |
| 51 | − | }; | |
| 84 | + | if (!grant.ok) return failed(grant.error); | |
| 52 | 85 | const created = await identity.createAccessToken( | |
| 53 | 86 | user, | |
| 54 | 87 | String(form.get("label") ?? ""), | |
| 55 | 88 | expiryTtl(form.get("expires")), | |
| 56 | 89 | { ...grant.value, listed: true }, | |
| 57 | 90 | ); | |
| 58 | − | return { | |
| 59 | − | newToken: created.token, | |
| 60 | − | created: created.info, | |
| 61 | − | error: null, | |
| 62 | − | editing: null, | |
| 63 | − | }; | |
| 91 | + | return { newToken: created.token, created: created.info, error: null, editing: null }; | |
| 92 | + | } | |
| 93 | + | case "add-fine-grained": { | |
| 94 | + | const input = fineGrainedFromForm(form); | |
| 95 | + | if (!input.ok) return failed(input.error); | |
| 96 | + | const created = await identity.createFineGrainedToken(user, input.value); | |
| 97 | + | if (!created.ok) return failed(created.error.message); | |
| 98 | + | return { newToken: created.value.token, created: created.value.info, error: null, editing: null }; | |
| 64 | 99 | } | |
| 65 | 100 | case "update-token": { | |
| 66 | 101 | const id = String(form.get("id") ?? ""); | |
| 67 | 102 | const grant = grantFromForm(form); | |
| 68 | − | if (!grant.ok) | |
| 69 | − | return { | |
| 70 | − | newToken: null, | |
| 71 | − | created: null, | |
| 72 | − | error: grant.error, | |
| 73 | − | editing: id, | |
| 74 | − | }; | |
| 103 | + | if (!grant.ok) return failed(grant.error, id); | |
| 75 | 104 | const updated = await identity.updateAccessToken(user, id, grant.value); | |
| 76 | − | if (!updated.ok) | |
| 77 | − | return { | |
| 78 | − | newToken: null, | |
| 79 | − | created: null, | |
| 80 | − | error: updated.error.message, | |
| 81 | − | editing: id, | |
| 82 | − | }; | |
| 105 | + | if (!updated.ok) return failed(updated.error.message, id); | |
| 106 | + | throw redirect("/settings/tokens?tab=classic"); | |
| 107 | + | } | |
| 108 | + | case "update-fine-grained": { | |
| 109 | + | const id = String(form.get("id") ?? ""); | |
| 110 | + | const input = fineGrainedFromForm(form, { editing: true }); | |
| 111 | + | if (!input.ok) return failed(input.error, id); | |
| 112 | + | const { name, description, repositorySelection, repositories, permissions } = input.value; | |
| 113 | + | const updated = await identity.updateFineGrainedToken(user, id, { | |
| 114 | + | name: name || undefined, | |
| 115 | + | description: description ?? "", | |
| 116 | + | repositorySelection, | |
| 117 | + | repositories, | |
| 118 | + | permissions, | |
| 119 | + | }); | |
| 120 | + | if (!updated.ok) return failed(updated.error.message, id); | |
| 83 | 121 | throw redirect("/settings/tokens"); | |
| 84 | 122 | } | |
| 85 | 123 | case "delete-token": | |
| ⋯ | |||
| 89 | 127 | return null; | |
| 90 | 128 | } | |
| 91 | 129 | ||
| 92 | − | export default function TokenSettings({ | |
| 93 | − | loaderData, | |
| 94 | − | actionData, | |
| 95 | − | }: Route.ComponentProps) { | |
| 96 | − | const { user, tokens } = loaderData; | |
| 130 | + | export default function TokenSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 131 | + | const { user, tokens, owners } = loaderData; | |
| 97 | 132 | const [params] = useSearchParams(); | |
| 98 | − | const editing = actionData?.editing ?? params.get("edit"); | |
| 99 | − | const workspaces = (user.workspaces ?? []).map( | |
| 100 | − | (membership) => membership.slug, | |
| 101 | − | ); | |
| 102 | − | const created = actionData?.created; | |
| 133 | + | const created = actionData?.created ?? null; | |
| 134 | + | const tab: Tab = created ? (created.kind === "fine_grained" ? "fine-grained" : "classic") : tabOf(params.get("tab")); | |
| 135 | + | const fine = tokens.filter((token) => token.kind === "fine_grained"); | |
| 136 | + | const classic = tokens.filter((token) => token.kind !== "fine_grained"); | |
| 137 | + | const workspaces = (user.workspaces ?? []).map((membership) => membership.slug); | |
| 103 | 138 | return ( | |
| 104 | 139 | <section id="tokens" className="scroll-mt-20"> | |
| 105 | − | {workspaces.length > 0 && ( | |
| 106 | − | <p className="text-sm text-muted"> | |
| 107 | − | For CI and integrations that work for a team, use a workspace's own | |
| 108 | − | tokens instead:{" "} | |
| 109 | − | {workspaces.map((slug, i) => ( | |
| 110 | − | <span key={slug}> | |
| 111 | − | {i > 0 && ", "} | |
| 112 | − | <Link | |
| 113 | − | to={`/${slug}/-/tokens`} | |
| 114 | − | className="font-mono text-fg underline underline-offset-4" | |
| 115 | − | > | |
| 116 | − | {slug} | |
| 117 | − | </Link> | |
| 118 | − | </span> | |
| 119 | − | ))} | |
| 120 | − | . | |
| 121 | − | </p> | |
| 122 | − | )} | |
| 123 | − | {actionData?.newToken && ( | |
| 124 | − | <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4"> | |
| 140 | + | <nav aria-label="Kinds of token" className="mb-5 flex gap-1 border-b border-line"> | |
| 141 | + | {( | |
| 142 | + | [ | |
| 143 | + | ["fine-grained", "Fine-grained tokens", fine.length], | |
| 144 | + | ["classic", "Tokens (classic)", classic.length], | |
| 145 | + | ] as const | |
| 146 | + | ).map(([id, label, count]) => ( | |
| 147 | + | <Link | |
| 148 | + | key={id} | |
| 149 | + | to={id === "classic" ? "?tab=classic" : "?"} | |
| 150 | + | preventScrollReset | |
| 151 | + | aria-current={tab === id ? "page" : undefined} | |
| 152 | + | className={cn( | |
| 153 | + | "-mb-px flex items-center gap-2 border-b-2 px-3 pb-2.5 text-sm whitespace-nowrap transition-colors", | |
| 154 | + | tab === id ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg", | |
| 155 | + | )} | |
| 156 | + | > | |
| 157 | + | {label} | |
| 158 | + | {count > 0 && <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">{count}</span>} | |
| 159 | + | </Link> | |
| 160 | + | ))} | |
| 161 | + | </nav> | |
| 162 | + | ||
| 163 | + | {actionData?.newToken && created && ( | |
| 164 | + | <div className="mb-5 rounded-md border border-accent/40 bg-surface p-4"> | |
| 125 | 165 | <p className="text-sm"> | |
| 126 | − | {created ? ( | |
| 127 | − | <span className="font-medium">{created.name}</span> | |
| 128 | − | ) : ( | |
| 129 | − | "Your token" | |
| 130 | − | )}{" "} | |
| 131 | − | is ready. Copy it now. It will not be shown again. | |
| 166 | + | <span className="font-medium">{created.name}</span> is ready. Copy it now. It will not be shown again. | |
| 132 | 167 | </p> | |
| 133 | − | <pre className="mt-2 font-mono text-sm break-all whitespace-pre-wrap text-accent"> | |
| 134 | − | {actionData.newToken} | |
| 135 | − | </pre> | |
| 136 | − | {created && ( | |
| 137 | − | <> | |
| 138 | − | <AccessSummary holder={created} className="mt-3" /> | |
| 139 | − | <p className="mt-1.5 text-xs text-faint"> | |
| 140 | − | {describeExpiry(created.expiresAt)} | |
| 141 | − | </p> | |
| 142 | − | </> | |
| 143 | − | )} | |
| 168 | + | <pre className="mt-2 font-mono text-sm break-all whitespace-pre-wrap text-accent">{actionData.newToken}</pre> | |
| 169 | + | {created.kind === "fine_grained" ? <FineGrainedSummary token={created} /> : <AccessSummary holder={created} className="mt-3" />} | |
| 170 | + | <p className="mt-1.5 text-xs text-faint">{describeExpiry(created.expiresAt)}</p> | |
| 144 | 171 | </div> | |
| 145 | 172 | )} | |
| 173 | + | ||
| 174 | + | {tab === "fine-grained" ? ( | |
| 175 | + | <FineGrainedTokens tokens={fine} owners={owners} actionData={actionData ?? null} /> | |
| 176 | + | ) : ( | |
| 177 | + | <ClassicTokens tokens={classic} workspaces={workspaces} actionData={actionData ?? null} /> | |
| 178 | + | )} | |
| 179 | + | </section> | |
| 180 | + | ); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /** A fine-grained token's reach, permissions and status, under its name. */ | |
| 184 | + | function FineGrainedSummary({ token }: { token: AccessToken }) { | |
| 185 | + | const badge = statusBadge(token.fineGrained?.status); | |
| 186 | + | return ( | |
| 187 | + | <div className="mt-1.5 space-y-1.5"> | |
| 188 | + | <p className="flex flex-wrap items-center gap-1.5 text-xs text-muted"> | |
| 189 | + | <Badge tone="accent">Fine-grained</Badge> | |
| 190 | + | {badge && <Badge tone={badge.tone}>{badge.label}</Badge>} | |
| 191 | + | <span>{reachSummary(token)}</span> | |
| 192 | + | </p> | |
| 193 | + | <div className="flex flex-wrap gap-1.5"> | |
| 194 | + | {permissionChips(token.fineGrained?.permissions).map((chip) => ( | |
| 195 | + | <span key={chip} className="rounded border border-line px-1.5 py-px text-[0.6875rem] text-muted"> | |
| 196 | + | {chip} | |
| 197 | + | </span> | |
| 198 | + | ))} | |
| 199 | + | </div> | |
| 200 | + | {token.fineGrained?.repositorySelection === "selected" && token.fineGrained.repositories.length > 0 && ( | |
| 201 | + | <p className="truncate font-mono text-[0.6875rem] text-faint">{token.fineGrained.repositories.join(", ")}</p> | |
| 202 | + | )} | |
| 203 | + | {token.fineGrained?.reviewReason && ( | |
| 204 | + | <p className="text-xs text-faint">Owner's note: {token.fineGrained.reviewReason}</p> | |
| 205 | + | )} | |
| 206 | + | </div> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | function Meta({ token }: { token: AccessToken }) { | |
| 211 | + | const expiry = describeExpiry(token.expiresAt); | |
| 212 | + | return ( | |
| 213 | + | <p className="text-xs text-faint"> | |
| 214 | + | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 215 | + | {token.lastUsedAt ? ( | |
| 216 | + | <> | |
| 217 | + | last used <TimeAgo at={token.lastUsedAt} /> | |
| 218 | + | </> | |
| 219 | + | ) : ( | |
| 220 | + | "never used" | |
| 221 | + | )}{" "} | |
| 222 | + | · <span className={expiry === "Expired" ? "text-danger" : undefined}>{expiry}</span> | |
| 223 | + | </p> | |
| 224 | + | ); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | function FineGrainedTokens({ | |
| 228 | + | tokens, | |
| 229 | + | owners, | |
| 230 | + | actionData, | |
| 231 | + | }: { | |
| 232 | + | tokens: AccessToken[]; | |
| 233 | + | owners: OwnerChoice[]; | |
| 234 | + | actionData: ActionResult | null; | |
| 235 | + | }) { | |
| 236 | + | const [params] = useSearchParams(); | |
| 237 | + | const editing = actionData?.editing ?? params.get("edit"); | |
| 238 | + | const created = actionData?.created; | |
| 239 | + | return ( | |
| 240 | + | <> | |
| 241 | + | <p className="text-sm text-muted"> | |
| 242 | + | A fine-grained token reaches one resource owner (one workspace, or your own account), only the repositories | |
| 243 | + | you choose, and only with the permissions you give it. It always expires. | |
| 244 | + | </p> | |
| 245 | + | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 246 | + | {tokens.map((token) => ( | |
| 247 | + | <li key={token.id} className="px-4 py-3"> | |
| 248 | + | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> | |
| 249 | + | <div className="min-w-0 grow basis-60"> | |
| 250 | + | <p className="truncate text-sm font-medium">{token.name}</p> | |
| 251 | + | {token.description && <p className="truncate text-xs text-muted">{token.description}</p>} | |
| 252 | + | <Meta token={token} /> | |
| 253 | + | <FineGrainedSummary token={token} /> | |
| 254 | + | </div> | |
| 255 | + | <div className="ml-auto flex shrink-0 items-center gap-2"> | |
| 256 | + | {editing !== token.id && ( | |
| 257 | + | <ButtonLink variant="quiet" to={`?edit=${token.id}`} preventScrollReset> | |
| 258 | + | Edit | |
| 259 | + | </ButtonLink> | |
| 260 | + | )} | |
| 261 | + | <DeleteButton intent="delete-token" id={token.id} /> | |
| 262 | + | </div> | |
| 263 | + | </div> | |
| 264 | + | {editing === token.id && ( | |
| 265 | + | <Form method="post" className="mt-4 space-y-5 border-t border-line pt-4"> | |
| 266 | + | <input type="hidden" name="intent" value="update-fine-grained" /> | |
| 267 | + | <input type="hidden" name="id" value={token.id} /> | |
| 268 | + | <p className="text-sm text-muted"> | |
| 269 | + | The token stays the same; what it reaches changes from its next request. A workspace that approves | |
| 270 | + | tokens asks again when you widen it. | |
| 271 | + | </p> | |
| 272 | + | <FineGrainedForm owners={owners} editing={token} /> | |
| 273 | + | <ErrorText>{actionData?.editing === token.id ? actionData.error : null}</ErrorText> | |
| 274 | + | <div className="flex gap-2"> | |
| 275 | + | <SubmitButton pending="Saving…" match={{ intent: "update-fine-grained", id: token.id }}> | |
| 276 | + | Save | |
| 277 | + | </SubmitButton> | |
| 278 | + | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 279 | + | Cancel | |
| 280 | + | </ButtonLink> | |
| 281 | + | </div> | |
| 282 | + | </Form> | |
| 283 | + | )} | |
| 284 | + | </li> | |
| 285 | + | ))} | |
| 286 | + | </ul> | |
| 287 | + | {!editing && ( | |
| 288 | + | <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"> | |
| 289 | + | <input type="hidden" name="intent" value="add-fine-grained" /> | |
| 290 | + | <h2 className="font-medium">New fine-grained token</h2> | |
| 291 | + | <FineGrainedForm owners={owners} /> | |
| 292 | + | {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>} | |
| 293 | + | <SubmitButton pending="Generating…" match={{ intent: "add-fine-grained" }}> | |
| 294 | + | Generate token | |
| 295 | + | </SubmitButton> | |
| 296 | + | </Form> | |
| 297 | + | )} | |
| 298 | + | </> | |
| 299 | + | ); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | function ClassicTokens({ | |
| 303 | + | tokens, | |
| 304 | + | workspaces, | |
| 305 | + | actionData, | |
| 306 | + | }: { | |
| 307 | + | tokens: AccessToken[]; | |
| 308 | + | workspaces: string[]; | |
| 309 | + | actionData: ActionResult | null; | |
| 310 | + | }) { | |
| 311 | + | const [params] = useSearchParams(); | |
| 312 | + | const editing = actionData?.editing ?? params.get("edit"); | |
| 313 | + | const created = actionData?.created; | |
| 314 | + | return ( | |
| 315 | + | <> | |
| 316 | + | <p className="text-sm text-muted"> | |
| 317 | + | A classic token reaches every workspace and repository you can, and its scopes say what it may do there. A | |
| 318 | + | workspace can keep classic tokens out. | |
| 319 | + | {workspaces.length > 0 && ( | |
| 320 | + | <> | |
| 321 | + | {" "} | |
| 322 | + | For CI and integrations that work for a team, use a workspace's own tokens instead:{" "} | |
| 323 | + | {workspaces.map((slug, i) => ( | |
| 324 | + | <span key={slug}> | |
| 325 | + | {i > 0 && ", "} | |
| 326 | + | <Link to={`/${slug}/-/tokens`} className="font-mono text-fg underline underline-offset-4"> | |
| 327 | + | {slug} | |
| 328 | + | </Link> | |
| 329 | + | </span> | |
| 330 | + | ))} | |
| 331 | + | . | |
| 332 | + | </> | |
| 333 | + | )} | |
| 334 | + | </p> | |
| 146 | 335 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> | |
| 147 | 336 | {tokens.map((token) => ( | |
| 148 | − | <TokenRow | |
| 337 | + | <ClassicRow | |
| 149 | 338 | key={token.id} | |
| 150 | 339 | token={token} | |
| 151 | 340 | editing={editing === token.id} | |
| ⋯ | |||
| 156 | 345 | ||
| 157 | 346 | {!editing && ( | |
| 158 | 347 | // Keyed on the token just made, so the fields start over for the next. | |
| 159 | − | <Form | |
| 160 | − | key={created?.id ?? "new"} | |
| 161 | − | method="post" | |
| 162 | − | className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5" | |
| 163 | − | > | |
| 348 | + | <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"> | |
| 164 | 349 | <input type="hidden" name="intent" value="add-token" /> | |
| 165 | − | <h2 className="font-medium">New token</h2> | |
| 350 | + | <h2 className="font-medium">New classic token</h2> | |
| 166 | 351 | <div className="grid gap-4 sm:grid-cols-[1fr_11rem]"> | |
| 167 | 352 | <Field label="Name" hint="Name it after what will use it."> | |
| 168 | − | <Input | |
| 169 | − | name="label" | |
| 170 | − | maxLength={100} | |
| 171 | − | placeholder="laptop" | |
| 172 | − | required | |
| 173 | − | /> | |
| 353 | + | <Input name="label" maxLength={100} placeholder="laptop" required /> | |
| 174 | 354 | </Field> | |
| 175 | 355 | <ExpiryField /> | |
| 176 | 356 | </div> | |
| ⋯ | |||
| 181 | 361 | </SubmitButton> | |
| 182 | 362 | </Form> | |
| 183 | 363 | )} | |
| 184 | − | </section> | |
| 364 | + | </> | |
| 185 | 365 | ); | |
| 186 | 366 | } | |
| 187 | 367 | ||
| 188 | − | function TokenRow({ | |
| 368 | + | function ClassicRow({ | |
| 189 | 369 | token, | |
| 190 | 370 | editing, | |
| 191 | 371 | error, | |
| ⋯ | |||
| 195 | 375 | error: string | null | undefined; | |
| 196 | 376 | }) { | |
| 197 | 377 | const legacy = token.legacy && token.scopes === null; | |
| 198 | − | const expiry = describeExpiry(token.expiresAt); | |
| 199 | 378 | return ( | |
| 200 | 379 | <li className="px-4 py-3"> | |
| 201 | 380 | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> | |
| 202 | 381 | <div className="min-w-0 grow basis-60"> | |
| 203 | 382 | <p className="truncate text-sm font-medium">{token.name}</p> | |
| 204 | − | <p className="text-xs text-faint"> | |
| 205 | − | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 206 | − | {token.lastUsedAt ? ( | |
| 207 | − | <> | |
| 208 | − | last used <TimeAgo at={token.lastUsedAt} /> | |
| 209 | − | </> | |
| 210 | − | ) : ( | |
| 211 | − | "never used" | |
| 212 | − | )}{" "} | |
| 213 | − | ·{" "} | |
| 214 | − | <span className={expiry === "Expired" ? "text-danger" : undefined}> | |
| 215 | − | {expiry} | |
| 216 | − | </span> | |
| 217 | − | </p> | |
| 383 | + | <Meta token={token} /> | |
| 218 | 384 | <AccessSummary holder={token} /> | |
| 219 | 385 | {legacy && ( | |
| 220 | 386 | <p className="mt-1.5 text-xs text-warn"> | |
| ⋯ | |||
| 227 | 393 | {!editing && ( | |
| 228 | 394 | <ButtonLink | |
| 229 | 395 | variant="quiet" | |
| 230 | − | to={`?edit=${token.id}`} | |
| 396 | + | to={`?tab=classic&edit=${token.id}`} | |
| 231 | 397 | preventScrollReset | |
| 232 | 398 | > | |
| 233 | 399 | {legacy ? "Narrow this token" : "Edit access"} | |
| ⋯ | |||
| 253 | 419 | <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}> | |
| 254 | 420 | Save access | |
| 255 | 421 | </SubmitButton> | |
| 256 | − | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 422 | + | <ButtonLink variant="quiet" to="?tab=classic" preventScrollReset> | |
| 257 | 423 | Cancel | |
| 258 | 424 | </ButtonLink> | |
| 259 | 425 | </div> | |
| 63 | 63 | }, | |
| 64 | 64 | tokens: { | |
| 65 | 65 | title: "Access tokens", | |
| 66 | − | about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.", | |
| 66 | + | about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team. Each has Write on the workspace's repositories, or Admin when an owner gives it that.", | |
| 67 | + | }, | |
| 68 | + | "personal-access-tokens": { | |
| 69 | + | title: "Personal access tokens", | |
| 70 | + | about: "Which of your members' own tokens may reach the workspace and for how long, the fine-grained tokens waiting for an owner's approval, and every token that can reach it.", | |
| 67 | 71 | }, | |
| 68 | 72 | packages: { | |
| 69 | 73 | title: "Packages", |
| 1 | + | import { Check, KeyRound, X } from "lucide-react"; | |
| 2 | + | import { Form, Link, useSearchParams } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { MemberToken, TokenKind } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/personal-access-tokens"; | |
| 7 | + | import { page } from "../../lib/meta"; | |
| 8 | + | import { Avatar, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 9 | + | import { Badge } from "../../components/ui/badge"; | |
| 10 | + | import { SwitchCard } from "../../components/ui/switch"; | |
| 11 | + | import { AccessSummary } from "../../components/token-scopes"; | |
| 12 | + | import { identity } from "../../lib/services.server"; | |
| 13 | + | import { describeExpiry } from "../../lib/token-scopes"; | |
| 14 | + | import { lifetimeFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained"; | |
| 15 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 16 | + | import { cn } from "../../lib/cn"; | |
| 17 | + | ||
| 18 | + | // A workspace's rules for its members' personal access tokens, the tokens | |
| 19 | + | // that reach it, and approving fine-grained ones. Owners only; identity | |
| 20 | + | // decides (services/identity/src/token_reach.rs). | |
| 21 | + | ||
| 22 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 23 | + | return page(args, { title: `Personal access tokens · ${params.owner} · g1t` }); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 27 | + | const viewer = getViewer(context); | |
| 28 | + | const slug = params.owner.toLowerCase(); | |
| 29 | + | const role = roleIn(viewer, params.owner); | |
| 30 | + | if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] }; | |
| 31 | + | const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), identity.listMemberTokens(viewer, slug)]); | |
| 32 | + | return { slug, role, policy: unwrap(policy), tokens: unwrap(tokens) }; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 36 | + | assertSameOrigin(request); | |
| 37 | + | const user = requireUser(context, request); | |
| 38 | + | const form = await request.formData(); | |
| 39 | + | const slug = params.owner; | |
| 40 | + | const id = String(form.get("id") ?? ""); | |
| 41 | + | const reason = String(form.get("reason") ?? "").trim() || null; | |
| 42 | + | switch (form.get("intent")) { | |
| 43 | + | case "policy": { | |
| 44 | + | const lifetime = lifetimeFromForm(form.get("max_lifetime_days")); | |
| 45 | + | if (!lifetime.ok) return { error: lifetime.error, saved: false }; | |
| 46 | + | const saved = await identity.setTokenPolicy(user, slug, { | |
| 47 | + | allowClassic: form.get("allow_classic") === "on", | |
| 48 | + | allowFineGrained: form.get("allow_fine_grained") === "on", | |
| 49 | + | requireApproval: form.get("require_approval") === "on", | |
| 50 | + | maxLifetimeDays: lifetime.value ?? 0, | |
| 51 | + | forbidNoExpiry: form.get("forbid_no_expiry") === "on", | |
| 52 | + | }); | |
| 53 | + | return saved.ok ? { error: null, saved: true } : { error: saved.error.message, saved: false }; | |
| 54 | + | } | |
| 55 | + | case "approve": | |
| 56 | + | case "deny": { | |
| 57 | + | const reviewed = await identity.reviewTokenRequest(user, slug, id, form.get("intent") === "approve", reason); | |
| 58 | + | return reviewed.ok ? { error: null, saved: false } : { error: reviewed.error.message, saved: false }; | |
| 59 | + | } | |
| 60 | + | case "revoke": { | |
| 61 | + | const revoked = await identity.revokeMemberToken(user, slug, id, reason); | |
| 62 | + | return revoked.ok ? { error: null, saved: false } : { error: revoked.error.message, saved: false }; | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return null; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | const LIFETIMES = [ | |
| 69 | + | ["", "No limit"], | |
| 70 | + | ["7", "7 days"], | |
| 71 | + | ["30", "30 days"], | |
| 72 | + | ["60", "60 days"], | |
| 73 | + | ["90", "90 days"], | |
| 74 | + | ["180", "180 days"], | |
| 75 | + | ["366", "1 year"], | |
| 76 | + | ] as const; | |
| 77 | + | ||
| 78 | + | export default function PersonalAccessTokens({ loaderData, actionData }: Route.ComponentProps) { | |
| 79 | + | const { slug, role, policy, tokens } = loaderData; | |
| 80 | + | const [params] = useSearchParams(); | |
| 81 | + | if (role !== "owner" || !policy) { | |
| 82 | + | return ( | |
| 83 | + | <EmptyState title="Owners only"> | |
| 84 | + | Only an owner of {slug} can see which personal access tokens reach it. Your own tokens are under{" "} | |
| 85 | + | <Link to="/settings/tokens" className="text-fg underline underline-offset-4"> | |
| 86 | + | your settings | |
| 87 | + | </Link> | |
| 88 | + | . | |
| 89 | + | </EmptyState> | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | const pending = tokens.filter((member) => member.token.fineGrained?.status === "pending"); | |
| 93 | + | const kind = (params.get("kind") as TokenKind | null) ?? null; | |
| 94 | + | const listed = tokens.filter((member) => member.token.fineGrained?.status !== "pending" && (!kind || member.token.kind === kind)); | |
| 95 | + | const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays); | |
| 96 | + | return ( | |
| 97 | + | <div className="max-w-3xl space-y-10"> | |
| 98 | + | <section aria-labelledby="pat-rules"> | |
| 99 | + | <h2 id="pat-rules" className="font-medium"> | |
| 100 | + | Rules | |
| 101 | + | </h2> | |
| 102 | + | <p className="mt-1 text-sm text-muted"> | |
| 103 | + | They apply from each token's next request, to tokens made before them too. A token they keep out still works | |
| 104 | + | elsewhere, and reads {slug}'s public repositories as anyone can. | |
| 105 | + | </p> | |
| 106 | + | <Form method="post" className="mt-4 space-y-3"> | |
| 107 | + | <input type="hidden" name="intent" value="policy" /> | |
| 108 | + | <SwitchCard name="allow_fine_grained" defaultChecked={policy.allowFineGrained} title="Allow fine-grained personal access tokens"> | |
| 109 | + | Members may make tokens that name {slug} as their resource owner, reaching only the repositories and | |
| 110 | + | permissions they choose. | |
| 111 | + | </SwitchCard> | |
| 112 | + | <SwitchCard name="require_approval" defaultChecked={policy.requireApproval} title="Require approval of fine-grained tokens"> | |
| 113 | + | A member's fine-grained token waits for an owner to approve it, and again when they widen it. Owners' own | |
| 114 | + | tokens never wait. | |
| 115 | + | </SwitchCard> | |
| 116 | + | <SwitchCard name="allow_classic" defaultChecked={policy.allowClassic} title="Allow classic personal access tokens"> | |
| 117 | + | Classic tokens reach every workspace their owner belongs to. Off: they no longer reach {slug}. | |
| 118 | + | </SwitchCard> | |
| 119 | + | <SwitchCard name="forbid_no_expiry" defaultChecked={policy.forbidNoExpiry} title="Tokens must expire"> | |
| 120 | + | A token that never expires does not reach {slug}. | |
| 121 | + | </SwitchCard> | |
| 122 | + | <div className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 sm:flex-row sm:items-center sm:justify-between"> | |
| 123 | + | <label htmlFor="pat-lifetime" className="min-w-0"> | |
| 124 | + | <span className="block text-sm font-medium text-fg">Longest lifetime</span> | |
| 125 | + | <span className="mt-1 block text-sm text-muted">A token that lasts longer does not reach {slug}.</span> | |
| 126 | + | </label> | |
| 127 | + | <select | |
| 128 | + | id="pat-lifetime" | |
| 129 | + | name="max_lifetime_days" | |
| 130 | + | defaultValue={LIFETIMES.some(([value]) => value === lifetime) ? lifetime : ""} | |
| 131 | + | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm sm:w-40" | |
| 132 | + | > | |
| 133 | + | {LIFETIMES.map(([value, label]) => ( | |
| 134 | + | <option key={value} value={value}> | |
| 135 | + | {label} | |
| 136 | + | </option> | |
| 137 | + | ))} | |
| 138 | + | </select> | |
| 139 | + | </div> | |
| 140 | + | <div className="flex items-center gap-3"> | |
| 141 | + | <SubmitButton match={{ intent: "policy" }} pending="Saving…"> | |
| 142 | + | Save rules | |
| 143 | + | </SubmitButton> | |
| 144 | + | {actionData?.saved && <span className="text-sm text-success">Saved.</span>} | |
| 145 | + | {policy.updatedBy && ( | |
| 146 | + | <span className="text-xs text-faint"> | |
| 147 | + | Last changed by <span className="font-mono">{policy.updatedBy}</span> | |
| 148 | + | {policy.updatedAt && ( | |
| 149 | + | <> | |
| 150 | + | {" "} | |
| 151 | + | <TimeAgo at={policy.updatedAt} /> | |
| 152 | + | </> | |
| 153 | + | )} | |
| 154 | + | </span> | |
| 155 | + | )} | |
| 156 | + | </div> | |
| 157 | + | </Form> | |
| 158 | + | </section> | |
| 159 | + | ||
| 160 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 161 | + | ||
| 162 | + | <section aria-labelledby="pat-requests"> | |
| 163 | + | <h2 id="pat-requests" className="flex items-center gap-2 font-medium"> | |
| 164 | + | Waiting for approval | |
| 165 | + | {pending.length > 0 && <Badge tone="warn">{pending.length}</Badge>} | |
| 166 | + | </h2> | |
| 167 | + | {pending.length === 0 ? ( | |
| 168 | + | <p className="mt-2 text-sm text-muted">No fine-grained token is waiting for approval.</p> | |
| 169 | + | ) : ( | |
| 170 | + | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> | |
| 171 | + | {pending.map((member) => ( | |
| 172 | + | <li key={member.token.id} className="px-4 py-3"> | |
| 173 | + | <TokenLine member={member} /> | |
| 174 | + | <Form method="post" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 175 | + | <input type="hidden" name="id" value={member.token.id} /> | |
| 176 | + | <div className="min-w-0 grow"> | |
| 177 | + | <Input name="reason" maxLength={500} placeholder="Note for the owner (optional)" aria-label="Note for the token's owner" /> | |
| 178 | + | </div> | |
| 179 | + | <div className="flex shrink-0 gap-2"> | |
| 180 | + | <SubmitButton name="intent" value="approve" match={{ intent: "approve", id: member.token.id }} pending="Approving…"> | |
| 181 | + | <Check size={14} /> Approve | |
| 182 | + | </SubmitButton> | |
| 183 | + | <SubmitButton name="intent" value="deny" variant="quiet" match={{ intent: "deny", id: member.token.id }} pending="Denying…"> | |
| 184 | + | <X size={14} /> Deny | |
| 185 | + | </SubmitButton> | |
| 186 | + | </div> | |
| 187 | + | </Form> | |
| 188 | + | </li> | |
| 189 | + | ))} | |
| 190 | + | </ul> | |
| 191 | + | )} | |
| 192 | + | </section> | |
| 193 | + | ||
| 194 | + | <section aria-labelledby="pat-active"> | |
| 195 | + | <div className="flex flex-wrap items-baseline justify-between gap-3"> | |
| 196 | + | <h2 id="pat-active" className="font-medium"> | |
| 197 | + | Tokens that can reach {slug} | |
| 198 | + | </h2> | |
| 199 | + | <nav aria-label="Kind" className="flex gap-1 text-xs"> | |
| 200 | + | {( | |
| 201 | + | [ | |
| 202 | + | [null, "All"], | |
| 203 | + | ["fine_grained", "Fine-grained"], | |
| 204 | + | ["classic", "Classic"], | |
| 205 | + | ] as const | |
| 206 | + | ).map(([value, label]) => ( | |
| 207 | + | <Link | |
| 208 | + | key={label} | |
| 209 | + | to={value ? `?kind=${value}` : "?"} | |
| 210 | + | preventScrollReset | |
| 211 | + | aria-current={kind === value ? "page" : undefined} | |
| 212 | + | className={cn( | |
| 213 | + | "rounded-full border px-2.5 py-0.5 transition-colors", | |
| 214 | + | kind === value ? "border-accent/50 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg", | |
| 215 | + | )} | |
| 216 | + | > | |
| 217 | + | {label} | |
| 218 | + | </Link> | |
| 219 | + | ))} | |
| 220 | + | </nav> | |
| 221 | + | </div> | |
| 222 | + | <p className="mt-1 text-sm text-muted"> | |
| 223 | + | Members' and outside collaborators' tokens, never the tokens themselves. The workspace's own tokens are under{" "} | |
| 224 | + | <Link to={`/${slug}/-/tokens`} className="text-fg underline underline-offset-4"> | |
| 225 | + | Access tokens | |
| 226 | + | </Link> | |
| 227 | + | . | |
| 228 | + | </p> | |
| 229 | + | {listed.length === 0 ? ( | |
| 230 | + | <div className="mt-3"> | |
| 231 | + | <EmptyState title="No tokens">No member has a personal access token of this kind that can reach {slug}.</EmptyState> | |
| 232 | + | </div> | |
| 233 | + | ) : ( | |
| 234 | + | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> | |
| 235 | + | {listed.map((member) => ( | |
| 236 | + | <li key={member.token.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start"> | |
| 237 | + | <div className="min-w-0 grow"> | |
| 238 | + | <TokenLine member={member} /> | |
| 239 | + | </div> | |
| 240 | + | {member.token.fineGrained?.status !== "revoked" && !(member.blockedBy === "revoked") && ( | |
| 241 | + | <Form method="post" className="shrink-0"> | |
| 242 | + | <input type="hidden" name="intent" value="revoke" /> | |
| 243 | + | <input type="hidden" name="id" value={member.token.id} /> | |
| 244 | + | <SubmitButton variant="quiet" match={{ intent: "revoke", id: member.token.id }} pending="Revoking…"> | |
| 245 | + | Revoke | |
| 246 | + | </SubmitButton> | |
| 247 | + | </Form> | |
| 248 | + | )} | |
| 249 | + | </li> | |
| 250 | + | ))} | |
| 251 | + | </ul> | |
| 252 | + | )} | |
| 253 | + | </section> | |
| 254 | + | </div> | |
| 255 | + | ); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /** One member's token: whose, what it is, what it reaches and may do. */ | |
| 259 | + | function TokenLine({ member }: { member: MemberToken }) { | |
| 260 | + | const { token } = member; | |
| 261 | + | const fine = token.kind === "fine_grained"; | |
| 262 | + | const badge = statusBadge(token.fineGrained?.status); | |
| 263 | + | const expiry = describeExpiry(token.expiresAt); | |
| 264 | + | return ( | |
| 265 | + | <div className="min-w-0 space-y-1.5"> | |
| 266 | + | <p className="flex min-w-0 flex-wrap items-center gap-2 text-sm"> | |
| 267 | + | <Avatar name={member.owner} size={18} /> | |
| 268 | + | <Link to={`/u/${member.owner}`} className="font-mono text-fg hover:underline"> | |
| 269 | + | {member.owner} | |
| 270 | + | </Link> | |
| 271 | + | <span className="flex min-w-0 items-center gap-1.5 font-medium"> | |
| 272 | + | <KeyRound size={13} className="shrink-0 text-faint" /> | |
| 273 | + | <span className="truncate">{token.name}</span> | |
| 274 | + | </span> | |
| 275 | + | <Badge tone={fine ? "accent" : "neutral"}>{fine ? "Fine-grained" : "Classic"}</Badge> | |
| 276 | + | {badge && <Badge tone={badge.tone}>{badge.label}</Badge>} | |
| 277 | + | {!badge && !member.reaches && member.blockedBy && <Badge tone="danger">Kept out: {member.blockedBy}</Badge>} | |
| 278 | + | </p> | |
| 279 | + | {token.description && <p className="text-xs text-muted">{token.description}</p>} | |
| 280 | + | <p className="text-xs text-faint"> | |
| 281 | + | Created <TimeAgo at={token.createdAt} /> ·{" "} | |
| 282 | + | {token.lastUsedAt ? ( | |
| 283 | + | <> | |
| 284 | + | last used <TimeAgo at={token.lastUsedAt} /> | |
| 285 | + | </> | |
| 286 | + | ) : ( | |
| 287 | + | "never used" | |
| 288 | + | )}{" "} | |
| 289 | + | · <span className={expiry === "No expiry" ? "text-warn" : undefined}>{expiry}</span> | |
| 290 | + | {fine && <> · {reachSummary(token)}</>} | |
| 291 | + | </p> | |
| 292 | + | {fine ? ( | |
| 293 | + | <div className="flex flex-wrap gap-1.5"> | |
| 294 | + | {permissionChips(token.fineGrained?.permissions).map((chip) => ( | |
| 295 | + | <span key={chip} className="rounded border border-line px-1.5 py-px text-[0.6875rem] text-muted"> | |
| 296 | + | {chip} | |
| 297 | + | </span> | |
| 298 | + | ))} | |
| 299 | + | </div> | |
| 300 | + | ) : ( | |
| 301 | + | <AccessSummary holder={token} className="mt-0" /> | |
| 302 | + | )} | |
| 303 | + | {fine && token.fineGrained?.repositorySelection === "selected" && token.fineGrained.repositories.length > 0 && ( | |
| 304 | + | <p className="truncate font-mono text-[0.6875rem] text-faint">{token.fineGrained.repositories.join(", ")}</p> | |
| 305 | + | )} | |
| 306 | + | </div> | |
| 307 | + | ); | |
| 308 | + | } |
| 1 | − | import { Form } from "react-router"; | |
| 1 | + | import { TriangleAlert } from "lucide-react"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 2 | 3 | ||
| 3 | 4 | import { presetScopes } from "@g1t/contracts"; | |
| 4 | 5 | ||
| ⋯ | |||
| 15 | 16 | TimeAgo, | |
| 16 | 17 | } from "../../components/ui"; | |
| 17 | 18 | import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes"; | |
| 19 | + | import { Badge } from "../../components/ui/badge"; | |
| 18 | 20 | import { identity } from "../../lib/services.server"; | |
| 19 | 21 | import { describeExpiry, expiryTtl, grantFromForm } from "../../lib/token-scopes"; | |
| 20 | 22 | import { | |
| ⋯ | |||
| 56 | 58 | user, | |
| 57 | 59 | params.owner, | |
| 58 | 60 | String(form.get("label") ?? ""), | |
| 59 | − | { ...grant.value, ttlSeconds: expiryTtl(form.get("expires")) }, | |
| 61 | + | { ...grant.value, ttlSeconds: expiryTtl(form.get("expires")), admin: form.get("admin") === "on" }, | |
| 60 | 62 | ); | |
| 61 | 63 | return created.ok | |
| 62 | 64 | ? { token: created.value, error: null } | |
| ⋯ | |||
| 99 | 101 | {tokens.map((token) => ( | |
| 100 | 102 | <li key={token.id} className="flex items-start gap-4 px-4 py-3"> | |
| 101 | 103 | <div className="min-w-0 grow"> | |
| 102 | − | <p className="truncate text-sm font-medium">{token.name}</p> | |
| 104 | + | <p className="flex min-w-0 items-center gap-2 text-sm font-medium"> | |
| 105 | + | <span className="truncate">{token.name}</span> | |
| 106 | + | <Badge tone={token.admin ? "danger" : "neutral"}>{token.admin ? "Admin" : "Write"}</Badge> | |
| 107 | + | </p> | |
| 103 | 108 | <p className="mt-0.5 text-xs text-faint"> | |
| 104 | 109 | Created <TimeAgo at={token.createdAt} /> | |
| 105 | 110 | {token.createdBy ? ( | |
| ⋯ | |||
| 158 | 163 | <ExpiryField /> | |
| 159 | 164 | </div> | |
| 160 | 165 | <ScopeChecklist initial={presetScopes("ci")} /> | |
| 166 | + | <label className="flex cursor-pointer items-start gap-2.5 rounded-md border border-danger/30 px-3 py-2.5"> | |
| 167 | + | <input type="checkbox" name="admin" className="mt-0.5 size-4 shrink-0 accent-danger" /> | |
| 168 | + | <span className="min-w-0"> | |
| 169 | + | <span className="block text-sm text-fg">Admin on the workspace's repositories</span> | |
| 170 | + | <span className="mt-0.5 flex items-start gap-1.5 text-xs text-faint"> | |
| 171 | + | <TriangleAlert size={13} className="mt-px shrink-0 text-danger" /> | |
| 172 | + | Without it the token has Write, as a member does. With it, the token can also manage webhooks, | |
| 173 | + | secrets, deploy keys and who has access, and act as an owner on teams, as far as its scopes allow. | |
| 174 | + | </span> | |
| 175 | + | </span> | |
| 176 | + | </label> | |
| 161 | 177 | <SubmitButton match={{ action: "create" }} pending="Creating…"> | |
| 162 | 178 | Create token | |
| 163 | 179 | </SubmitButton> | |
| ⋯ | |||
| 175 | 191 | <h3 className="font-medium">What a token can do</h3> | |
| 176 | 192 | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 177 | 193 | <li> | |
| 178 | − | What its scopes allow, in this workspace only, and never more | |
| 179 | − | than a member can: push, open and merge pull requests, manage | |
| 180 | − | issues. | |
| 194 | + | What its scopes allow, in this workspace only, with Write on its | |
| 195 | + | repositories, as a member: push, open and merge pull requests, | |
| 196 | + | manage issues. Admin only when an owner gives it that. | |
| 181 | 197 | </li> | |
| 182 | 198 | <li> | |
| 183 | 199 | It acts as <span className="font-mono text-fg">{slug}</span>, so | |
| ⋯ | |||
| 187 | 203 | <li>It cannot manage people, tokens or other workspaces.</li> | |
| 188 | 204 | </ul> | |
| 189 | 205 | </section> | |
| 206 | + | {role === "owner" && ( | |
| 207 | + | <section className="rounded-xl border border-line p-5"> | |
| 208 | + | <h3 className="font-medium">Your members' own tokens</h3> | |
| 209 | + | <p className="mt-2 text-muted"> | |
| 210 | + | Which personal tokens may reach {slug}, how long they may last, and approving fine-grained ones:{" "} | |
| 211 | + | <Link to={`/${slug}/-/personal-access-tokens`} className="text-fg underline underline-offset-4"> | |
| 212 | + | Personal access tokens | |
| 213 | + | </Link> | |
| 214 | + | . | |
| 215 | + | </p> | |
| 216 | + | </section> | |
| 217 | + | )} | |
| 190 | 218 | <section> | |
| 191 | 219 | <h3 className="font-medium">Using one</h3> | |
| 192 | 220 | <p className="mt-2 text-muted">With git, as the password:</p> | |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./fine-grained": "./src/fine-grained.ts" }, | |
| 8 | 8 | "scripts": { "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 90 | 90 | name, | |
| 91 | 91 | scopes: grant?.scopes ?? null, | |
| 92 | 92 | ttl_seconds: grant?.ttlSeconds ?? null, | |
| 93 | + | admin: grant?.admin ?? false, | |
| 93 | 94 | }), | |
| 94 | 95 | removeWorkspaceToken: (actor, slug, id) => | |
| 95 | 96 | call("remove_workspace_token", { actor, slug, id }), | |
| 97 | + | createFineGrainedToken: (user, input) => | |
| 98 | + | call("create_fine_grained_token", { | |
| 99 | + | user, | |
| 100 | + | name: input.name, | |
| 101 | + | description: input.description ?? null, | |
| 102 | + | ttl_seconds: input.ttlSeconds, | |
| 103 | + | workspace: input.workspace, | |
| 104 | + | repository_selection: input.repositorySelection, | |
| 105 | + | repositories: input.repositories, | |
| 106 | + | permissions: input.permissions, | |
| 107 | + | }), | |
| 108 | + | updateFineGrainedToken: (user, id, change) => | |
| 109 | + | call("update_fine_grained_token", { | |
| 110 | + | user, | |
| 111 | + | id, | |
| 112 | + | name: change.name ?? null, | |
| 113 | + | description: change.description ?? null, | |
| 114 | + | repository_selection: change.repositorySelection ?? null, | |
| 115 | + | repositories: change.repositories ?? null, | |
| 116 | + | permissions: change.permissions ?? null, | |
| 117 | + | }), | |
| 118 | + | getTokenPolicy: (slug, viewer) => call("get_token_policy", { slug, viewer }), | |
| 119 | + | setTokenPolicy: (actor, slug, change) => | |
| 120 | + | call("set_token_policy", { | |
| 121 | + | actor, | |
| 122 | + | slug, | |
| 123 | + | allow_classic: change.allowClassic ?? null, | |
| 124 | + | allow_fine_grained: change.allowFineGrained ?? null, | |
| 125 | + | require_approval: change.requireApproval ?? null, | |
| 126 | + | max_lifetime_days: change.maxLifetimeDays ?? null, | |
| 127 | + | forbid_no_expiry: change.forbidNoExpiry ?? null, | |
| 128 | + | surface: "web", | |
| 129 | + | }), | |
| 130 | + | listMemberTokens: (actor, slug, filter = {}) => | |
| 131 | + | call("list_member_tokens", { actor, slug, status: filter.status ?? null, kind: filter.kind ?? null }), | |
| 132 | + | reviewTokenRequest: (actor, slug, id, approve, reason) => | |
| 133 | + | call("review_token_request", { actor, slug, id, approve, reason: reason ?? null, surface: "web" }), | |
| 134 | + | revokeMemberToken: (actor, slug, id, reason) => | |
| 135 | + | call("revoke_member_token", { actor, slug, id, reason: reason ?? null, surface: "web" }), | |
| 96 | 136 | userForSession: (sessionToken) => call("user_for_session", { sessionToken }), | |
| 97 | 137 | registration: () => call("registration", {}), | |
| 98 | 138 | listInvites: (user) => call("list_invites", { user }), |
| 1 | 1 | import type { AccessClient, BasePermission, RepoGrant } from "./access"; | |
| 2 | + | import type { PermissionAccess, RepositorySelection } from "./fine-grained"; | |
| 2 | 3 | import type { Acting, CreateRunCredentialInput, RunBinding } from "./audit"; | |
| 3 | 4 | import type { RepoPath } from "./repos"; | |
| 4 | 5 | import type { Result } from "./result"; | |
| ⋯ | |||
| 379 | 380 | legacy: boolean; | |
| 380 | 381 | /** RFC 3339. Null: it does not expire. */ | |
| 381 | 382 | expiresAt: string | null; | |
| 383 | + | /** Classic, fine-grained, or a workspace's own. */ | |
| 384 | + | kind?: TokenKind; | |
| 385 | + | /** What it is for, as its owner wrote it. */ | |
| 386 | + | description?: string | null; | |
| 387 | + | /** A fine-grained token's resource owner, repositories, permissions and status. */ | |
| 388 | + | fineGrained?: FineGrainedDetails | null; | |
| 389 | + | /** A workspace's own token an owner gave Admin when making it. */ | |
| 390 | + | admin?: boolean; | |
| 391 | + | }; | |
| 392 | + | ||
| 393 | + | export type TokenKind = "classic" | "fine_grained" | "workspace"; | |
| 394 | + | ||
| 395 | + | /** Whether a fine-grained token may be used on its resource owner. */ | |
| 396 | + | export type TokenStatus = "active" | "pending" | "denied" | "revoked"; | |
| 397 | + | ||
| 398 | + | export type FineGrainedDetails = { | |
| 399 | + | /** The resource owner's slug; null for your own account. */ | |
| 400 | + | workspace: string | null; | |
| 401 | + | repositorySelection: RepositorySelection; | |
| 402 | + | /** With `selected`: the repositories, as `owner/name`, that you can see. */ | |
| 403 | + | repositories: string[]; | |
| 404 | + | permissions: Partial<Record<string, PermissionAccess>>; | |
| 405 | + | status: TokenStatus; | |
| 406 | + | /** Why an owner denied or revoked it. */ | |
| 407 | + | reviewReason?: string | null; | |
| 408 | + | }; | |
| 409 | + | ||
| 410 | + | /** What a new fine-grained token is. */ | |
| 411 | + | export type FineGrainedTokenInput = { | |
| 412 | + | name: string; | |
| 413 | + | description?: string | null; | |
| 414 | + | /** Between 1 and 366 days, and no more than the workspace allows. */ | |
| 415 | + | ttlSeconds: number; | |
| 416 | + | /** The resource owner: a workspace's slug, or null for your own account. */ | |
| 417 | + | workspace: string | null; | |
| 418 | + | repositorySelection: RepositorySelection; | |
| 419 | + | /** With `selected`: `owner/name` or names in the workspace. */ | |
| 420 | + | repositories: string[]; | |
| 421 | + | /** Each permission's level by name; names left out are none. */ | |
| 422 | + | permissions: Record<string, PermissionAccess>; | |
| 423 | + | }; | |
| 424 | + | ||
| 425 | + | /** A workspace's rules for personal access tokens. */ | |
| 426 | + | export type TokenPolicy = { | |
| 427 | + | allowClassic: boolean; | |
| 428 | + | allowFineGrained: boolean; | |
| 429 | + | requireApproval: boolean; | |
| 430 | + | /** Null: no limit. */ | |
| 431 | + | maxLifetimeDays: number | null; | |
| 432 | + | forbidNoExpiry: boolean; | |
| 433 | + | updatedBy?: string | null; | |
| 434 | + | updatedAt?: string | null; | |
| 435 | + | }; | |
| 436 | + | ||
| 437 | + | /** A member's personal token that reaches a workspace, as its owners see it. */ | |
| 438 | + | export type MemberToken = { | |
| 439 | + | owner: string; | |
| 440 | + | token: AccessToken; | |
| 441 | + | /** Whether it reaches the workspace now. */ | |
| 442 | + | reaches: boolean; | |
| 443 | + | /** Why not: pending approval, denied, revoked, classic tokens not allowed, lasts too long, never expires. */ | |
| 444 | + | blockedBy?: string | null; | |
| 382 | 445 | }; | |
| 383 | 446 | ||
| 384 | 447 | /** What a new or changed token may do. */ | |
| ⋯ | |||
| 614 | 677 | actor: User, | |
| 615 | 678 | slug: string, | |
| 616 | 679 | name: string, | |
| 617 | − | grant?: TokenGrant & { ttlSeconds?: number }, | |
| 680 | + | grant?: TokenGrant & { ttlSeconds?: number; admin?: boolean }, | |
| 618 | 681 | ): Promise<Result<{ token: string; info: AccessToken }>>; | |
| 619 | 682 | /** Owners only. */ | |
| 620 | 683 | removeWorkspaceToken(actor: User, slug: string, id: string): Promise<Result<boolean>>; | |
| 621 | 684 | ||
| 685 | + | /** | |
| 686 | + | * A fine-grained personal access token: one resource owner, some of its | |
| 687 | + | * repositories, a level for each permission. People only. It starts | |
| 688 | + | * pending when its workspace asks for approval and you are not an owner. | |
| 689 | + | */ | |
| 690 | + | createFineGrainedToken(user: User, input: FineGrainedTokenInput): Promise<Result<{ token: string; info: AccessToken }>>; | |
| 691 | + | /** Its owner changes it; what is left out stays. Widening it asks for approval again. */ | |
| 692 | + | updateFineGrainedToken( | |
| 693 | + | user: User, | |
| 694 | + | id: string, | |
| 695 | + | change: Partial<Omit<FineGrainedTokenInput, "ttlSeconds" | "workspace">>, | |
| 696 | + | ): Promise<Result<AccessToken>>; | |
| 697 | + | /** A workspace's rules for personal access tokens. Members only. */ | |
| 698 | + | getTokenPolicy(slug: string, viewer: Viewer): Promise<Result<TokenPolicy>>; | |
| 699 | + | /** Owners only, as people. `maxLifetimeDays` of 0 removes the limit. */ | |
| 700 | + | setTokenPolicy( | |
| 701 | + | actor: User, | |
| 702 | + | slug: string, | |
| 703 | + | change: Partial<Omit<TokenPolicy, "updatedBy" | "updatedAt">>, | |
| 704 | + | ): Promise<Result<TokenPolicy>>; | |
| 705 | + | /** The members' tokens that can reach a workspace. Owners only. */ | |
| 706 | + | listMemberTokens( | |
| 707 | + | actor: User, | |
| 708 | + | slug: string, | |
| 709 | + | filter?: { status?: TokenStatus; kind?: TokenKind }, | |
| 710 | + | ): Promise<Result<MemberToken[]>>; | |
| 711 | + | /** Approve or deny a fine-grained token waiting for approval. Owners only. */ | |
| 712 | + | reviewTokenRequest(actor: User, slug: string, id: string, approve: boolean, reason?: string | null): Promise<Result<MemberToken>>; | |
| 713 | + | /** Take a member's token out of the workspace. Owners only. */ | |
| 714 | + | revokeMemberToken(actor: User, slug: string, id: string, reason?: string | null): Promise<Result<boolean>>; | |
| 715 | + | ||
| 622 | 716 | userForSession(sessionToken: string): Promise<Viewer>; | |
| 623 | 717 | ||
| 624 | 718 | /** Whether registration is invite-only. */ | |
| 974 | 974 | notify: owners, | |
| 975 | 975 | title: format!("{} asks to use a fine-grained token in {slug}", requester.username), | |
| 976 | 976 | body: format!("{}: {permissions}", token.name), | |
| 977 | − | link: format!("/{slug}/-/settings/tokens"), | |
| 977 | + | link: format!("/{slug}/-/personal-access-tokens"), | |
| 978 | 978 | }, | |
| 979 | 979 | ) | |
| 980 | 980 | .await; |