Skip to content
308 linesCodeBlameRaw
1import { Check, KeyRound, X } from "lucide-react";
2import { Form, Link, useSearchParams } from "react-router";
3
4import type { MemberToken, TokenKind } from "@g1t/contracts";
5
6import type { Route } from "./+types/personal-access-tokens";
7import { page } from "../../lib/meta";
8import { Avatar, EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { SwitchCard } from "../../components/ui/switch";
11import { AccessSummary } from "../../components/token-scopes";
12import { identity } from "../../lib/services.server";
13import { describeExpiry } from "../../lib/token-scopes";
14import { lifetimeFromForm, permissionChips, reachSummary, statusBadge } from "../../lib/fine-grained";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16import { 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
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Personal access tokens · ${params.owner} · g1t` });
24}
25
26export 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
35export 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
68const 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
78export 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. */
259function 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}