| 1 | import { identity, repos } from "../../lib/services.server"; |
| 2 | import { Form, Link, redirect, useSearchParams } from "react-router"; |
| 3 | |
| 4 | import { presetScopes, type AccessToken } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/tokens"; |
| 7 | import { page } from "../../lib/meta"; |
| 8 | import { |
| 9 | SubmitButton, |
| 10 | ButtonLink, |
| 11 | ErrorText, |
| 12 | Field, |
| 13 | Input, |
| 14 | TimeAgo, |
| 15 | } from "../../components/ui"; |
| 16 | import { Badge } from "../../components/ui/badge"; |
| 17 | import { DeleteButton } from "../../components/account-settings"; |
| 18 | import { FineGrainedForm, type OwnerChoice } from "../../components/fine-grained-form"; |
| 19 | import { |
| 20 | AccessSummary, |
| 21 | ExpiryField, |
| 22 | ScopeChecklist, |
| 23 | } from "../../components/token-scopes"; |
| 24 | import { |
| 25 | describeExpiry, |
| 26 | expiryTtl, |
| 27 | grantFromForm, |
| 28 | } from "../../lib/token-scopes"; |
| 29 | import { fineGrainedFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained"; |
| 30 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; |
| 31 | import { cn } from "../../lib/cn"; |
| 32 | |
| 33 | export function meta(args: Route.MetaArgs) { |
| 34 | return page(args, { title: "Access tokens · Settings · g1t" }); |
| 35 | } |
| 36 | |
| 37 | type Tab = "fine-grained" | "classic"; |
| 38 | |
| 39 | function tabOf(value: string | null): Tab { |
| 40 | return value === "classic" ? "classic" : "fine-grained"; |
| 41 | } |
| 42 | |
| 43 | export async function loader({ request, context }: Route.LoaderArgs) { |
| 44 | const user = requireUser(context, request); |
| 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 }; |
| 66 | } |
| 67 | |
| 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> { |
| 78 | assertSameOrigin(request); |
| 79 | const user = requireUser(context, request); |
| 80 | const form = await request.formData(); |
| 81 | switch (form.get("intent")) { |
| 82 | case "add-token": { |
| 83 | const grant = grantFromForm(form); |
| 84 | if (!grant.ok) return failed(grant.error); |
| 85 | const created = await identity.createAccessToken( |
| 86 | user, |
| 87 | String(form.get("label") ?? ""), |
| 88 | expiryTtl(form.get("expires")), |
| 89 | { ...grant.value, listed: true }, |
| 90 | ); |
| 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 }; |
| 99 | } |
| 100 | case "update-token": { |
| 101 | const id = String(form.get("id") ?? ""); |
| 102 | const grant = grantFromForm(form); |
| 103 | if (!grant.ok) return failed(grant.error, id); |
| 104 | const updated = await identity.updateAccessToken(user, id, grant.value); |
| 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); |
| 121 | throw redirect("/settings/tokens"); |
| 122 | } |
| 123 | case "delete-token": |
| 124 | await identity.removeAccessToken(user, String(form.get("id") ?? "")); |
| 125 | return null; |
| 126 | } |
| 127 | return null; |
| 128 | } |
| 129 | |
| 130 | export default function TokenSettings({ loaderData, actionData }: Route.ComponentProps) { |
| 131 | const { user, tokens, owners } = loaderData; |
| 132 | const [params] = useSearchParams(); |
| 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); |
| 138 | return ( |
| 139 | <section id="tokens" className="scroll-mt-20"> |
| 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"> |
| 165 | <p className="text-sm"> |
| 166 | <span className="font-medium">{created.name}</span> is ready. Copy it now. It will not be shown again. |
| 167 | </p> |
| 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> |
| 171 | </div> |
| 172 | )} |
| 173 | |
| 174 | {tab === "fine-grained" ? ( |
| 175 | <FineGrainedTokens tokens={fine} owners={owners} actionData={actionData ?? null} /> |
| 176 | ) : ( |
| 177 | <ClassicTokens tokens={classic} workspaces={workspaces} actionData={actionData ?? null} /> |
| 178 | )} |
| 179 | </section> |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | /** A fine-grained token's reach, permissions and status, under its name. */ |
| 184 | function FineGrainedSummary({ token }: { token: AccessToken }) { |
| 185 | const badge = statusBadge(token.fineGrained?.status); |
| 186 | return ( |
| 187 | <div className="mt-1.5 space-y-1.5"> |
| 188 | <p className="flex flex-wrap items-center gap-1.5 text-xs text-muted"> |
| 189 | <Badge tone="accent">Fine-grained</Badge> |
| 190 | {badge && <Badge tone={badge.tone}>{badge.label}</Badge>} |
| 191 | <span>{reachSummary(token)}</span> |
| 192 | </p> |
| 193 | <div className="flex flex-wrap gap-1.5"> |
| 194 | {permissionChips(token.fineGrained?.permissions).map((chip) => ( |
| 195 | <span key={chip} className="rounded border border-line px-1.5 py-px text-[0.6875rem] text-muted"> |
| 196 | {chip} |
| 197 | </span> |
| 198 | ))} |
| 199 | </div> |
| 200 | {token.fineGrained?.repositorySelection === "selected" && token.fineGrained.repositories.length > 0 && ( |
| 201 | <p className="truncate font-mono text-[0.6875rem] text-faint">{token.fineGrained.repositories.join(", ")}</p> |
| 202 | )} |
| 203 | {token.fineGrained?.reviewReason && ( |
| 204 | <p className="text-xs text-faint">Owner's note: {token.fineGrained.reviewReason}</p> |
| 205 | )} |
| 206 | </div> |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | function Meta({ token }: { token: AccessToken }) { |
| 211 | const expiry = describeExpiry(token.expiresAt); |
| 212 | return ( |
| 213 | <p className="text-xs text-faint"> |
| 214 | Created <TimeAgo at={token.createdAt} /> ·{" "} |
| 215 | {token.lastUsedAt ? ( |
| 216 | <> |
| 217 | last used <TimeAgo at={token.lastUsedAt} /> |
| 218 | </> |
| 219 | ) : ( |
| 220 | "never used" |
| 221 | )}{" "} |
| 222 | · <span className={expiry === "Expired" ? "text-danger" : undefined}>{expiry}</span> |
| 223 | </p> |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | function FineGrainedTokens({ |
| 228 | tokens, |
| 229 | owners, |
| 230 | actionData, |
| 231 | }: { |
| 232 | tokens: AccessToken[]; |
| 233 | owners: OwnerChoice[]; |
| 234 | actionData: ActionResult | null; |
| 235 | }) { |
| 236 | const [params] = useSearchParams(); |
| 237 | const editing = actionData?.editing ?? params.get("edit"); |
| 238 | const created = actionData?.created; |
| 239 | return ( |
| 240 | <> |
| 241 | <p className="text-sm text-muted"> |
| 242 | A fine-grained token reaches one resource owner (one workspace, or your own account), only the repositories |
| 243 | you choose, and only with the permissions you give it. It always expires. |
| 244 | </p> |
| 245 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> |
| 246 | {tokens.map((token) => ( |
| 247 | <li key={token.id} className="px-4 py-3"> |
| 248 | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> |
| 249 | <div className="min-w-0 grow basis-60"> |
| 250 | <p className="truncate text-sm font-medium">{token.name}</p> |
| 251 | {token.description && <p className="truncate text-xs text-muted">{token.description}</p>} |
| 252 | <Meta token={token} /> |
| 253 | <FineGrainedSummary token={token} /> |
| 254 | </div> |
| 255 | <div className="ml-auto flex shrink-0 items-center gap-2"> |
| 256 | {editing !== token.id && ( |
| 257 | <ButtonLink variant="quiet" to={`?edit=${token.id}`} preventScrollReset> |
| 258 | Edit |
| 259 | </ButtonLink> |
| 260 | )} |
| 261 | <DeleteButton intent="delete-token" id={token.id} /> |
| 262 | </div> |
| 263 | </div> |
| 264 | {editing === token.id && ( |
| 265 | <Form method="post" className="mt-4 space-y-5 border-t border-line pt-4"> |
| 266 | <input type="hidden" name="intent" value="update-fine-grained" /> |
| 267 | <input type="hidden" name="id" value={token.id} /> |
| 268 | <p className="text-sm text-muted"> |
| 269 | The token stays the same; what it reaches changes from its next request. A workspace that approves |
| 270 | tokens asks again when you widen it. |
| 271 | </p> |
| 272 | <FineGrainedForm owners={owners} editing={token} /> |
| 273 | <ErrorText>{actionData?.editing === token.id ? actionData.error : null}</ErrorText> |
| 274 | <div className="flex gap-2"> |
| 275 | <SubmitButton pending="Saving…" match={{ intent: "update-fine-grained", id: token.id }}> |
| 276 | Save |
| 277 | </SubmitButton> |
| 278 | <ButtonLink variant="quiet" to="." preventScrollReset> |
| 279 | Cancel |
| 280 | </ButtonLink> |
| 281 | </div> |
| 282 | </Form> |
| 283 | )} |
| 284 | </li> |
| 285 | ))} |
| 286 | </ul> |
| 287 | {!editing && ( |
| 288 | <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"> |
| 289 | <input type="hidden" name="intent" value="add-fine-grained" /> |
| 290 | <h2 className="font-medium">New fine-grained token</h2> |
| 291 | <FineGrainedForm owners={owners} /> |
| 292 | {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>} |
| 293 | <SubmitButton pending="Generating…" match={{ intent: "add-fine-grained" }}> |
| 294 | Generate token |
| 295 | </SubmitButton> |
| 296 | </Form> |
| 297 | )} |
| 298 | </> |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | function ClassicTokens({ |
| 303 | tokens, |
| 304 | workspaces, |
| 305 | actionData, |
| 306 | }: { |
| 307 | tokens: AccessToken[]; |
| 308 | workspaces: string[]; |
| 309 | actionData: ActionResult | null; |
| 310 | }) { |
| 311 | const [params] = useSearchParams(); |
| 312 | const editing = actionData?.editing ?? params.get("edit"); |
| 313 | const created = actionData?.created; |
| 314 | return ( |
| 315 | <> |
| 316 | <p className="text-sm text-muted"> |
| 317 | A classic token reaches every workspace and repository you can, and its scopes say what it may do there. A |
| 318 | workspace can keep classic tokens out. |
| 319 | {workspaces.length > 0 && ( |
| 320 | <> |
| 321 | {" "} |
| 322 | For CI and integrations that work for a team, use a workspace's own tokens instead:{" "} |
| 323 | {workspaces.map((slug, i) => ( |
| 324 | <span key={slug}> |
| 325 | {i > 0 && ", "} |
| 326 | <Link to={`/${slug}/-/tokens`} className="font-mono text-fg underline underline-offset-4"> |
| 327 | {slug} |
| 328 | </Link> |
| 329 | </span> |
| 330 | ))} |
| 331 | . |
| 332 | </> |
| 333 | )} |
| 334 | </p> |
| 335 | <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden"> |
| 336 | {tokens.map((token) => ( |
| 337 | <ClassicRow |
| 338 | key={token.id} |
| 339 | token={token} |
| 340 | editing={editing === token.id} |
| 341 | error={actionData?.editing === token.id ? actionData.error : null} |
| 342 | /> |
| 343 | ))} |
| 344 | </ul> |
| 345 | |
| 346 | {!editing && ( |
| 347 | // Keyed on the token just made, so the fields start over for the next. |
| 348 | <Form key={created?.id ?? "new"} method="post" className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"> |
| 349 | <input type="hidden" name="intent" value="add-token" /> |
| 350 | <h2 className="font-medium">New classic token</h2> |
| 351 | <div className="grid gap-4 sm:grid-cols-[1fr_11rem]"> |
| 352 | <Field label="Name" hint="Name it after what will use it."> |
| 353 | <Input name="label" maxLength={100} placeholder="laptop" required /> |
| 354 | </Field> |
| 355 | <ExpiryField /> |
| 356 | </div> |
| 357 | <ScopeChecklist initial={presetScopes("agent")} /> |
| 358 | {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>} |
| 359 | <SubmitButton pending="Creating…" match={{ intent: "add-token" }}> |
| 360 | Create token |
| 361 | </SubmitButton> |
| 362 | </Form> |
| 363 | )} |
| 364 | </> |
| 365 | ); |
| 366 | } |
| 367 | |
| 368 | function ClassicRow({ |
| 369 | token, |
| 370 | editing, |
| 371 | error, |
| 372 | }: { |
| 373 | token: AccessToken; |
| 374 | editing: boolean; |
| 375 | error: string | null | undefined; |
| 376 | }) { |
| 377 | const legacy = token.legacy && token.scopes === null; |
| 378 | return ( |
| 379 | <li className="px-4 py-3"> |
| 380 | <div className="flex flex-wrap items-start gap-x-4 gap-y-2"> |
| 381 | <div className="min-w-0 grow basis-60"> |
| 382 | <p className="truncate text-sm font-medium">{token.name}</p> |
| 383 | <Meta token={token} /> |
| 384 | <AccessSummary holder={token} /> |
| 385 | {legacy && ( |
| 386 | <p className="mt-1.5 text-xs text-warn"> |
| 387 | Made before tokens had scopes, so it can do everything you can. |
| 388 | Narrow it to what it needs. |
| 389 | </p> |
| 390 | )} |
| 391 | </div> |
| 392 | <div className="ml-auto flex shrink-0 items-center gap-2"> |
| 393 | {!editing && ( |
| 394 | <ButtonLink |
| 395 | variant="quiet" |
| 396 | to={`?tab=classic&edit=${token.id}`} |
| 397 | preventScrollReset |
| 398 | > |
| 399 | {legacy ? "Narrow this token" : "Edit access"} |
| 400 | </ButtonLink> |
| 401 | )} |
| 402 | <DeleteButton intent="delete-token" id={token.id} /> |
| 403 | </div> |
| 404 | </div> |
| 405 | {editing && ( |
| 406 | <Form |
| 407 | method="post" |
| 408 | className="mt-4 space-y-5 border-t border-line pt-4" |
| 409 | > |
| 410 | <input type="hidden" name="intent" value="update-token" /> |
| 411 | <input type="hidden" name="id" value={token.id} /> |
| 412 | <p className="text-sm text-muted"> |
| 413 | The token stays the same; only what it may do changes, from its next |
| 414 | request. |
| 415 | </p> |
| 416 | <ScopeChecklist initial={token.scopes} /> |
| 417 | <ErrorText>{error}</ErrorText> |
| 418 | <div className="flex gap-2"> |
| 419 | <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}> |
| 420 | Save access |
| 421 | </SubmitButton> |
| 422 | <ButtonLink variant="quiet" to="?tab=classic" preventScrollReset> |
| 423 | Cancel |
| 424 | </ButtonLink> |
| 425 | </div> |
| 426 | </Form> |
| 427 | )} |
| 428 | </li> |
| 429 | ); |
| 430 | } |