| 1 | import { Form } from "react-router"; |
| 2 | |
| 3 | import { presetScopes } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/tokens"; |
| 6 | import { page } from "../../lib/meta"; |
| 7 | import { useAddresses } from "../../lib/addresses"; |
| 8 | import { |
| 9 | CopyLine, |
| 10 | EmptyState, |
| 11 | ErrorText, |
| 12 | Field, |
| 13 | Input, |
| 14 | SubmitButton, |
| 15 | TimeAgo, |
| 16 | } from "../../components/ui"; |
| 17 | import { AccessSummary, ExpiryField, ScopeChecklist } from "../../components/token-scopes"; |
| 18 | import { identity } from "../../lib/services.server"; |
| 19 | import { describeExpiry, expiryTtl, grantFromForm } from "../../lib/token-scopes"; |
| 20 | import { |
| 21 | assertSameOrigin, |
| 22 | getViewer, |
| 23 | requireUser, |
| 24 | roleIn, |
| 25 | unwrap, |
| 26 | } from "../../lib/session.server"; |
| 27 | |
| 28 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 29 | return page(args, { title: `Access tokens · ${params.owner} · g1t` }); |
| 30 | } |
| 31 | |
| 32 | export 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 | |
| 41 | export 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 | |
| 66 | export 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 | } |