Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

273 linesCodeBlameRaw
1import { Check, KeyRound, X } from "lucide-react";
2import { Form, Link, redirect } from "react-router";
3
4import type { MemberToken } from "@g1t/contracts";
5
6import type { Route } from "./+types/personal-access-tokens";
7import { page } from "../../lib/meta";
8import { EmptyState, ErrorText, Input, SubmitButton, TimeAgo } from "../../components/ui";
9import { Avatar } from "../../components/ui/avatar";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { SelectField } from "../../components/ui/select";
13import { SwitchCard } from "../../components/ui/switch";
14import { TokenBadges, TokenFacts, TokenMeta } from "../../components/token-list";
15import { identity } from "../../lib/services.server";
16import { currentTokensPath, lifetimeFromForm } from "../../lib/access-tokens";
17import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
18import { Unavailable } from "../../components/boundary";
19import { isFailed, orElse, part, type Failed } from "../../lib/partial";
20
21// A workspace's rules for its members' personal access tokens, the tokens
22// that reach it, and approving the ones made for it. Owners only; identity
23// decides (services/identity/src/token_reach.rs).
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Personal access tokens · ${params.owner} · g1t` });
27}
28
29export async function loader({ request, params, context }: Route.LoaderArgs) {
30 const slug = params.owner.toLowerCase();
31 // `?kind=` filtered the two kinds tokens used to come in.
32 const moved = currentTokensPath(`/${slug}/-/personal-access-tokens`, new URL(request.url).searchParams);
33 if (moved) throw redirect(moved, 301);
34 const viewer = getViewer(context);
35 const role = roleIn(viewer, params.owner);
36 if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] | Failed };
37 // The rules are what the page is about, and their form is never shown
38 // without them. The tokens are listed beside them: when they cannot be
39 // read, their sections say so and the rules can still be changed.
40 const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), part(identity.listMemberTokens(viewer, slug), "Members' tokens")]);
41 return { slug, role, policy: unwrap(policy), tokens: isFailed(tokens) ? tokens : unwrap(tokens) };
42}
43
44export async function action({ request, params, context }: Route.ActionArgs) {
45 assertSameOrigin(request);
46 const user = requireUser(context, request);
47 const form = await request.formData();
48 const slug = params.owner;
49 const id = String(form.get("id") ?? "");
50 const reason = String(form.get("reason") ?? "").trim() || null;
51 switch (form.get("intent")) {
52 case "policy": {
53 const lifetime = lifetimeFromForm(form.get("max_lifetime_days"));
54 if (!lifetime.ok) return { error: lifetime.error, saved: false };
55 const saved = await identity.setTokenPolicy(user, slug, {
56 allowTokensForAllWorkspaces: form.get("allow_tokens_for_all_workspaces") === "on",
57 allowTokensForThisWorkspace: form.get("allow_tokens_for_this_workspace") === "on",
58 requireApproval: form.get("require_approval") === "on",
59 maxLifetimeDays: lifetime.value ?? 0,
60 forbidNoExpiry: form.get("forbid_no_expiry") === "on",
61 });
62 return saved.ok ? { error: null, saved: true } : { error: saved.error.message, saved: false };
63 }
64 case "approve":
65 case "deny": {
66 const reviewed = await identity.reviewTokenRequest(user, slug, id, form.get("intent") === "approve", reason);
67 return reviewed.ok ? { error: null, saved: false } : { error: reviewed.error.message, saved: false };
68 }
69 case "revoke": {
70 const revoked = await identity.revokeMemberToken(user, slug, id, reason);
71 return revoked.ok ? { error: null, saved: false } : { error: revoked.error.message, saved: false };
72 }
73 }
74 return null;
75}
76
77const LIFETIMES = [
78 ["", "No limit"],
79 ["7", "7 days"],
80 ["30", "30 days"],
81 ["60", "60 days"],
82 ["90", "90 days"],
83 ["180", "180 days"],
84 ["366", "1 year"],
85] as const;
86
87export default function PersonalAccessTokens({ loaderData, actionData }: Route.ComponentProps) {
88 const { slug, role, policy, tokens } = loaderData;
89 if (role !== "owner" || !policy) {
90 return (
91 <EmptyState title="Owners only">
92 Only an owner of {slug} can see which personal access tokens reach it. Your own tokens are under{" "}
93 <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
94 your settings
95 </Link>
96 .
97 </EmptyState>
98 );
99 }
100 const pending = orElse(tokens, []).filter((member) => member.token.status === "pending");
101 const listed = orElse(tokens, []).filter((member) => member.token.status !== "pending");
102 const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays);
103 return (
104 <div className="max-w-3xl space-y-10">
105 <section aria-labelledby="pat-rules">
106 <h2 id="pat-rules" className="font-medium">
107 Rules
108 </h2>
109 <p className="mt-1 text-sm text-muted">
110 They apply from each token's next request, to tokens made before them too. A token they keep out still works elsewhere, and reads {slug}'s public
111 repositories as anyone can.
112 </p>
113 <Form method="post" className="mt-4 space-y-3">
114 <input type="hidden" name="intent" value="policy" />
115 <SwitchCard name="allow_tokens_for_this_workspace" defaultChecked={policy.allowTokensForThisWorkspace} title={`Allow tokens made for ${slug}`}>
116 Members may make tokens for {slug} alone, reaching only the repositories and permissions they choose.
117 </SwitchCard>
118 <SwitchCard name="require_approval" defaultChecked={policy.requireApproval} title={`Require approval of tokens made for ${slug}`}>
119 A member's token made for {slug} waits for an owner to approve it, and again when they widen it. Owners' own tokens never wait.
120 </SwitchCard>
121 <SwitchCard
122 name="allow_tokens_for_all_workspaces"
123 defaultChecked={policy.allowTokensForAllWorkspaces}
124 title="Allow tokens made for all of a member's workspaces"
125 >
126 A token made for every workspace its owner belongs to reaches {slug} too. Off: such tokens no longer reach {slug}, and members make a token for{" "}
127 {slug} alone instead.
128 </SwitchCard>
129 <SwitchCard name="forbid_no_expiry" defaultChecked={policy.forbidNoExpiry} title="Tokens must expire">
130 A token that never expires does not reach {slug}.
131 </SwitchCard>
132 <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
133 <label htmlFor="pat-lifetime" className="min-w-0">
134 <span className="block text-sm font-medium text-fg">Longest lifetime</span>
135 <span className="mt-1 block text-sm text-muted">A token that lasts longer does not reach {slug}.</span>
136 </label>
137 <SelectField
138 id="pat-lifetime"
139 name="max_lifetime_days"
140 defaultValue={LIFETIMES.some(([value]) => value === lifetime) ? lifetime : ""}
141 className="h-auto w-full py-2 sm:w-40"
142 options={LIFETIMES.map(([value, label]) => ({ value, label }))}
143 />
144 </Card>
145 <div className="flex items-center gap-3">
146 <SubmitButton match={{ intent: "policy" }} pending="Saving…">
147 Save rules
148 </SubmitButton>
149 {actionData?.saved && <span className="text-sm text-success">Saved.</span>}
150 {policy.updatedBy && (
151 <span className="text-xs text-faint">
152 Last changed by <span className="font-mono">{policy.updatedBy}</span>
153 {policy.updatedAt && (
154 <>
155 {" "}
156 <TimeAgo at={policy.updatedAt} />
157 </>
158 )}
159 </span>
160 )}
161 </div>
162 </Form>
163 </section>
164
165 <ErrorText>{actionData?.error}</ErrorText>
166
167 <section aria-labelledby="pat-requests">
168 <h2 id="pat-requests" className="flex items-center gap-2 font-medium">
169 Waiting for approval
170 {pending.length > 0 && <Badge tone="warn">{pending.length}</Badge>}
171 </h2>
172 {isFailed(tokens) ? (
173 <div className="mt-2">
174 <Unavailable what={tokens.what} compact />
175 </div>
176 ) : pending.length === 0 ? (
177 <p className="mt-2 text-sm text-muted">No token is waiting for approval.</p>
178 ) : (
179 <Card asChild tone="plain" divided className="mt-3">
180 <ul>
181 {pending.map((member) => (
182 <li key={member.token.id} className="px-4 py-3">
183 <TokenLine member={member} />
184 <Form method="post" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
185 <input type="hidden" name="id" value={member.token.id} />
186 <div className="min-w-0 grow">
187 <Input name="reason" maxLength={500} placeholder="Note for the owner (optional)" aria-label="Note for the token's owner" />
188 </div>
189 <div className="flex shrink-0 gap-2">
190 <SubmitButton name="intent" value="approve" match={{ intent: "approve", id: member.token.id }} pending="Approving…">
191 <Check size={14} /> Approve
192 </SubmitButton>
193 <SubmitButton name="intent" value="deny" variant="outline" match={{ intent: "deny", id: member.token.id }} pending="Denying…">
194 <X size={14} /> Deny
195 </SubmitButton>
196 </div>
197 </Form>
198 </li>
199 ))}
200 </ul>
201 </Card>
202 )}
203 </section>
204
205 <section aria-labelledby="pat-active">
206 <h2 id="pat-active" className="font-medium">
207 Tokens that can reach {slug}
208 </h2>
209 <p className="mt-1 text-sm text-muted">
210 Members' and outside collaborators' tokens, never the tokens themselves. The workspace's own tokens are under{" "}
211 <Link to={`/${slug}/-/tokens`} className="text-fg underline underline-offset-4">
212 Access tokens
213 </Link>
214 .
215 </p>
216 {isFailed(tokens) ? (
217 <div className="mt-3">
218 <Unavailable what={tokens.what} />
219 </div>
220 ) : listed.length === 0 ? (
221 <div className="mt-3">
222 <EmptyState title="No tokens">No member has a personal access token that can reach {slug}.</EmptyState>
223 </div>
224 ) : (
225 <Card asChild tone="plain" divided className="mt-3">
226 <ul>
227 {listed.map((member) => (
228 <li key={member.token.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start">
229 <div className="min-w-0 grow">
230 <TokenLine member={member} />
231 </div>
232 {member.token.status !== "revoked" && member.blockedBy !== "revoked" && (
233 <Form method="post" className="shrink-0">
234 <input type="hidden" name="intent" value="revoke" />
235 <input type="hidden" name="id" value={member.token.id} />
236 <SubmitButton variant="outline" match={{ intent: "revoke", id: member.token.id }} pending="Revoking…">
237 Revoke
238 </SubmitButton>
239 </Form>
240 )}
241 </li>
242 ))}
243 </ul>
244 </Card>
245 )}
246 </section>
247 </div>
248 );
249}
250
251/** One member's token: whose, what it is, what it reaches and may do. */
252function TokenLine({ member }: { member: MemberToken }) {
253 const { token } = member;
254 return (
255 <div className="min-w-0 space-y-1.5">
256 <p className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
257 <Avatar name={member.owner} size={18} />
258 <Link to={`/u/${member.owner}`} className="font-mono text-fg hover:underline">
259 {member.owner}
260 </Link>
261 <span className="flex min-w-0 items-center gap-1.5 font-medium">
262 <KeyRound size={13} className="shrink-0 text-faint" />
263 <span className="truncate">{token.name}</span>
264 </span>
265 <TokenBadges token={token} />
266 {(token.status ?? "active") === "active" && !member.reaches && member.blockedBy && <Badge tone="danger">Kept out: {member.blockedBy}</Badge>}
267 </p>
268 {token.description && <p className="text-xs text-muted">{token.description}</p>}
269 <TokenMeta token={token} />
270 <TokenFacts token={token} />
271 </div>
272 );
273}