Skip to content
258 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
60 ? { intent, ok: true, message: `Added ${added.value.title}.`, error: null }
61 : failed(added.error.message);
62 }
63 if (intent === "delete") {
64 const removed = await identity.removeDeployKey(user, params.owner, params.repo, String(form.get("id") ?? ""));
65 return removed.ok ? { intent, ok: true, message: "Deleted the deploy key.", error: null } : failed(removed.error.message);
66 }
67 return failed("Unknown action.");
68}
69
70export default function DeployKeys({ loaderData, actionData, params }: Route.ComponentProps) {
71 const { keys } = loaderData;
72 const full = `${params.owner}/${params.repo}`;
73 const result = actionData;
74 return (
75 <>
76 <RepoSettingsHeading base={`/${full}`} />
77 <div className="space-y-10">
78 <Card asChild tone="plain" radius="lg" className="border-dashed p-3 text-xs text-muted">
79 <p>
80 Deploy keys are used over SSH, and git over SSH is not on yet: it is waiting on inbound TCP on Cloudflare,
81 which g1t has applied for. Keys you add now will work as soon as it is. Until then, machines can clone and
82 push over HTTPS with a{" "}
83 <Link to={`/${params.owner}/-/tokens`} className="text-fg underline underline-offset-4">
84 workspace access token
85 </Link>
86 .
87 </p>
88 </Card>
89
90 <Section
91 title="Keys"
92 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.`}
93 >
94 {keys.length === 0 ? (
95 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
96 <p>
97 No deploy keys yet.
98 </p>
99 </Card>
100 ) : (
101 <Card asChild tone="plain" divided>
102 <ul>
103 {keys.map((key) => (
104 <KeyRow key={key.id} deployKey={key} />
105 ))}
106 </ul>
107 </Card>
108 )}
109 {result?.intent === "delete" && (
110 <p className={`text-sm ${result.ok ? "text-success" : "text-danger"}`} role="status">
111 {result.ok ? result.message : result.error}
112 </p>
113 )}
114 </Section>
115
116 <Section
117 title="Add a deploy key"
118 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."
119 >
120 {keys.length >= MAX_DEPLOY_KEYS ? (
121 <p className="text-sm text-muted">
122 {full} has {MAX_DEPLOY_KEYS} deploy keys, the most a repository can have. Delete one to add another.
123 </p>
124 ) : (
125 <AddForm result={result?.intent === "add" ? result : undefined} />
126 )}
127 </Section>
128 </div>
129 </>
130 );
131}
132
133function KeyRow({ deployKey }: { deployKey: DeployKey }) {
134 return (
135 <li className="flex items-start gap-3 px-4 py-3 sm:gap-4">
136 <KeyRound size={16} className="mt-0.5 shrink-0 text-faint" aria-hidden="true" />
137 <div className="min-w-0 flex-1">
138 <p className="flex flex-wrap items-center gap-2 text-sm font-medium">
139 <span className="min-w-0 break-words">{deployKey.title}</span>
140 {deployKey.read_only ? (
141 <Badge>Read-only</Badge>
142 ) : (
143 <Hint label="This key can push, workflow files included.">
144 <Badge tone="warn" tabIndex={0}>
145 Read and write
146 </Badge>
147 </Hint>
148 )}
149 </p>
150 <p className="mt-0.5 truncate font-mono text-xs text-muted">{deployKey.fingerprint}</p>
151 <p className="mt-1 text-xs text-faint">
152 Added {deployKey.created_by ? `by ${deployKey.created_by} ` : ""}
153 <TimeAgo at={deployKey.created_at} /> ·{" "}
154 {deployKey.last_used_at ? (
155 <>
156 Last used <TimeAgo at={deployKey.last_used_at} />
157 </>
158 ) : (
159 "Never used"
160 )}
161 </p>
162 </div>
163 <DeleteKey deployKey={deployKey} />
164 </li>
165 );
166}
167
168function DeleteKey({ deployKey }: { deployKey: DeployKey }) {
169 const deleting = usePending({ intent: "delete", id: deployKey.id });
170 return (
171 <AlertDialog>
172 <AlertDialogTrigger asChild>
173 <Button type="button" variant="outline" disabled={deleting} aria-label={`Delete ${deployKey.title}`}>
174 {deleting ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />}
175 <span className="hidden sm:inline">{deleting ? "Deleting…" : "Delete"}</span>
176 </Button>
177 </AlertDialogTrigger>
178 <AlertDialogContent>
179 <Form method="post" className="grid gap-4">
180 <input type="hidden" name="intent" value="delete" />
181 <input type="hidden" name="id" value={deployKey.id} />
182 <AlertDialogHeader>
183 <AlertDialogTitle>Delete {deployKey.title}?</AlertDialogTitle>
184 <AlertDialogDescription>
185 Anything using this key can no longer clone{deployKey.read_only ? "" : " or push"} at once. To use it
186 again, add it again.
187 </AlertDialogDescription>
188 </AlertDialogHeader>
189 <AlertDialogFooter>
190 <AlertDialogCancel>Cancel</AlertDialogCancel>
191 <AlertDialogAction asChild>
192 <button type="submit">
193 <Trash2 size={14} />
194 Delete key
195 </button>
196 </AlertDialogAction>
197 </AlertDialogFooter>
198 </Form>
199 </AlertDialogContent>
200 </AlertDialog>
201 );
202}
203
204function AddForm({ result }: { result: Outcome | undefined }) {
205 const [write, setWrite] = useState(false);
206 const [round, setRound] = useState(0);
207 // Cleared once a key was added, ready for the next.
208 useEffect(() => {
209 if (result?.ok) {
210 setRound((n) => n + 1);
211 setWrite(false);
212 }
213 }, [result]);
214 return (
215 <Card asChild key={round} className="space-y-4 p-4">
216 <Form method="post">
217 <input type="hidden" name="intent" value="add" />
218 <Field label="Title" hint="Such as the machine or the pipeline that uses it. Left empty, the key's comment.">
219 <Input name="title" maxLength={100} placeholder="Production server" />
220 </Field>
221 <Field label="Key">
222 <Textarea
223 name="key"
224 required
225 rows={3}
226 spellCheck={false}
227 placeholder="Begins with ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521 or ssh-rsa"
228 />
229 </Field>
230 <CheckboxOption
231 name="write"
232 checked={write}
233 onCheckedChange={(checked) => setWrite(checked === true)}
234 label="Allow write access"
235 description="Lets this key push to the repository, workflow files included."
236 />
237 {write && (
238 <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">
239 <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
240 <span>
241 Anyone with the private key can push to this repository and change its workflows, which run with its
242 secrets. Keep it read-only unless the machine must push.
243 </span>
244 </p>
245 )}
246 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
247 {result?.ok && (
248 <p className="text-sm text-success" role="status">
249 {result.message}
250 </p>
251 )}
252 <SubmitButton pending="Adding…" match={{ intent: "add" }}>
253 Add deploy key
254 </SubmitButton>
255 </Form>
256 </Card>
257 );
258}