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