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

passkeys.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.

passkeys.tsxBlame1169 lines · 2 contributors
2df1f8cClaude1/**
2 * WebAuthn passkey routes (Block B5).
3 *
4 * Registration (authed):
5 * POST /api/passkeys/register/options → challenge + pubkey-cred-params
6 * POST /api/passkeys/register/verify → save credential
7 * GET /settings/passkeys → list + add + rename + delete
8 * POST /settings/passkeys/:id/delete
9 * POST /settings/passkeys/:id/rename
10 *
11 * Authentication (unauthed):
12 * POST /api/passkeys/auth/options → challenge (username optional)
13 * POST /api/passkeys/auth/verify → issues full session on success
14 *
15 * The browser-side glue lives in `/views/components.tsx`
16 * (`PasskeyScript`) — vanilla JS using the native `navigator.credentials` API.
c9188afClaude17 *
18 * 2026 polish: status card hero, gradient-CTA register button, per-passkey
19 * cards with device + last-used metadata, amber warning on remove. Every
20 * CSS rule scoped under `.pk-*` — no overlap with 2FA's `.tfa-*`. ALL
21 * WebAuthn ceremony JS, JSON endpoints, audit hooks, and POST handlers
22 * preserved EXACTLY.
2df1f8cClaude23 */
24
25import { Hono } from "hono";
26import { setCookie } from "hono/cookie";
c9188afClaude27import { eq } from "drizzle-orm";
2df1f8cClaude28import { db } from "../db";
29import { users, userPasskeys, sessions } from "../db/schema";
30import type { AuthEnv } from "../middleware/auth";
31import { requireAuth } from "../middleware/auth";
32import { Layout } from "../views/layout";
9b776daccantynz-alt33import { SettingsNav, settingsNavStyles } from "../views/components";
2df1f8cClaude34import {
35 startRegistration,
36 finishRegistration,
37 startAuthentication,
38 finishAuthentication,
39} from "../lib/webauthn";
40import {
41 generateSessionToken,
42 sessionCookieOptions,
43 sessionExpiry,
44} from "../lib/auth";
45import { audit } from "../lib/notify";
46
47const passkeys = new Hono<AuthEnv>();
48
49passkeys.use("/settings/passkeys", requireAuth);
50passkeys.use("/settings/passkeys/*", requireAuth);
51passkeys.use("/api/passkeys/register/*", requireAuth);
52
53// --- Settings UI ------------------------------------------------------------
54
c9188afClaude55/* ─────────────────────────────────────────────────────────────────────────
56 * Scoped CSS — every class prefixed `.pk-` so it cannot bleed into
57 * settings-2fa.tsx (`.tfa-`) or any other surface. Mirrors the
58 * gradient-hairline hero + card patterns from admin-integrations.tsx and
59 * admin-ops.tsx.
60 * ───────────────────────────────────────────────────────────────────── */
61const pkStyles = `
eed4684Claude62 .pk-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
c9188afClaude63
64 /* ─── Hero ─── */
65 .pk-hero {
66 position: relative;
67 margin-bottom: var(--space-5);
68 padding: var(--space-5) var(--space-6);
69 background: var(--bg-elevated);
70 border: 1px solid var(--border);
71 border-radius: 16px;
72 overflow: hidden;
73 }
74 .pk-hero::before {
75 content: '';
76 position: absolute;
77 top: 0; left: 0; right: 0;
78 height: 2px;
6fd5915Claude79 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
c9188afClaude80 opacity: 0.7;
81 pointer-events: none;
82 }
83 .pk-hero-orb {
84 position: absolute;
85 inset: -20% -10% auto auto;
86 width: 380px; height: 380px;
6fd5915Claude87 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
c9188afClaude88 filter: blur(80px);
89 opacity: 0.7;
90 pointer-events: none;
91 z-index: 0;
92 }
93 .pk-hero-inner { position: relative; z-index: 1; max-width: 720px; }
94 .pk-eyebrow {
95 font-size: 12px;
96 color: var(--text-muted);
97 margin-bottom: var(--space-2);
98 letter-spacing: 0.02em;
99 display: inline-flex;
100 align-items: center;
101 gap: 8px;
102 text-transform: uppercase;
103 font-family: var(--font-mono);
104 font-weight: 600;
105 }
106 .pk-eyebrow-pill {
107 display: inline-flex;
108 align-items: center;
109 justify-content: center;
110 width: 18px; height: 18px;
111 border-radius: 6px;
6fd5915Claude112 background: rgba(91,110,232,0.14);
113 color: #5b6ee8;
114 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
c9188afClaude115 }
116 .pk-crumb { color: var(--text-muted); text-decoration: none; }
117 .pk-crumb:hover { color: var(--text); text-decoration: none; }
118 .pk-title {
119 font-size: clamp(28px, 4vw, 40px);
120 font-family: var(--font-display);
121 font-weight: 800;
122 letter-spacing: -0.028em;
123 line-height: 1.05;
124 margin: 0 0 var(--space-2);
125 color: var(--text-strong);
126 }
127 .pk-title-grad {
6fd5915Claude128 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
c9188afClaude129 -webkit-background-clip: text;
130 background-clip: text;
131 -webkit-text-fill-color: transparent;
132 color: transparent;
133 }
134 .pk-sub {
135 font-size: 15px;
136 color: var(--text-muted);
137 margin: 0;
138 line-height: 1.55;
139 max-width: 620px;
140 }
141
142 /* ─── Banners ─── */
143 .pk-banner {
144 margin-bottom: var(--space-4);
145 padding: 10px 14px;
146 border-radius: 10px;
147 font-size: 13.5px;
148 border: 1px solid var(--border);
149 background: rgba(255,255,255,0.025);
150 color: var(--text);
151 display: flex;
152 align-items: center;
153 gap: 10px;
154 }
155 .pk-banner.is-ok {
156 border-color: rgba(52,211,153,0.40);
157 background: rgba(52,211,153,0.08);
158 color: #bbf7d0;
159 }
160 .pk-banner.is-error {
161 border-color: rgba(248,113,113,0.40);
162 background: rgba(248,113,113,0.08);
163 color: #fecaca;
164 }
165 .pk-banner-dot {
166 width: 8px; height: 8px;
167 border-radius: 9999px;
168 background: currentColor;
169 flex-shrink: 0;
170 }
171
172 /* ─── Status card ─── */
173 .pk-status {
174 position: relative;
175 margin-bottom: var(--space-5);
176 padding: var(--space-5);
177 background: var(--bg-elevated);
178 border: 1px solid var(--border);
179 border-radius: 14px;
180 display: flex;
181 align-items: center;
182 gap: var(--space-4);
183 flex-wrap: wrap;
184 overflow: hidden;
185 }
186 .pk-status.is-on {
187 border-color: rgba(52,211,153,0.32);
6fd5915Claude188 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
c9188afClaude189 }
190 .pk-status.is-off {
191 border-color: rgba(251,191,36,0.32);
6fd5915Claude192 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(22,27,34,0) 60%), var(--bg-elevated);
c9188afClaude193 }
194 .pk-status-mark {
195 flex-shrink: 0;
196 width: 56px; height: 56px;
197 border-radius: 14px;
198 display: flex;
199 align-items: center;
200 justify-content: center;
201 color: #fff;
202 }
203 .pk-status-mark.is-on {
204 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
205 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
206 }
207 .pk-status-mark.is-off {
208 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
209 color: #1a1206;
210 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
211 }
212 .pk-status-text { flex: 1; min-width: 220px; }
213 .pk-status-headline {
214 margin: 0 0 4px;
215 font-family: var(--font-display);
216 font-size: 19px;
217 font-weight: 700;
218 letter-spacing: -0.018em;
219 color: var(--text-strong);
220 }
221 .pk-status-desc {
222 margin: 0;
223 font-size: 13.5px;
224 color: var(--text-muted);
225 line-height: 1.5;
226 }
227 .pk-status-actions {
228 display: flex;
229 gap: 8px;
230 flex-wrap: wrap;
231 align-items: center;
232 }
233
234 /* ─── Section card ─── */
235 .pk-section {
236 margin-bottom: var(--space-5);
237 background: var(--bg-elevated);
238 border: 1px solid var(--border);
239 border-radius: 14px;
240 overflow: hidden;
241 }
242 .pk-section-head {
243 padding: var(--space-4) var(--space-5);
244 border-bottom: 1px solid var(--border);
245 display: flex;
246 align-items: flex-start;
247 justify-content: space-between;
248 gap: var(--space-3);
249 flex-wrap: wrap;
250 }
251 .pk-section-head-text { flex: 1; min-width: 240px; }
252 .pk-section-title {
253 margin: 0;
254 font-family: var(--font-display);
255 font-size: 17px;
256 font-weight: 700;
257 letter-spacing: -0.018em;
258 color: var(--text-strong);
259 display: flex;
260 align-items: center;
261 gap: 10px;
262 }
263 .pk-section-title-icon {
264 display: inline-flex;
265 align-items: center;
266 justify-content: center;
267 width: 26px; height: 26px;
268 border-radius: 8px;
6fd5915Claude269 background: rgba(91,110,232,0.12);
270 color: #5b6ee8;
271 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
c9188afClaude272 flex-shrink: 0;
273 }
274 .pk-section-sub {
275 margin: 6px 0 0 36px;
276 font-size: 12.5px;
277 color: var(--text-muted);
278 line-height: 1.5;
279 }
280 .pk-section-body { padding: var(--space-4) var(--space-5); }
281
282 /* ─── Add-passkey CTA row ─── */
283 .pk-cta-row {
284 display: flex;
285 align-items: center;
286 gap: var(--space-3);
287 flex-wrap: wrap;
288 }
289 .pk-cta-status {
290 color: var(--text-muted);
291 font-size: 13px;
292 min-height: 18px;
293 }
294 .pk-cta-status.is-error { color: #fecaca; }
6fd5915Claude295 .pk-cta-status.is-progress { color: #5b6ee8; }
c9188afClaude296
297 /* ─── Passkey list ─── */
298 .pk-list {
299 list-style: none;
300 padding: 0;
301 margin: 0;
302 display: flex;
303 flex-direction: column;
304 gap: 10px;
305 }
306 .pk-card {
307 display: flex;
308 align-items: center;
309 gap: var(--space-3);
310 padding: 14px 16px;
311 background: var(--bg);
312 border: 1px solid var(--border);
313 border-radius: 12px;
314 flex-wrap: wrap;
315 transition: border-color 120ms ease, background 120ms ease;
316 }
317 .pk-card:hover {
318 border-color: var(--border-strong);
319 background: rgba(255,255,255,0.02);
320 }
321 .pk-card-icon {
322 flex-shrink: 0;
323 width: 40px; height: 40px;
324 border-radius: 10px;
6fd5915Claude325 background: rgba(91,110,232,0.10);
326 color: #5b6ee8;
c9188afClaude327 display: flex;
328 align-items: center;
329 justify-content: center;
6fd5915Claude330 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
c9188afClaude331 }
332 .pk-card-body { flex: 1; min-width: 200px; }
333 .pk-card-name-row {
334 display: flex;
335 align-items: center;
336 gap: 8px;
337 flex-wrap: wrap;
338 }
339 .pk-card-name {
340 font-family: var(--font-display);
341 font-size: 14.5px;
342 font-weight: 700;
343 color: var(--text-strong);
344 letter-spacing: -0.008em;
345 }
346 .pk-card-tag {
347 display: inline-flex;
348 align-items: center;
349 gap: 4px;
350 padding: 2px 8px;
351 border-radius: 9999px;
6fd5915Claude352 background: rgba(91,110,232,0.10);
353 color: #5b6ee8;
c9188afClaude354 font-size: 10.5px;
355 font-weight: 600;
356 letter-spacing: 0.04em;
357 text-transform: uppercase;
6fd5915Claude358 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.25);
c9188afClaude359 }
360 .pk-card-meta {
361 margin-top: 4px;
362 display: flex;
363 gap: 12px;
364 flex-wrap: wrap;
365 font-size: 12px;
366 color: var(--text-muted);
367 }
368 .pk-card-meta .sep { color: var(--text-muted); opacity: 0.55; }
369 .pk-card-meta-label {
370 text-transform: uppercase;
371 letter-spacing: 0.10em;
372 font-size: 10.5px;
373 font-weight: 700;
374 color: var(--text-muted);
375 margin-right: 4px;
376 }
377 .pk-card-actions {
378 display: flex;
379 gap: 8px;
380 align-items: center;
381 flex-wrap: wrap;
382 }
383 .pk-card-rename {
384 display: flex;
385 gap: 6px;
386 align-items: center;
387 margin: 0;
388 }
389 .pk-rename-input {
390 width: 160px;
391 padding: 7px 10px;
392 font-size: 13px;
393 color: var(--text);
394 background: var(--bg-elevated);
395 border: 1px solid var(--border-strong);
396 border-radius: 8px;
397 outline: none;
398 font-family: var(--font-mono);
399 transition: border-color 120ms ease, box-shadow 120ms ease;
400 }
401 .pk-rename-input:focus {
6fd5915Claude402 border-color: var(--border-focus, rgba(91,110,232,0.55));
403 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
c9188afClaude404 }
405
406 /* ─── Empty state ─── */
407 .pk-empty {
408 padding: 32px 20px;
409 text-align: center;
410 color: var(--text-muted);
411 background: var(--bg);
412 border: 1px dashed var(--border-strong);
413 border-radius: 12px;
414 }
415 .pk-empty-icon {
416 margin: 0 auto 12px;
417 width: 48px; height: 48px;
418 border-radius: 12px;
6fd5915Claude419 background: rgba(91,110,232,0.10);
420 color: #5b6ee8;
c9188afClaude421 display: flex;
422 align-items: center;
423 justify-content: center;
6fd5915Claude424 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
c9188afClaude425 }
426 .pk-empty-title {
427 margin: 0 0 4px;
428 font-family: var(--font-display);
429 font-size: 15px;
430 font-weight: 700;
431 color: var(--text-strong);
432 }
433 .pk-empty-body {
434 margin: 0;
435 font-size: 13px;
436 color: var(--text-muted);
437 }
438
439 /* ─── Buttons ─── */
440 .pk-btn {
441 display: inline-flex;
442 align-items: center;
443 justify-content: center;
444 gap: 6px;
445 padding: 10px 18px;
446 border-radius: 10px;
447 font-size: 13.5px;
448 font-weight: 600;
449 text-decoration: none;
450 border: 1px solid transparent;
451 cursor: pointer;
452 font-family: inherit;
453 line-height: 1;
454 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
455 }
456 .pk-btn-sm { padding: 7px 12px; font-size: 12.5px; border-radius: 8px; }
457 .pk-btn-primary {
6fd5915Claude458 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
c9188afClaude459 color: #fff;
6fd5915Claude460 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
c9188afClaude461 }
462 .pk-btn-primary:hover {
463 transform: translateY(-1px);
6fd5915Claude464 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
c9188afClaude465 color: #fff;
466 text-decoration: none;
467 }
468 .pk-btn-ghost {
469 background: rgba(255,255,255,0.025);
470 color: var(--text);
471 border-color: var(--border-strong);
472 }
473 .pk-btn-ghost:hover {
6fd5915Claude474 background: rgba(91,110,232,0.06);
475 border-color: rgba(91,110,232,0.45);
c9188afClaude476 color: var(--text-strong);
477 text-decoration: none;
478 }
479 .pk-btn-danger {
480 background: transparent;
481 color: #fecaca;
482 border-color: rgba(248,113,113,0.40);
483 }
484 .pk-btn-danger:hover {
485 background: rgba(248,113,113,0.10);
486 border-color: rgba(248,113,113,0.65);
487 color: #fee2e2;
488 text-decoration: none;
489 }
490
491 /* ─── Warning banner (amber) ─── */
492 .pk-warning {
493 margin-bottom: var(--space-4);
494 padding: 12px 16px;
495 border-radius: 10px;
496 background: rgba(251,191,36,0.06);
497 border: 1px solid rgba(251,191,36,0.32);
498 color: #fde68a;
499 font-size: 13px;
500 line-height: 1.55;
501 display: flex;
502 gap: 12px;
503 align-items: flex-start;
504 }
505 .pk-warning-icon {
506 flex-shrink: 0;
507 width: 18px; height: 18px;
508 margin-top: 1px;
509 color: #fbbf24;
510 }
511 .pk-warning strong { color: #fef3c7; font-weight: 700; }
512
513 /* ─── WebAuthn explainer ─── */
514 .pk-explain-grid {
515 display: grid;
516 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
517 gap: var(--space-3);
518 margin-top: 4px;
519 }
520 .pk-explain {
521 padding: 14px 16px;
522 background: var(--bg);
523 border: 1px solid var(--border);
524 border-radius: 10px;
525 }
526 .pk-explain-icon {
527 width: 26px; height: 26px;
528 border-radius: 8px;
6fd5915Claude529 background: rgba(95,143,160,0.10);
c9188afClaude530 color: #67e8f9;
531 display: flex;
532 align-items: center;
533 justify-content: center;
6fd5915Claude534 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.25);
c9188afClaude535 margin-bottom: 8px;
536 }
537 .pk-explain-title {
538 margin: 0 0 4px;
539 font-family: var(--font-display);
540 font-size: 13.5px;
541 font-weight: 700;
542 color: var(--text-strong);
543 letter-spacing: -0.008em;
544 }
545 .pk-explain-body {
546 margin: 0;
547 font-size: 12.5px;
548 color: var(--text-muted);
549 line-height: 1.5;
550 }
551`;
552
553const KeyIcon = () => (
554 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
555 <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" />
556 </svg>
557);
558const CheckIconLg = () => (
559 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
560 <polyline points="20 6 9 17 4 12" />
561 </svg>
562);
563const WarnIconLg = () => (
564 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
565 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
566 <line x1="12" y1="9" x2="12" y2="13" />
567 <line x1="12" y1="17" x2="12.01" y2="17" />
568 </svg>
569);
570const WarnBannerIcon = () => (
571 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pk-warning-icon" aria-hidden="true">
572 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
573 <line x1="12" y1="9" x2="12" y2="13" />
574 <line x1="12" y1="17" x2="12.01" y2="17" />
575 </svg>
576);
577
578// Pick a platform label from the transports array stored alongside the key.
579// We don't know the OS, but transport hints tell us if it's hardware, USB,
580// internal, etc. Keeps the visual chip honest without inventing data.
581function describePasskey(transportsJson: string | null): {
582 label: string;
583 hint: string;
584} {
585 let arr: string[] = [];
586 try {
587 if (transportsJson) {
588 const parsed = JSON.parse(transportsJson);
589 if (Array.isArray(parsed)) arr = parsed.filter((s) => typeof s === "string");
590 }
591 } catch {
592 /* ignore */
593 }
594 if (arr.includes("internal") && arr.includes("hybrid")) {
595 return { label: "Phone or platform", hint: "internal + hybrid" };
596 }
597 if (arr.includes("internal")) {
598 return { label: "Platform authenticator", hint: "this device" };
599 }
600 if (arr.includes("hybrid")) {
601 return { label: "Cross-device", hint: "phone or QR pairing" };
602 }
603 if (arr.includes("usb")) {
604 return { label: "Security key", hint: "USB" };
605 }
606 if (arr.includes("nfc") || arr.includes("ble")) {
607 return { label: "Security key", hint: arr.join("·") };
608 }
609 return { label: "Passkey", hint: "registered" };
610}
611
612function formatDate(d: Date | string | null | undefined): string {
613 if (!d) return "—";
614 const date = d instanceof Date ? d : new Date(d);
615 if (isNaN(date.getTime())) return "—";
616 return date.toLocaleDateString(undefined, {
617 year: "numeric",
618 month: "short",
619 day: "numeric",
620 });
621}
622
2df1f8cClaude623passkeys.get("/settings/passkeys", async (c) => {
624 const user = c.get("user")!;
625 const error = c.req.query("error");
626 const success = c.req.query("success");
627
628 let keys: (typeof userPasskeys.$inferSelect)[] = [];
629 try {
630 keys = await db
631 .select()
632 .from(userPasskeys)
633 .where(eq(userPasskeys.userId, user.id));
634 } catch (err) {
635 console.error("[passkeys] list:", err);
636 }
637
c9188afClaude638 const hasKeys = keys.length > 0;
639
2df1f8cClaude640 return c.html(
641 <Layout title="Passkeys" user={user}>
9b776daccantynz-alt642 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
643 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
644 <SettingsNav active="passkeys" />
645 <div class="pk-wrap settings-content" style="max-width:none;margin:0;padding:0">
c9188afClaude646 <section class="pk-hero">
647 <div class="pk-hero-orb" aria-hidden="true" />
648 <div class="pk-hero-inner">
649 <div class="pk-eyebrow">
650 <span class="pk-eyebrow-pill" aria-hidden="true">
651 <KeyIcon />
652 </span>
653 <a href="/settings" class="pk-crumb">Settings</a>
654 <span>/</span>
655 <span>Passkeys</span>
656 </div>
657 <h2 class="pk-title">
658 <span class="pk-title-grad">Passkeys.</span>
659 </h2>
660 <p class="pk-sub">
661 Phishing-resistant sign-in built into your device. The private
662 key never leaves your authenticator — Touch ID, Face ID, Windows
663 Hello, or a hardware security key.
664 </p>
665 </div>
666 </section>
667
668 {error && (
669 <div class="pk-banner is-error" role="alert">
670 <span class="pk-banner-dot" aria-hidden="true" />
671 {decodeURIComponent(error)}
672 </div>
673 )}
2df1f8cClaude674 {success && (
c9188afClaude675 <div class="pk-banner is-ok" role="status">
676 <span class="pk-banner-dot" aria-hidden="true" />
677 {decodeURIComponent(success)}
678 </div>
2df1f8cClaude679 )}
c9188afClaude680
681 {hasKeys ? (
682 <section class="pk-status is-on" aria-label="Passkey status">
683 <div class="pk-status-mark is-on" aria-hidden="true">
684 <CheckIconLg />
2df1f8cClaude685 </div>
c9188afClaude686 <div class="pk-status-text">
687 <h3 class="pk-status-headline">
688 Passkeys are ON · {keys.length} registered
689 </h3>
690 <p class="pk-status-desc">
691 You can sign in with any registered passkey. Keep at least one
692 backup — losing your only passkey locks you out.
693 </p>
694 </div>
695 </section>
696 ) : (
697 <section class="pk-status is-off" aria-label="Passkey status">
698 <div class="pk-status-mark is-off" aria-hidden="true">
699 <WarnIconLg />
700 </div>
701 <div class="pk-status-text">
702 <h3 class="pk-status-headline">No passkeys yet</h3>
703 <p class="pk-status-desc">
704 Add one to enable phishing-resistant sign-in. We recommend
705 registering at least two — one on your daily device and one
706 stored as a backup.
707 </p>
708 </div>
709 </section>
710 )}
711
712 <section class="pk-section">
713 <header class="pk-section-head">
714 <div class="pk-section-head-text">
715 <h3 class="pk-section-title">
716 <span class="pk-section-title-icon" aria-hidden="true">
717 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
718 <line x1="12" y1="5" x2="12" y2="19" />
719 <line x1="5" y1="12" x2="19" y2="12" />
720 </svg>
721 </span>
722 Register a new passkey
723 </h3>
724 <p class="pk-section-sub">
725 Your browser will ask you to confirm with your biometric or
726 security key. The whole exchange takes one tap.
727 </p>
728 </div>
729 </header>
730 <div class="pk-section-body">
731 <div class="pk-cta-row">
732 <button type="button" id="pk-add-btn" class="pk-btn pk-btn-primary">
733 <KeyIcon />
734 Register a new passkey
735 </button>
736 <span id="pk-add-status" class="pk-cta-status" aria-live="polite" />
737 </div>
738
739 <div class="pk-explain-grid" style="margin-top: var(--space-4)">
740 <div class="pk-explain">
741 <div class="pk-explain-icon" aria-hidden="true">
742 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
743 <rect x="3" y="11" width="18" height="11" rx="2" />
744 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
745 </svg>
2df1f8cClaude746 </div>
c9188afClaude747 <h4 class="pk-explain-title">Private key stays local</h4>
748 <p class="pk-explain-body">
749 Generated and held inside your device's secure element.
750 Never leaves, never crosses the network.
751 </p>
752 </div>
753 <div class="pk-explain">
754 <div class="pk-explain-icon" aria-hidden="true">
755 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
756 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
757 </svg>
758 </div>
759 <h4 class="pk-explain-title">Phishing-resistant</h4>
760 <p class="pk-explain-body">
761 The credential is bound to <code style="font-family:var(--font-mono);font-size:11px;background:var(--bg-tertiary);padding:1px 4px;border-radius:3px">gluecron.com</code>{" "}
762 — a lookalike site simply can't reuse it.
763 </p>
764 </div>
765 <div class="pk-explain">
766 <div class="pk-explain-icon" aria-hidden="true">
767 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
768 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
769 </svg>
2df1f8cClaude770 </div>
c9188afClaude771 <h4 class="pk-explain-title">One tap to sign in</h4>
772 <p class="pk-explain-body">
773 Touch ID, Face ID, Windows Hello, or a hardware security
774 key. No password, no TOTP prompt.
775 </p>
2df1f8cClaude776 </div>
c9188afClaude777 </div>
778 </div>
779 </section>
780
781 <section class="pk-section">
782 <header class="pk-section-head">
783 <div class="pk-section-head-text">
784 <h3 class="pk-section-title">
785 <span class="pk-section-title-icon" aria-hidden="true">
786 <KeyIcon />
787 </span>
788 Registered passkeys
789 </h3>
790 <p class="pk-section-sub">
791 Rename a passkey to make it easier to identify, or revoke one
792 you no longer use.
793 </p>
794 </div>
795 </header>
796 <div class="pk-section-body">
797 {hasKeys ? (
798 <ul class="pk-list">
799 {keys.map((k) => {
800 const desc = describePasskey(k.transports ?? null);
801 return (
802 <li class="pk-card">
803 <div class="pk-card-icon" aria-hidden="true">
804 <KeyIcon />
805 </div>
806 <div class="pk-card-body">
807 <div class="pk-card-name-row">
808 <span class="pk-card-name">{k.name}</span>
809 <span class="pk-card-tag" title={desc.hint}>
810 {desc.label}
811 </span>
812 </div>
813 <div class="pk-card-meta">
814 <span>
815 <span class="pk-card-meta-label">Added</span>
816 {formatDate(k.createdAt)}
817 </span>
818 <span class="sep">·</span>
819 <span>
820 <span class="pk-card-meta-label">Last used</span>
821 {k.lastUsedAt ? formatDate(k.lastUsedAt) : "never"}
822 </span>
823 </div>
824 </div>
825 <div class="pk-card-actions">
826 <form
827 method="post"
828 action={`/settings/passkeys/${k.id}/rename`}
829 class="pk-card-rename"
830 >
831 <input
832 type="text"
833 name="name"
834 defaultValue={k.name}
835 maxLength={60}
836 aria-label="Passkey name"
837 class="pk-rename-input"
838 />
839 <button type="submit" class="pk-btn pk-btn-ghost pk-btn-sm">
840 Save
841 </button>
842 </form>
843 <form
844 method="post"
845 action={`/settings/passkeys/${k.id}/delete`}
846 onsubmit="return confirm('Remove this passkey? You can no longer sign in with it.')"
847 style="margin:0"
848 >
849 <button type="submit" class="pk-btn pk-btn-danger pk-btn-sm">
850 Revoke
851 </button>
852 </form>
853 </div>
854 </li>
855 );
856 })}
857 </ul>
858 ) : (
859 <div class="pk-empty">
860 <div class="pk-empty-icon" aria-hidden="true">
861 <KeyIcon />
862 </div>
863 <h4 class="pk-empty-title">No passkeys registered yet</h4>
864 <p class="pk-empty-body">
865 Hit the button above to register your first one.
866 </p>
867 </div>
868 )}
869
870 {hasKeys && (
871 <div class="pk-warning" role="alert" style="margin-top: var(--space-4); margin-bottom: 0">
872 <WarnBannerIcon />
873 <div>
874 <strong>Revoking a passkey is immediate.</strong> Any device
875 signed in with it stays signed in until the session expires,
876 but it can no longer be used for new logins. Make sure you
877 have at least one other way to sign in before removing your
878 last passkey.
879 </div>
880 </div>
881 )}
882 </div>
883 </section>
2df1f8cClaude884
885 <script
886 dangerouslySetInnerHTML={{
887 __html: /* js */ `
888 (function () {
889 const btn = document.getElementById('pk-add-btn');
890 const status = document.getElementById('pk-add-status');
891 if (!btn) return;
c9188afClaude892 function setStatus(text, kind) {
893 status.textContent = text;
894 status.classList.remove('is-error', 'is-progress');
895 if (kind === 'error') status.classList.add('is-error');
896 if (kind === 'progress') status.classList.add('is-progress');
897 }
2df1f8cClaude898 function b64uToBuf(s) {
899 s = s.replace(/-/g,'+').replace(/_/g,'/');
900 while (s.length % 4) s += '=';
901 const bin = atob(s);
902 const buf = new Uint8Array(bin.length);
903 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
904 return buf.buffer;
905 }
906 function bufToB64u(buf) {
907 const bytes = new Uint8Array(buf);
908 let bin = '';
909 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
910 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
911 }
912 btn.addEventListener('click', async function () {
913 if (!window.PublicKeyCredential) {
c9188afClaude914 setStatus('Passkeys not supported in this browser.', 'error');
2df1f8cClaude915 return;
916 }
c9188afClaude917 setStatus('Preparing…', 'progress');
2df1f8cClaude918 try {
919 const optsRes = await fetch('/api/passkeys/register/options', {
920 method: 'POST',
921 headers: { 'content-type': 'application/json' },
922 body: '{}'
923 });
924 if (!optsRes.ok) throw new Error('options failed');
925 const { options, sessionKey } = await optsRes.json();
926 options.challenge = b64uToBuf(options.challenge);
927 options.user.id = b64uToBuf(options.user.id);
928 if (options.excludeCredentials) {
929 options.excludeCredentials = options.excludeCredentials.map(function (c) {
930 return Object.assign({}, c, { id: b64uToBuf(c.id) });
931 });
932 }
c9188afClaude933 setStatus('Touch your authenticator…', 'progress');
2df1f8cClaude934 const cred = await navigator.credentials.create({ publicKey: options });
935 const resp = {
936 id: cred.id,
937 rawId: bufToB64u(cred.rawId),
938 type: cred.type,
939 response: {
940 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
941 attestationObject: bufToB64u(cred.response.attestationObject),
942 transports: cred.response.getTransports ? cred.response.getTransports() : []
943 },
944 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
945 };
946 const verifyRes = await fetch('/api/passkeys/register/verify', {
947 method: 'POST',
948 headers: { 'content-type': 'application/json' },
949 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
950 });
951 if (!verifyRes.ok) {
952 const j = await verifyRes.json().catch(() => ({}));
953 throw new Error(j.error || 'verify failed');
954 }
c9188afClaude955 setStatus('Saved. Reloading…', 'progress');
2df1f8cClaude956 window.location.reload();
957 } catch (e) {
c9188afClaude958 setStatus('Error: ' + (e && e.message ? e.message : e), 'error');
2df1f8cClaude959 }
960 });
961 })();
962 `,
963 }}
964 />
965 </div>
9b776daccantynz-alt966 </div>
c9188afClaude967 <style dangerouslySetInnerHTML={{ __html: pkStyles }} />
2df1f8cClaude968 </Layout>
969 );
970});
971
972passkeys.post("/settings/passkeys/:id/delete", async (c) => {
973 const user = c.get("user")!;
974 const id = c.req.param("id");
975 try {
976 const [row] = await db
977 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
978 .from(userPasskeys)
979 .where(eq(userPasskeys.id, id))
980 .limit(1);
981 if (!row || row.userId !== user.id) {
982 return c.redirect("/settings/passkeys?error=Not+found");
983 }
984 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
985 await audit({
986 userId: user.id,
987 action: "passkey.delete",
988 targetType: "passkey",
989 targetId: id,
990 });
991 return c.redirect("/settings/passkeys?success=Passkey+removed");
992 } catch (err) {
993 console.error("[passkeys] delete:", err);
994 return c.redirect("/settings/passkeys?error=Service+unavailable");
995 }
996});
997
998passkeys.post("/settings/passkeys/:id/rename", async (c) => {
999 const user = c.get("user")!;
1000 const id = c.req.param("id");
1001 const body = await c.req.parseBody();
1002 const name = String(body.name || "").trim().slice(0, 60);
1003 if (!name) {
1004 return c.redirect("/settings/passkeys?error=Name+required");
1005 }
1006 try {
1007 const [row] = await db
1008 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
1009 .from(userPasskeys)
1010 .where(eq(userPasskeys.id, id))
1011 .limit(1);
1012 if (!row || row.userId !== user.id) {
1013 return c.redirect("/settings/passkeys?error=Not+found");
1014 }
1015 await db
1016 .update(userPasskeys)
1017 .set({ name })
1018 .where(eq(userPasskeys.id, id));
1019 return c.redirect("/settings/passkeys?success=Renamed");
1020 } catch (err) {
1021 console.error("[passkeys] rename:", err);
1022 return c.redirect("/settings/passkeys?error=Service+unavailable");
1023 }
1024});
1025
1026// --- Registration JSON endpoints (authed) -----------------------------------
1027
1028passkeys.post("/api/passkeys/register/options", async (c) => {
1029 const user = c.get("user")!;
1030 try {
1031 const existing = await db
1032 .select({ credentialId: userPasskeys.credentialId })
1033 .from(userPasskeys)
1034 .where(eq(userPasskeys.userId, user.id));
1035 const { options, sessionKey } = await startRegistration({
1036 userId: user.id,
1037 userName: user.username,
1038 userDisplayName: user.displayName || user.username,
1039 excludeCredentialIds: existing.map((e) => e.credentialId),
1040 });
1041 return c.json({ options, sessionKey });
1042 } catch (err) {
1043 console.error("[passkeys] register/options:", err);
1044 return c.json({ error: "Service unavailable" }, 503);
1045 }
1046});
1047
1048passkeys.post("/api/passkeys/register/verify", async (c) => {
1049 const user = c.get("user")!;
1050 let body: { sessionKey: string; response: any };
1051 try {
1052 body = await c.req.json();
1053 } catch {
1054 return c.json({ error: "Invalid JSON" }, 400);
1055 }
1056 if (!body.sessionKey || !body.response) {
1057 return c.json({ error: "sessionKey and response required" }, 400);
1058 }
1059 const result = await finishRegistration({
1060 sessionKey: body.sessionKey,
1061 response: body.response,
1062 });
1063 if (!result.ok) return c.json({ error: result.error }, 400);
1064
1065 try {
1066 const transports = Array.isArray(body.response?.response?.transports)
1067 ? JSON.stringify(body.response.response.transports)
1068 : null;
1069 await db.insert(userPasskeys).values({
1070 userId: user.id,
1071 credentialId: result.credentialId,
1072 publicKey: result.publicKey,
1073 counter: result.counter,
1074 transports,
1075 });
1076 await audit({
1077 userId: user.id,
1078 action: "passkey.create",
1079 targetType: "passkey",
1080 metadata: { credentialId: result.credentialId },
1081 });
1082 return c.json({ ok: true });
1083 } catch (err: any) {
1084 if (String(err?.message || err).includes("user_passkeys")) {
1085 return c.json({ error: "Credential already registered" }, 409);
1086 }
1087 console.error("[passkeys] register save:", err);
1088 return c.json({ error: "Service unavailable" }, 503);
1089 }
1090});
1091
1092// --- Authentication JSON endpoints (unauthed) -------------------------------
1093
1094passkeys.post("/api/passkeys/auth/options", async (c) => {
1095 let body: { username?: string };
1096 try {
1097 body = await c.req.json();
1098 } catch {
1099 body = {};
1100 }
1101 try {
1102 let userId: string | undefined;
1103 let allowCreds: string[] = [];
1104 if (body.username) {
1105 const [u] = await db
1106 .select({ id: users.id })
1107 .from(users)
1108 .where(eq(users.username, body.username.trim().toLowerCase()))
1109 .limit(1);
1110 if (u) {
1111 userId = u.id;
1112 const rows = await db
1113 .select({ credentialId: userPasskeys.credentialId })
1114 .from(userPasskeys)
1115 .where(eq(userPasskeys.userId, u.id));
1116 allowCreds = rows.map((r) => r.credentialId);
1117 }
1118 }
1119 const { options, sessionKey } = await startAuthentication({
1120 userId,
1121 allowCredentialIds: allowCreds,
1122 });
1123 return c.json({ options, sessionKey });
1124 } catch (err) {
1125 console.error("[passkeys] auth/options:", err);
1126 return c.json({ error: "Service unavailable" }, 503);
1127 }
1128});
1129
1130passkeys.post("/api/passkeys/auth/verify", async (c) => {
1131 let body: { sessionKey: string; response: any };
1132 try {
1133 body = await c.req.json();
1134 } catch {
1135 return c.json({ error: "Invalid JSON" }, 400);
1136 }
1137 if (!body.sessionKey || !body.response) {
1138 return c.json({ error: "sessionKey and response required" }, 400);
1139 }
1140 const result = await finishAuthentication({
1141 sessionKey: body.sessionKey,
1142 response: body.response,
1143 });
1144 if (!result.ok) return c.json({ error: result.error }, 400);
1145
1146 try {
1147 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
1148 const token = generateSessionToken();
1149 await db.insert(sessions).values({
1150 userId: result.userId,
1151 token,
1152 expiresAt: sessionExpiry(),
1153 requires2fa: false,
1154 });
1155 setCookie(c, "session", token, sessionCookieOptions());
1156 await audit({
1157 userId: result.userId,
1158 action: "passkey.login",
1159 targetType: "passkey",
1160 metadata: { credentialId: result.credentialId },
1161 });
1162 return c.json({ ok: true });
1163 } catch (err) {
1164 console.error("[passkeys] auth/verify:", err);
1165 return c.json({ error: "Service unavailable" }, 503);
1166 }
1167});
1168
1169export default passkeys;