Skip to content

Commit

Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules

syntaqxcommitted Parent7f45726Browse files
13 files+1325−1200/13 viewed
+294−0
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+}
+7−1
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";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
866866 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
867867 Access tokens
868868 </SidebarLink>
869+ {owner && (
870+ <SidebarLink to={`/${slug}/-/personal-access-tokens`} icon={<UserRoundKey size={15} />}>
871+ Personal access tokens
872+ </SidebarLink>
873+ )}
869874 <SidebarLink to={`/${slug}/-/rules`} icon={<Scale size={15} />}>
870875 Rules
871876 </SidebarLink>
14011406 projects: "Projects",
14021407 teams: "Teams",
14031408 tokens: "Access tokens",
1409+ "personal-access-tokens": "Personal access tokens",
14041410 usage: "Usage",
14051411 gateway: "AI Gateway",
14061412 billing: "Billing and plans",
+100−0
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+});
+163−0
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+}
+2−0
8181 route("settings", "routes/workspace/team/settings.tsx"),
8282 ]),
8383 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"),
8486 route("-/usage", "routes/workspace/usage.tsx"),
8587 route("-/gateway", "routes/workspace/gateway.tsx"),
8688 route("-/billing", "routes/workspace/billing.tsx"),
+275−109
1−import { identity } from "../../lib/services.server";
1+import { identity, repos } from "../../lib/services.server";
22 import { Form, Link, redirect, useSearchParams } from "react-router";
33
44 import { presetScopes, type AccessToken } from "@g1t/contracts";
1313 Input,
1414 TimeAgo,
1515 } from "../../components/ui";
16+import { Badge } from "../../components/ui/badge";
1617 import { DeleteButton } from "../../components/account-settings";
18+import { FineGrainedForm, type OwnerChoice } from "../../components/fine-grained-form";
1719 import {
1820 AccessSummary,
1921 ExpiryField,
2426 expiryTtl,
2527 grantFromForm,
2628 } from "../../lib/token-scopes";
29+import { fineGrainedFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained";
2730 import { assertSameOrigin, requireUser } from "../../lib/session.server";
31+import { cn } from "../../lib/cn";
2832
2933 export function meta(args: Route.MetaArgs) {
3034 return page(args, { title: "Access tokens · Settings · g1t" });
3135 }
3236
37+type Tab = "fine-grained" | "classic";
38+
39+function tabOf(value: string | null): Tab {
40+ return value === "classic" ? "classic" : "fine-grained";
41+}
42+
3343 export async function loader({ request, context }: Route.LoaderArgs) {
3444 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 };
3666 }
3767
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> {
3978 assertSameOrigin(request);
4079 const user = requireUser(context, request);
4180 const form = await request.formData();
4281 switch (form.get("intent")) {
4382 case "add-token": {
4483 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);
5285 const created = await identity.createAccessToken(
5386 user,
5487 String(form.get("label") ?? ""),
5588 expiryTtl(form.get("expires")),
5689 { ...grant.value, listed: true },
5790 );
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 };
6499 }
65100 case "update-token": {
66101 const id = String(form.get("id") ?? "");
67102 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);
75104 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);
83121 throw redirect("/settings/tokens");
84122 }
85123 case "delete-token":
89127 return null;
90128 }
91129
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;
97132 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);
103138 return (
104139 <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">
125165 <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.
132167 </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>
144171 </div>
145172 )}
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>
146335 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
147336 {tokens.map((token) => (
148− <TokenRow
337+ <ClassicRow
149338 key={token.id}
150339 token={token}
151340 editing={editing === token.id}
156345
157346 {!editing && (
158347 // 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">
164349 <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>
166351 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
167352 <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 />
174354 </Field>
175355 <ExpiryField />
176356 </div>
181361 </SubmitButton>
182362 </Form>
183363 )}
184− </section>
364+ </>
185365 );
186366 }
187367
188−function TokenRow({
368+function ClassicRow({
189369 token,
190370 editing,
191371 error,
195375 error: string | null | undefined;
196376 }) {
197377 const legacy = token.legacy && token.scopes === null;
198− const expiry = describeExpiry(token.expiresAt);
199378 return (
200379 <li className="px-4 py-3">
201380 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
202381 <div className="min-w-0 grow basis-60">
203382 <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} />
218384 <AccessSummary holder={token} />
219385 {legacy && (
220386 <p className="mt-1.5 text-xs text-warn">
227393 {!editing && (
228394 <ButtonLink
229395 variant="quiet"
230− to={`?edit=${token.id}`}
396+ to={`?tab=classic&edit=${token.id}`}
231397 preventScrollReset
232398 >
233399 {legacy ? "Narrow this token" : "Edit access"}
253419 <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}>
254420 Save access
255421 </SubmitButton>
256− <ButtonLink variant="quiet" to="." preventScrollReset>
422+ <ButtonLink variant="quiet" to="?tab=classic" preventScrollReset>
257423 Cancel
258424 </ButtonLink>
259425 </div>
+5−1
6363 },
6464 tokens: {
6565 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.",
6771 },
6872 packages: {
6973 title: "Packages",
+308−0
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+}
+34−6
1−import { Form } from "react-router";
1+import { TriangleAlert } from "lucide-react";
2+import { Form, Link } from "react-router";
23
34 import { presetScopes } from "@g1t/contracts";
45
1516 TimeAgo,
1617 } from "../../components/ui";
1718 import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes";
19+import { Badge } from "../../components/ui/badge";
1820 import { identity } from "../../lib/services.server";
1921 import { describeExpiry, expiryTtl, grantFromForm } from "../../lib/token-scopes";
2022 import {
5658 user,
5759 params.owner,
5860 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" },
6062 );
6163 return created.ok
6264 ? { token: created.value, error: null }
99101 {tokens.map((token) => (
100102 <li key={token.id} className="flex items-start gap-4 px-4 py-3">
101103 <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>
103108 <p className="mt-0.5 text-xs text-faint">
104109 Created <TimeAgo at={token.createdAt} />
105110 {token.createdBy ? (
158163 <ExpiryField />
159164 </div>
160165 <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>
161177 <SubmitButton match={{ action: "create" }} pending="Creating…">
162178 Create token
163179 </SubmitButton>
175191 <h3 className="font-medium">What a token can do</h3>
176192 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
177193 <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.
181197 </li>
182198 <li>
183199 It acts as <span className="font-mono text-fg">{slug}</span>, so
187203 <li>It cannot manage people, tokens or other workspaces.</li>
188204 </ul>
189205 </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+ )}
190218 <section>
191219 <h3 className="font-medium">Using one</h3>
192220 <p className="mt-2 text-muted">With git, as the password:</p>
+1−1
44 "private": true,
55 "type": "module",
66 "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" },
88 "scripts": { "typecheck": "tsc -p tsconfig.json" }
99 }
+40−0
9090 name,
9191 scopes: grant?.scopes ?? null,
9292 ttl_seconds: grant?.ttlSeconds ?? null,
93+ admin: grant?.admin ?? false,
9394 }),
9495 removeWorkspaceToken: (actor, slug, id) =>
9596 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" }),
96136 userForSession: (sessionToken) => call("user_for_session", { sessionToken }),
97137 registration: () => call("registration", {}),
98138 listInvites: (user) => call("list_invites", { user }),
+95−1
11 import type { AccessClient, BasePermission, RepoGrant } from "./access";
2+import type { PermissionAccess, RepositorySelection } from "./fine-grained";
23 import type { Acting, CreateRunCredentialInput, RunBinding } from "./audit";
34 import type { RepoPath } from "./repos";
45 import type { Result } from "./result";
379380 legacy: boolean;
380381 /** RFC 3339. Null: it does not expire. */
381382 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;
382445 };
383446
384447 /** What a new or changed token may do. */
614677 actor: User,
615678 slug: string,
616679 name: string,
617− grant?: TokenGrant & { ttlSeconds?: number },
680+ grant?: TokenGrant & { ttlSeconds?: number; admin?: boolean },
618681 ): Promise<Result<{ token: string; info: AccessToken }>>;
619682 /** Owners only. */
620683 removeWorkspaceToken(actor: User, slug: string, id: string): Promise<Result<boolean>>;
621684
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+
622716 userForSession(sessionToken: string): Promise<Viewer>;
623717
624718 /** Whether registration is invite-only. */
+1−1
974974 notify: owners,
975975 title: format!("{} asks to use a fine-grained token in {slug}", requester.username),
976976 body: format!("{}: {permissions}", token.name),
977− link: format!("/{slug}/-/settings/tokens"),
977+ link: format!("/{slug}/-/personal-access-tokens"),
978978 },
979979 )
980980 .await;