| 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 | } |