Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

252 linesCodeBlameRaw
1import { KeyRound, LoaderCircle, Trash2, TriangleAlert } from "lucide-react";
2import { useEffect, useState } from "react";
3import { Form, Link } from "react-router";
4
5import { type DeployKey, MAX_DEPLOY_KEYS } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings-deploy-keys";
8import { RepoSettingsHeading } from "../../components/repo-settings-heading";
9import { SettingsSection as Section } from "../../components/settings-section";
10import { ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo, usePending } from "../../components/ui";
11import {
12 AlertDialog,
13 AlertDialogAction,
14 AlertDialogCancel,
15 AlertDialogContent,
16 AlertDialogDescription,
17 AlertDialogFooter,
18 AlertDialogHeader,
19 AlertDialogTitle,
20 AlertDialogTrigger,
21} from "../../components/ui/alert-dialog";
22import { Badge } from "../../components/ui/badge";
23import { Button } from "../../components/ui/button";
24import { Card } from "../../components/ui/card";
25import { CheckboxOption } from "../../components/ui/checkbox";
26import { Hint } from "../../components/ui/hint";
27import { page } from "../../lib/meta";
28import { refusal, requireInsider } from "../../lib/access.server";
29import { identity } from "../../lib/services.server";
30import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
31
32export function meta({ params, ...args }: Route.MetaArgs) {
33 return page(args, { title: `Deploy keys · ${params.owner}/${params.repo} · g1t` });
34}
35
36export async function loader({ params, context }: Route.LoaderArgs) {
37 // Admins; to anyone without a role here the page does not exist.
38 const { viewer } = await requireInsider(context, params, "manage_access");
39 return { keys: unwrap(await identity.listDeployKeys(viewer, params.owner, params.repo)) };
40}
41
42/** What a form on this page came back with. */
43type Outcome = { intent: string; ok: boolean; message: string | null; error: string | null };
44
45export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
46 assertSameOrigin(request);
47 const user = requireUser(context, request);
48 const form = await request.formData();
49 const intent = String(form.get("intent") ?? "");
50 const failed = (error: string): Outcome => ({ intent, ok: false, message: null, error });
51 const refused = await refusal(context, params, "manage_access");
52 if (refused) return failed(refused);
53 if (intent === "add") {
54 const added = await identity.addDeployKey(user, params.owner, params.repo, {
55 title: String(form.get("title") ?? ""),
56 key: String(form.get("key") ?? ""),
57 readOnly: form.get("write") !== "on",
58 });
59 return added.ok ? { intent, ok: true, message: `Added ${added.value.title}.`, error: null } : failed(added.error.message);
60 }
61 if (intent === "delete") {
62 const removed = await identity.removeDeployKey(user, params.owner, params.repo, String(form.get("id") ?? ""));
63 return removed.ok ? { intent, ok: true, message: "Deleted the deploy key.", error: null } : failed(removed.error.message);
64 }
65 return failed("Unknown action.");
66}
67
68export default function DeployKeys({ loaderData, actionData, params }: Route.ComponentProps) {
69 const { keys } = loaderData;
70 const full = `${params.owner}/${params.repo}`;
71 const result = actionData;
72 return (
73 <>
74 <RepoSettingsHeading base={`/${full}`} />
75 <div className="space-y-10">
76 <Card asChild tone="plain" radius="lg" className="border-dashed p-3 text-xs text-muted">
77 <p>
78 Deploy keys are used over SSH, and git over SSH is not on yet: it is waiting on inbound TCP on Cloudflare, which g1t has applied for. Keys you add
79 now will work as soon as it is. Until then, machines can clone and push over HTTPS with a{" "}
80 <Link to={`/${params.owner}/-/tokens`} className="text-fg underline underline-offset-4">
81 workspace access token
82 </Link>
83 .
84 </p>
85 </Card>
86
87 <Section
88 title="Keys"
89 about={`SSH keys that reach ${full} and no other repository, for a server or a pipeline. Each is read-only unless you allowed write access.`}
90 >
91 {keys.length === 0 ? (
92 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
93 <p>No deploy keys yet.</p>
94 </Card>
95 ) : (
96 <Card asChild tone="plain" divided>
97 <ul>
98 {keys.map((key) => (
99 <KeyRow key={key.id} deployKey={key} />
100 ))}
101 </ul>
102 </Card>
103 )}
104 {result?.intent === "delete" && (
105 <p className={`text-sm ${result.ok ? "text-success" : "text-danger"}`} role="status">
106 {result.ok ? result.message : result.error}
107 </p>
108 )}
109 </Section>
110
111 <Section
112 title="Add a deploy key"
113 about="Paste the public key, one line such as the contents of id_ed25519.pub. A key can be registered once on g1t: give each machine its own."
114 >
115 {keys.length >= MAX_DEPLOY_KEYS ? (
116 <p className="text-sm text-muted">
117 {full} has {MAX_DEPLOY_KEYS} deploy keys, the most a repository can have. Delete one to add another.
118 </p>
119 ) : (
120 <AddForm result={result?.intent === "add" ? result : undefined} />
121 )}
122 </Section>
123 </div>
124 </>
125 );
126}
127
128function KeyRow({ deployKey }: { deployKey: DeployKey }) {
129 return (
130 <li className="flex items-start gap-3 px-4 py-3 sm:gap-4">
131 <KeyRound size={16} className="mt-0.5 shrink-0 text-faint" aria-hidden="true" />
132 <div className="min-w-0 flex-1">
133 <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
134 <span className="min-w-0 wrap-break-word">{deployKey.title}</span>
135 {deployKey.read_only ? (
136 <Badge>Read-only</Badge>
137 ) : (
138 <Hint label="This key can push, workflow files included.">
139 <Badge tone="warn" tabIndex={0}>
140 Read and write
141 </Badge>
142 </Hint>
143 )}
144 </p>
145 <p className="mt-0.5 truncate font-mono text-xs text-muted">{deployKey.fingerprint}</p>
146 <p className="mt-1 text-xs text-faint">
147 Added {deployKey.created_by ? `by ${deployKey.created_by} ` : ""}
148 <TimeAgo at={deployKey.created_at} /> ·{" "}
149 {deployKey.last_used_at ? (
150 <>
151 Last used <TimeAgo at={deployKey.last_used_at} />
152 </>
153 ) : (
154 "Never used"
155 )}
156 </p>
157 </div>
158 <DeleteKey deployKey={deployKey} />
159 </li>
160 );
161}
162
163function DeleteKey({ deployKey }: { deployKey: DeployKey }) {
164 const deleting = usePending({ intent: "delete", id: deployKey.id });
165 return (
166 <AlertDialog>
167 <AlertDialogTrigger asChild>
168 <Button type="button" variant="outline" disabled={deleting} aria-label={`Delete ${deployKey.title}`}>
169 {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
170 <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span>
171 </Button>
172 </AlertDialogTrigger>
173 <AlertDialogContent>
174 <Form method="post" className="grid gap-4">
175 <input type="hidden" name="intent" value="delete" />
176 <input type="hidden" name="id" value={deployKey.id} />
177 <AlertDialogHeader>
178 <AlertDialogTitle>Delete {deployKey.title}?</AlertDialogTitle>
179 <AlertDialogDescription>
180 Anything using this key can no longer clone{deployKey.read_only ? "" : " or push"} at once. To use it again, add it again.
181 </AlertDialogDescription>
182 </AlertDialogHeader>
183 <AlertDialogFooter>
184 <AlertDialogCancel>Cancel</AlertDialogCancel>
185 <AlertDialogAction asChild>
186 <button type="submit">
187 <Trash2 size={14} />
188 Delete key
189 </button>
190 </AlertDialogAction>
191 </AlertDialogFooter>
192 </Form>
193 </AlertDialogContent>
194 </AlertDialog>
195 );
196}
197
198function AddForm({ result }: { result: Outcome | undefined }) {
199 const [write, setWrite] = useState(false);
200 const [round, setRound] = useState(0);
201 // Cleared once a key was added, ready for the next.
202 useEffect(() => {
203 if (result?.ok) {
204 setRound((n) => n + 1);
205 setWrite(false);
206 }
207 }, [result]);
208 return (
209 <Card asChild key={round} className="space-y-4 p-4">
210 <Form method="post">
211 <input type="hidden" name="intent" value="add" />
212 <Field label="Title" hint="Such as the machine or the pipeline that uses it. Left empty, the key's comment.">
213 <Input name="title" maxLength={100} placeholder="Production server" />
214 </Field>
215 <Field label="Key">
216 <Textarea
217 name="key"
218 required
219 rows={3}
220 spellCheck={false}
221 placeholder="Begins with ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521 or ssh-rsa"
222 />
223 </Field>
224 <CheckboxOption
225 name="write"
226 checked={write}
227 onCheckedChange={(checked) => setWrite(checked === true)}
228 label="Allow write access"
229 description="Lets this key push to the repository, workflow files included."
230 />
231 {write && (
232 <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
233 <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
234 <span>
235 Anyone with the private key can push to this repository and change its workflows, which run with its secrets. Keep it read-only unless the machine
236 must push.
237 </span>
238 </p>
239 )}
240 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
241 {result?.ok && (
242 <p className="text-sm text-success" role="status">
243 {result.message}
244 </p>
245 )}
246 <SubmitButton pending="Adding…" match={{ intent: "add" }}>
247 Add deploy key
248 </SubmitButton>
249 </Form>
250 </Card>
251 );
252}