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 | |
| 304ce2a | 7 | * |
| 8 | * 2026 polish: | |
| 9 | * - Page-level eyebrow + display headline + subtitle (the settings layout | |
| 10 | * already provides the sidebar — no hero block here). | |
| 11 | * - Each key is a polished card showing title, key-type chip, optional | |
| 12 | * email, mono fingerprint, created timestamp (relative, tabular-nums), | |
| 13 | * and an "active" status pill. | |
| 14 | * - Add-key form is its own card with focus rings + primary gradient | |
| 15 | * submit button. | |
| 16 | * - Empty state is a dashed card with an orb + helpful CTA copy. | |
| 17 | * - All CSS scoped under `.sk-*`. | |
| 18 | * | |
| 19 | * Hard rules preserved: | |
| 20 | * - Every route, form action, POST handler is unchanged. | |
| 21 | * - Layout / ui.tsx / components.tsx are not modified. | |
| 3951454 | 22 | */ |
| 23 | ||
| 24 | import { Hono } from "hono"; | |
| 25 | import { Layout } from "../views/layout"; | |
| 26 | import type { AuthEnv } from "../middleware/auth"; | |
| 27 | import { requireAuth } from "../middleware/auth"; | |
| 28 | import { | |
| 29 | addSigningKey, | |
| 30 | deleteSigningKey, | |
| 31 | listSigningKeysForUser, | |
| 32 | } from "../lib/signatures"; | |
| 33 | import { audit } from "../lib/notify"; | |
| 34 | ||
| 35 | const signingKeysRoutes = new Hono<AuthEnv>(); | |
| 36 | signingKeysRoutes.use("/settings/signing-keys", requireAuth); | |
| 37 | signingKeysRoutes.use("/settings/signing-keys/*", requireAuth); | |
| 38 | ||
| 304ce2a | 39 | /* ───────────────────────────────────────────────────────────────────────── |
| 40 | * Scoped CSS — every class prefixed `.sk-` so this page can't bleed into | |
| 41 | * other settings surfaces. Mirrors the section-card pattern from | |
| 42 | * admin-integrations.tsx and admin-ops.tsx. | |
| 43 | * ───────────────────────────────────────────────────────────────────── */ | |
| 44 | const signingKeyStyles = ` | |
| eed4684 | 45 | .sk-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); } |
| 304ce2a | 46 | |
| 47 | /* ─── Page heading (no hero block — settings sidebar supplies framing) ─── */ | |
| 48 | .sk-head { margin-bottom: var(--space-5); } | |
| 49 | .sk-eyebrow { | |
| 50 | font-size: 12px; | |
| 51 | color: var(--text-muted); | |
| 52 | margin-bottom: var(--space-2); | |
| 53 | letter-spacing: 0.02em; | |
| 54 | display: inline-flex; | |
| 55 | align-items: center; | |
| 56 | gap: 8px; | |
| 57 | text-transform: uppercase; | |
| 58 | } | |
| 59 | .sk-eyebrow-pill { | |
| 60 | display: inline-flex; | |
| 61 | align-items: center; | |
| 62 | justify-content: center; | |
| 63 | width: 18px; height: 18px; | |
| 64 | border-radius: 6px; | |
| 6fd5915 | 65 | background: rgba(91,110,232,0.14); |
| 66 | color: #5b6ee8; | |
| 67 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35); | |
| 304ce2a | 68 | } |
| 69 | .sk-title { | |
| 70 | font-size: clamp(24px, 3.2vw, 32px); | |
| 71 | font-family: var(--font-display); | |
| 72 | font-weight: 800; | |
| 73 | letter-spacing: -0.024em; | |
| 74 | line-height: 1.08; | |
| 75 | margin: 0 0 var(--space-2); | |
| 76 | color: var(--text-strong); | |
| 77 | } | |
| 78 | .sk-title-grad { | |
| 6fd5915 | 79 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 304ce2a | 80 | -webkit-background-clip: text; |
| 81 | background-clip: text; | |
| 82 | -webkit-text-fill-color: transparent; | |
| 83 | color: transparent; | |
| 84 | } | |
| 85 | .sk-sub { | |
| 86 | font-size: 14.5px; | |
| 87 | color: var(--text-muted); | |
| 88 | margin: 0; | |
| 89 | line-height: 1.55; | |
| 90 | max-width: 620px; | |
| 91 | } | |
| 92 | .sk-sub code { | |
| 93 | font-family: var(--font-mono); | |
| 94 | font-size: 12.5px; | |
| 95 | background: var(--bg-tertiary); | |
| 96 | padding: 1px 5px; | |
| 97 | border-radius: 4px; | |
| 98 | } | |
| 99 | .sk-sub .sk-verified { | |
| 100 | color: #6ee7b7; | |
| 101 | font-weight: 600; | |
| 102 | } | |
| 103 | ||
| 104 | /* ─── Banners ─── */ | |
| 105 | .sk-banner { | |
| 106 | margin-bottom: var(--space-4); | |
| 107 | padding: 10px 14px; | |
| 108 | border-radius: 10px; | |
| 109 | font-size: 13.5px; | |
| 110 | border: 1px solid var(--border); | |
| 111 | background: rgba(255,255,255,0.025); | |
| 112 | color: var(--text); | |
| 113 | display: flex; | |
| 114 | align-items: center; | |
| 115 | gap: 10px; | |
| 116 | } | |
| 117 | .sk-banner.is-ok { | |
| 118 | border-color: rgba(52,211,153,0.40); | |
| 119 | background: rgba(52,211,153,0.08); | |
| 120 | color: #bbf7d0; | |
| 121 | } | |
| 122 | .sk-banner.is-error { | |
| 123 | border-color: rgba(248,113,113,0.40); | |
| 124 | background: rgba(248,113,113,0.08); | |
| 125 | color: #fecaca; | |
| 126 | } | |
| 127 | .sk-banner-dot { | |
| 128 | width: 8px; height: 8px; | |
| 129 | border-radius: 9999px; | |
| 130 | background: currentColor; | |
| 131 | flex-shrink: 0; | |
| 132 | } | |
| 133 | ||
| 134 | /* ─── Key cards ─── */ | |
| 135 | .sk-list { | |
| 136 | display: flex; | |
| 137 | flex-direction: column; | |
| 138 | gap: var(--space-3); | |
| 139 | margin-bottom: var(--space-5); | |
| 140 | } | |
| 141 | .sk-card { | |
| 142 | background: var(--bg-elevated); | |
| 143 | border: 1px solid var(--border); | |
| 144 | border-radius: 14px; | |
| 145 | padding: var(--space-4) var(--space-5); | |
| 146 | display: flex; | |
| 147 | flex-direction: column; | |
| 148 | gap: var(--space-3); | |
| 149 | transition: border-color 140ms ease; | |
| 150 | } | |
| 151 | .sk-card:hover { border-color: var(--border-strong); } | |
| 152 | .sk-card-top { | |
| 153 | display: flex; | |
| 154 | align-items: flex-start; | |
| 155 | justify-content: space-between; | |
| 156 | gap: var(--space-3); | |
| 157 | flex-wrap: wrap; | |
| 158 | } | |
| 159 | .sk-card-id { flex: 1; min-width: 240px; } | |
| 160 | .sk-card-name { | |
| 161 | font-family: var(--font-display); | |
| 162 | font-size: 15px; | |
| 163 | font-weight: 700; | |
| 164 | color: var(--text-strong); | |
| 165 | letter-spacing: -0.012em; | |
| 166 | margin: 0; | |
| 167 | display: flex; | |
| 168 | align-items: center; | |
| 169 | gap: 8px; | |
| 170 | flex-wrap: wrap; | |
| 171 | } | |
| 172 | .sk-card-email { | |
| 173 | font-size: 12.5px; | |
| 174 | color: var(--text-muted); | |
| 175 | font-weight: 500; | |
| 176 | font-family: var(--font-mono); | |
| 177 | } | |
| 178 | .sk-card-fp { | |
| 179 | margin-top: 8px; | |
| 180 | padding: 8px 10px; | |
| 181 | font-family: var(--font-mono); | |
| 182 | font-size: 11.5px; | |
| 183 | color: var(--text); | |
| 184 | background: rgba(255,255,255,0.025); | |
| 185 | border: 1px solid var(--border); | |
| 186 | border-radius: 8px; | |
| 187 | word-break: break-all; | |
| 188 | overflow-wrap: anywhere; | |
| 189 | line-height: 1.45; | |
| 190 | } | |
| 191 | .sk-card-meta { | |
| 192 | display: flex; | |
| 193 | align-items: center; | |
| 194 | gap: 10px; | |
| 195 | flex-wrap: wrap; | |
| 196 | font-size: 12px; | |
| 197 | color: var(--text-muted); | |
| 198 | } | |
| 199 | .sk-time { | |
| 200 | font-variant-numeric: tabular-nums; | |
| 201 | font-size: 12px; | |
| 202 | color: var(--text-muted); | |
| 203 | } | |
| 204 | ||
| 205 | /* ─── Chips + pills ─── */ | |
| 206 | .sk-type-chip { | |
| 207 | display: inline-flex; | |
| 208 | align-items: center; | |
| 209 | padding: 2px 9px; | |
| 210 | border-radius: 9999px; | |
| 6fd5915 | 211 | background: rgba(91,110,232,0.12); |
| 212 | border: 1px solid rgba(91,110,232,0.30); | |
| 304ce2a | 213 | color: #c4b5fd; |
| 214 | font-size: 10.5px; | |
| 215 | font-weight: 700; | |
| 216 | letter-spacing: 0.06em; | |
| 217 | text-transform: uppercase; | |
| 218 | font-family: var(--font-mono); | |
| 219 | } | |
| 220 | .sk-pill { | |
| 221 | display: inline-flex; | |
| 222 | align-items: center; | |
| 223 | gap: 6px; | |
| 224 | padding: 3px 10px; | |
| 225 | border-radius: 9999px; | |
| 226 | font-size: 11px; | |
| 227 | font-weight: 600; | |
| 228 | letter-spacing: 0.04em; | |
| 229 | text-transform: uppercase; | |
| 230 | flex-shrink: 0; | |
| 231 | } | |
| 232 | .sk-pill .dot { | |
| 233 | width: 6px; height: 6px; | |
| 234 | border-radius: 9999px; | |
| 235 | background: currentColor; | |
| 236 | } | |
| 237 | .sk-pill.is-active { | |
| 238 | background: rgba(52,211,153,0.14); | |
| 239 | color: #6ee7b7; | |
| 240 | box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); | |
| 241 | } | |
| 242 | .sk-pill.is-expired { | |
| 243 | background: rgba(248,113,113,0.12); | |
| 244 | color: #fca5a5; | |
| 245 | box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); | |
| 246 | } | |
| 247 | ||
| 248 | /* ─── Actions ─── */ | |
| 249 | .sk-actions { | |
| 250 | display: flex; | |
| 251 | align-items: center; | |
| 252 | gap: 8px; | |
| 253 | flex-wrap: wrap; | |
| 254 | } | |
| 255 | .sk-btn { | |
| 256 | display: inline-flex; | |
| 257 | align-items: center; | |
| 258 | gap: 6px; | |
| 259 | padding: 7px 14px; | |
| 260 | font-size: 12.5px; | |
| 261 | font-weight: 600; | |
| 262 | border-radius: 8px; | |
| 263 | cursor: pointer; | |
| 264 | font-family: inherit; | |
| 265 | text-decoration: none; | |
| 266 | border: 1px solid transparent; | |
| 267 | transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; | |
| 268 | } | |
| 269 | .sk-btn-danger { | |
| 270 | background: rgba(248,113,113,0.08); | |
| 271 | border-color: rgba(248,113,113,0.30); | |
| 272 | color: #fca5a5; | |
| 273 | } | |
| 274 | .sk-btn-danger:hover { | |
| 275 | background: rgba(248,113,113,0.14); | |
| 276 | border-color: rgba(248,113,113,0.50); | |
| 277 | color: #fecaca; | |
| 278 | } | |
| 279 | .sk-btn-primary { | |
| 6fd5915 | 280 | background: linear-gradient(135deg, #5b6ee8 0%, #6d4ee0 100%); |
| 304ce2a | 281 | color: #ffffff; |
| 6fd5915 | 282 | border-color: rgba(91,110,232,0.55); |
| 283 | box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45); | |
| 304ce2a | 284 | } |
| 285 | .sk-btn-primary:hover { | |
| 286 | transform: translateY(-1px); | |
| 6fd5915 | 287 | box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); |
| 304ce2a | 288 | } |
| 289 | .sk-card-form { margin: 0; } | |
| 290 | ||
| 291 | /* ─── Empty state ─── */ | |
| 292 | .sk-empty { | |
| 293 | position: relative; | |
| 294 | padding: var(--space-6) var(--space-5); | |
| 295 | margin-bottom: var(--space-5); | |
| 296 | border: 1px dashed var(--border-strong); | |
| 297 | border-radius: 16px; | |
| 298 | background: rgba(255,255,255,0.02); | |
| 299 | text-align: center; | |
| 300 | overflow: hidden; | |
| 301 | } | |
| 302 | .sk-empty-orb { | |
| 303 | position: absolute; | |
| 304 | inset: -40% -10% auto auto; | |
| 305 | width: 320px; height: 320px; | |
| 6fd5915 | 306 | background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 304ce2a | 307 | filter: blur(70px); |
| 308 | opacity: 0.6; | |
| 309 | pointer-events: none; | |
| 310 | z-index: 0; | |
| 311 | } | |
| 312 | .sk-empty-inner { position: relative; z-index: 1; } | |
| 313 | .sk-empty-title { | |
| 314 | font-family: var(--font-display); | |
| 315 | font-size: 17px; | |
| 316 | font-weight: 700; | |
| 317 | color: var(--text-strong); | |
| 318 | margin: 0 0 6px; | |
| 319 | letter-spacing: -0.018em; | |
| 320 | } | |
| 321 | .sk-empty-sub { | |
| 322 | font-size: 13.5px; | |
| 323 | color: var(--text-muted); | |
| 324 | margin: 0 auto; | |
| 325 | max-width: 460px; | |
| 326 | line-height: 1.5; | |
| 327 | } | |
| 328 | ||
| 329 | /* ─── Add-key form card ─── */ | |
| 330 | .sk-form-card { | |
| 331 | background: var(--bg-elevated); | |
| 332 | border: 1px solid var(--border); | |
| 333 | border-radius: 14px; | |
| 334 | overflow: hidden; | |
| 335 | } | |
| 336 | .sk-form-head { | |
| 337 | padding: var(--space-4) var(--space-5); | |
| 338 | border-bottom: 1px solid var(--border); | |
| 339 | display: flex; | |
| 340 | align-items: center; | |
| 341 | gap: 10px; | |
| 342 | } | |
| 343 | .sk-form-title { | |
| 344 | margin: 0; | |
| 345 | font-family: var(--font-display); | |
| 346 | font-size: 16px; | |
| 347 | font-weight: 700; | |
| 348 | letter-spacing: -0.018em; | |
| 349 | color: var(--text-strong); | |
| 350 | display: flex; | |
| 351 | align-items: center; | |
| 352 | gap: 10px; | |
| 353 | } | |
| 354 | .sk-form-title-icon { | |
| 355 | display: inline-flex; | |
| 356 | align-items: center; | |
| 357 | justify-content: center; | |
| 358 | width: 26px; height: 26px; | |
| 359 | border-radius: 8px; | |
| 6fd5915 | 360 | background: rgba(91,110,232,0.12); |
| 361 | color: #5b6ee8; | |
| 362 | box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28); | |
| 304ce2a | 363 | } |
| 364 | .sk-form-body { padding: var(--space-4) var(--space-5); } | |
| 365 | .sk-field { margin-bottom: var(--space-4); } | |
| 366 | .sk-field:last-of-type { margin-bottom: 0; } | |
| 367 | .sk-label { | |
| 368 | display: block; | |
| 369 | font-size: 12.5px; | |
| 370 | font-weight: 600; | |
| 371 | color: var(--text-strong); | |
| 372 | margin-bottom: 6px; | |
| 373 | letter-spacing: -0.005em; | |
| 374 | } | |
| 375 | .sk-input, | |
| 376 | .sk-select, | |
| 377 | .sk-textarea { | |
| 378 | width: 100%; | |
| 379 | padding: 9px 12px; | |
| 380 | font-size: 13.5px; | |
| 381 | color: var(--text); | |
| 382 | background: var(--bg); | |
| 383 | border: 1px solid var(--border-strong); | |
| 384 | border-radius: 8px; | |
| 385 | outline: none; | |
| 386 | font-family: var(--font-mono); | |
| 387 | transition: border-color 120ms ease, box-shadow 120ms ease; | |
| 388 | box-sizing: border-box; | |
| 389 | } | |
| 390 | .sk-input:focus, | |
| 391 | .sk-select:focus, | |
| 392 | .sk-textarea:focus { | |
| 393 | border-color: var(--border-focus); | |
| 6fd5915 | 394 | box-shadow: 0 0 0 3px rgba(91,110,232,0.18); |
| 304ce2a | 395 | } |
| 396 | .sk-textarea { | |
| 397 | resize: vertical; | |
| 398 | min-height: 180px; | |
| 399 | line-height: 1.5; | |
| 400 | } | |
| 401 | .sk-hint { | |
| 402 | margin-top: 6px; | |
| 403 | font-size: 11.5px; | |
| 404 | color: var(--text-muted); | |
| 405 | line-height: 1.45; | |
| 406 | } | |
| 407 | .sk-form-foot { | |
| 408 | padding: var(--space-3) var(--space-5); | |
| 409 | border-top: 1px solid var(--border); | |
| 410 | background: rgba(255,255,255,0.012); | |
| 411 | display: flex; | |
| 412 | justify-content: flex-end; | |
| 413 | gap: var(--space-2); | |
| 414 | align-items: center; | |
| 415 | flex-wrap: wrap; | |
| 416 | } | |
| 417 | `; | |
| 418 | ||
| 419 | /** Render a relative time like "12s ago", "3m ago", "2h ago", "3d ago". */ | |
| 420 | function skRelativeTime(from: Date | null, now: Date = new Date()): string { | |
| 421 | if (!from) return "—"; | |
| 422 | const ms = now.getTime() - new Date(from).getTime(); | |
| 423 | if (ms < 5_000) return "just now"; | |
| 424 | const s = Math.floor(ms / 1_000); | |
| 425 | if (s < 60) return `${s}s ago`; | |
| 426 | const m = Math.floor(s / 60); | |
| 427 | if (m < 60) return `${m}m ago`; | |
| 428 | const h = Math.floor(m / 60); | |
| 429 | if (h < 24) return `${h}h ago`; | |
| 430 | const d = Math.floor(h / 24); | |
| 431 | return `${d}d ago`; | |
| 432 | } | |
| 433 | ||
| 3951454 | 434 | signingKeysRoutes.get("/settings/signing-keys", async (c) => { |
| 435 | const user = c.get("user")!; | |
| 436 | const keys = await listSigningKeysForUser(user.id); | |
| 437 | const message = c.req.query("message"); | |
| 438 | const error = c.req.query("error"); | |
| 304ce2a | 439 | const now = new Date(); |
| 3951454 | 440 | return c.html( |
| 441 | <Layout title="Signing keys" user={user}> | |
| 304ce2a | 442 | <div class="sk-wrap"> |
| 443 | <header class="sk-head"> | |
| 444 | <div class="sk-eyebrow"> | |
| 445 | <span class="sk-eyebrow-pill" aria-hidden="true"> | |
| 446 | <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 447 | <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> | |
| 448 | </svg> | |
| 449 | </span> | |
| 450 | Signing keys · {user.username} | |
| 451 | </div> | |
| 452 | <h2 class="sk-title"> | |
| 453 | <span class="sk-title-grad">Prove</span> it was you. | |
| 454 | </h2> | |
| 455 | <p class="sk-sub"> | |
| 456 | Register the GPG or SSH public key you use for{" "} | |
| 457 | <code>git commit -S</code>. Commits we can match to a registered | |
| 458 | key render with a <span class="sk-verified">Verified</span>{" "} | |
| 459 | badge. This is identity matching by fingerprint — | |
| 460 | cryptographic verification is future work. | |
| 461 | </p> | |
| 462 | </header> | |
| 463 | ||
| 3951454 | 464 | {message && ( |
| 304ce2a | 465 | <div class="sk-banner is-ok" role="status"> |
| 466 | <span class="sk-banner-dot" aria-hidden="true" /> | |
| 3951454 | 467 | {decodeURIComponent(message)} |
| 468 | </div> | |
| 469 | )} | |
| 470 | {error && ( | |
| 304ce2a | 471 | <div class="sk-banner is-error" role="status"> |
| 472 | <span class="sk-banner-dot" aria-hidden="true" /> | |
| 3951454 | 473 | {decodeURIComponent(error)} |
| 474 | </div> | |
| 475 | )} | |
| 476 | ||
| 304ce2a | 477 | {keys.length > 0 ? ( |
| 478 | <div class="sk-list"> | |
| 479 | {keys.map((k) => { | |
| 480 | const expired = | |
| 481 | k.expiresAt && new Date(k.expiresAt).getTime() < now.getTime(); | |
| 482 | return ( | |
| 483 | <article class="sk-card"> | |
| 484 | <div class="sk-card-top"> | |
| 485 | <div class="sk-card-id"> | |
| 486 | <h3 class="sk-card-name"> | |
| 487 | <span class="sk-type-chip">{k.keyType}</span> | |
| 488 | <span>{k.title}</span> | |
| 489 | {k.email && ( | |
| 490 | <span class="sk-card-email">{k.email}</span> | |
| 491 | )} | |
| 492 | </h3> | |
| 493 | <div class="sk-card-fp" title={k.fingerprint}> | |
| 494 | {k.fingerprint} | |
| 495 | </div> | |
| 496 | <div class="sk-card-meta" style="margin-top:10px"> | |
| 497 | <span class="sk-time"> | |
| 498 | Added {skRelativeTime(k.createdAt)} | |
| 499 | </span> | |
| 500 | {k.lastUsedAt && ( | |
| 501 | <> | |
| 502 | <span aria-hidden="true">·</span> | |
| 503 | <span class="sk-time"> | |
| 504 | Last used {skRelativeTime(k.lastUsedAt)} | |
| 505 | </span> | |
| 506 | </> | |
| 507 | )} | |
| 508 | </div> | |
| 509 | </div> | |
| 3951454 | 510 | <span |
| 304ce2a | 511 | class={ |
| 512 | "sk-pill " + (expired ? "is-expired" : "is-active") | |
| 513 | } | |
| 3951454 | 514 | > |
| 304ce2a | 515 | <span class="dot" aria-hidden="true" /> |
| 516 | {expired ? "expired" : "active"} | |
| 3951454 | 517 | </span> |
| 518 | </div> | |
| 304ce2a | 519 | |
| 520 | <div class="sk-actions"> | |
| 521 | <form | |
| 522 | class="sk-card-form" | |
| 523 | method="post" | |
| 524 | action={`/settings/signing-keys/${k.id}/delete`} | |
| 3951454 | 525 | > |
| 304ce2a | 526 | <button type="submit" class="sk-btn sk-btn-danger"> |
| 527 | Revoke | |
| 528 | </button> | |
| 529 | </form> | |
| 530 | </div> | |
| 531 | </article> | |
| 532 | ); | |
| 533 | })} | |
| 534 | </div> | |
| 535 | ) : ( | |
| 536 | <div class="sk-empty"> | |
| 537 | <div class="sk-empty-orb" aria-hidden="true" /> | |
| 538 | <div class="sk-empty-inner"> | |
| 539 | <p class="sk-empty-title">No signing keys yet</p> | |
| 540 | <p class="sk-empty-sub"> | |
| 541 | Sign commits to prove they're you — your verified pushes will | |
| 542 | render with a green badge once we match the signature to a | |
| 543 | key on file. | |
| 544 | </p> | |
| 545 | </div> | |
| 3951454 | 546 | </div> |
| 547 | )} | |
| 548 | ||
| 304ce2a | 549 | <section class="sk-form-card" aria-labelledby="sk-add-title"> |
| 550 | <header class="sk-form-head"> | |
| 551 | <h3 class="sk-form-title" id="sk-add-title"> | |
| 552 | <span class="sk-form-title-icon" aria-hidden="true"> | |
| 553 | <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> | |
| 554 | <line x1="12" y1="5" x2="12" y2="19" /> | |
| 555 | <line x1="5" y1="12" x2="19" y2="12" /> | |
| 556 | </svg> | |
| 557 | </span> | |
| 558 | Add a key | |
| 559 | </h3> | |
| 560 | </header> | |
| 561 | <form | |
| 562 | method="post" | |
| 563 | action="/settings/signing-keys" | |
| 564 | class="auth-form" | |
| 565 | > | |
| 566 | <div class="sk-form-body"> | |
| 567 | <div class="sk-field"> | |
| 568 | <label class="sk-label" for="sk-title"> | |
| 569 | Title | |
| 570 | </label> | |
| 571 | <input | |
| 572 | type="text" | |
| 573 | id="sk-title" | |
| 574 | name="title" | |
| 575 | class="sk-input" | |
| 576 | placeholder="e.g. Work laptop" | |
| 577 | required | |
| 578 | maxLength={120} | |
| 579 | autocomplete="off" | |
| 580 | spellcheck={false} | |
| 581 | /> | |
| 582 | </div> | |
| 583 | <div class="sk-field"> | |
| 584 | <label class="sk-label" for="sk-type"> | |
| 585 | Key type | |
| 586 | </label> | |
| 587 | <select | |
| 588 | id="sk-type" | |
| 589 | name="key_type" | |
| 590 | class="sk-select" | |
| 591 | required | |
| 592 | > | |
| 593 | <option value="gpg">GPG</option> | |
| 594 | <option value="ssh">SSH</option> | |
| 595 | </select> | |
| 596 | </div> | |
| 597 | <div class="sk-field"> | |
| 598 | <label class="sk-label" for="sk-email"> | |
| 599 | Email (optional) | |
| 600 | </label> | |
| 601 | <input | |
| 602 | type="email" | |
| 603 | id="sk-email" | |
| 604 | name="email" | |
| 605 | class="sk-input" | |
| 606 | placeholder="commit-author@example.com" | |
| 607 | maxLength={200} | |
| 608 | autocomplete="off" | |
| 609 | spellcheck={false} | |
| 610 | /> | |
| 611 | <div class="sk-hint"> | |
| 612 | Helps match commits whose <code>Signed-off-by</code> uses a | |
| 613 | different address than your account. | |
| 614 | </div> | |
| 615 | </div> | |
| 616 | <div class="sk-field"> | |
| 617 | <label class="sk-label" for="sk-public"> | |
| 618 | Public key | |
| 619 | </label> | |
| 620 | <textarea | |
| 621 | id="sk-public" | |
| 622 | name="public_key" | |
| 623 | rows={10} | |
| 624 | required | |
| 625 | class="sk-textarea" | |
| 626 | placeholder={ | |
| 627 | "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n\nor: ssh-ed25519 AAAA... you@laptop" | |
| 628 | } | |
| 629 | /> | |
| 630 | </div> | |
| 631 | </div> | |
| 632 | <div class="sk-form-foot"> | |
| 633 | <button type="submit" class="sk-btn sk-btn-primary"> | |
| 634 | Add key | |
| 635 | </button> | |
| 636 | </div> | |
| 637 | </form> | |
| 638 | </section> | |
| 3951454 | 639 | </div> |
| 304ce2a | 640 | <style dangerouslySetInnerHTML={{ __html: signingKeyStyles }} /> |
| 3951454 | 641 | </Layout> |
| 642 | ); | |
| 643 | }); | |
| 644 | ||
| 645 | signingKeysRoutes.post("/settings/signing-keys", async (c) => { | |
| 646 | const user = c.get("user")!; | |
| 647 | const body = await c.req.parseBody(); | |
| 648 | const keyType = String(body.key_type || "").toLowerCase() as "gpg" | "ssh"; | |
| 649 | const title = String(body.title || ""); | |
| 650 | const publicKey = String(body.public_key || ""); | |
| 651 | const email = String(body.email || ""); | |
| 652 | ||
| 653 | const result = await addSigningKey({ | |
| 654 | userId: user.id, | |
| 655 | keyType, | |
| 656 | title, | |
| 657 | publicKey, | |
| 658 | email, | |
| 659 | }); | |
| 660 | ||
| 661 | if (!result.ok) { | |
| 662 | return c.redirect( | |
| 663 | `/settings/signing-keys?error=${encodeURIComponent(result.error)}` | |
| 664 | ); | |
| 665 | } | |
| 666 | await audit({ | |
| 667 | userId: user.id, | |
| 668 | action: "signing_keys.add", | |
| 669 | targetId: result.id, | |
| 670 | metadata: { keyType, fingerprint: result.fingerprint }, | |
| 671 | }); | |
| 672 | return c.redirect( | |
| 673 | `/settings/signing-keys?message=${encodeURIComponent( | |
| 674 | `Added key ${result.fingerprint.slice(0, 24)}…` | |
| 675 | )}` | |
| 676 | ); | |
| 677 | }); | |
| 678 | ||
| 679 | signingKeysRoutes.post("/settings/signing-keys/:id/delete", async (c) => { | |
| 680 | const user = c.get("user")!; | |
| 681 | const id = c.req.param("id"); | |
| 682 | const ok = await deleteSigningKey(id, user.id); | |
| 683 | if (ok) { | |
| 684 | await audit({ | |
| 685 | userId: user.id, | |
| 686 | action: "signing_keys.delete", | |
| 687 | targetId: id, | |
| 688 | }); | |
| 689 | } | |
| 690 | return c.redirect( | |
| 691 | `/settings/signing-keys?${ok ? "message" : "error"}=${encodeURIComponent( | |
| 692 | ok ? "Key removed." : "Key not found" | |
| 693 | )}` | |
| 694 | ); | |
| 695 | }); | |
| 696 | ||
| 697 | export default signingKeysRoutes; |