Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

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