neoagent 2.4.1-beta.45 → 2.4.1-beta.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -309,6 +309,367 @@
309
309
  color: var(--text-muted) !important;
310
310
  }
311
311
 
312
+ /* ── Stats grid ────────────────────────────────────────────── */
313
+ .stat-grid {
314
+ display: grid;
315
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
316
+ gap: 10px;
317
+ }
318
+
319
+ .stat-card {
320
+ background: var(--bg-secondary);
321
+ border: 1px solid var(--border);
322
+ border-radius: 8px;
323
+ padding: 18px 16px 14px;
324
+ }
325
+
326
+ .stat-value {
327
+ font-size: 28px;
328
+ font-weight: 700;
329
+ letter-spacing: -0.5px;
330
+ color: var(--text);
331
+ line-height: 1;
332
+ font-variant-numeric: tabular-nums;
333
+ }
334
+
335
+ .stat-label {
336
+ font-size: 11px;
337
+ font-weight: 600;
338
+ text-transform: uppercase;
339
+ letter-spacing: 0.06em;
340
+ color: var(--text-muted);
341
+ margin-top: 8px;
342
+ }
343
+
344
+ /* ── Analytics tables ──────────────────────────────────────── */
345
+ .analytics-table {
346
+ width: 100%;
347
+ border-collapse: collapse;
348
+ font-size: 13px;
349
+ }
350
+
351
+ .analytics-table th {
352
+ text-align: left;
353
+ font-size: 10px;
354
+ font-weight: 600;
355
+ text-transform: uppercase;
356
+ letter-spacing: 0.07em;
357
+ color: var(--text-muted);
358
+ padding: 6px 8px;
359
+ border-bottom: 1px solid var(--border);
360
+ }
361
+
362
+ .analytics-table td {
363
+ padding: 9px 8px;
364
+ border-bottom: 1px solid rgba(224,240,224,0.04);
365
+ color: var(--text-secondary);
366
+ }
367
+
368
+ .analytics-table tr:last-child td { border-bottom: none; }
369
+ .analytics-table tr:hover td { background: rgba(224,240,224,0.025); }
370
+
371
+ .cell-truncate {
372
+ max-width: 280px;
373
+ overflow: hidden;
374
+ text-overflow: ellipsis;
375
+ white-space: nowrap;
376
+ }
377
+
378
+ .run-badge {
379
+ display: inline-block;
380
+ padding: 2px 8px;
381
+ border-radius: 999px;
382
+ font-size: 10px;
383
+ font-weight: 600;
384
+ }
385
+
386
+ .run-completed { background: rgba(116,192,124,0.15); color: var(--success); }
387
+ .run-failed { background: rgba(222,138,120,0.15); color: var(--danger); }
388
+ .run-running { background: var(--accent-muted); color: var(--accent); }
389
+ .run-pending { background: rgba(126,136,119,0.15); color: var(--text-muted); }
390
+
391
+ /* ── User avatar ────────────────────────────────────────────── */
392
+ .user-avatar {
393
+ display: inline-flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ width: 28px;
397
+ height: 28px;
398
+ min-width: 28px;
399
+ border-radius: 50%;
400
+ background: var(--accent-muted);
401
+ color: var(--accent);
402
+ font-size: 12px;
403
+ font-weight: 700;
404
+ }
405
+
406
+ /* ── Mini bar ───────────────────────────────────────────────── */
407
+ .mini-bar {
408
+ height: 4px;
409
+ background: var(--bg-secondary);
410
+ border-radius: 999px;
411
+ width: 80px;
412
+ overflow: hidden;
413
+ }
414
+
415
+ .mini-bar-fill {
416
+ height: 100%;
417
+ background: var(--accent);
418
+ border-radius: 999px;
419
+ }
420
+
421
+ /* ── Users table ────────────────────────────────────────────── */
422
+ .users-table {
423
+ width: 100%;
424
+ border-collapse: collapse;
425
+ font-size: 13px;
426
+ }
427
+
428
+ .users-table th {
429
+ text-align: left;
430
+ font-size: 10px;
431
+ font-weight: 600;
432
+ text-transform: uppercase;
433
+ letter-spacing: 0.07em;
434
+ color: var(--text-muted);
435
+ padding: 6px 8px;
436
+ border-bottom: 1px solid var(--border);
437
+ white-space: nowrap;
438
+ }
439
+
440
+ .users-table td {
441
+ padding: 10px 8px;
442
+ border-bottom: 1px solid rgba(224,240,224,0.04);
443
+ vertical-align: middle;
444
+ }
445
+
446
+ .users-table tr:last-child td { border-bottom: none; }
447
+ .users-table tr:hover td { background: rgba(224,240,224,0.025); }
448
+
449
+ .gdpr-note {
450
+ font-size: 11px;
451
+ color: var(--text-muted);
452
+ margin-top: 14px;
453
+ padding: 10px 14px;
454
+ background: rgba(222, 138, 120, 0.05);
455
+ border: 1px solid rgba(222, 138, 120, 0.15);
456
+ border-radius: var(--radius-sm);
457
+ line-height: 1.5;
458
+ }
459
+
460
+ /* ── SQL editor ─────────────────────────────────────────────── */
461
+ .sql-toolbar {
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 8px;
465
+ margin-bottom: 10px;
466
+ }
467
+
468
+ .sql-textarea {
469
+ width: 100%;
470
+ min-height: 140px;
471
+ background: var(--bg-input);
472
+ border: 1px solid var(--border-light);
473
+ border-radius: var(--radius-sm);
474
+ padding: 12px 14px;
475
+ color: var(--text);
476
+ font-family: var(--font-mono);
477
+ font-size: 13px;
478
+ line-height: 1.6;
479
+ resize: vertical;
480
+ outline: none;
481
+ tab-size: 2;
482
+ }
483
+
484
+ .sql-textarea:focus {
485
+ border-color: var(--accent);
486
+ box-shadow: 0 0 0 3px var(--accent-muted);
487
+ }
488
+
489
+ .sql-meta {
490
+ display: flex;
491
+ align-items: center;
492
+ gap: 8px;
493
+ font-size: 12px;
494
+ color: var(--text-muted);
495
+ margin: 12px 0 8px;
496
+ }
497
+
498
+ .sql-result-wrap {
499
+ overflow-x: auto;
500
+ border: 1px solid var(--border);
501
+ border-radius: var(--radius-sm);
502
+ max-height: 420px;
503
+ overflow-y: auto;
504
+ }
505
+
506
+ .sql-result-table {
507
+ width: 100%;
508
+ border-collapse: collapse;
509
+ font-size: 12px;
510
+ font-family: var(--font-mono);
511
+ }
512
+
513
+ .sql-result-table th {
514
+ background: var(--bg-secondary);
515
+ padding: 7px 12px;
516
+ text-align: left;
517
+ font-size: 10px;
518
+ font-weight: 700;
519
+ text-transform: uppercase;
520
+ letter-spacing: 0.05em;
521
+ color: var(--text-muted);
522
+ white-space: nowrap;
523
+ position: sticky;
524
+ top: 0;
525
+ z-index: 1;
526
+ border-bottom: 1px solid var(--border-light);
527
+ }
528
+
529
+ .sql-result-table td {
530
+ padding: 6px 12px;
531
+ border-bottom: 1px solid rgba(224,240,224,0.04);
532
+ color: var(--text-secondary);
533
+ white-space: nowrap;
534
+ max-width: 320px;
535
+ overflow: hidden;
536
+ text-overflow: ellipsis;
537
+ }
538
+
539
+ .sql-result-table tr:last-child td { border-bottom: none; }
540
+ .sql-result-table tr:hover td { background: rgba(224,240,224,0.03); }
541
+
542
+ .sql-error {
543
+ padding: 12px 14px;
544
+ margin-top: 10px;
545
+ background: rgba(222,138,120,0.08);
546
+ border: 1px solid rgba(222,138,120,0.2);
547
+ border-radius: var(--radius-sm);
548
+ color: var(--danger);
549
+ font-family: var(--font-mono);
550
+ font-size: 12px;
551
+ }
552
+
553
+ /* ── Access / toggle ───────────────────────────────────────── */
554
+ .access-row {
555
+ display: flex;
556
+ align-items: flex-start;
557
+ justify-content: space-between;
558
+ gap: 20px;
559
+ }
560
+
561
+ .access-label {
562
+ font-size: 14px;
563
+ font-weight: 600;
564
+ color: var(--text);
565
+ margin-bottom: 4px;
566
+ }
567
+
568
+ .access-desc {
569
+ font-size: 12px;
570
+ color: var(--text-muted);
571
+ line-height: 1.5;
572
+ max-width: 480px;
573
+ }
574
+
575
+ .access-desc code {
576
+ font-family: var(--font-mono);
577
+ font-size: 11px;
578
+ background: var(--bg-secondary);
579
+ padding: 1px 5px;
580
+ border-radius: 4px;
581
+ color: var(--accent);
582
+ }
583
+
584
+ .access-hint-row {
585
+ display: flex;
586
+ align-items: center;
587
+ gap: 12px;
588
+ margin-top: 12px;
589
+ }
590
+
591
+ .access-actions {
592
+ display: flex;
593
+ gap: 8px;
594
+ margin-top: 16px;
595
+ }
596
+
597
+ .access-status {
598
+ font-size: 12px;
599
+ margin-top: 8px;
600
+ min-height: 16px;
601
+ }
602
+
603
+ /* Toggle switch */
604
+ .toggle {
605
+ position: relative;
606
+ width: 40px;
607
+ height: 22px;
608
+ flex-shrink: 0;
609
+ cursor: pointer;
610
+ }
611
+
612
+ .toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
613
+
614
+ .toggle-track {
615
+ position: absolute;
616
+ inset: 0;
617
+ background: var(--bg-secondary);
618
+ border: 1px solid var(--border-light);
619
+ border-radius: 999px;
620
+ transition: background 0.18s, border-color 0.18s;
621
+ }
622
+
623
+ .toggle input:checked ~ .toggle-track {
624
+ background: var(--accent);
625
+ border-color: var(--accent);
626
+ }
627
+
628
+ .toggle-thumb {
629
+ position: absolute;
630
+ top: 3px;
631
+ left: 3px;
632
+ width: 16px;
633
+ height: 16px;
634
+ background: #fff;
635
+ border-radius: 50%;
636
+ transition: transform 0.18s;
637
+ pointer-events: none;
638
+ }
639
+
640
+ .toggle input:checked ~ .toggle-thumb { transform: translateX(18px); }
641
+
642
+ /* API key display */
643
+ .api-key-hint {
644
+ font-family: var(--font-mono);
645
+ font-size: 13px;
646
+ color: var(--text-secondary);
647
+ background: var(--bg-secondary);
648
+ padding: 4px 10px;
649
+ border-radius: 6px;
650
+ letter-spacing: 0.04em;
651
+ }
652
+
653
+ .api-key-full {
654
+ font-family: var(--font-mono);
655
+ font-size: 12px;
656
+ color: var(--text);
657
+ background: var(--bg-secondary);
658
+ padding: 6px 10px;
659
+ border-radius: 6px;
660
+ word-break: break-all;
661
+ flex: 1;
662
+ user-select: all;
663
+ }
664
+
665
+ .key-reveal-box {
666
+ background: rgba(225, 176, 82, 0.07);
667
+ border: 1px solid rgba(225, 176, 82, 0.25);
668
+ border-radius: var(--radius-sm);
669
+ padding: 12px 14px;
670
+ margin-top: 14px;
671
+ }
672
+
312
673
  /* ── Provider rows ─────────────────────────────────────────── */
