| 1 | import { KeyRound, LoaderCircle, Trash2, TriangleAlert } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { Form, Link } from "react-router"; |
| 4 | |
| 5 | import { type DeployKey, MAX_DEPLOY_KEYS } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/settings-deploy-keys"; |
| 8 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| 9 | import { SettingsSection as Section } from "../../components/settings-section"; |
| 10 | import { Button, ErrorText, Field, Input, SubmitButton, Textarea, TimeAgo, usePending } from "../../components/ui"; |
| 11 | import { |
| 12 | AlertDialog, |
| 13 | AlertDialogAction, |
| 14 | AlertDialogCancel, |
| 15 | AlertDialogContent, |
| 16 | AlertDialogDescription, |
| 17 | AlertDialogFooter, |
| 18 | AlertDialogHeader, |
| 19 | AlertDialogTitle, |
| 20 | AlertDialogTrigger, |
| 21 | } from "../../components/ui/alert-dialog"; |
| 22 | import { Badge } from "../../components/ui/badge"; |
| 23 | import { CheckboxOption } from "../../components/ui/checkbox"; |
| 24 | import { Hint } from "../../components/ui/hint"; |
| 25 | import { page } from "../../lib/meta"; |
| 26 | import { refusal, requireInsider } from "../../lib/access.server"; |
| 27 | import { identity } from "../../lib/services.server"; |
| 28 | import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server"; |
| 29 | |
| 30 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 31 | return page(args, { title: `Deploy keys · ${params.owner}/${params.repo} · g1t` }); |
| 32 | } |
| 33 | |
| 34 | export 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. */ |
| 41 | type Outcome = { intent: string; ok: boolean; message: string | null; error: string | null }; |
| 42 | |
| 43 | export 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 | |
| 68 | export 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 | |
| 125 | function 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 | |
| 160 | function 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 | |
| 196 | function 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 | } |