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

signing-keys.tsxBlame697 lines · 1 contributor
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";
26import type { AuthEnv } from "../middleware/auth";
27import { requireAuth } from "../middleware/auth";
28import {
29 addSigningKey,
30 deleteSigningKey,
31 listSigningKeysForUser,
32} from "../lib/signatures";
33import { audit } from "../lib/notify";
34
35const signingKeysRoutes = new Hono<AuthEnv>();
36signingKeysRoutes.use("/settings/signing-keys", requireAuth);
37signingKeysRoutes.use("/settings/signing-keys/*", requireAuth);
38
304ce2aClaude39/* ─────────────────────────────────────────────────────────────────────────
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 * ───────────────────────────────────────────────────────────────────── */
44const signingKeyStyles = `
eed4684Claude45 .sk-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
304ce2aClaude46
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;
6fd5915Claude65 background: rgba(91,110,232,0.14);
66 color: #5b6ee8;
67 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
304ce2aClaude68 }
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 {
6fd5915Claude79 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
304ce2aClaude80 -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;
6fd5915Claude211 background: rgba(91,110,232,0.12);
212 border: 1px solid rgba(91,110,232,0.30);
304ce2aClaude213 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 {
6fd5915Claude280 background: linear-gradient(135deg, #5b6ee8 0%, #6d4ee0 100%);
304ce2aClaude281 color: #ffffff;
6fd5915Claude282 border-color: rgba(91,110,232,0.55);
283 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45);
304ce2aClaude284 }
285 .sk-btn-primary:hover {
286 transform: translateY(-1px);
6fd5915Claude287 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55);
304ce2aClaude288 }
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;
6fd5915Claude306 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
304ce2aClaude307 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;
6fd5915Claude360 background: rgba(91,110,232,0.12);
361 color: #5b6ee8;
362 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
304ce2aClaude363 }
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);
6fd5915Claude394 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
304ce2aClaude395 }
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". */
420function 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
3951454Claude434signingKeysRoutes.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");
304ce2aClaude439 const now = new Date();
3951454Claude440 return c.html(
441 <Layout title="Signing keys" user={user}>
304ce2aClaude442 <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
3951454Claude464 {message && (
304ce2aClaude465 <div class="sk-banner is-ok" role="status">
466 <span class="sk-banner-dot" aria-hidden="true" />
3951454Claude467 {decodeURIComponent(message)}
468 </div>
469 )}
470 {error && (
304ce2aClaude471 <div class="sk-banner is-error" role="status">
472 <span class="sk-banner-dot" aria-hidden="true" />
3951454Claude473 {decodeURIComponent(error)}
474 </div>
475 )}
476
304ce2aClaude477 {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>
3951454Claude510 <span
304ce2aClaude511 class={
512 "sk-pill " + (expired ? "is-expired" : "is-active")
513 }
3951454Claude514 >
304ce2aClaude515 <span class="dot" aria-hidden="true" />
516 {expired ? "expired" : "active"}
3951454Claude517 </span>
518 </div>
304ce2aClaude519
520 <div class="sk-actions">
521 <form
522 class="sk-card-form"
523 method="post"
524 action={`/settings/signing-keys/${k.id}/delete`}
3951454Claude525 >
304ce2aClaude526 <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>
3951454Claude546 </div>
547 )}
548
304ce2aClaude549 <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>
3951454Claude639 </div>
304ce2aClaude640 <style dangerouslySetInnerHTML={{ __html: signingKeyStyles }} />
3951454Claude641 </Layout>
642 );
643});
644
645signingKeysRoutes.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
679signingKeysRoutes.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
697export default signingKeysRoutes;