Commitc9188afunknown_key
polish(settings): 2026 status cards + step flows for 2FA + passkeys
3 files changed+2232−346c9188afbcf1829562a46cee83aa8610cf8e48788
3 changed files+2232−346
Modifiedsrc/routes/admin-status.tsx+717−117View fileUnifiedSplit
@@ -7,7 +7,22 @@
77 * Both gated behind `isSiteAdmin`. The public `/status` page (see
88 * `src/routes/status.tsx`) stays open to everyone; this is the detailed
99 * 14-row health table for the owner.
10 *
11 * Visual recipe (2026 polish — mirrors admin-integrations / admin-ops /
12 * admin-deploys-page):
13 * - Gradient hairline strip across the top of the hero (purple→cyan, 2px)
14 * - Soft radial orb in the corner of the hero
15 * - Eyebrow with pill icon + actor name
16 * - Display headline with gradient-text on the verb ("Live now." /
17 * "Falling.")
18 * - Live-update banner with pulsing green dot + SSE event counter
19 * - Real-time activity feed: rows with tabular-nums timestamps, mono
20 * action verb with subtle accent, target check name
21 *
22 * Scoped CSS — every class prefixed `.status-` so this surface can't
23 * bleed into other admin pages.
1024 */
25/* eslint-disable @typescript-eslint/no-explicit-any */
1126
1227import { Hono } from "hono";
1328import { Layout } from "../views/layout";
@@ -33,10 +48,11 @@ async function gate(c: any): Promise<{ user: any } | Response> {
3348 if (!(await isSiteAdmin(user.id))) {
3449 return c.html(
3550 <Layout title="Forbidden" user={user}>
36 <div class="empty-state">
51 <div class="status-403">
3752 <h2>403 — Not a site admin</h2>
3853 <p>You don't have permission to view this page.</p>
3954 </div>
55 <style dangerouslySetInnerHTML={{ __html: STATUS_CSS }} />
4056 </Layout>,
4157 403
4258 );
@@ -44,13 +60,6 @@ async function gate(c: any): Promise<{ user: any } | Response> {
4460 return { user };
4561}
4662
47function statusDot(status: SyntheticCheckResult["status"] | undefined): string {
48 if (status === "green") return "\u{1F7E2}"; // green circle
49 if (status === "red") return "\u{1F534}"; // red circle
50 if (status === "yellow") return "\u{1F7E1}"; // yellow circle
51 return "⚪"; // white circle — never run
52}
53
5463function fmtAgo(checkedAt: Date | undefined): string {
5564 if (!checkedAt) return "never";
5665 const diffMs = Date.now() - checkedAt.getTime();
@@ -66,6 +75,36 @@ function fmtAgo(checkedAt: Date | undefined): string {
6675 return `${d}d ago`;
6776}
6877
78function dotClass(status: SyntheticCheckResult["status"] | undefined): string {
79 if (status === "green") return "is-green";
80 if (status === "red") return "is-red";
81 if (status === "yellow") return "is-yellow";
82 return "is-idle";
83}
84
85function IconArrowLeft() {
86 return (
87 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
88 <line x1="19" y1="12" x2="5" y2="12" />
89 <polyline points="12 19 5 12 12 5" />
90 </svg>
91 );
92}
93function IconPulse() {
94 return (
95 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
96 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
97 </svg>
98 );
99}
100function IconPlay() {
101 return (
102 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
103 <polygon points="5 3 19 12 5 21 5 3" />
104 </svg>
105 );
106}
107
69108adminStatus.get("/admin/status", async (c) => {
70109 const g = await gate(c);
71110 if (g instanceof Response) return g;
@@ -77,6 +116,9 @@ adminStatus.get("/admin/status", async (c) => {
77116 const allGreen = SYNTHETIC_CHECKS.every(
78117 (spec) => latest[spec.name]?.status === "green"
79118 );
119 const redCount = SYNTHETIC_CHECKS.filter(
120 (spec) => latest[spec.name]?.status === "red"
121 ).length;
80122
81123 // Find the most-recent checkedAt across all rows so we can render the
82124 // "last run Xs ago" badge.
@@ -87,127 +129,229 @@ adminStatus.get("/admin/status", async (c) => {
87129 if (!lastRunAt || row.checkedAt > lastRunAt) lastRunAt = row.checkedAt;
88130 }
89131
132 // Headline state — green/falling/idle for the gradient-text variant.
133 const headlineState: "green" | "falling" | "idle" = !lastRunAt
134 ? "idle"
135 : allGreen
136 ? "green"
137 : "falling";
138 const headline =
139 headlineState === "idle"
140 ? "Idle."
141 : headlineState === "green"
142 ? "Live now."
143 : "Falling.";
144
90145 return c.html(
91146 <Layout title="Synthetic monitor — admin" user={user}>
92 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
93 <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px">
94 <span
95 style={`display: inline-block; width: 14px; height: 14px; border-radius: 50%; background: ${allGreen ? "var(--green, #2da44e)" : "var(--red, #cf222e)"}`}
96 />
97 <h1 style="margin: 0; font-size: 26px">
98 {allGreen ? "All checks green" : "One or more checks failing"}
99 </h1>
100 </div>
101 <p style="color: var(--text-muted); margin-bottom: 24px">
102 Synthetic monitor — runs every autopilot tick. Last run{" "}
103 <span data-last-run-at>{fmtAgo(lastRunAt ?? undefined)}</span>.
104 </p>
105
106 <div class="panel" style="margin-bottom: 20px">
107 <table
108 style="width: 100%; border-collapse: collapse; font-size: 14px"
109 id="synthetic-table"
110 >
111 <thead>
112 <tr style="text-align: left; color: var(--text-muted); font-size: 12px; text-transform: uppercase">
113 <th style="padding: 8px 12px; width: 28px"></th>
114 <th style="padding: 8px 12px">Check</th>
115 <th style="padding: 8px 12px; width: 80px">Status</th>
116 <th style="padding: 8px 12px; width: 90px">Duration</th>
117 <th style="padding: 8px 12px; width: 110px">Last run</th>
118 </tr>
119 </thead>
120 <tbody>
121 {SYNTHETIC_CHECKS.map((spec) => {
122 const row = latest[spec.name];
123 return (
124 <tr
125 data-check-name={spec.name}
126 style="border-top: 1px solid var(--border)"
147 <div class="status-wrap">
148 {/* ─── Hero ─── */}
149 <section class="status-hero">
150 <div class="status-hero-orb" aria-hidden="true" />
151 <div class="status-hero-inner">
152 <div class="status-hero-top">
153 <div class="status-hero-text">
154 <div class="status-eyebrow">
155 <span class="status-eyebrow-pill" aria-hidden="true">
156 <IconPulse />
157 </span>
158 Synthetic monitor · Site admin · <span class="status-who">{user.username}</span>
159 </div>
160 <h1 class="status-title">
161 <span
162 class={
163 "status-title-grad " +
164 (headlineState === "falling"
165 ? "is-falling"
166 : headlineState === "idle"
167 ? "is-idle"
168 : "is-green")
169 }
127170 >
128 <td
129 style="padding: 8px 12px"
130 data-cell="dot"
131 >
132 {statusDot(row?.status)}
133 </td>
134 <td style="padding: 8px 12px">
135 <code>{spec.name}</code>
136 {row?.error ? (
137 <div
138 style="font-size: 11px; color: var(--red, #cf222e); margin-top: 2px"
139 data-cell="error"
140 >
141 {row.error}
142 </div>
143 ) : null}
144 </td>
145 <td style="padding: 8px 12px" data-cell="status-code">
146 {row?.statusCode ?? "—"}
147 </td>
148 <td style="padding: 8px 12px" data-cell="duration">
149 {row ? `${row.durationMs}ms` : "—"}
150 </td>
151 <td
152 style="padding: 8px 12px; color: var(--text-muted)"
153 data-cell="ago"
154 >
155 {fmtAgo(row?.checkedAt)}
156 </td>
157 </tr>
158 );
159 })}
160 </tbody>
161 </table>
162 </div>
171 {headline}
172 </span>
173 </h1>
174 <p class="status-sub">
175 {allGreen
176 ? "All synthetic checks are green. Runs every autopilot tick."
177 : `${redCount} check${redCount === 1 ? "" : "s"} red — see the table below for the failing rows.`}{" "}
178 Last run{" "}
179 <span data-last-run-at class="status-tabular">
180 {fmtAgo(lastRunAt ?? undefined)}
181 </span>
182 .
183 </p>
184 </div>
185 <a href="/admin" class="status-hero-back">
186 <IconArrowLeft />
187 Back to admin
188 </a>
189 </div>
190 </div>
191 </section>
163192
164 <form
165 action="/admin/status/run"
166 method="post"
167 style="margin-bottom: 32px"
193 {/* ─── Live SSE banner ─── */}
194 <div
195 class="status-live"
196 role="status"
197 aria-live="polite"
198 data-sse-banner
168199 >
169 <button class="btn-primary" type="submit">
170 Run all checks now
171 </button>
172 </form>
173
174 <h2 style="font-size: 16px; margin-bottom: 12px">
175 Recent red checks (last 24h)
176 </h2>
177 {recent.length === 0 ? (
178 <p style="color: var(--text-muted); font-size: 13px">
179 No red checks in the last 24 hours.
180 </p>
181 ) : (
182 <div class="panel">
183 {recent.map((r) => (
184 <div
185 class="panel-item"
186 style="justify-content: space-between; font-size: 13px"
187 >
188 <div>
189 <code>{r.name}</code>{" "}
190 <span style="color: var(--text-muted)">
191 — {r.error || "(no error message)"}
192 </span>
193 </div>
194 <span style="color: var(--text-muted); font-size: 12px">
195 {fmtAgo(r.checkedAt)}
196 </span>
200 <span class="status-live-dot" aria-hidden="true" />
201 <span class="status-live-label" data-sse-state>
202 SSE connecting…
203 </span>
204 <span class="status-live-sep" aria-hidden="true">·</span>
205 <span class="status-live-rate">
206 <span class="status-tabular" data-sse-rate>0</span> events / min
207 </span>
208 <span class="status-live-sep" aria-hidden="true">·</span>
209 <span class="status-live-topic">
210 topic <code>{SSE_TOPIC}</code>
211 </span>
212 </div>
213
214 {/* ─── Synthetic-check table ─── */}
215 <section class="status-section">
216 <header class="status-section-head">
217 <div class="status-section-head-text">
218 <h3 class="status-section-title">Synthetic checks</h3>
219 <p class="status-section-sub">
220 {SYNTHETIC_CHECKS.length} probes — every row patches in place
221 as the SSE stream fires.
222 </p>
223 </div>
224 <form action="/admin/status/run" method="post" class="status-runform">
225 <button type="submit" class="status-btn status-btn-primary">
226 <IconPlay />
227 Run all checks now
228 </button>
229 </form>
230 </header>
231 <div class="status-section-body status-section-body--flush">
232 <table class="status-table" id="synthetic-table">
233 <thead>
234 <tr>
235 <th class="status-col-dot"></th>
236 <th class="status-col-name">Check</th>
237 <th class="status-col-num">Status</th>
238 <th class="status-col-num">Duration</th>
239 <th class="status-col-when">Last run</th>
240 </tr>
241 </thead>
242 <tbody>
243 {SYNTHETIC_CHECKS.map((spec) => {
244 const row = latest[spec.name];
245 return (
246 <tr data-check-name={spec.name}>
247 <td class="status-col-dot" data-cell="dot">
248 <span
249 class={"status-dot " + dotClass(row?.status)}
250 aria-label={row?.status ?? "idle"}
251 />
252 </td>
253 <td class="status-col-name">
254 <code class="status-action">{spec.name}</code>
255 {row?.error ? (
256 <div class="status-error" data-cell="error">
257 {row.error}
258 </div>
259 ) : null}
260 </td>
261 <td class="status-col-num status-tabular" data-cell="status-code">
262 {row?.statusCode ?? "—"}
263 </td>
264 <td class="status-col-num status-tabular" data-cell="duration">
265 {row ? `${row.durationMs}ms` : "—"}
266 </td>
267 <td class="status-col-when status-tabular" data-cell="ago">
268 {fmtAgo(row?.checkedAt)}
269 </td>
270 </tr>
271 );
272 })}
273 </tbody>
274 </table>
275 </div>
276 </section>
277
278 {/* ─── Recent activity feed (red checks in last 24h) ─── */}
279 <section class="status-section">
280 <header class="status-section-head">
281 <div class="status-section-head-text">
282 <h3 class="status-section-title">Recent activity</h3>
283 <p class="status-section-sub">
284 Red checks in the last 24 hours — most recent first.
285 </p>
286 </div>
287 </header>
288 <div class="status-section-body status-section-body--flush">
289 {recent.length === 0 ? (
290 <div class="status-empty">
291 No red checks in the last 24 hours.
197292 </div>
198 ))}
293 ) : (
294 <ol class="status-feed" aria-label="Recent red checks">
295 {recent.map((r) => (
296 <li class="status-feed-row">
297 <span
298 class="status-feed-when status-tabular"
299 title={r.checkedAt.toISOString()}
300 >
301 {fmtAgo(r.checkedAt)}
302 </span>
303 <span class="status-feed-action">
304 <span class="status-dot is-red" aria-hidden="true" />
305 <code>red</code>
306 </span>
307 <a
308 class="status-feed-target"
309 href={`#check-${r.name}`}
310 onclick={`var t=document.querySelector('tr[data-check-name="${r.name}"]');if(t){t.scrollIntoView({behavior:'smooth',block:'center'});t.classList.add('status-row-flash');setTimeout(function(){t.classList.remove('status-row-flash');},1600);}`}
311 >
312 {r.name}
313 </a>
314 <span class="status-feed-msg">
315 {r.error || "(no error message)"}
316 </span>
317 </li>
318 ))}
319 </ol>
320 )}
199321 </div>
200 )}
322 </section>
201323
324 {/* Live update via SSE. Each event is a SyntheticCheckResult; we
325 patch the matching <tr data-check-name=...> in place. The
326 banner counts events received in the last 60s. */}
202327 <script
203 // Live update via SSE. Each event is a SyntheticCheckResult; we
204 // patch the matching <tr data-check-name=...> in place.
205328 dangerouslySetInnerHTML={{
206329 __html: `
207330(function() {
331 var bannerLabel = document.querySelector('[data-sse-state]');
332 var bannerRate = document.querySelector('[data-sse-rate]');
333 var bannerEl = document.querySelector('[data-sse-banner]');
334 var eventTimes = [];
335 function setBanner(state, cls) {
336 if (bannerLabel) bannerLabel.textContent = state;
337 if (bannerEl) {
338 bannerEl.classList.remove('is-on','is-off','is-connecting');
339 bannerEl.classList.add(cls);
340 }
341 }
342 function tickRate() {
343 var cutoff = Date.now() - 60000;
344 while (eventTimes.length && eventTimes[0] < cutoff) eventTimes.shift();
345 if (bannerRate) bannerRate.textContent = String(eventTimes.length);
346 }
347 setBanner('SSE connecting…', 'is-connecting');
348 setInterval(tickRate, 1000);
208349 try {
209350 var src = new EventSource('/live-events/${SSE_TOPIC}');
351 src.onopen = function(){ setBanner('SSE connected', 'is-on'); };
352 src.onerror = function(){ setBanner('SSE reconnecting…', 'is-connecting'); };
210353 src.addEventListener('check', function(ev) {
354 eventTimes.push(Date.now()); tickRate();
211355 var data;
212356 try { data = JSON.parse(ev.data); } catch (e) { return; }
213357 var row = document.querySelector('tr[data-check-name="' + data.name + '"]');
@@ -216,19 +360,30 @@ adminStatus.get("/admin/status", async (c) => {
216360 var statusCode = row.querySelector('[data-cell="status-code"]');
217361 var duration = row.querySelector('[data-cell="duration"]');
218362 var ago = row.querySelector('[data-cell="ago"]');
219 if (dot) dot.textContent = data.status === 'green' ? '\\uD83D\\uDFE2' : (data.status === 'red' ? '\\uD83D\\uDD34' : '\\uD83D\\uDFE1');
220 if (statusCode) statusCode.textContent = data.statusCode != null ? String(data.statusCode) : '\\u2014';
363 if (dot) {
364 var span = dot.querySelector('.status-dot');
365 var cls = data.status === 'green' ? 'is-green'
366 : data.status === 'red' ? 'is-red'
367 : data.status === 'yellow'? 'is-yellow' : 'is-idle';
368 if (span) { span.className = 'status-dot ' + cls; }
369 }
370 if (statusCode) statusCode.textContent = data.statusCode != null ? String(data.statusCode) : '—';
221371 if (duration) duration.textContent = data.durationMs + 'ms';
222372 if (ago) ago.textContent = 'just now';
223373 var lastRun = document.querySelector('[data-last-run-at]');
224374 if (lastRun) lastRun.textContent = 'just now';
375 row.classList.add('status-row-flash');
376 setTimeout(function(){ row.classList.remove('status-row-flash'); }, 1100);
225377 });
226 } catch (e) { /* SSE not available — page still renders the SSR snapshot */ }
378 } catch (e) {
379 setBanner('SSE unavailable', 'is-off');
380 }
227381})();
228382`,
229383 }}
230384 />
231385 </div>
386 <style dangerouslySetInnerHTML={{ __html: STATUS_CSS }} />
232387 </Layout>
233388 );
234389});
@@ -251,4 +406,449 @@ adminStatus.post("/admin/status/run", async (c) => {
251406 return c.redirect("/admin/status");
252407});
253408
409// ---------------------------------------------------------------------------
410// Scoped CSS — every class prefixed `.status-` so the synthetic-monitor
411// surface can't bleed into other admin pages. Mirrors the gradient-hairline
412// hero + section-card motif from admin-ops / admin-deploys-page.
413// ---------------------------------------------------------------------------
414
415const STATUS_CSS = `
416 .status-wrap {
417 max-width: 1100px;
418 margin: 0 auto;
419 padding: var(--space-6) var(--space-4) var(--space-12);
420 }
421 .status-tabular { font-variant-numeric: tabular-nums; }
422
423 /* ─── Hero ─── */
424 .status-hero {
425 position: relative;
426 margin-bottom: var(--space-5);
427 padding: clamp(28px, 4vw, 44px) clamp(24px, 4vw, 44px);
428 background: var(--bg-elevated);
429 border: 1px solid var(--border);
430 border-radius: 18px;
431 overflow: hidden;
432 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 18px 44px -16px rgba(0,0,0,0.42);
433 }
434 .status-hero::before {
435 content: '';
436 position: absolute;
437 top: 0; left: 0; right: 0;
438 height: 2px;
439 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
440 opacity: 0.75;
441 pointer-events: none;
442 }
443 .status-hero-orb {
444 position: absolute;
445 inset: -30% -10% auto auto;
446 width: 460px; height: 460px;
447 background: radial-gradient(circle, rgba(52,211,153,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
448 filter: blur(80px);
449 opacity: 0.7;
450 pointer-events: none;
451 z-index: 0;
452 }
453 .status-hero-inner { position: relative; z-index: 1; }
454 .status-hero-top {
455 display: flex;
456 align-items: flex-start;
457 justify-content: space-between;
458 gap: var(--space-4);
459 flex-wrap: wrap;
460 }
461 .status-hero-text { flex: 1; min-width: 280px; }
462 .status-eyebrow {
463 display: inline-flex;
464 align-items: center;
465 gap: 8px;
466 font-size: 12px;
467 color: var(--text-muted);
468 margin-bottom: 14px;
469 letter-spacing: 0.02em;
470 }
471 .status-eyebrow-pill {
472 display: inline-flex;
473 align-items: center;
474 justify-content: center;
475 width: 18px; height: 18px;
476 border-radius: 6px;
477 background: rgba(52,211,153,0.14);
478 color: #6ee7b7;
479 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.35);
480 }
481 .status-who { color: var(--accent); font-weight: 600; }
482 .status-title {
483 font-family: var(--font-display);
484 font-size: clamp(28px, 4vw, 40px);
485 font-weight: 800;
486 letter-spacing: -0.028em;
487 line-height: 1.05;
488 margin: 0 0 var(--space-2);
489 color: var(--text-strong);
490 }
491 .status-title-grad {
492 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #36c5d6 100%);
493 -webkit-background-clip: text;
494 background-clip: text;
495 -webkit-text-fill-color: transparent;
496 color: transparent;
497 }
498 .status-title-grad.is-falling {
499 background-image: linear-gradient(135deg, #fca5a5 0%, #f87171 50%, #ef4444 100%);
500 }
501 .status-title-grad.is-idle {
502 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
503 }
504 .status-sub {
505 font-size: 14.5px;
506 color: var(--text-muted);
507 margin: 0;
508 line-height: 1.55;
509 max-width: 640px;
510 }
511 .status-hero-back {
512 display: inline-flex;
513 align-items: center;
514 gap: 6px;
515 padding: 7px 12px;
516 font-size: 12.5px;
517 color: var(--text-muted);
518 background: rgba(255,255,255,0.02);
519 border: 1px solid var(--border);
520 border-radius: 8px;
521 text-decoration: none;
522 font-weight: 500;
523 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
524 flex-shrink: 0;
525 }
526 .status-hero-back:hover {
527 border-color: var(--border-strong);
528 color: var(--text-strong);
529 background: rgba(255,255,255,0.04);
530 text-decoration: none;
531 }
532
533 /* ─── Live SSE banner ─── */
534 .status-live {
535 display: flex;
536 align-items: center;
537 gap: 10px;
538 padding: 10px 14px;
539 margin-bottom: var(--space-4);
540 background: rgba(52,211,153,0.05);
541 border: 1px solid rgba(52,211,153,0.22);
542 border-radius: 10px;
543 font-size: 12.5px;
544 color: var(--text);
545 flex-wrap: wrap;
546 }
547 .status-live.is-connecting {
548 background: rgba(251,191,36,0.05);
549 border-color: rgba(251,191,36,0.22);
550 color: #fde68a;
551 }
552 .status-live.is-off {
553 background: rgba(248,113,113,0.06);
554 border-color: rgba(248,113,113,0.25);
555 color: #fecaca;
556 }
557 .status-live-dot {
558 width: 8px; height: 8px;
559 border-radius: 9999px;
560 background: #34d399;
561 box-shadow: 0 0 0 3px rgba(52,211,153,0.20);
562 animation: status-pulse 1.4s ease-in-out infinite;
563 flex-shrink: 0;
564 }
565 .status-live.is-connecting .status-live-dot {
566 background: #fbbf24;
567 box-shadow: 0 0 0 3px rgba(251,191,36,0.22);
568 }
569 .status-live.is-off .status-live-dot {
570 background: #f87171;
571 box-shadow: 0 0 0 3px rgba(248,113,113,0.22);
572 animation: none;
573 }
574 @keyframes status-pulse {
575 0%, 100% { opacity: 1; transform: scale(1); }
576 50% { opacity: 0.55; transform: scale(0.85); }
577 }
578 @media (prefers-reduced-motion: reduce) {
579 .status-live-dot { animation: none; }
580 }
581 .status-live-label {
582 font-family: var(--font-mono);
583 font-size: 12px;
584 color: inherit;
585 font-weight: 600;
586 }
587 .status-live-rate { color: inherit; }
588 .status-live-topic {
589 color: var(--text-muted);
590 margin-left: auto;
591 font-size: 11.5px;
592 }
593 .status-live-topic code {
594 font-family: var(--font-mono);
595 font-size: 11.5px;
596 background: rgba(255,255,255,0.04);
597 border: 1px solid var(--border);
598 padding: 1px 6px;
599 border-radius: 4px;
600 color: var(--text);
601 }
602 .status-live-sep { color: var(--text-muted); opacity: 0.45; }
603
604 /* ─── Section cards ─── */
605 .status-section {
606 margin-bottom: var(--space-5);
607 background: var(--bg-elevated);
608 border: 1px solid var(--border);
609 border-radius: 14px;
610 overflow: hidden;
611 }
612 .status-section-head {
613 padding: var(--space-4) var(--space-5);
614 border-bottom: 1px solid var(--border);
615 display: flex;
616 align-items: flex-start;
617 justify-content: space-between;
618 gap: var(--space-3);
619 flex-wrap: wrap;
620 }
621 .status-section-head-text { flex: 1; min-width: 240px; }
622 .status-section-title {
623 margin: 0;
624 font-family: var(--font-display);
625 font-size: 17px;
626 font-weight: 700;
627 letter-spacing: -0.018em;
628 color: var(--text-strong);
629 }
630 .status-section-sub {
631 margin: 4px 0 0;
632 font-size: 12.5px;
633 color: var(--text-muted);
634 }
635 .status-section-body { padding: var(--space-4) var(--space-5); }
636 .status-section-body--flush { padding: 0; }
637
638 .status-runform { margin: 0; }
639 .status-btn {
640 display: inline-flex;
641 align-items: center;
642 gap: 6px;
643 padding: 9px 16px;
644 border-radius: 10px;
645 font-size: 13px;
646 font-weight: 600;
647 text-decoration: none;
648 border: 1px solid transparent;
649 cursor: pointer;
650 font: inherit;
651 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
652 line-height: 1;
653 }
654 .status-btn-primary {
655 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
656 color: #ffffff;
657 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
658 }
659 .status-btn-primary:hover {
660 transform: translateY(-1px);
661 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
662 }
663
664 /* ─── Synthetic table ─── */
665 .status-table {
666 width: 100%;
667 border-collapse: collapse;
668 font-size: 13px;
669 }
670 .status-table thead th {
671 padding: 10px 14px;
672 text-align: left;
673 font-family: var(--font-mono);
674 font-size: 10.5px;
675 text-transform: uppercase;
676 letter-spacing: 0.14em;
677 color: var(--text-muted);
678 font-weight: 700;
679 background: rgba(255,255,255,0.012);
680 border-bottom: 1px solid var(--border);
681 }
682 .status-table tbody td {
683 padding: 10px 14px;
684 border-bottom: 1px solid var(--border);
685 vertical-align: middle;
686 }
687 .status-table tbody tr:last-child td { border-bottom: 0; }
688 .status-table tbody tr {
689 transition: background 200ms ease;
690 }
691 .status-table tbody tr:hover {
692 background: rgba(255,255,255,0.022);
693 }
694 .status-table .status-col-dot { width: 30px; }
695 .status-table .status-col-num { width: 90px; }
696 .status-table .status-col-when { width: 110px; color: var(--text-muted); }
697 .status-row-flash {
698 background: rgba(140,109,255,0.10) !important;
699 animation: status-flash 1.1s ease-out;
700 }
701 @keyframes status-flash {
702 0% { background: rgba(140,109,255,0.30); }
703 100% { background: rgba(140,109,255,0.00); }
704 }
705
706 .status-action {
707 font-family: var(--font-mono);
708 font-size: 12.5px;
709 color: var(--text-strong);
710 background: linear-gradient(135deg, rgba(140,109,255,0.10), rgba(54,197,214,0.06));
711 border: 1px solid rgba(140,109,255,0.20);
712 padding: 2px 8px;
713 border-radius: 5px;
714 }
715 .status-error {
716 margin-top: 4px;
717 font-family: var(--font-mono);
718 font-size: 11.5px;
719 color: #fca5a5;
720 line-height: 1.4;
721 }
722
723 .status-dot {
724 display: inline-block;
725 width: 10px; height: 10px;
726 border-radius: 9999px;
727 background: var(--text-muted);
728 box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
729 vertical-align: middle;
730 }
731 .status-dot.is-green {
732 background: #34d399;
733 box-shadow: 0 0 0 3px rgba(52,211,153,0.18), 0 0 8px rgba(52,211,153,0.40);
734 }
735 .status-dot.is-red {
736 background: #f87171;
737 box-shadow: 0 0 0 3px rgba(248,113,113,0.20), 0 0 10px rgba(248,113,113,0.45);
738 animation: status-pulse 1.6s ease-in-out infinite;
739 }
740 .status-dot.is-yellow {
741 background: #fbbf24;
742 box-shadow: 0 0 0 3px rgba(251,191,36,0.22);
743 }
744 .status-dot.is-idle { background: var(--text-muted); }
745 @media (prefers-reduced-motion: reduce) {
746 .status-dot.is-red { animation: none; }
747 }
748
749 /* ─── Feed ─── */
750 .status-empty {
751 padding: 22px 16px;
752 text-align: center;
753 color: var(--text-muted);
754 font-size: 13px;
755 }
756 .status-feed {
757 list-style: none;
758 margin: 0;
759 padding: 0;
760 }
761 .status-feed-row {
762 display: grid;
763 grid-template-columns: 96px 84px minmax(140px, 220px) 1fr;
764 gap: 14px;
765 align-items: center;
766 padding: 10px 14px;
767 border-bottom: 1px solid var(--border);
768 font-size: 13px;
769 }
770 .status-feed-row:last-child { border-bottom: 0; }
771 .status-feed-row:hover { background: rgba(255,255,255,0.022); }
772 .status-feed-when {
773 font-family: var(--font-mono);
774 font-size: 11.5px;
775 color: var(--text-muted);
776 }
777 .status-feed-action {
778 display: inline-flex;
779 align-items: center;
780 gap: 6px;
781 font-size: 12px;
782 color: #fca5a5;
783 }
784 .status-feed-action code {
785 font-family: var(--font-mono);
786 font-size: 11.5px;
787 background: rgba(248,113,113,0.08);
788 border: 1px solid rgba(248,113,113,0.28);
789 padding: 1px 6px;
790 border-radius: 4px;
791 color: #fca5a5;
792 text-transform: uppercase;
793 letter-spacing: 0.06em;
794 font-weight: 700;
795 }
796 .status-feed-target {
797 font-family: var(--font-mono);
798 font-size: 12.5px;
799 color: var(--text-strong);
800 background: rgba(255,255,255,0.04);
801 border: 1px solid var(--border);
802 padding: 2px 8px;
803 border-radius: 5px;
804 text-decoration: none;
805 overflow: hidden;
806 text-overflow: ellipsis;
807 white-space: nowrap;
808 }
809 .status-feed-target:hover {
810 background: rgba(140,109,255,0.10);
811 border-color: rgba(140,109,255,0.40);
812 color: var(--text-strong);
813 text-decoration: none;
814 }
815 .status-feed-msg {
816 color: var(--text-muted);
817 font-size: 12.5px;
818 overflow: hidden;
819 text-overflow: ellipsis;
820 white-space: nowrap;
821 }
822
823 /* ─── 403 fallback ─── */
824 .status-403 {
825 max-width: 540px;
826 margin: var(--space-12) auto;
827 padding: var(--space-6);
828 text-align: center;
829 background: var(--bg-elevated);
830 border: 1px solid var(--border);
831 border-radius: 16px;
832 }
833 .status-403 h2 {
834 font-family: var(--font-display);
835 font-size: 22px;
836 margin: 0 0 8px;
837 color: var(--text-strong);
838 }
839 .status-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
840
841 @media (max-width: 720px) {
842 .status-wrap { padding: var(--space-4) var(--space-3) var(--space-8); }
843 .status-feed-row {
844 grid-template-columns: 96px 84px 1fr;
845 grid-template-areas:
846 'when action target'
847 'when action msg';
848 }
849 .status-feed-msg { grid-column: 3 / 4; }
850 .status-live-topic { margin-left: 0; }
851 }
852`;
853
254854export default adminStatus;
Modifiedsrc/routes/insights.tsx+693−142View fileUnifiedSplit
@@ -3,6 +3,11 @@
33 *
44 * These are the pages that don't exist on GitHub.
55 * This is why developers will switch.
6 *
7 * 2026 polish: gradient-hairline hero + radial orb + stat cards + polished
8 * timeline. Every class prefixed `.insights-` so this surface doesn't
9 * bleed into the wider repo polish. All data fetches, queries, and
10 * actions preserved exactly.
611 */
712
813import { Hono } from "hono";
@@ -32,6 +37,373 @@ const insights = new Hono<AuthEnv>();
3237
3338insights.use("*", softAuth);
3439
40/* ─────────────────────────────────────────────────────────────────────────
41 * Scoped CSS — every class prefixed `.insights-`. Mirrors the
42 * gradient-hairline hero + radial orb + per-card pattern from
43 * `admin-integrations` and `admin-diagnose`. Stat cards use
44 * `font-variant-numeric: tabular-nums` so columns of numbers line up.
45 * ───────────────────────────────────────────────────────────────────── */
46const styles = `
47 .insights-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
48
49 .insights-hero {
50 position: relative;
51 margin-bottom: var(--space-5);
52 padding: var(--space-5) var(--space-6);
53 background: var(--bg-elevated);
54 border: 1px solid var(--border);
55 border-radius: 16px;
56 overflow: hidden;
57 }
58 .insights-hero::before {
59 content: '';
60 position: absolute;
61 top: 0; left: 0; right: 0;
62 height: 2px;
63 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
64 opacity: 0.75;
65 pointer-events: none;
66 }
67 .insights-hero-orb {
68 position: absolute;
69 inset: -30% -15% auto auto;
70 width: 460px; height: 460px;
71 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
72 filter: blur(80px);
73 opacity: 0.75;
74 pointer-events: none;
75 z-index: 0;
76 }
77 .insights-hero-inner { position: relative; z-index: 1; max-width: 760px; }
78 .insights-eyebrow {
79 display: inline-flex;
80 align-items: center;
81 gap: 8px;
82 text-transform: uppercase;
83 font-family: var(--font-mono);
84 font-size: 11px;
85 letter-spacing: 0.18em;
86 color: var(--text-muted);
87 font-weight: 600;
88 margin-bottom: 14px;
89 }
90 .insights-eyebrow-dot {
91 width: 8px; height: 8px;
92 border-radius: 9999px;
93 background: linear-gradient(135deg, #8c6dff, #36c5d6);
94 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
95 }
96 .insights-title {
97 font-family: var(--font-display);
98 font-size: clamp(28px, 4vw, 40px);
99 font-weight: 800;
100 letter-spacing: -0.028em;
101 line-height: 1.05;
102 margin: 0 0 var(--space-2);
103 color: var(--text-strong);
104 }
105 .insights-title-grad {
106 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
107 -webkit-background-clip: text;
108 background-clip: text;
109 -webkit-text-fill-color: transparent;
110 color: transparent;
111 }
112 .insights-sub {
113 font-size: 15px;
114 color: var(--text-muted);
115 margin: 0;
116 line-height: 1.55;
117 }
118
119 /* Stat-card grid */
120 .insights-stats {
121 display: grid;
122 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
123 gap: var(--space-3);
124 margin-bottom: var(--space-5);
125 }
126 .insights-stat {
127 position: relative;
128 background: var(--bg-elevated);
129 border: 1px solid var(--border);
130 border-radius: 14px;
131 padding: var(--space-4);
132 transition: border-color 120ms ease, transform 120ms ease;
133 }
134 .insights-stat:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
135 .insights-stat-label {
136 font-size: 10.5px;
137 letter-spacing: 0.14em;
138 text-transform: uppercase;
139 color: var(--text-muted);
140 font-weight: 700;
141 margin-bottom: 6px;
142 }
143 .insights-stat-value {
144 font-family: var(--font-display);
145 font-size: 32px;
146 font-weight: 800;
147 letter-spacing: -0.022em;
148 color: var(--text-strong);
149 font-variant-numeric: tabular-nums;
150 line-height: 1;
151 }
152 .insights-stat-value.is-warn { color: #fca5a5; }
153 .insights-stat-trend {
154 margin-top: 6px;
155 display: inline-flex;
156 align-items: center;
157 gap: 4px;
158 font-size: 12px;
159 font-variant-numeric: tabular-nums;
160 color: var(--text-muted);
161 }
162 .insights-stat-trend.is-up { color: #6ee7b7; }
163 .insights-stat-trend.is-down { color: #fca5a5; }
164 .insights-stat-trend .arrow { font-size: 11px; line-height: 1; }
165 .insights-stat-hint {
166 margin-top: 6px;
167 font-size: 12px;
168 color: var(--text-muted);
169 }
170
171 /* Section heading */
172 .insights-section-head {
173 margin: 0 0 var(--space-3);
174 display: flex;
175 align-items: baseline;
176 justify-content: space-between;
177 gap: var(--space-3);
178 flex-wrap: wrap;
179 }
180 .insights-section-title {
181 margin: 0;
182 font-family: var(--font-display);
183 font-size: 18px;
184 font-weight: 700;
185 letter-spacing: -0.018em;
186 color: var(--text-strong);
187 }
188 .insights-section-sub { font-size: 12.5px; color: var(--text-muted); }
189
190 .insights-blurb {
191 margin: 0 0 var(--space-3);
192 font-size: 13px;
193 color: var(--text-muted);
194 line-height: 1.55;
195 }
196
197 /* Generic card list */
198 .insights-list {
199 display: flex;
200 flex-direction: column;
201 gap: var(--space-2);
202 margin-bottom: var(--space-5);
203 }
204 .insights-card {
205 position: relative;
206 background: var(--bg-elevated);
207 border: 1px solid var(--border);
208 border-radius: 12px;
209 padding: var(--space-3) var(--space-4);
210 transition: border-color 120ms ease, transform 120ms ease;
211 }
212 .insights-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
213 .insights-card.is-warn { border-color: rgba(248,113,113,0.30); }
214
215 .insights-card-row {
216 display: flex;
217 justify-content: space-between;
218 align-items: flex-start;
219 gap: 16px;
220 flex-wrap: wrap;
221 }
222 .insights-card-main { min-width: 0; flex: 1; }
223 .insights-card-title {
224 font-family: var(--font-display);
225 font-size: 14.5px;
226 font-weight: 700;
227 color: var(--text-strong);
228 letter-spacing: -0.005em;
229 line-height: 1.3;
230 margin: 0 0 4px;
231 word-break: break-word;
232 }
233 .insights-card-title a { color: inherit; text-decoration: none; }
234 .insights-card-title a:hover { color: var(--accent); }
235 .insights-card-sub {
236 font-size: 12.5px;
237 color: var(--text-muted);
238 line-height: 1.5;
239 margin: 0;
240 }
241 .insights-card-meta {
242 font-family: var(--font-mono);
243 font-size: 12px;
244 color: var(--text-muted);
245 text-align: right;
246 white-space: nowrap;
247 font-variant-numeric: tabular-nums;
248 }
249 .insights-card-meta .add { color: #6ee7b7; }
250 .insights-card-meta .del { color: #fca5a5; }
251
252 .insights-coupled {
253 font-family: var(--font-mono);
254 font-size: 13px;
255 color: var(--text);
256 word-break: break-word;
257 }
258 .insights-coupled a { color: var(--accent); text-decoration: none; }
259 .insights-coupled a:hover { text-decoration: underline; }
260 .insights-coupled .plus { color: var(--text-muted); margin: 0 8px; }
261
262 /* Timeline (revisions / milestones) */
263 .insights-timeline {
264 position: relative;
265 padding-left: 22px;
266 margin: 0;
267 list-style: none;
268 }
269 .insights-timeline::before {
270 content: '';
271 position: absolute;
272 left: 6px;
273 top: 6px;
274 bottom: 6px;
275 width: 2px;
276 background: linear-gradient(180deg, rgba(140,109,255,0.22), rgba(54,197,214,0.06));
277 border-radius: 9999px;
278 }
279 .insights-timeline-item {
280 position: relative;
281 padding: 0 0 var(--space-3) 0;
282 }
283 .insights-timeline-dot {
284 position: absolute;
285 left: -22px;
286 top: 6px;
287 width: 12px; height: 12px;
288 border-radius: 9999px;
289 background: var(--text-muted);
290 box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
291 }
292 .insights-timeline-dot.is-milestone {
293 background: linear-gradient(135deg, #34d399, #36c5d6);
294 box-shadow: 0 0 0 4px rgba(52,211,153,0.18);
295 width: 14px; height: 14px;
296 left: -23px;
297 }
298 .insights-timeline-card {
299 background: var(--bg-elevated);
300 border: 1px solid var(--border);
301 border-radius: 12px;
302 padding: var(--space-3) var(--space-4);
303 transition: border-color 120ms ease, transform 120ms ease;
304 }
305 .insights-timeline-card:hover { border-color: var(--border-strong, var(--border)); transform: translateY(-1px); }
306
307 /* Unused-deps banner */
308 .insights-banner {
309 margin-bottom: var(--space-4);
310 padding: 12px 16px;
311 border-radius: 12px;
312 border: 1px solid rgba(248,113,113,0.34);
313 background: rgba(248,113,113,0.08);
314 color: #fecaca;
315 font-size: 13px;
316 line-height: 1.55;
317 }
318 .insights-banner strong { color: #fecaca; font-weight: 700; }
319 .insights-banner code {
320 font-family: var(--font-mono);
321 font-size: 12.5px;
322 background: rgba(255,255,255,0.04);
323 border: 1px solid rgba(248,113,113,0.22);
324 padding: 1px 6px;
325 border-radius: 5px;
326 }
327 .insights-banner-hint {
328 margin-top: 6px;
329 font-size: 12px;
330 color: rgba(254,202,202,0.78);
331 }
332
333 /* Dep usage block */
334 .insights-dep-uses {
335 margin-top: 10px;
336 padding-top: 10px;
337 border-top: 1px solid var(--border);
338 font-size: 12px;
339 color: var(--text-muted);
340 display: flex;
341 flex-direction: column;
342 gap: 4px;
343 }
344 .insights-dep-uses code {
345 font-family: var(--font-mono);
346 font-size: 12px;
347 color: var(--text);
348 background: rgba(255,255,255,0.04);
349 border: 1px solid var(--border);
350 padding: 1px 6px;
351 border-radius: 5px;
352 }
353 .insights-dep-uses a { color: var(--accent); text-decoration: none; }
354 .insights-dep-uses a:hover { text-decoration: underline; }
355 .insights-dep-badge {
356 display: inline-flex;
357 align-items: center;
358 gap: 4px;
359 padding: 1px 7px;
360 border-radius: 9999px;
361 font-size: 10.5px;
362 font-weight: 700;
363 letter-spacing: 0.06em;
364 text-transform: uppercase;
365 background: rgba(96,165,250,0.12);
366 color: #93c5fd;
367 box-shadow: inset 0 0 0 1px rgba(96,165,250,0.30);
368 }
369 .insights-dep-unused {
370 color: #fca5a5;
371 font-weight: 600;
372 }
373
374 /* Empty state — dashed orb card */
375 .insights-empty {
376 position: relative;
377 overflow: hidden;
378 text-align: center;
379 padding: var(--space-6) var(--space-4);
380 border: 1px dashed var(--border-strong, var(--border));
381 border-radius: 16px;
382 background: rgba(255,255,255,0.012);
383 color: var(--text-muted);
384 margin-bottom: var(--space-5);
385 }
386 .insights-empty::before {
387 content: '';
388 position: absolute;
389 inset: -40% -20% auto auto;
390 width: 320px; height: 320px;
391 background: radial-gradient(circle, rgba(140,109,255,0.14), rgba(54,197,214,0.06) 45%, transparent 70%);
392 filter: blur(60px);
393 pointer-events: none;
394 }
395 .insights-empty-inner { position: relative; z-index: 1; }
396 .insights-empty strong {
397 display: block;
398 font-family: var(--font-display);
399 font-size: 16px;
400 font-weight: 700;
401 color: var(--text-strong);
402 margin-bottom: 4px;
403 }
404 .insights-empty span { font-size: 13px; }
405`;
406
35407// ─── TIME TRAVEL ─────────────────────────────────────────────
36408
37409// File evolution timeline
@@ -65,46 +437,65 @@ insights.get("/:owner/:repo/timeline/:ref{.+$}", async (c) => {
65437 <Layout title={`Timeline: ${filePath} — ${owner}/${repo}`} user={user}>
66438 <RepoHeader owner={owner} repo={repo} />
67439 <RepoNav owner={owner} repo={repo} active="code" />
68 <h2 style="margin-bottom: 4px">
69 Time Travel: {filePath}
70 </h2>
71 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
72 {timeline.totalRevisions} revision{timeline.totalRevisions !== 1 ? "s" : ""} | First seen{" "}
73 {new Date(timeline.firstSeen.date).toLocaleDateString()} by {timeline.firstSeen.author}
74 </p>
75
76 <div class="timeline">
77 {timeline.revisions.map((rev, i) => (
78 <div class="timeline-item">
79 <div class="timeline-dot" />
80 <div class="timeline-content">
81 <div style="display: flex; justify-content: space-between; align-items: start">
82 <div>
83 <a
84 href={`/${owner}/${repo}/commit/${rev.sha}`}
85 style="font-weight: 600; font-size: 14px"
86 >
87 {rev.message}
88 </a>
89 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
90 {rev.author} — {new Date(rev.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
440
441 <div class="insights-wrap">
442 <section class="insights-hero">
443 <div class="insights-hero-orb" aria-hidden="true" />
444 <div class="insights-hero-inner">
445 <div class="insights-eyebrow">
446 <span class="insights-eyebrow-dot" aria-hidden="true" />
447 Time travel · {owner}/{repo}
448 </div>
449 <h2 class="insights-title">
450 <span class="insights-title-grad">{filePath}</span>
451 </h2>
452 <p class="insights-sub">
453 {timeline.totalRevisions} revision
454 {timeline.totalRevisions !== 1 ? "s" : ""} · First seen{" "}
455 {new Date(timeline.firstSeen.date).toLocaleDateString()} by{" "}
456 {timeline.firstSeen.author}
457 </p>
458 </div>
459 </section>
460
461 <ul class="insights-timeline">
462 {timeline.revisions.map((rev) => (
463 <li class="insights-timeline-item">
464 <span class="insights-timeline-dot" aria-hidden="true" />
465 <div class="insights-timeline-card">
466 <div class="insights-card-row">
467 <div class="insights-card-main">
468 <h4 class="insights-card-title">
469 <a href={`/${owner}/${repo}/commit/${rev.sha}`}>
470 {rev.message}
471 </a>
472 </h4>
473 <p class="insights-card-sub">
474 {rev.author} —{" "}
475 {new Date(rev.date).toLocaleDateString("en-US", {
476 month: "short",
477 day: "numeric",
478 year: "numeric",
479 })}
480 </p>
481 </div>
482 <div class="insights-card-meta">
483 <span class="add">+{rev.linesAdded}</span>{" "}
484 <span class="del">-{rev.linesRemoved}</span>
485 <div style="color: var(--text-muted)">{rev.sizeAfter} bytes</div>
91486 </div>
92 </div>
93 <div style="text-align: right; font-size: 12px; white-space: nowrap">
94 <span class="stat-add">+{rev.linesAdded}</span>{" "}
95 <span class="stat-del">-{rev.linesRemoved}</span>
96 <div style="color: var(--text-muted)">{rev.sizeAfter} bytes</div>
97487 </div>
98488 </div>
99 </div>
100 </div>
101 ))}
489 </li>
490 ))}
491 </ul>
102492 </div>
493 <style dangerouslySetInnerHTML={{ __html: styles }} />
103494 </Layout>
104495 );
105496});
106497
107// Coupled files analysis
498// Coupled files analysis (the canonical "Insights" landing surface)
108499insights.get("/:owner/:repo/coupling", async (c) => {
109500 const { owner, repo } = c.req.param();
110501 const user = c.get("user");
@@ -116,64 +507,161 @@ insights.get("/:owner/:repo/coupling", async (c) => {
116507 const story = await getRepoStory(owner, repo, ref);
117508 const milestones = story.filter((s) => s.significance !== "normal").slice(0, 20);
118509
510 // Stat-card values derived from the data we already fetched. These line up
511 // with the spec (commits, files touched, milestones, coupled-pairs) so the
512 // grid renders even when the repo is empty.
513 const totalCommits = story.length;
514 const totalAdditions = story.reduce(
515 (n, s) => n + (s.stats?.additions || 0),
516 0
517 );
518 const totalDeletions = story.reduce(
519 (n, s) => n + (s.stats?.deletions || 0),
520 0
521 );
522
119523 return c.html(
120524 <Layout title={`Insights — ${owner}/${repo}`} user={user}>
121525 <RepoHeader owner={owner} repo={repo} />
122 <RepoNav owner={owner} repo={repo} active="code" />
123 <h2 style="margin-bottom: 20px">Code Insights</h2>
124
125 <h3 style="margin-bottom: 12px">Coupled Files</h3>
126 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 12px">
127 Files that change together frequently — potential architectural coupling
128 </p>
129 {coupled.length === 0 ? (
130 <p style="color: var(--text-muted)">No strong coupling detected.</p>
131 ) : (
132 <div class="issue-list" style="margin-bottom: 32px">
133 {coupled.map((pair) => (
134 <div class="issue-item">
135 <div style="font-size: 13px; font-family: var(--font-mono)">
136 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[0]}`}>
137 {pair.files[0]}
138 </a>
139 <span style="color: var(--text-muted); margin: 0 8px">+</span>
140 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[1]}`}>
141 {pair.files[1]}
142 </a>
143 </div>
144 <div style="font-size: 12px; color: var(--text-muted); white-space: nowrap">
145 {pair.cochanges} co-changes ({pair.percentage}%)
146 </div>
526 <RepoNav owner={owner} repo={repo} active="insights" />
527
528 <div class="insights-wrap">
529 <section class="insights-hero">
530 <div class="insights-hero-orb" aria-hidden="true" />
531 <div class="insights-hero-inner">
532 <div class="insights-eyebrow">
533 <span class="insights-eyebrow-dot" aria-hidden="true" />
534 Insights · {owner}/{repo}
147535 </div>
148 ))}
536 <h2 class="insights-title">
537 <span class="insights-title-grad">Code intelligence.</span>
538 </h2>
539 <p class="insights-sub">
540 File coupling, milestone history, and contributor signals — the
541 kind of intelligence GitHub doesn't ship.
542 </p>
543 </div>
544 </section>
545
546 <div class="insights-stats">
547 <div class="insights-stat">
548 <div class="insights-stat-label">Commits indexed</div>
549 <div class="insights-stat-value">{totalCommits.toLocaleString()}</div>
550 <div class="insights-stat-hint">On {ref}</div>
551 </div>
552 <div class="insights-stat">
553 <div class="insights-stat-label">Lines added</div>
554 <div class="insights-stat-value">{totalAdditions.toLocaleString()}</div>
555 <div class="insights-stat-trend is-up">
556 <span class="arrow" aria-hidden="true">▲</span>
557 across history
558 </div>
559 </div>
560 <div class="insights-stat">
561 <div class="insights-stat-label">Lines removed</div>
562 <div class="insights-stat-value">{totalDeletions.toLocaleString()}</div>
563 <div class="insights-stat-trend is-down">
564 <span class="arrow" aria-hidden="true">▼</span>
565 across history
566 </div>
567 </div>
568 <div class="insights-stat">
569 <div class="insights-stat-label">Milestones</div>
570 <div class="insights-stat-value">{milestones.length}</div>
571 <div class="insights-stat-hint">Significant commits</div>
572 </div>
149573 </div>
150 )}
151
152 <h3 style="margin-bottom: 12px">Project Milestones</h3>
153 {milestones.length === 0 ? (
154 <p style="color: var(--text-muted)">No milestones detected yet.</p>
155 ) : (
156 <div class="timeline">
157 {milestones.map((m) => (
158 <div class="timeline-item">
159 <div
160 class="timeline-dot"
161 style={m.significance === "milestone" ? "background: var(--green); width: 12px; height: 12px" : ""}
162 />
163 <div class="timeline-content">
164 <a href={`/${owner}/${repo}/commit/${m.sha}`} style="font-weight: 600; font-size: 14px">
165 {m.message}
166 </a>
167 <div style="font-size: 12px; color: var(--text-muted)">
168 {m.author} — {new Date(m.date).toLocaleDateString()} |{" "}
169 <span class="stat-add">+{m.stats.additions}</span>{" "}
170 <span class="stat-del">-{m.stats.deletions}</span> in {m.stats.files} files
574
575 <div class="insights-section-head">
576 <h3 class="insights-section-title">Coupled files</h3>
577 <span class="insights-section-sub">{coupled.length} pair{coupled.length === 1 ? "" : "s"}</span>
578 </div>
579 <p class="insights-blurb">
580 Files that change together frequently — potential architectural
581 coupling worth refactoring.
582 </p>
583 {coupled.length === 0 ? (
584 <div class="insights-empty">
585 <div class="insights-empty-inner">
586 <strong>No strong coupling detected</strong>
587 <span>Push more code to see relationships emerge.</span>
588 </div>
589 </div>
590 ) : (
591 <div class="insights-list">
592 {coupled.map((pair) => (
593 <div class="insights-card">
594 <div class="insights-card-row">
595 <div class="insights-card-main">
596 <div class="insights-coupled">
597 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[0]}`}>
598 {pair.files[0]}
599 </a>
600 <span class="plus" aria-hidden="true">+</span>
601 <a href={`/${owner}/${repo}/blob/${ref}/${pair.files[1]}`}>
602 {pair.files[1]}
603 </a>
604 </div>
605 </div>
606 <div class="insights-card-meta">
607 {pair.cochanges} co-changes ({pair.percentage}%)
608 </div>
171609 </div>
172610 </div>
173 </div>
174 ))}
611 ))}
612 </div>
613 )}
614
615 <div class="insights-section-head">
616 <h3 class="insights-section-title">Project milestones</h3>
617 <span class="insights-section-sub">{milestones.length} milestone{milestones.length === 1 ? "" : "s"}</span>
175618 </div>
176 )}
619 {milestones.length === 0 ? (
620 <div class="insights-empty">
621 <div class="insights-empty-inner">
622 <strong>No milestones detected yet</strong>
623 <span>Push code to see insights emerge.</span>
624 </div>
625 </div>
626 ) : (
627 <ul class="insights-timeline">
628 {milestones.map((m) => (
629 <li class="insights-timeline-item">
630 <span
631 class={
632 "insights-timeline-dot" +
633 (m.significance === "milestone" ? " is-milestone" : "")
634 }
635 aria-hidden="true"
636 />
637 <div class="insights-timeline-card">
638 <div class="insights-card-row">
639 <div class="insights-card-main">
640 <h4 class="insights-card-title">
641 <a href={`/${owner}/${repo}/commit/${m.sha}`}>
642 {m.message}
643 </a>
644 </h4>
645 <p class="insights-card-sub">
646 {m.author} —{" "}
647 {new Date(m.date).toLocaleDateString()}
648 </p>
649 </div>
650 <div class="insights-card-meta">
651 <span class="add">+{m.stats.additions}</span>{" "}
652 <span class="del">-{m.stats.deletions}</span>
653 <div style="color: var(--text-muted)">
654 {m.stats.files} file{m.stats.files === 1 ? "" : "s"}
655 </div>
656 </div>
657 </div>
658 </div>
659 </li>
660 ))}
661 </ul>
662 )}
663 </div>
664 <style dangerouslySetInnerHTML={{ __html: styles }} />
177665 </Layout>
178666 );
179667});
@@ -194,75 +682,138 @@ insights.get("/:owner/:repo/dependencies", async (c) => {
194682 <Layout title={`Dependencies — ${owner}/${repo}`} user={user}>
195683 <RepoHeader owner={owner} repo={repo} />
196684 <RepoNav owner={owner} repo={repo} active="code" />
197 <h2 style="margin-bottom: 4px">Dependency Intelligence</h2>
198 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
199 {graph.externalDependencies} dependencies | {graph.internalModules} source files
200 {graph.circularDeps.length > 0 && (
201 <span style="color: var(--red)">
202 {" "}| {graph.circularDeps.length} circular dependency chain{graph.circularDeps.length !== 1 ? "s" : ""}
203 </span>
204 )}
205 </p>
206
207 {unused.length > 0 && (
208 <div style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 20px">
209 <strong style="color: var(--red)">Unused dependencies:</strong>{" "}
210 <span style="font-family: var(--font-mono); font-size: 13px">
211 {unused.join(", ")}
212 </span>
213 <div style="font-size: 12px; color: var(--text-muted); margin-top: 4px">
214 These are installed but never imported. Removing them reduces install time and attack surface.
685
686 <div class="insights-wrap">
687 <section class="insights-hero">
688 <div class="insights-hero-orb" aria-hidden="true" />
689 <div class="insights-hero-inner">
690 <div class="insights-eyebrow">
691 <span class="insights-eyebrow-dot" aria-hidden="true" />
692 Dependency intelligence · {owner}/{repo}
693 </div>
694 <h2 class="insights-title">
695 <span class="insights-title-grad">What you depend on.</span>
696 </h2>
697 <p class="insights-sub">
698 Static import graph across the repo — every package, how it's
699 used, and which ones are dead weight.
700 </p>
701 </div>
702 </section>
703
704 <div class="insights-stats">
705 <div class="insights-stat">
706 <div class="insights-stat-label">Dependencies</div>
707 <div class="insights-stat-value">{graph.externalDependencies}</div>
708 <div class="insights-stat-hint">External packages</div>
709 </div>
710 <div class="insights-stat">
711 <div class="insights-stat-label">Source files</div>
712 <div class="insights-stat-value">{graph.internalModules}</div>
713 <div class="insights-stat-hint">Internal modules</div>
714 </div>
715 <div class={"insights-stat"}>
716 <div class="insights-stat-label">Unused</div>
717 <div
718 class={
719 "insights-stat-value" + (unused.length > 0 ? " is-warn" : "")
720 }
721 >
722 {unused.length}
723 </div>
724 <div class="insights-stat-hint">Installed but never imported</div>
725 </div>
726 <div class="insights-stat">
727 <div class="insights-stat-label">Circular chains</div>
728 <div
729 class={
730 "insights-stat-value" +
731 (graph.circularDeps.length > 0 ? " is-warn" : "")
732 }
733 >
734 {graph.circularDeps.length}
735 </div>
736 <div class="insights-stat-hint">Cycles in the import graph</div>
215737 </div>
216738 </div>
217 )}
218
219 <div class="issue-list">
220 {graph.dependencies.map((dep) => (
221 <div class="issue-item" style="flex-direction: column; align-items: stretch">
222 <div style="display: flex; justify-content: space-between; align-items: center">
223 <div>
224 <strong style="font-size: 14px">{dep.name}</strong>
225 <span style="margin-left: 8px; font-size: 12px; color: var(--text-muted)">
226 {dep.version}
227 </span>
228 {dep.isDevDep && (
229 <span class="badge" style="margin-left: 8px; font-size: 10px">
230 dev
231 </span>
232 )}
233 </div>
234 <span style="font-size: 13px; color: var(--text-muted)">
235 {dep.totalImports === 0 ? (
236 <span style="color: var(--red)">unused</span>
237 ) : (
238 `${dep.totalImports} import${dep.totalImports !== 1 ? "s" : ""}`
239 )}
240 </span>
739
740 {unused.length > 0 && (
741 <div class="insights-banner">
742 <strong>Unused dependencies:</strong>{" "}
743 <code>{unused.join(", ")}</code>
744 <div class="insights-banner-hint">
745 These are installed but never imported. Removing them reduces
746 install time and attack surface.
241747 </div>
242 {dep.usedIn.length > 0 && (
243 <div style="margin-top: 8px; font-size: 12px">
244 {dep.usedIn.slice(0, 3).map((usage) => (
245 <div style="color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px">
246 <a href={`/${owner}/${repo}/blob/${ref}/${usage.file}`}>
247 {usage.file}:{usage.line}
248 </a>
249 <span style="margin-left: 8px">
250 {"{"}
251 {usage.importedSymbols.join(", ")}
252 {"}"}
253 </span>
748 </div>
749 )}
750
751 <div class="insights-section-head">
752 <h3 class="insights-section-title">Packages</h3>
753 <span class="insights-section-sub">{graph.dependencies.length} total</span>
754 </div>
755 {graph.dependencies.length === 0 ? (
756 <div class="insights-empty">
757 <div class="insights-empty-inner">
758 <strong>No dependencies detected</strong>
759 <span>Push code with a manifest to see insights.</span>
760 </div>
761 </div>
762 ) : (
763 <div class="insights-list">
764 {graph.dependencies.map((dep) => (
765 <div
766 class={"insights-card" + (dep.totalImports === 0 ? " is-warn" : "")}
767 >
768 <div class="insights-card-row">
769 <div class="insights-card-main">
770 <h4 class="insights-card-title">
771 {dep.name}{" "}
772 <span
773 style="font-size:12px;font-weight:500;color:var(--text-muted);font-family:var(--font-mono);margin-left:6px"
774 >
775 {dep.version}
776 </span>
777 {dep.isDevDep && (
778 <span class="insights-dep-badge" style="margin-left:8px">
779 dev
780 </span>
781 )}
782 </h4>
254783 </div>
255 ))}
256 {dep.usedIn.length > 3 && (
257 <div style="color: var(--text-muted); margin-top: 2px">
258 +{dep.usedIn.length - 3} more
784 <div class="insights-card-meta">
785 {dep.totalImports === 0 ? (
786 <span class="insights-dep-unused">unused</span>
787 ) : (
788 `${dep.totalImports} import${dep.totalImports !== 1 ? "s" : ""}`
789 )}
790 </div>
791 </div>
792 {dep.usedIn.length > 0 && (
793 <div class="insights-dep-uses">
794 {dep.usedIn.slice(0, 3).map((usage) => (
795 <div>
796 <a href={`/${owner}/${repo}/blob/${ref}/${usage.file}`}>
797 {usage.file}:{usage.line}
798 </a>
799 <code style="margin-left:8px">
800 {"{ "}
801 {usage.importedSymbols.join(", ")}
802 {" }"}
803 </code>
804 </div>
805 ))}
806 {dep.usedIn.length > 3 && (
807 <div>+{dep.usedIn.length - 3} more</div>
808 )}
259809 </div>
260810 )}
261811 </div>
262 )}
812 ))}
263813 </div>
264 ))}
814 )}
265815 </div>
816 <style dangerouslySetInnerHTML={{ __html: styles }} />
266817 </Layout>
267818 );
268819});
Modifiedsrc/routes/passkeys.tsx+822−87View fileUnifiedSplit
@@ -14,11 +14,17 @@
1414 *
1515 * The browser-side glue lives in `/views/components.tsx`
1616 * (`PasskeyScript`) — vanilla JS using the native `navigator.credentials` API.
17 *
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.
1723 */
1824
1925import { Hono } from "hono";
2026import { setCookie } from "hono/cookie";
21import { and, eq } from "drizzle-orm";
27import { eq } from "drizzle-orm";
2228import { db } from "../db";
2329import { users, userPasskeys, sessions } from "../db/schema";
2430import type { AuthEnv } from "../middleware/auth";
@@ -45,6 +51,574 @@ passkeys.use("/api/passkeys/register/*", requireAuth);
4551
4652// --- Settings UI ------------------------------------------------------------
4753
54/* ─────────────────────────────────────────────────────────────────────────
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 = `
61 .pk-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
62
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
48622passkeys.get("/settings/passkeys", async (c) => {
49623 const user = c.get("user")!;
50624 const error = c.req.query("error");
@@ -60,95 +634,249 @@ passkeys.get("/settings/passkeys", async (c) => {
60634 console.error("[passkeys] list:", err);
61635 }
62636
637 const hasKeys = keys.length > 0;
638
63639 return c.html(
64640 <Layout title="Passkeys" user={user}>
65 <div class="settings-container">
66 <div class="breadcrumb">
67 <a href="/settings">settings</a>
68 <span>/</span>
69 <span>passkeys</span>
70 </div>
71 <h2>Passkeys</h2>
72 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
641 <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 )}
73670 {success && (
74 <div class="auth-success">{decodeURIComponent(success)}</div>
671 <div class="pk-banner is-ok" role="status">
672 <span class="pk-banner-dot" aria-hidden="true" />
673 {decodeURIComponent(success)}
674 </div>
75675 )}
76 <p style="color: var(--text-muted); font-size: 13px">
77 Passkeys are a phishing-resistant replacement for passwords. Your
78 device stores the private key and never shares it — sign-in is a
79 single Touch ID / Face ID / security-key tap.
80 </p>
81
82 <div style="margin: 16px 0">
83 <button
84 type="button"
85 id="pk-add-btn"
86 class="btn btn-primary"
87 >
88 Add a passkey
89 </button>
90 <span
91 id="pk-add-status"
92 style="color: var(--text-muted); font-size: 13px; margin-left: 8px"
93 />
94 </div>
95
96 <div
97 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
98 >
99 {keys.length === 0 ? (
100 <div
101 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
102 >
103 No passkeys registered yet.
676
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 />
104681 </div>
105 ) : (
106 keys.map((k) => (
107 <div
108 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
109 >
110 <div>
111 <strong>{k.name}</strong>
112 <div
113 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
114 >
115 added {new Date(k.createdAt).toLocaleDateString()}
116 {k.lastUsedAt &&
117 ` · last used ${new Date(k.lastUsedAt).toLocaleDateString()}`}
118 </div>
682 <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>
119742 </div>
120 <div style="display: flex; gap: 6px">
121 <form
122 method="post"
123 action={`/settings/passkeys/${k.id}/rename`}
124 style="display: flex; gap: 4px"
125 >
126 <input
127 type="text"
128 name="name"
129 defaultValue={k.name}
130 maxLength={60}
131 aria-label="Passkey name"
132 style="width: 160px"
133 />
134 <button type="submit" class="btn btn-sm">
135 save
136 </button>
137 </form>
138 <form
139 method="post"
140 action={`/settings/passkeys/${k.id}/delete`}
141 onsubmit="return confirm('Remove this passkey?')"
142 >
143 <button type="submit" class="btn btn-sm btn-danger">
144 remove
145 </button>
146 </form>
743 <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>
147766 </div>
767 <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>
148772 </div>
149 ))
150 )}
151 </div>
773 </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>
152880
153881 <script
154882 dangerouslySetInnerHTML={{
@@ -157,6 +885,12 @@ passkeys.get("/settings/passkeys", async (c) => {
157885 const btn = document.getElementById('pk-add-btn');
158886 const status = document.getElementById('pk-add-status');
159887 if (!btn) return;
888 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 }
160894 function b64uToBuf(s) {
161895 s = s.replace(/-/g,'+').replace(/_/g,'/');
162896 while (s.length % 4) s += '=';
@@ -173,10 +907,10 @@ passkeys.get("/settings/passkeys", async (c) => {
173907 }
174908 btn.addEventListener('click', async function () {
175909 if (!window.PublicKeyCredential) {
176 status.textContent = 'Passkeys not supported in this browser.';
910 setStatus('Passkeys not supported in this browser.', 'error');
177911 return;
178912 }
179 status.textContent = 'Preparing…';
913 setStatus('Preparing…', 'progress');
180914 try {
181915 const optsRes = await fetch('/api/passkeys/register/options', {
182916 method: 'POST',
@@ -192,7 +926,7 @@ passkeys.get("/settings/passkeys", async (c) => {
192926 return Object.assign({}, c, { id: b64uToBuf(c.id) });
193927 });
194928 }
195 status.textContent = 'Touch your authenticator…';
929 setStatus('Touch your authenticator…', 'progress');
196930 const cred = await navigator.credentials.create({ publicKey: options });
197931 const resp = {
198932 id: cred.id,
@@ -214,10 +948,10 @@ passkeys.get("/settings/passkeys", async (c) => {
214948 const j = await verifyRes.json().catch(() => ({}));
215949 throw new Error(j.error || 'verify failed');
216950 }
217 status.textContent = 'Saved. Reloading…';
951 setStatus('Saved. Reloading…', 'progress');
218952 window.location.reload();
219953 } catch (e) {
220 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
954 setStatus('Error: ' + (e && e.message ? e.message : e), 'error');
221955 }
222956 });
223957 })();
@@ -225,6 +959,7 @@ passkeys.get("/settings/passkeys", async (c) => {
225959 }}
226960 />
227961 </div>
962
228963 </Layout>
229964 );
230965});
231966