Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.