g1t/apps/web/app/routes/settings/tokens.tsx

264 lines8,225 bytesCodeBlame
1import { identity } from "../../lib/services.server";
2import { Form, Link, redirect, useSearchParams } from "react-router";
3
4import { presetScopes, type AccessToken } from "@g1t/contracts";
5
6import type { Route } from "./+types/tokens";
7import { page } from "../../lib/meta";
8import {
9 SubmitButton,
10 ButtonLink,
11 ErrorText,
12 Field,
13 Input,
14 TimeAgo,
15} from "../../components/ui";
16import { DeleteButton } from "../../components/account-settings";
17import {
18 AccessSummary,
19 ExpiryField,
20 ScopeChecklist,
21} from "../../components/token-scopes";
22import {
23 describeExpiry,
24 expiryTtl,
25 grantFromForm,
26} from "../../lib/token-scopes";
27import { assertSameOrigin, requireUser } from "../../lib/session.server";
28
29export function meta(args: Route.MetaArgs) {
30 return page(args, { title: "Access tokens · Settings · g1t" });
31}
32
33export async function loader({ request, context }: Route.LoaderArgs) {
34 const user = requireUser(context, request);
35 return { user, tokens: await identity.listAccessTokens(user) };
36}
37
38export async function action({ request, context }: Route.ActionArgs) {
39 assertSameOrigin(request);
40 const user = requireUser(context, request);
41 const form = await request.formData();
42 switch (form.get("intent")) {
43 case "add-token": {
44 const grant = grantFromForm(form);
45 if (!grant.ok)
46 return {
47 newToken: null,
48 created: null,
49 error: grant.error,
50 editing: null,
51 };
52 const created = await identity.createAccessToken(
53 user,
54 String(form.get("label") ?? ""),
55 expiryTtl(form.get("expires")),
56 { ...grant.value, listed: true },
57 );
58 return {
59 newToken: created.token,
60 created: created.info,
61 error: null,
62 editing: null,
63 };
64 }
65 case "update-token": {
66 const id = String(form.get("id") ?? "");
67 const grant = grantFromForm(form);
68 if (!grant.ok)
69 return {
70 newToken: null,
71 created: null,
72 error: grant.error,
73 editing: id,
74 };
75 const updated = await identity.updateAccessToken(user, id, grant.value);
76 if (!updated.ok)
77 return {
78 newToken: null,
79 created: null,
80 error: updated.error.message,
81 editing: id,
82 };
83 throw redirect("/settings/tokens");
84 }
85 case "delete-token":
86 await identity.removeAccessToken(user, String(form.get("id") ?? ""));
87 return null;
88 }
89 return null;
90}
91
92export default function TokenSettings({
93 loaderData,
94 actionData,
95}: Route.ComponentProps) {
96 const { user, tokens } = loaderData;
97 const [params] = useSearchParams();
98 const editing = actionData?.editing ?? params.get("edit");
99 const workspaces = (user.workspaces ?? []).map(
100 (membership) => membership.slug,
101 );
102 const created = actionData?.created;
103 return (
104 <section id="tokens" className="scroll-mt-20">
105 {workspaces.length > 0 && (
106 <p className="text-sm text-muted">
107 For CI and integrations that work for a team, use a workspace's own
108 tokens instead:{" "}
109 {workspaces.map((slug, i) => (
110 <span key={slug}>
111 {i > 0 && ", "}
112 <Link
113 to={`/${slug}/-/tokens`}
114 className="font-mono text-fg underline underline-offset-4"
115 >
116 {slug}
117 </Link>
118 </span>
119 ))}
120 .
121 </p>
122 )}
123 {actionData?.newToken && (
124 <div className="mt-4 rounded-md border border-accent/40 bg-surface p-4">
125 <p className="text-sm">
126 {created ? (
127 <span className="font-medium">{created.name}</span>
128 ) : (
129 "Your token"
130 )}{" "}
131 is ready. Copy it now. It will not be shown again.
132 </p>
133 <pre className="mt-2 font-mono text-sm break-all whitespace-pre-wrap text-accent">
134 {actionData.newToken}
135 </pre>
136 {created && (
137 <>
138 <AccessSummary holder={created} className="mt-3" />
139 <p className="mt-1.5 text-xs text-faint">
140 {describeExpiry(created.expiresAt)}
141 </p>
142 </>
143 )}
144 </div>
145 )}
146 <ul className="mt-4 divide-y divide-line rounded-md border border-line empty:hidden">
147 {tokens.map((token) => (
148 <TokenRow
149 key={token.id}
150 token={token}
151 editing={editing === token.id}
152 error={actionData?.editing === token.id ? actionData.error : null}
153 />
154 ))}
155 </ul>
156
157 {!editing && (
158 // Keyed on the token just made, so the fields start over for the next.
159 <Form
160 key={created?.id ?? "new"}
161 method="post"
162 className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"
163 >
164 <input type="hidden" name="intent" value="add-token" />
165 <h2 className="font-medium">New token</h2>
166 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
167 <Field label="Name" hint="Name it after what will use it.">
168 <Input
169 name="label"
170 maxLength={100}
171 placeholder="laptop"
172 required
173 />
174 </Field>
175 <ExpiryField />
176 </div>
177 <ScopeChecklist initial={presetScopes("agent")} />
178 {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>}
179 <SubmitButton pending="Creating…" match={{ intent: "add-token" }}>
180 Create token
181 </SubmitButton>
182 </Form>
183 )}
184 </section>
185 );
186}
187
188function TokenRow({
189 token,
190 editing,
191 error,
192}: {
193 token: AccessToken;
194 editing: boolean;
195 error: string | null | undefined;
196}) {
197 const legacy = token.legacy && token.scopes === null;
198 const expiry = describeExpiry(token.expiresAt);
199 return (
200 <li className="px-4 py-3">
201 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
202 <div className="min-w-0 grow basis-60">
203 <p className="truncate text-sm font-medium">{token.name}</p>
204 <p className="text-xs text-faint">
205 Created <TimeAgo at={token.createdAt} /> ·{" "}
206 {token.lastUsedAt ? (
207 <>
208 last used <TimeAgo at={token.lastUsedAt} />
209 </>
210 ) : (
211 "never used"
212 )}{" "}
213 ·{" "}
214 <span className={expiry === "Expired" ? "text-danger" : undefined}>
215 {expiry}
216 </span>
217 </p>
218 <AccessSummary holder={token} />
219 {legacy && (
220 <p className="mt-1.5 text-xs text-warn">
221 Made before tokens had scopes, so it can do everything you can.
222 Narrow it to what it needs.
223 </p>
224 )}
225 </div>
226 <div className="ml-auto flex shrink-0 items-center gap-2">
227 {!editing && (
228 <ButtonLink
229 variant="quiet"
230 to={`?edit=${token.id}`}
231 preventScrollReset
232 >
233 {legacy ? "Narrow this token" : "Edit access"}
234 </ButtonLink>
235 )}
236 <DeleteButton intent="delete-token" id={token.id} />
237 </div>
238 </div>
239 {editing && (
240 <Form
241 method="post"
242 className="mt-4 space-y-5 border-t border-line pt-4"
243 >
244 <input type="hidden" name="intent" value="update-token" />
245 <input type="hidden" name="id" value={token.id} />
246 <p className="text-sm text-muted">
247 The token stays the same; only what it may do changes, from its next
248 request.
249 </p>
250 <ScopeChecklist initial={token.scopes} />
251 <ErrorText>{error}</ErrorText>
252 <div className="flex gap-2">
253 <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}>
254 Save access
255 </SubmitButton>
256 <ButtonLink variant="quiet" to="." preventScrollReset>
257 Cancel
258 </ButtonLink>
259 </div>
260 </Form>
261 )}
262 </li>
263 );
264}