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