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.

267 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";
18
19// A workspace's rules for its members' personal access tokens, the tokens
20// that reach it, and approving the ones made for it. Owners only; identity
21// decides (services/identity/src/token_reach.rs).
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Personal access tokens · ${params.owner} · g1t` });
25}
26
27export async function loader({ request, params, context }: Route.LoaderArgs) {
28 const slug = params.owner.toLowerCase();
29 // `?kind=` filtered the two kinds tokens used to come in.
30 const moved = currentTokensPath(`/${slug}/-/personal-access-tokens`, new URL(request.url).searchParams);
31 if (moved) throw redirect(moved, 301);
32 const viewer = getViewer(context);
33 const role = roleIn(viewer, params.owner);
34 if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] };
35 const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), identity.listMemberTokens(viewer, slug)]);
36 return { slug, role, policy: unwrap(policy), tokens: unwrap(tokens) };
37}
38
39export async function action({ request, params, context }: Route.ActionArgs) {
40 assertSameOrigin(request);
41 const user = requireUser(context, request);
42 const form = await request.formData();
43 const slug = params.owner;
44 const id = String(form.get("id") ?? "");
45 const reason = String(form.get("reason") ?? "").trim() || null;
46 switch (form.get("intent")) {
47 case "policy": {
48 const lifetime = lifetimeFromForm(form.get("max_lifetime_days"));
49 if (!lifetime.ok) return { error: lifetime.error, saved: false };
50 const saved = await identity.setTokenPolicy(user, slug, {
51 allowTokensForAllWorkspaces: form.get("allow_tokens_for_all_workspaces") === "on",
52 allowTokensForThisWorkspace: form.get("allow_tokens_for_this_workspace") === "on",
53 requireApproval: form.get("require_approval") === "on",
54 maxLifetimeDays: lifetime.value ?? 0,
55 forbidNoExpiry: form.get("forbid_no_expiry") === "on",
56 });
57 return saved.ok ? { error: null, saved: true } : { error: saved.error.message, saved: false };
58 }
59 case "approve":
60 case "deny": {
61 const reviewed = await identity.reviewTokenRequest(user, slug, id, form.get("intent") === "approve", reason);
62 return reviewed.ok ? { error: null, saved: false } : { error: reviewed.error.message, saved: false };
63 }
64 case "revoke": {
65 const revoked = await identity.revokeMemberToken(user, slug, id, reason);
66 return revoked.ok ? { error: null, saved: false } : { error: revoked.error.message, saved: false };
67 }
68 }
69 return null;
70}
71
72const LIFETIMES = [
73 ["", "No limit"],
74 ["7", "7 days"],
75 ["30", "30 days"],
76 ["60", "60 days"],
77 ["90", "90 days"],
78 ["180", "180 days"],
79 ["366", "1 year"],
80] as const;
81
82export default function PersonalAccessTokens({ loaderData, actionData }: Route.ComponentProps) {
83 const { slug, role, policy, tokens } = loaderData;
84 if (role !== "owner" || !policy) {
85 return (
86 <EmptyState title="Owners only">
87 Only an owner of {slug} can see which personal access tokens reach it. Your own tokens are under{" "}
88 <Link to="/settings/tokens" className="text-fg underline underline-offset-4">
89 your settings
90 </Link>
91 .
92 </EmptyState>
93 );
94 }
95 const pending = tokens.filter((member) => member.token.status === "pending");
96 const listed = tokens.filter((member) => member.token.status !== "pending");
97 const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays);
98 return (
99 <div className="max-w-3xl space-y-10">
100 <section aria-labelledby="pat-rules">
101 <h2 id="pat-rules" className="font-medium">
102 Rules
103 </h2>
104 <p className="mt-1 text-sm text-muted">
105 They apply from each token's next request, to tokens made before them too. A token they keep out still works
106 elsewhere, and reads {slug}'s public repositories as anyone can.
107 </p>
108 <Form method="post" className="mt-4 space-y-3">
109 <input type="hidden" name="intent" value="policy" />
110 <SwitchCard
111 name="allow_tokens_for_this_workspace"
112 defaultChecked={policy.allowTokensForThisWorkspace}
113 title={`Allow tokens made for ${slug}`}
114 >
115 Members may make tokens for {slug} alone, reaching only the repositories and permissions they choose.
116 </SwitchCard>
117 <SwitchCard name="require_approval" defaultChecked={policy.requireApproval} title={`Require approval of tokens made for ${slug}`}>
118 A member's token made for {slug} waits for an owner to approve it, and again when they widen it. Owners' own
119 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},
127 and members make a token for {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 {pending.length === 0 ? (
173 <p className="mt-2 text-sm text-muted">No token is waiting for approval.</p>
174 ) : (
175 <Card asChild tone="plain" divided className="mt-3">
176 <ul>
177 {pending.map((member) => (
178 <li key={member.token.id} className="px-4 py-3">
179 <TokenLine member={member} />
180 <Form method="post" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
181 <input type="hidden" name="id" value={member.token.id} />
182 <div className="min-w-0 grow">
183 <Input name="reason" maxLength={500} placeholder="Note for the owner (optional)" aria-label="Note for the token's owner" />
184 </div>
185 <div className="flex shrink-0 gap-2">
186 <SubmitButton name="intent" value="approve" match={{ intent: "approve", id: member.token.id }} pending="Approving…">
187 <Check size={14} /> Approve
188 </SubmitButton>
189 <SubmitButton name="intent" value="deny" variant="outline" match={{ intent: "deny", id: member.token.id }} pending="Denying…">
190 <X size={14} /> Deny
191 </SubmitButton>
192 </div>
193 </Form>
194 </li>
195 ))}
196 </ul>
197 </Card>
198 )}
199 </section>
200
201 <section aria-labelledby="pat-active">
202 <h2 id="pat-active" className="font-medium">
203 Tokens that can reach {slug}
204 </h2>
205 <p className="mt-1 text-sm text-muted">
206 Members' and outside collaborators' tokens, never the tokens themselves. The workspace's own tokens are under{" "}
207 <Link to={`/${slug}/-/tokens`} className="text-fg underline underline-offset-4">
208 Access tokens
209 </Link>
210 .
211 </p>
212 {listed.length === 0 ? (
213 <div className="mt-3">
214 <EmptyState title="No tokens">No member has a personal access token that can reach {slug}.</EmptyState>
215 </div>
216 ) : (
217 <Card asChild tone="plain" divided className="mt-3">
218 <ul>
219 {listed.map((member) => (
220 <li key={member.token.id} className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-start">
221 <div className="min-w-0 grow">
222 <TokenLine member={member} />
223 </div>
224 {member.token.status !== "revoked" && member.blockedBy !== "revoked" && (
225 <Form method="post" className="shrink-0">
226 <input type="hidden" name="intent" value="revoke" />
227 <input type="hidden" name="id" value={member.token.id} />
228 <SubmitButton variant="outline" match={{ intent: "revoke", id: member.token.id }} pending="Revoking…">
229 Revoke
230 </SubmitButton>
231 </Form>
232 )}
233 </li>
234 ))}
235 </ul>
236 </Card>
237 )}
238 </section>
239 </div>
240 );
241}
242
243/** One member's token: whose, what it is, what it reaches and may do. */
244function TokenLine({ member }: { member: MemberToken }) {
245 const { token } = member;
246 return (
247 <div className="min-w-0 space-y-1.5">
248 <p className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
249 <Avatar name={member.owner} size={18} />
250 <Link to={`/u/${member.owner}`} className="font-mono text-fg hover:underline">
251 {member.owner}
252 </Link>
253 <span className="flex min-w-0 items-center gap-1.5 font-medium">
254 <KeyRound size={13} className="shrink-0 text-faint" />
255 <span className="truncate">{token.name}</span>
256 </span>
257 <TokenBadges token={token} />
258 {(token.status ?? "active") === "active" && !member.reaches && member.blockedBy && (
259 <Badge tone="danger">Kept out: {member.blockedBy}</Badge>
260 )}
261 </p>
262 {token.description && <p className="text-xs text-muted">{token.description}</p>}
263 <TokenMeta token={token} />
264 <TokenFacts token={token} />
265 </div>
266 );
267}