313
674
  .provider-row {
314
675
  display: grid;
@@ -379,7 +740,7 @@
379
740
  </div>
380
741
 
381
742
  <nav aria-label="Admin navigation">
382
- <div class="nav-section-label">Dashboard</div>
743
+ <div class="nav-section-label">Monitor</div>
383
744
 
384
745
  <button class="nav-item active" data-page="overview" onclick="showPage('overview',this)">
385
746
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
@@ -402,6 +763,38 @@
402
763
  Updates
403
764
  </button>
404
765
 
766
+ <div class="nav-section-label" style="margin-top:8px;">Manage</div>
767
+
768
+ <button class="nav-item" data-page="analytics" onclick="showPage('analytics',this)">
769
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
770
+ <path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
771
+ </svg>
772
+ Analytics
773
+ </button>
774
+
775
+ <button class="nav-item" data-page="users" onclick="showPage('users',this)">
776
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
777
+ <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"/>
778
+ </svg>
779
+ Users
780
+ </button>
781
+
782
+ <button class="nav-item" data-page="sql" onclick="showPage('sql',this)">
783
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
784
+ <path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V5zm11 1H6v8l4-2 4 2V6z" clip-rule="evenodd"/>
785
+ </svg>
786
+ SQL Editor
787
+ </button>
788
+
789
+ <div class="nav-section-label" style="margin-top:8px;">Settings</div>
790
+
791
+ <button class="nav-item" data-page="access" onclick="showPage('access',this)">
792
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
793
+ <path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
794
+ </svg>
795
+ Access
796
+ </button>
797
+
405
798
  <button class="nav-item" data-page="providers" onclick="showPage('providers',this)">
406
799
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
407
800
  <path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/>
@@ -493,6 +886,70 @@
493
886
  </div>
494
887
  </section>
495
888
 
889
+ <!-- Analytics -->
890
+ <section id="page-analytics" class="page" aria-label="Analytics">
891
+ <div class="page-header">
892
+ <div>
893
+ <h1 class="page-title">Analytics</h1>
894
+ <p class="page-subtitle">Usage statistics, top users, and recent activity</p>
895
+ </div>
896
+ <span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
897
+ </div>
898
+ <div class="content">
899
+ <div id="analytics-content"><div class="empty"><span class="spinner"></span></div></div>
900
+ </div>
901
+ </section>
902
+
903
+ <!-- Users -->
904
+ <section id="page-users" class="page" aria-label="User Management">
905
+ <div class="page-header">
906
+ <div>
907
+ <h1 class="page-title">Users</h1>
908
+ <p class="page-subtitle">Account management — force logout, GDPR data erasure</p>
909
+ </div>
910
+ <span class="toolbar-count" id="users-count" aria-live="polite" style="padding-top:6px;"></span>
911
+ </div>
912
+ <div class="content">
913
+ <div class="card">
914
+ <div class="toolbar" style="margin-bottom:14px;">
915
+ <input type="search" id="user-search" placeholder="Search by username or email…"
916
+ oninput="onUserSearch()" style="width:280px;padding:7px 12px;font-size:13px;" aria-label="Search users">
917
+ <span class="spacer"></span>
918
+ <button class="btn btn-ghost" onclick="loadUsers()">Refresh</button>
919
+ </div>
920
+ <div id="users-table-wrap"><div class="empty">Loading…</div></div>
921
+ </div>
922
+ </div>
923
+ </section>
924
+
925
+ <!-- SQL Editor -->
926
+ <section id="page-sql" class="page" aria-label="SQL Editor">
927
+ <div class="page-header">
928
+ <div>
929
+ <h1 class="page-title">SQL Editor</h1>
930
+ <p class="page-subtitle">Read-only SELECT queries against the live database — results capped at 500 rows</p>
931
+ </div>
932
+ </div>
933
+ <div class="content">
934
+ <div class="card">
935
+ <div id="sql-content"><div class="empty"><span class="spinner"></span></div></div>
936
+ </div>
937
+ </div>
938
+ </section>
939
+
940
+ <!-- Access -->
941
+ <section id="page-access" class="page" aria-label="Access Settings">
942
+ <div class="page-header">
943
+ <div>
944
+ <h1 class="page-title">Access</h1>
945
+ <p class="page-subtitle">Signup policy and programmatic API key management</p>
946
+ </div>
947
+ </div>
948
+ <div class="content">
949
+ <div id="access-content"><div class="empty"><span class="spinner"></span></div></div>
950
+ </div>
951
+ </section>
952
+
496
953
  <!-- Providers -->
497
954
  <section id="page-providers" class="page" aria-label="Providers">
498
955
  <div class="page-header">
@@ -528,5 +985,9 @@
528
985
  </main>
529
986
 
530
987
  <script src="/admin/admin.js"></script>
988
+ <script src="/admin/analytics.js"></script>
989
+ <script src="/admin/users.js"></script>
990
+ <script src="/admin/sql.js"></script>
991
+ <script src="/admin/access.js"></script>
531
992
  </body>
532
993
  </html>
@@ -85,47 +85,95 @@
85
85
  </div>
86
86
  </div>
87
87
 
88
- <h1 class="login-heading">Sign in</h1>
89
- <p class="login-sub">
90
- Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
91
- </p>
92
-
93
- <div class="alert alert-error" id="error" role="alert"></div>
94
-
95
- <form id="form" novalidate>
96
- <div class="field">
97
- <label for="username">Username</label>
98
- <input
99
- id="username"
100
- type="text"
101
- autocomplete="username"
102
- autofocus
103
- required
104
- spellcheck="false"
105
- autocorrect="off"
106
- autocapitalize="none"
107
- >
108
- </div>
109
- <div class="field">
110
- <label for="password">Password</label>
111
- <input
112
- id="password"
113
- type="password"
114
- autocomplete="current-password"
115
- required
116
- >
88
+ <!-- Step 1: credentials -->
89
+ <div id="step-credentials">
90
+ <h1 class="login-heading">Sign in</h1>
91
+ <p class="login-sub">
92
+ Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
93
+ </p>
94
+
95
+ <div class="alert alert-error" id="error" role="alert"></div>
96
+
97
+ <form id="form" novalidate>
98
+ <div class="field">
99
+ <label for="username">Username</label>
100
+ <input
101
+ id="username"
102
+ type="text"
103
+ autocomplete="username"
104
+ autofocus
105
+ required
106
+ spellcheck="false"
107
+ autocorrect="off"
108
+ autocapitalize="none"
109
+ >
110
+ </div>
111
+ <div class="field">
112
+ <label for="password">Password</label>
113
+ <input
114
+ id="password"
115
+ type="password"
116
+ autocomplete="current-password"
117
+ required
118
+ >
119
+ </div>
120
+ <button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
121
+ Sign in
122
+ </button>
123
+ </form>
124
+ </div>
125
+
126
+ <!-- Step 2: 2FA code (shown after password is accepted) -->
127
+ <div id="step-totp" style="display:none;">
128
+ <h1 class="login-heading">Two-factor authentication</h1>
129
+ <p class="login-sub">Enter the 6-digit code from your authenticator app, or a recovery code.</p>
130
+
131
+ <div class="alert alert-error" id="totp-error" role="alert"></div>
132
+
133
+ <form id="totp-form" novalidate>
134
+ <div class="field">
135
+ <label for="totp-code">Authenticator code</label>
136
+ <input
137
+ id="totp-code"
138
+ type="text"
139
+ inputmode="numeric"
140
+ autocomplete="one-time-code"
141
+ maxlength="11"
142
+ placeholder="123 456"
143
+ spellcheck="false"
144
+ autocorrect="off"
145
+ autocapitalize="none"
146
+ required
147
+ >
148
+ </div>
149
+ <button type="submit" class="btn btn-primary submit-btn" id="totp-btn">
150
+ Verify
151
+ </button>
152
+ </form>
153
+ <div style="margin-top:14px;text-align:center;">
154
+ <button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
155
+ onclick="backToCredentials()">← Back</button>
117
156
  </div>
118
- <button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
119
- Sign in
120
- </button>
121
- </form>
157
+ </div>
122
158
  </div>
123
159
  </div>
124
160
 
125
161
  <script>
126
- const form = document.getElementById('form');
162
+ const form = document.getElementById('form');
127
163
  const submitBtn = document.getElementById('submit-btn');
128
- const errorEl = document.getElementById('error');
164
+ const errorEl = document.getElementById('error');
165
+ const totpForm = document.getElementById('totp-form');
166
+ const totpBtn = document.getElementById('totp-btn');
167
+ const totpError = document.getElementById('totp-error');
168
+
169
+ function showStep(step) {
170
+ document.getElementById('step-credentials').style.display = step === 1 ? '' : 'none';
171
+ document.getElementById('step-totp').style.display = step === 2 ? '' : 'none';
172
+ if (step === 2) setTimeout(() => document.getElementById('totp-code')?.focus(), 50);
173
+ if (step === 1) setTimeout(() => document.getElementById('username')?.focus(), 50);
174
+ }
175
+
176
+ function backToCredentials() { showStep(1); }
129
177
 
130
178
  form.addEventListener('submit', async (e) => {
131
179
  e.preventDefault();
@@ -143,12 +191,18 @@
143
191
  }),
144
192
  });
