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

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.tsxBlame1166 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";
ae2a071ccanty labs33import { SettingsSubnav } from "./settings";
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}>
ae2a071ccanty labs642 <SettingsSubnav active="passkeys" />
c9188afClaude643 <div class="pk-wrap">
644 <section class="pk-hero">
645 <div class="pk-hero-orb" aria-hidden="true" />
646 <div class="pk-hero-inner">
647 <div class="pk-eyebrow">
648 <span class="pk-eyebrow-pill" aria-hidden="true">
649 <KeyIcon />
650 </span>
651 <a href="/settings" class="pk-crumb">Settings</a>
652 <span>/</span>
653 <span>Passkeys</span>
654 </div>
655 <h2 class="pk-title">
656 <span class="pk-title-grad">Passkeys.</span>
657 </h2>
658 <p class="pk-sub">
659 Phishing-resistant sign-in built into your device. The private
660 key never leaves your authenticator — Touch ID, Face ID, Windows
661 Hello, or a hardware security key.
662 </p>
663 </div>
664 </section>
665
666 {error && (
667 <div class="pk-banner is-error" role="alert">
668 <span class="pk-banner-dot" aria-hidden="true" />
669 {decodeURIComponent(error)}
670 </div>
671 )}
2df1f8cClaude672 {success && (
c9188afClaude673 <div class="pk-banner is-ok" role="status">
674 <span class="pk-banner-dot" aria-hidden="true" />
675 {decodeURIComponent(success)}
676 </div>
2df1f8cClaude677 )}
c9188afClaude678
679 {hasKeys ? (
680 <section class="pk-status is-on" aria-label="Passkey status">
681 <div class="pk-status-mark is-on" aria-hidden="true">
682 <CheckIconLg />
2df1f8cClaude683 </div>
c9188afClaude684 <div class="pk-status-text">
685 <h3 class="pk-status-headline">
686 Passkeys are ON · {keys.length} registered
687 </h3>
688 <p class="pk-status-desc">
689 You can sign in with any registered passkey. Keep at least one
690 backup — losing your only passkey locks you out.
691 </p>
692 </div>
693 </section>
694 ) : (
695 <section class="pk-status is-off" aria-label="Passkey status">
696 <div class="pk-status-mark is-off" aria-hidden="true">
697 <WarnIconLg />
698 </div>
699 <div class="pk-status-text">
700 <h3 class="pk-status-headline">No passkeys yet</h3>
701 <p class="pk-status-desc">
702 Add one to enable phishing-resistant sign-in. We recommend
703 registering at least two — one on your daily device and one
704 stored as a backup.
705 </p>
706 </div>
707 </section>
708 )}
709
710 <section class="pk-section">
711 <header class="pk-section-head">
712 <div class="pk-section-head-text">
713 <h3 class="pk-section-title">
714 <span class="pk-section-title-icon" aria-hidden="true">
715 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
716 <line x1="12" y1="5" x2="12" y2="19" />
717 <line x1="5" y1="12" x2="19" y2="12" />
718 </svg>
719 </span>
720 Register a new passkey
721 </h3>
722 <p class="pk-section-sub">
723 Your browser will ask you to confirm with your biometric or
724 security key. The whole exchange takes one tap.
725 </p>
726 </div>
727 </header>
728 <div class="pk-section-body">
729 <div class="pk-cta-row">
730 <button type="button" id="pk-add-btn" class="pk-btn pk-btn-primary">
731 <KeyIcon />
732 Register a new passkey
733 </button>
734 <span id="pk-add-status" class="pk-cta-status" aria-live="polite" />
735 </div>
736
737 <div class="pk-explain-grid" style="margin-top: var(--space-4)">
738 <div class="pk-explain">
739 <div class="pk-explain-icon" aria-hidden="true">
740 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
741 <rect x="3" y="11" width="18" height="11" rx="2" />
742 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
743 </svg>
2df1f8cClaude744 </div>
c9188afClaude745 <h4 class="pk-explain-title">Private key stays local</h4>
746 <p class="pk-explain-body">
747 Generated and held inside your device's secure element.
748 Never leaves, never crosses the network.
749 </p>
750 </div>
751 <div class="pk-explain">
752 <div class="pk-explain-icon" aria-hidden="true">
753 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
754 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
755 </svg>
756 </div>
757 <h4 class="pk-explain-title">Phishing-resistant</h4>
758 <p class="pk-explain-body">
759 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>{" "}
760 — a lookalike site simply can't reuse it.
761 </p>
762 </div>
763 <div class="pk-explain">
764 <div class="pk-explain-icon" aria-hidden="true">
765 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
766 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
767 </svg>
2df1f8cClaude768 </div>
c9188afClaude769 <h4 class="pk-explain-title">One tap to sign in</h4>
770 <p class="pk-explain-body">
771 Touch ID, Face ID, Windows Hello, or a hardware security
772 key. No password, no TOTP prompt.
773 </p>
2df1f8cClaude774 </div>
c9188afClaude775 </div>
776 </div>
777 </section>
778
779 <section class="pk-section">
780 <header class="pk-section-head">
781 <div class="pk-section-head-text">
782 <h3 class="pk-section-title">
783 <span class="pk-section-title-icon" aria-hidden="true">
784 <KeyIcon />
785 </span>
786 Registered passkeys
787 </h3>
788 <p class="pk-section-sub">
789 Rename a passkey to make it easier to identify, or revoke one
790 you no longer use.
791 </p>
792 </div>
793 </header>
794 <div class="pk-section-body">
795 {hasKeys ? (
796 <ul class="pk-list">
797 {keys.map((k) => {
798 const desc = describePasskey(k.transports ?? null);
799 return (
800 <li class="pk-card">
801 <div class="pk-card-icon" aria-hidden="true">
802 <KeyIcon />
803 </div>
804 <div class="pk-card-body">
805 <div class="pk-card-name-row">
806 <span class="pk-card-name">{k.name}</span>
807 <span class="pk-card-tag" title={desc.hint}>
808 {desc.label}
809 </span>
810 </div>
811 <div class="pk-card-meta">
812 <span>
813 <span class="pk-card-meta-label">Added</span>
814 {formatDate(k.createdAt)}
815 </span>
816 <span class="sep">·</span>
817 <span>
818 <span class="pk-card-meta-label">Last used</span>
819 {k.lastUsedAt ? formatDate(k.lastUsedAt) : "never"}
820 </span>
821 </div>
822 </div>
823 <div class="pk-card-actions">
824 <form
825 method="post"
826 action={`/settings/passkeys/${k.id}/rename`}
827 class="pk-card-rename"
828 >
829 <input
830 type="text"
831 name="name"
832 defaultValue={k.name}
833 maxLength={60}
834 aria-label="Passkey name"
835 class="pk-rename-input"
836 />
837 <button type="submit" class="pk-btn pk-btn-ghost pk-btn-sm">
838 Save
839 </button>
840 </form>
841 <form
842 method="post"
843 action={`/settings/passkeys/${k.id}/delete`}
844 onsubmit="return confirm('Remove this passkey? You can no longer sign in with it.')"
845 style="margin:0"
846 >
847 <button type="submit" class="pk-btn pk-btn-danger pk-btn-sm">
848 Revoke
849 </button>
850 </form>
851 </div>
852 </li>
853 );
854 })}
855 </ul>
856 ) : (
857 <div class="pk-empty">
858 <div class="pk-empty-icon" aria-hidden="true">
859 <KeyIcon />
860 </div>
861 <h4 class="pk-empty-title">No passkeys registered yet</h4>
862 <p class="pk-empty-body">
863 Hit the button above to register your first one.
864 </p>
865 </div>
866 )}
867
868 {hasKeys && (
869 <div class="pk-warning" role="alert" style="margin-top: var(--space-4); margin-bottom: 0">
870 <WarnBannerIcon />
871 <div>
872 <strong>Revoking a passkey is immediate.</strong> Any device
873 signed in with it stays signed in until the session expires,
874 but it can no longer be used for new logins. Make sure you
875 have at least one other way to sign in before removing your
876 last passkey.
877 </div>
878 </div>
879 )}
880 </div>
881 </section>
2df1f8cClaude882
883 <script
884 dangerouslySetInnerHTML={{
885 __html: /* js */ `
886 (function () {
887 const btn = document.getElementById('pk-add-btn');
888 const status = document.getElementById('pk-add-status');
889 if (!btn) return;
c9188afClaude890 function setStatus(text, kind) {
891 status.textContent = text;
892 status.classList.remove('is-error', 'is-progress');
893 if (kind === 'error') status.classList.add('is-error');
894 if (kind === 'progress') status.classList.add('is-progress');
895 }
2df1f8cClaude896 function b64uToBuf(s) {
897 s = s.replace(/-/g,'+').replace(/_/g,'/');
898 while (s.length % 4) s += '=';
899 const bin = atob(s);
900 const buf = new Uint8Array(bin.length);
901 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
902 return buf.buffer;
903 }
904 function bufToB64u(buf) {
905 const bytes = new Uint8Array(buf);
906 let bin = '';
907 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
908 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
909 }
910 btn.addEventListener('click', async function () {
911 if (!window.PublicKeyCredential) {
c9188afClaude912 setStatus('Passkeys not supported in this browser.', 'error');
2df1f8cClaude913 return;
914 }
c9188afClaude915 setStatus('Preparing…', 'progress');
2df1f8cClaude916 try {
917 const optsRes = await fetch('/api/passkeys/register/options', {
918 method: 'POST',
919 headers: { 'content-type': 'application/json' },
920 body: '{}'
921 });
922 if (!optsRes.ok) throw new Error('options failed');
923 const { options, sessionKey } = await optsRes.json();
924 options.challenge = b64uToBuf(options.challenge);
925 options.user.id = b64uToBuf(options.user.id);
926 if (options.excludeCredentials) {
927 options.excludeCredentials = options.excludeCredentials.map(function (c) {
928 return Object.assign({}, c, { id: b64uToBuf(c.id) });
929 });
930 }
c9188afClaude931 setStatus('Touch your authenticator…', 'progress');
2df1f8cClaude932 const cred = await navigator.credentials.create({ publicKey: options });
933 const resp = {
934 id: cred.id,
935 rawId: bufToB64u(cred.rawId),
936 type: cred.type,
937 response: {
938 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
939 attestationObject: bufToB64u(cred.response.attestationObject),
940 transports: cred.response.getTransports ? cred.response.getTransports() : []
941 },
942 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
943 };
944 const verifyRes = await fetch('/api/passkeys/register/verify', {
945 method: 'POST',
946 headers: { 'content-type': 'application/json' },
947 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
948 });
949 if (!verifyRes.ok) {
950 const j = await verifyRes.json().catch(() => ({}));
951 throw new Error(j.error || 'verify failed');
952 }
c9188afClaude953 setStatus('Saved. Reloading…', 'progress');
2df1f8cClaude954 window.location.reload();
955 } catch (e) {
c9188afClaude956 setStatus('Error: ' + (e && e.message ? e.message : e), 'error');
2df1f8cClaude957 }
958 });
959 })();
960 `,
961 }}
962 />
963 </div>
c9188afClaude964 <style dangerouslySetInnerHTML={{ __html: pkStyles }} />
2df1f8cClaude965 </Layout>
966 );
967});
968
969passkeys.post("/settings/passkeys/:id/delete", async (c) => {
970 const user = c.get("user")!;
971 const id = c.req.param("id");
972 try {
973 const [row] = await db
974 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
975 .from(userPasskeys)
976 .where(eq(userPasskeys.id, id))
977 .limit(1);
978 if (!row || row.userId !== user.id) {
979 return c.redirect("/settings/passkeys?error=Not+found");
980 }
981 await db.delete(userPasskeys).where(eq(userPasskeys.id, id));
982 await audit({
983 userId: user.id,
984 action: "passkey.delete",
985 targetType: "passkey",
986 targetId: id,
987 });
988 return c.redirect("/settings/passkeys?success=Passkey+removed");
989 } catch (err) {
990 console.error("[passkeys] delete:", err);
991 return c.redirect("/settings/passkeys?error=Service+unavailable");
992 }
993});
994
995passkeys.post("/settings/passkeys/:id/rename", async (c) => {
996 const user = c.get("user")!;
997 const id = c.req.param("id");
998 const body = await c.req.parseBody();
999 const name = String(body.name || "").trim().slice(0, 60);
1000 if (!name) {
1001 return c.redirect("/settings/passkeys?error=Name+required");
1002 }
1003 try {
1004 const [row] = await db
1005 .select({ id: userPasskeys.id, userId: userPasskeys.userId })
1006 .from(userPasskeys)
1007 .where(eq(userPasskeys.id, id))
1008 .limit(1);
1009 if (!row || row.userId !== user.id) {
1010 return c.redirect("/settings/passkeys?error=Not+found");
1011 }
1012 await db
1013 .update(userPasskeys)
1014 .set({ name })
1015 .where(eq(userPasskeys.id, id));
1016 return c.redirect("/settings/passkeys?success=Renamed");
1017 } catch (err) {
1018 console.error("[passkeys] rename:", err);
1019 return c.redirect("/settings/passkeys?error=Service+unavailable");
1020 }
1021});
1022
1023// --- Registration JSON endpoints (authed) -----------------------------------
1024
1025passkeys.post("/api/passkeys/register/options", async (c) => {
1026 const user = c.get("user")!;
1027 try {
1028 const existing = await db
1029 .select({ credentialId: userPasskeys.credentialId })
1030 .from(userPasskeys)
1031 .where(eq(userPasskeys.userId, user.id));
1032 const { options, sessionKey } = await startRegistration({
1033 userId: user.id,
1034 userName: user.username,
1035 userDisplayName: user.displayName || user.username,
1036 excludeCredentialIds: existing.map((e) => e.credentialId),
1037 });
1038 return c.json({ options, sessionKey });
1039 } catch (err) {
1040 console.error("[passkeys] register/options:", err);
1041 return c.json({ error: "Service unavailable" }, 503);
1042 }
1043});
1044
1045passkeys.post("/api/passkeys/register/verify", async (c) => {
1046 const user = c.get("user")!;
1047 let body: { sessionKey: string; response: any };
1048 try {
1049 body = await c.req.json();
1050 } catch {
1051 return c.json({ error: "Invalid JSON" }, 400);
1052 }
1053 if (!body.sessionKey || !body.response) {
1054 return c.json({ error: "sessionKey and response required" }, 400);
1055 }
1056 const result = await finishRegistration({
1057 sessionKey: body.sessionKey,
1058 response: body.response,
1059 });
1060 if (!result.ok) return c.json({ error: result.error }, 400);
1061
1062 try {
1063 const transports = Array.isArray(body.response?.response?.transports)
1064 ? JSON.stringify(body.response.response.transports)
1065 : null;
1066 await db.insert(userPasskeys).values({
1067 userId: user.id,
1068 credentialId: result.credentialId,
1069 publicKey: result.publicKey,
1070 counter: result.counter,
1071 transports,
1072 });
1073 await audit({
1074 userId: user.id,
1075 action: "passkey.create",
1076 targetType: "passkey",
1077 metadata: { credentialId: result.credentialId },
1078 });
1079 return c.json({ ok: true });
1080 } catch (err: any) {
1081 if (String(err?.message || err).includes("user_passkeys")) {
1082 return c.json({ error: "Credential already registered" }, 409);
1083 }
1084 console.error("[passkeys] register save:", err);
1085 return c.json({ error: "Service unavailable" }, 503);
1086 }
1087});
1088
1089// --- Authentication JSON endpoints (unauthed) -------------------------------
1090
1091passkeys.post("/api/passkeys/auth/options", async (c) => {
1092 let body: { username?: string };
1093 try {
1094 body = await c.req.json();
1095 } catch {
1096 body = {};
1097 }
1098 try {
1099 let userId: string | undefined;
1100 let allowCreds: string[] = [];
1101 if (body.username) {
1102 const [u] = await db
1103 .select({ id: users.id })
1104 .from(users)
1105 .where(eq(users.username, body.username.trim().toLowerCase()))
1106 .limit(1);
1107 if (u) {
1108 userId = u.id;
1109 const rows = await db
1110 .select({ credentialId: userPasskeys.credentialId })
1111 .from(userPasskeys)
1112 .where(eq(userPasskeys.userId, u.id));
1113 allowCreds = rows.map((r) => r.credentialId);
1114 }
1115 }
1116 const { options, sessionKey } = await startAuthentication({
1117 userId,
1118 allowCredentialIds: allowCreds,
1119 });
1120 return c.json({ options, sessionKey });
1121 } catch (err) {
1122 console.error("[passkeys] auth/options:", err);
1123 return c.json({ error: "Service unavailable" }, 503);
1124 }
1125});
1126
1127passkeys.post("/api/passkeys/auth/verify", async (c) => {
1128 let body: { sessionKey: string; response: any };
1129 try {
1130 body = await c.req.json();
1131 } catch {
1132 return c.json({ error: "Invalid JSON" }, 400);
1133 }
1134 if (!body.sessionKey || !body.response) {
1135 return c.json({ error: "sessionKey and response required" }, 400);
1136 }
1137 const result = await finishAuthentication({
1138 sessionKey: body.sessionKey,
1139 response: body.response,
1140 });
1141 if (!result.ok) return c.json({ error: result.error }, 400);
1142
1143 try {
1144 // Passkey is phishing-resistant + user-verifying; skip TOTP prompt.
1145 const token = generateSessionToken();
1146 await db.insert(sessions).values({
1147 userId: result.userId,
1148 token,
1149 expiresAt: sessionExpiry(),
1150 requires2fa: false,
1151 });
1152 setCookie(c, "session", token, sessionCookieOptions());
1153 await audit({
1154 userId: result.userId,
1155 action: "passkey.login",
1156 targetType: "passkey",
1157 metadata: { credentialId: result.credentialId },
1158 });
1159 return c.json({ ok: true });
1160 } catch (err) {
1161 console.error("[passkeys] auth/verify:", err);
1162 return c.json({ error: "Service unavailable" }, 503);
1163 }
1164});
1165
1166export default passkeys;