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

258 lines7,931 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 Button,
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 <Form
159 method="post"
160 className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"
161 >
162 <input type="hidden" name="intent" value="add-token" />
163 <h2 className="font-medium">New token</h2>
164 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
165 <Field label="Name" hint="Name it after what will use it.">
166 <Input
167 name="label"
168 maxLength={100}
169 placeholder="laptop"
170 required
171 />
172 </Field>
173 <ExpiryField />
174 </div>
175 <ScopeChecklist initial={presetScopes("agent")} />
176 {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>}
177 <Button type="submit">Create token</Button>
178 </Form>
179 )}
180 </section>
181 );
182}
183
184function TokenRow({
185 token,
186 editing,
187 error,
188}: {
189 token: AccessToken;
190 editing: boolean;
191 error: string | null | undefined;
192}) {
193 const legacy = token.legacy && token.scopes === null;
194 const expiry = describeExpiry(token.expiresAt);
195 return (
196 <li className="px-4 py-3">
197 <div className="flex flex-wrap items-start gap-x-4 gap-y-2">
198 <div className="min-w-0 grow basis-60">
199 <p className="truncate text-sm font-medium">{token.name}</p>
200 <p className="text-xs text-faint">
201 Created <TimeAgo at={token.createdAt} /> ·{" "}
202 {token.lastUsedAt ? (
203 <>
204 last used <TimeAgo at={token.lastUsedAt} />
205 </>
206 ) : (
207 "never used"
208 )}{" "}
209 ·{" "}
210 <span className={expiry === "Expired" ? "text-danger" : undefined}>
211 {expiry}
212 </span>
213 </p>
214 <AccessSummary holder={token} />
215 {legacy && (
216 <p className="mt-1.5 text-xs text-warn">
217 Made before tokens had scopes, so it can do everything you can.
218 Narrow it to what it needs.
219 </p>
220 )}
221 </div>
222 <div className="ml-auto flex shrink-0 items-center gap-2">
223 {!editing && (
224 <ButtonLink
225 variant="quiet"
226 to={`?edit=${token.id}`}
227 preventScrollReset
228 >
229 {legacy ? "Narrow this token" : "Edit access"}
230 </ButtonLink>
231 )}
232 <DeleteButton intent="delete-token" id={token.id} />
233 </div>
234 </div>
235 {editing && (
236 <Form
237 method="post"
238 className="mt-4 space-y-5 border-t border-line pt-4"
239 >
240 <input type="hidden" name="intent" value="update-token" />
241 <input type="hidden" name="id" value={token.id} />
242 <p className="text-sm text-muted">
243 The token stays the same; only what it may do changes, from its next
244 request.
245 </p>
246 <ScopeChecklist initial={token.scopes} />
247 <ErrorText>{error}</ErrorText>
248 <div className="flex gap-2">
249 <Button type="submit">Save access</Button>
250 <ButtonLink variant="quiet" to="." preventScrollReset>
251 Cancel
252 </ButtonLink>
253 </div>
254 </Form>
255 )}
256 </li>
257 );
258}