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

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