145
193
 
146
- if (res.ok) {
147
- window.location.replace('/admin');
194
+ const body = await res.json().catch(() => ({}));
195
+
196
+ if (res.ok || body.requiresTwoFactor) {
197
+ if (body.requiresTwoFactor) {
198
+ // Password accepted — go to 2FA step
199
+ showStep(2);
200
+ } else {
201
+ window.location.replace('/admin');
202
+ }
148
203
  return;
149
204
  }
150
205
 
151
- const body = await res.json().catch(() => ({}));
152
206
  errorEl.textContent = body.error || 'Invalid credentials';
153
207
  errorEl.classList.add('visible');
154
208
  } catch {
@@ -159,6 +213,39 @@
159
213
  submitBtn.textContent = 'Sign in';
160
214
  }
161
215
  });
216
+
217
+ totpForm.addEventListener('submit', async (e) => {
218
+ e.preventDefault();
219
+ totpError.classList.remove('visible');
220
+ totpBtn.disabled = true;
221
+ totpBtn.textContent = 'Verifying…';
222
+
223
+ try {
224
+ const code = document.getElementById('totp-code').value.trim();
225
+ const res = await fetch('/admin/api/2fa/verify', {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ code }),
229
+ });
230
+
231
+ if (res.ok) {
232
+ window.location.replace('/admin');
233
+ return;
234
+ }
235
+
236
+ const body = await res.json().catch(() => ({}));
237
+ totpError.textContent = body.error || 'Invalid code';
238
+ totpError.classList.add('visible');
239
+ document.getElementById('totp-code').value = '';
240
+ document.getElementById('totp-code').focus();
241
+ } catch {
242
+ totpError.textContent = 'Network error';
243
+ totpError.classList.add('visible');
244
+ } finally {
245
+ totpBtn.disabled = false;
246
+ totpBtn.textContent = 'Verify';
247
+ }
248
+ });
162
249
  </script>
163
250
  </body>
164
251
  </html>