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

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