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