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

210 lines8,215 bytesCodeBlame
1import { Form } from "react-router";
2
3import { presetScopes } from "@g1t/contracts";
4
5import type { Route } from "./+types/tokens";
6import { page } from "../../lib/meta";
7import { useAddresses } from "../../lib/addresses";
8import {
9 CopyLine,
10 EmptyState,
11 ErrorText,
12 Field,
13 Input,
14 SubmitButton,
15 TimeAgo,
16} from "../../components/ui";
17import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes";
18import { identity } from "../../lib/services.server";
19import { describeExpiry, expiryTtl, grantFromForm } from "../../lib/token-scopes";
20import {
21 assertSameOrigin,
22 getViewer,
23 requireUser,
24 roleIn,
25 unwrap,
26} from "../../lib/session.server";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Access tokens · ${params.owner} · g1t` });
30}
31
32export async function loader({ params, context }: Route.LoaderArgs) {
33 const viewer = getViewer(context);
34 return {
35 slug: params.owner.toLowerCase(),
36 role: roleIn(viewer, params.owner),
37 tokens: unwrap(await identity.listWorkspaceTokens(params.owner, viewer)),
38 };
39}
40
41export async function action({ request, params, context }: Route.ActionArgs) {
42 assertSameOrigin(request);
43 const user = requireUser(context, request);
44 const form = await request.formData();
45 if (form.get("action") === "delete") {
46 const removed = await identity.removeWorkspaceToken(
47 user,
48 params.owner,
49 String(form.get("id") ?? ""),
50 );
51 return { token: null, error: removed.ok ? null : removed.error.message };
52 }
53 const grant = grantFromForm(form);
54 if (!grant.ok) return { token: null, error: grant.error };
55 const created = await identity.createWorkspaceToken(
56 user,
57 params.owner,
58 String(form.get("label") ?? ""),
59 { ...grant.value, ttlSeconds: expiryTtl(form.get("expires")) },
60 );
61 return created.ok
62 ? { token: created.value, error: null }
63 : { token: null, error: created.error.message };
64}
65
66export default function WorkspaceTokens({ loaderData, actionData }: Route.ComponentProps) {
67 const { slug, role, tokens } = loaderData;
68 const created = actionData?.token;
69 const { site, api } = useAddresses();
70 // With git, the token is the password in the clone address, after the scheme.
71 const [scheme, rest] = site.split("://");
72 return (
73 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
74 <div className="min-w-0">
75
76 {created && (
77 <div className="mt-5 rounded-xl border border-accent/40 bg-surface p-4">
78 <p className="text-sm">
79 <span className="font-medium">{created.info.name}</span> is ready. Copy
80 it now; it will not be shown again.
81 </p>
82 <div className="mt-3">
83 <CopyLine text={created.token} />
84 </div>
85 <AccessSummary holder={created.info} className="mt-3" />
86 <p className="mt-1.5 text-xs text-faint">{describeExpiry(created.info.expiresAt)}</p>
87 </div>
88 )}
89
90 <div className="mt-5">
91 {tokens.length === 0 ? (
92 <EmptyState title="No access tokens yet">
93 {role === "owner"
94 ? "Create one below and give it to whatever needs to act for this workspace."
95 : "An owner can create one."}
96 </EmptyState>
97 ) : (
98 <ul className="divide-y divide-line rounded-xl border border-line">
99 {tokens.map((token) => (
100 <li key={token.id} className="flex items-start gap-4 px-4 py-3">
101 <div className="min-w-0 grow">
102 <p className="truncate text-sm font-medium">{token.name}</p>
103 <p className="mt-0.5 text-xs text-faint">
104 Created <TimeAgo at={token.createdAt} />
105 {token.createdBy ? (
106 <>
107 {" "}
108 by <span className="font-mono">{token.createdBy}</span>
109 </>
110 ) : (
111 " by someone who has since left g1t"
112 )}{" "}
113 ·{" "}
114 {token.lastUsedAt ? (
115 <>
116 last used <TimeAgo at={token.lastUsedAt} />
117 </>
118 ) : (
119 "never used"
120 )}{" "}
121 ·{" "}
122 <span className={describeExpiry(token.expiresAt) === "Expired" ? "text-danger" : undefined}>
123 {describeExpiry(token.expiresAt)}
124 </span>
125 </p>
126 <AccessSummary holder={token} />
127 {token.legacy && token.scopes === null && (
128 <p className="mt-1.5 text-xs text-warn">
129 Made before tokens had scopes, so it can do everything a member can here.
130 Replace it with a narrower one.
131 </p>
132 )}
133 </div>
134 {role === "owner" && (
135 <Form method="post">
136 <input type="hidden" name="action" value="delete" />
137 <input type="hidden" name="id" value={token.id} />
138 <SubmitButton variant="quiet" match={{ action: "delete", id: token.id }} pending="Deleting…">
139 Delete
140 </SubmitButton>
141 </Form>
142 )}
143 </li>
144 ))}
145 </ul>
146 )}
147 </div>
148
149 {role === "owner" ? (
150 // Empty again once the token is made; kept as filled in when it failed.
151 <Form method="post" key={created?.info.id ?? ""} className="mt-6 space-y-5 rounded-xl border border-line p-4 sm:p-5">
152 <input type="hidden" name="action" value="create" />
153 <h2 className="font-medium">New token</h2>
154 <div className="grid gap-4 sm:grid-cols-[1fr_11rem]">
155 <Field label="Name" hint="Name it after what will use it.">
156 <Input name="label" required maxLength={100} placeholder="deploy pipeline" />
157 </Field>
158 <ExpiryField />
159 </div>
160 <ScopeChecklist initial={presetScopes("ci")} />
161 <SubmitButton match={{ action: "create" }} pending="Creating…">
162 Create token
163 </SubmitButton>
164 </Form>
165 ) : (
166 <p className="mt-4 text-sm text-muted">
167 Only owners can create or delete a workspace's tokens.
168 </p>
169 )}
170 <ErrorText>{actionData?.error}</ErrorText>
171 </div>
172
173 <aside className="space-y-5 text-sm">
174 <section className="rounded-xl border border-line bg-surface p-5">
175 <h3 className="font-medium">What a token can do</h3>
176 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
177 <li>
178 What its scopes allow, in this workspace only, and never more
179 than a member can: push, open and merge pull requests, manage
180 issues.
181 </li>
182 <li>
183 It acts as <span className="font-mono text-fg">{slug}</span>, so
184 what it does is shown as the workspace's doing.
185 </li>
186 <li>It keeps working when the person who made it leaves.</li>
187 <li>It cannot manage people, tokens or other workspaces.</li>
188 </ul>
189 </section>
190 <section>
191 <h3 className="font-medium">Using one</h3>
192 <p className="mt-2 text-muted">With git, as the password:</p>
193 <div className="mt-2">
194 <CopyLine
195 prompt
196 text={`git clone ${scheme}://${slug}:$G1T_TOKEN@${rest}/${slug}/<repo>.git`}
197 />
198 </div>
199 <p className="mt-4 text-muted">With the API and the MCP server:</p>
200 <div className="mt-2">
201 <CopyLine
202 prompt
203 text={`curl -H "Authorization: Bearer $G1T_TOKEN" ${api}/user`}
204 />
205 </div>
206 </section>
207 </aside>
208 </div>
209 );
210}