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