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