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