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.tsxBlame699 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";
ae2a071ccanty labs26import { SettingsSubnav } from "./settings";
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);
67 color: #5b6ee8;
68 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 {
101 color: #6ee7b7;
102 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);
240 color: #6ee7b7;
241 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);
245 color: #fca5a5;
246 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);
273 color: #fca5a5;
274 }
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);
362 color: #5b6ee8;
363 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}>
ae2a071ccanty labs443 <SettingsSubnav active="signing-keys" />
304ce2aClaude444 <div class="sk-wrap">
445 <header class="sk-head">
446 <div class="sk-eyebrow">
447 <span class="sk-eyebrow-pill" aria-hidden="true">
448 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
449 <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" />
450 </svg>
451 </span>
452 Signing keys · {user.username}
453 </div>
454 <h2 class="sk-title">
455 <span class="sk-title-grad">Prove</span> it was you.
456 </h2>
457 <p class="sk-sub">
458 Register the GPG or SSH public key you use for{" "}
459 <code>git commit -S</code>. Commits we can match to a registered
460 key render with a <span class="sk-verified">Verified</span>{" "}
461 badge. This is identity matching by fingerprint —
462 cryptographic verification is future work.
463 </p>
464 </header>
465
3951454Claude466 {message && (
304ce2aClaude467 <div class="sk-banner is-ok" role="status">
468 <span class="sk-banner-dot" aria-hidden="true" />
3951454Claude469 {decodeURIComponent(message)}
470 </div>
471 )}
472 {error && (
304ce2aClaude473 <div class="sk-banner is-error" role="status">
474 <span class="sk-banner-dot" aria-hidden="true" />
3951454Claude475 {decodeURIComponent(error)}
476 </div>
477 )}
478
304ce2aClaude479 {keys.length > 0 ? (
480 <div class="sk-list">
481 {keys.map((k) => {
482 const expired =
483 k.expiresAt && new Date(k.expiresAt).getTime() < now.getTime();
484 return (
485 <article class="sk-card">
486 <div class="sk-card-top">
487 <div class="sk-card-id">
488 <h3 class="sk-card-name">
489 <span class="sk-type-chip">{k.keyType}</span>
490 <span>{k.title}</span>
491 {k.email && (
492 <span class="sk-card-email">{k.email}</span>
493 )}
494 </h3>
495 <div class="sk-card-fp" title={k.fingerprint}>
496 {k.fingerprint}
497 </div>
498 <div class="sk-card-meta" style="margin-top:10px">
499 <span class="sk-time">
500 Added {skRelativeTime(k.createdAt)}
501 </span>
502 {k.lastUsedAt && (
503 <>
504 <span aria-hidden="true">·</span>
505 <span class="sk-time">
506 Last used {skRelativeTime(k.lastUsedAt)}
507 </span>
508 </>
509 )}
510 </div>
511 </div>
3951454Claude512 <span
304ce2aClaude513 class={
514 "sk-pill " + (expired ? "is-expired" : "is-active")
515 }
3951454Claude516 >
304ce2aClaude517 <span class="dot" aria-hidden="true" />
518 {expired ? "expired" : "active"}
3951454Claude519 </span>
520 </div>
304ce2aClaude521
522 <div class="sk-actions">
523 <form
524 class="sk-card-form"
525 method="post"
526 action={`/settings/signing-keys/${k.id}/delete`}
3951454Claude527 >
304ce2aClaude528 <button type="submit" class="sk-btn sk-btn-danger">
529 Revoke
530 </button>
531 </form>
532 </div>
533 </article>
534 );
535 })}
536 </div>
537 ) : (
538 <div class="sk-empty">
539 <div class="sk-empty-orb" aria-hidden="true" />
540 <div class="sk-empty-inner">
541 <p class="sk-empty-title">No signing keys yet</p>
542 <p class="sk-empty-sub">
543 Sign commits to prove they're you — your verified pushes will
544 render with a green badge once we match the signature to a
545 key on file.
546 </p>
547 </div>
3951454Claude548 </div>
549 )}
550
304ce2aClaude551 <section class="sk-form-card" aria-labelledby="sk-add-title">
552 <header class="sk-form-head">
553 <h3 class="sk-form-title" id="sk-add-title">
554 <span class="sk-form-title-icon" aria-hidden="true">
555 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
556 <line x1="12" y1="5" x2="12" y2="19" />
557 <line x1="5" y1="12" x2="19" y2="12" />
558 </svg>
559 </span>
560 Add a key
561 </h3>
562 </header>
563 <form
564 method="post"
565 action="/settings/signing-keys"
566 class="auth-form"
567 >
568 <div class="sk-form-body">
569 <div class="sk-field">
570 <label class="sk-label" for="sk-title">
571 Title
572 </label>
573 <input
574 type="text"
575 id="sk-title"
576 name="title"
577 class="sk-input"
578 placeholder="e.g. Work laptop"
579 required
580 maxLength={120}
581 autocomplete="off"
582 spellcheck={false}
583 />
584 </div>
585 <div class="sk-field">
586 <label class="sk-label" for="sk-type">
587 Key type
588 </label>
589 <select
590 id="sk-type"
591 name="key_type"
592 class="sk-select"
593 required
594 >
595 <option value="gpg">GPG</option>
596 <option value="ssh">SSH</option>
597 </select>
598 </div>
599 <div class="sk-field">
600 <label class="sk-label" for="sk-email">
601 Email (optional)
602 </label>
603 <input
604 type="email"
605 id="sk-email"
606 name="email"
607 class="sk-input"
608 placeholder="commit-author@example.com"
609 maxLength={200}
610 autocomplete="off"
611 spellcheck={false}
612 />
613 <div class="sk-hint">
614 Helps match commits whose <code>Signed-off-by</code> uses a
615 different address than your account.
616 </div>
617 </div>
618 <div class="sk-field">
619 <label class="sk-label" for="sk-public">
620 Public key
621 </label>
622 <textarea
623 id="sk-public"
624 name="public_key"
625 rows={10}
626 required
627 class="sk-textarea"
628 placeholder={
629 "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----\n\nor: ssh-ed25519 AAAA... you@laptop"
630 }
631 />
632 </div>
633 </div>
634 <div class="sk-form-foot">
635 <button type="submit" class="sk-btn sk-btn-primary">
636 Add key
637 </button>
638 </div>
639 </form>
640 </section>
3951454Claude641 </div>
304ce2aClaude642 <style dangerouslySetInnerHTML={{ __html: signingKeyStyles }} />
3951454Claude643 </Layout>
644 );
645});
646
647signingKeysRoutes.post("/settings/signing-keys", async (c) => {
648 const user = c.get("user")!;
649 const body = await c.req.parseBody();
650 const keyType = String(body.key_type || "").toLowerCase() as "gpg" | "ssh";
651 const title = String(body.title || "");
652 const publicKey = String(body.public_key || "");
653 const email = String(body.email || "");
654
655 const result = await addSigningKey({
656 userId: user.id,
657 keyType,
658 title,
659 publicKey,
660 email,
661 });
662
663 if (!result.ok) {
664 return c.redirect(
665 `/settings/signing-keys?error=${encodeURIComponent(result.error)}`
666 );
667 }
668 await audit({
669 userId: user.id,
670 action: "signing_keys.add",
671 targetId: result.id,
672 metadata: { keyType, fingerprint: result.fingerprint },
673 });
674 return c.redirect(
675 `/settings/signing-keys?message=${encodeURIComponent(
676 `Added key ${result.fingerprint.slice(0, 24)}…`
677 )}`
678 );
679});
680
681signingKeysRoutes.post("/settings/signing-keys/:id/delete", async (c) => {
682 const user = c.get("user")!;
683 const id = c.req.param("id");
684 const ok = await deleteSigningKey(id, user.id);
685 if (ok) {
686 await audit({
687 userId: user.id,
688 action: "signing_keys.delete",
689 targetId: id,
690 });
691 }
692 return c.redirect(
693 `/settings/signing-keys?${ok ? "message" : "error"}=${encodeURIComponent(
694 ok ? "Key removed." : "Key not found"
695 )}`
696 );
697});
698
699export default signingKeysRoutes;