CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
signing-keys.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3951454 | 1 | /** |
| 2 | * Block J3 — Signing keys UI. | |
| 3 | * | |
| 4 | * GET /settings/signing-keys — list + add form | |
| 5 | * POST /settings/signing-keys — add new key | |
| 6 | * POST /settings/signing-keys/:id/delete | |
| 7 | */ | |
| 8 | ||
| 9 | import { Hono } from "hono"; | |
| 10 | import { Layout } from "../views/layout"; | |
| 11 | import type { AuthEnv } from "../middleware/auth"; | |
| 12 | import { requireAuth } from "../middleware/auth"; | |
| 13 | import { | |
| 14 | addSigningKey, | |
| 15 | deleteSigningKey, | |
| 16 | listSigningKeysForUser, | |
| 17 | } from "../lib/signatures"; | |
| 18 | import { audit } from "../lib/notify"; | |
| 19 | ||
| 20 | const signingKeysRoutes = new Hono<AuthEnv>(); | |
| 21 | signingKeysRoutes.use("/settings/signing-keys", requireAuth); | |
| 22 | signingKeysRoutes.use("/settings/signing-keys/*", requireAuth); | |
| 23 | ||
| 24 | signingKeysRoutes.get("/settings/signing-keys", async (c) => { | |
| 25 | const user = c.get("user")!; | |
| 26 | const keys = await listSigningKeysForUser(user.id); | |
| 27 | const message = c.req.query("message"); | |
| 28 | const error = c.req.query("error"); | |
| 29 | return c.html( | |
| 30 | <Layout title="Signing keys" user={user}> | |
| 31 | <div class="settings-container"> | |
| 32 | <h2>Signing keys</h2> | |
| 33 | <p style="color:var(--text-muted)"> | |
| 34 | Register the GPG or SSH public key you use for{" "} | |
| 35 | <code>git commit -S</code>. Commits we can match to a registered key | |
| 36 | render with a <span style="color:var(--green);font-weight:600">Verified</span>{" "} | |
| 37 | badge. This is identity matching by fingerprint — cryptographic | |
| 38 | verification is future work. | |
| 39 | </p> | |
| 40 | {message && ( | |
| 41 | <div class="auth-success" style="margin-top:12px"> | |
| 42 | {decodeURIComponent(message)} | |
| 43 | </div> | |
| 44 | )} | |
| 45 | {error && ( | |
| 46 | <div class="auth-error" style="margin-top:12px"> | |
| 47 | {decodeURIComponent(error)} | |
| 48 | </div> | |
| 49 | )} | |
| 50 | ||
| 51 | <h3 style="margin-top:24px">Your keys</h3> | |
| 52 | {keys.length === 0 ? ( | |
| 53 | <div class="panel-empty" style="padding:16px"> | |
| 54 | No signing keys yet. | |
| 55 | </div> | |
| 56 | ) : ( | |
| 57 | <div class="panel"> | |
| 58 | {keys.map((k) => ( | |
| 59 | <div | |
| 60 | class="panel-item" | |
| 61 | style="flex-direction:column;align-items:stretch;gap:4px" | |
| 62 | > | |
| 63 | <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap"> | |
| 64 | <div> | |
| 65 | <span | |
| 66 | style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px" | |
| 67 | > | |
| 68 | {k.keyType} | |
| 69 | </span> | |
| 70 | <span style="font-weight:600">{k.title}</span> | |
| 71 | {k.email && ( | |
| 72 | <span | |
| 73 | style="font-size:12px;color:var(--text-muted);margin-left:8px" | |
| 74 | > | |
| 75 | {k.email} | |
| 76 | </span> | |
| 77 | )} | |
| 78 | </div> | |
| 79 | <form | |
| 001af43 | 80 | method="post" |
| 3951454 | 81 | action={`/settings/signing-keys/${k.id}/delete`} |
| 82 | > | |
| 83 | <button | |
| 84 | type="submit" | |
| 85 | class="btn btn-sm" | |
| 86 | style="font-size:11px" | |
| 87 | > | |
| 88 | Delete | |
| 89 | </button> | |
| 90 | </form> | |
| 91 | </div> | |
| 92 | <div | |
| 93 | style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);word-break:break-all" | |
| 94 | > | |
| 95 | {k.fingerprint} | |
| 96 | </div> | |
| 97 | </div> | |
| 98 | ))} | |
| 99 | </div> | |
| 100 | )} | |
| 101 | ||
| 102 | <h3 style="margin-top:24px">Add a key</h3> | |
| 103 | <form | |
| 001af43 | 104 | method="post" |
| 3951454 | 105 | action="/settings/signing-keys" |
| 106 | class="auth-form" | |
| 107 | style="max-width:720px" | |
| 108 | > | |
| 109 | <div class="form-group"> | |
| 110 | <label for="sk-title">Title</label> | |
| 111 | <input | |
| 112 | type="text" | |
| 113 | id="sk-title" | |
| 114 | name="title" | |
| 115 | placeholder="e.g. Work laptop" | |
| 116 | required | |
| 117 | maxLength={120} | |
| 118 | /> | |
| 119 | </div> | |
| 120 | <div class="form-group"> | |
| 121 | <label for="sk-type">Key type</label> | |
| 122 | <select id="sk-type" name="key_type" required> | |
| 123 | <option value="gpg">GPG</option> | |
| 124 | <option value="ssh">SSH</option> | |
| 125 | </select> | |
| 126 | </div> | |
| 127 | <div class="form-group"> | |
| 128 | <label for="sk-email">Email (optional)</label> | |
| 129 | <input | |
| 130 | type="email" | |
| 131 | id="sk-email" | |
| 132 | name="email" | |
| 133 | placeholder="commit-author@example.com" | |
| 134 | maxLength={200} | |
| 135 | /> | |
| 136 | </div> | |
| 137 | <div class="form-group"> | |
| 138 | <label for="sk-public">Public key</label> | |
| 139 | <textarea | |
| 140 | id="sk-public" | |
| 141 | name="public_key" | |
| 142 | rows={10} | |
| 143 | required | |
| 144 | placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK----- or: ssh-ed25519 AAAA... you@laptop" | |
| 145 | style="font-family:var(--font-mono);font-size:12px" | |
| 146 | /> | |
| 147 | </div> | |
| 148 | <button type="submit" class="btn btn-primary"> | |
| 149 | Add key | |
| 150 | </button> | |
| 151 | </form> | |
| 152 | </div> | |
| 153 | </Layout> | |
| 154 | ); | |
| 155 | }); | |
| 156 | ||
| 157 | signingKeysRoutes.post("/settings/signing-keys", async (c) => { | |
| 158 | const user = c.get("user")!; | |
| 159 | const body = await c.req.parseBody(); | |
| 160 | const keyType = String(body.key_type || "").toLowerCase() as "gpg" | "ssh"; | |
| 161 | const title = String(body.title || ""); | |
| 162 | const publicKey = String(body.public_key || ""); | |
| 163 | const email = String(body.email || ""); | |
| 164 | ||
| 165 | const result = await addSigningKey({ | |
| 166 | userId: user.id, | |
| 167 | keyType, | |
| 168 | title, | |
| 169 | publicKey, | |
| 170 | email, | |
| 171 | }); | |
| 172 | ||
| 173 | if (!result.ok) { | |
| 174 | return c.redirect( | |
| 175 | `/settings/signing-keys?error=${encodeURIComponent(result.error)}` | |
| 176 | ); | |
| 177 | } | |
| 178 | await audit({ | |
| 179 | userId: user.id, | |
| 180 | action: "signing_keys.add", | |
| 181 | targetId: result.id, | |
| 182 | metadata: { keyType, fingerprint: result.fingerprint }, | |
| 183 | }); | |
| 184 | return c.redirect( | |
| 185 | `/settings/signing-keys?message=${encodeURIComponent( | |
| 186 | `Added key ${result.fingerprint.slice(0, 24)}…` | |
| 187 | )}` | |
| 188 | ); | |
| 189 | }); | |
| 190 | ||
| 191 | signingKeysRoutes.post("/settings/signing-keys/:id/delete", async (c) => { | |
| 192 | const user = c.get("user")!; | |
| 193 | const id = c.req.param("id"); | |
| 194 | const ok = await deleteSigningKey(id, user.id); | |
| 195 | if (ok) { | |
| 196 | await audit({ | |
| 197 | userId: user.id, | |
| 198 | action: "signing_keys.delete", | |
| 199 | targetId: id, | |
| 200 | }); | |
| 201 | } | |
| 202 | return c.redirect( | |
| 203 | `/settings/signing-keys?${ok ? "message" : "error"}=${encodeURIComponent( | |
| 204 | ok ? "Key removed." : "Key not found" | |
| 205 | )}` | |
| 206 | ); | |
| 207 | }); | |
| 208 | ||
| 209 | export default signingKeysRoutes; |