Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Settings: fine-grained tokens, workspace token Admin, workspace personal access token rules | 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 | } |