Skip to content
163 linesCodeBlameRaw
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
11import {
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";
20import type { AccessToken, FineGrainedTokenInput, TokenPolicy, TokenStatus } from "@g1t/contracts";
21
22import 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. */
25export const FINE_GRAINED_EXPIRY_DAYS = [7, 30, 60, 90, 180, FINE_GRAINED_MAX_LIFETIME_DAYS] as const;
26
27export const DEFAULT_FINE_GRAINED_DAYS = 30;
28
29/** The lifetimes a workspace's rules leave, longest last; all of them for your own account. */
30export 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
37export 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
42const 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. */
45export 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. */
50export 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 */
68export 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". */
110export 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. */
127export 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. */
135export 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. */
149export 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. */
157export 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}