Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin.tsxBlame3603 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
24import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
25import { db } from "../db";
26import { repositories, users } from "../db/schema";
27import { Layout } from "../views/layout";
5618f9aClaude28import { softAuth } from "../middleware/auth";
8f50ed0Claude29import type { AuthEnv } from "../middleware/auth";
30import {
31 grantSiteAdmin,
32 isSiteAdmin,
33 KNOWN_FLAGS,
34 listFlags,
35 listSiteAdmins,
36 revokeSiteAdmin,
37 setFlag,
38} from "../lib/admin";
39import { audit } from "../lib/notify";
08420cdClaude40import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude41import {
42 getLastTick,
43 getTickCount,
44 runAutopilotTick,
45} from "../lib/autopilot";
988380aClaude46import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude47
48const admin = new Hono<AuthEnv>();
49admin.use("*", softAuth);
50
07f4b70Claude51/* ─────────────────────────────────────────────────────────────────────────
52 * Scoped CSS — every class prefixed `.admin-` so the block cannot bleed
53 * into other surfaces. Pattern mirrors dashboard-hero (commit a004c46),
54 * repo-home (commit 544d842), and settings polish (commit 98eb360).
55 * ───────────────────────────────────────────────────────────────────── */
56const adminStyles = `
57 .admin-wrap { max-width: 1080px; margin: 0 auto; }
58
59 /* ─── Hero (main dashboard) ─── */
60 .admin-hero {
61 position: relative;
62 margin-bottom: var(--space-6);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .admin-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
75 opacity: 0.7;
76 pointer-events: none;
77 }
78 .admin-hero-bg {
79 position: absolute;
80 inset: -20% -10% auto auto;
81 width: 380px; height: 380px;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .admin-hero-orb {
86 position: absolute;
87 inset: 0;
88 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
89 filter: blur(80px);
90 opacity: 0.7;
91 animation: adminHeroOrb 14s ease-in-out infinite;
92 }
93 @keyframes adminHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .admin-hero-orb { animation: none; }
99 }
100 .admin-hero-inner {
101 position: relative;
102 z-index: 1;
103 max-width: 720px;
104 }
105 .admin-hero-eyebrow {
106 font-size: 12px;
107 color: var(--text-muted);
108 margin-bottom: var(--space-2);
109 letter-spacing: 0.02em;
110 text-transform: none;
111 display: inline-flex;
112 align-items: center;
113 gap: 8px;
114 }
115 .admin-hero-eyebrow .admin-shield {
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 width: 18px; height: 18px;
120 border-radius: 6px;
121 background: rgba(140,109,255,0.14);
122 color: #b69dff;
123 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
124 }
125 .admin-hero-eyebrow .admin-who {
126 color: var(--accent);
127 font-weight: 600;
128 }
129 .admin-hero-title {
130 font-size: clamp(28px, 4vw, 40px);
131 font-family: var(--font-display);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 var(--space-2);
136 color: var(--text-strong);
137 }
138 .admin-hero-title .gradient-text,
139 .admin-hero-title-grad {
140 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
141 -webkit-background-clip: text;
142 background-clip: text;
143 -webkit-text-fill-color: transparent;
144 color: transparent;
145 }
146 .admin-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 620px;
152 }
153
154 /* ─── Section hero (sub-pages) ─── */
155 .admin-sec-hero {
156 position: relative;
157 margin-bottom: var(--space-5);
158 padding: var(--space-4) var(--space-5);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 overflow: hidden;
163 display: flex;
164 align-items: flex-end;
165 justify-content: space-between;
166 gap: var(--space-4);
167 flex-wrap: wrap;
168 }
169 .admin-sec-hero::before {
170 content: '';
171 position: absolute;
172 top: 0; left: 0; right: 0;
173 height: 2px;
174 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
175 opacity: 0.6;
176 pointer-events: none;
177 }
178 .admin-sec-hero-text { flex: 1; min-width: 240px; }
179 .admin-sec-eyebrow {
180 font-size: 11px;
181 font-weight: 600;
182 letter-spacing: 0.08em;
183 text-transform: uppercase;
184 color: var(--accent);
185 margin-bottom: 6px;
186 }
187 .admin-sec-title {
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.8vw, 28px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 margin: 0 0 4px;
194 color: var(--text-strong);
195 }
196 .admin-sec-sub {
197 font-size: 13.5px;
198 color: var(--text-muted);
199 margin: 0;
200 line-height: 1.5;
201 max-width: 620px;
202 }
203 .admin-sec-hero-actions {
204 display: flex;
205 gap: var(--space-2);
206 flex-wrap: wrap;
207 }
208
209 /* ─── Banners ─── */
210 .admin-banner {
211 margin-bottom: var(--space-4);
212 padding: 10px 14px;
213 border-radius: 10px;
214 font-size: 13.5px;
215 border: 1px solid var(--border);
216 background: rgba(255,255,255,0.025);
217 color: var(--text);
218 }
219 .admin-banner.is-error {
220 border-color: rgba(248,113,113,0.40);
221 background: rgba(248,113,113,0.08);
222 color: #fecaca;
223 }
224 .admin-banner.is-ok {
225 border-color: rgba(52,211,153,0.40);
226 background: rgba(52,211,153,0.08);
227 color: #bbf7d0;
228 }
229
230 /* ─── Stat grid ─── */
231 .admin-stat-grid {
232 display: grid;
233 grid-template-columns: repeat(3, 1fr);
234 gap: var(--space-3);
235 margin-bottom: var(--space-5);
236 }
237 @media (max-width: 720px) {
238 .admin-stat-grid { grid-template-columns: 1fr; }
239 }
240 .admin-stat {
241 position: relative;
242 padding: var(--space-4) var(--space-4);
243 background: var(--bg-elevated);
244 border: 1px solid var(--border);
245 border-radius: 14px;
246 overflow: hidden;
247 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
248 }
249 .admin-stat::after {
250 content: '';
251 position: absolute;
252 inset: 0;
253 border-radius: inherit;
254 background: linear-gradient(135deg, rgba(140,109,255,0.05), rgba(54,197,214,0.04));
255 opacity: 0;
256 pointer-events: none;
257 transition: opacity 200ms ease;
258 }
259 .admin-stat:hover {
260 transform: translateY(-2px);
261 border-color: var(--border-strong);
262 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
263 }
264 .admin-stat:hover::after { opacity: 1; }
265 .admin-stat-head {
266 display: flex;
267 align-items: center;
268 justify-content: space-between;
269 margin-bottom: var(--space-2);
270 position: relative;
271 z-index: 1;
272 }
273 .admin-stat-label {
274 font-size: 11px;
275 font-weight: 600;
276 letter-spacing: 0.08em;
277 text-transform: uppercase;
278 color: var(--text-muted);
279 }
280 .admin-stat-icon {
281 display: inline-flex;
282 align-items: center;
283 justify-content: center;
284 width: 26px; height: 26px;
285 border-radius: 8px;
286 background: rgba(140,109,255,0.12);
287 color: #b69dff;
288 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
289 }
290 .admin-stat-value {
291 position: relative;
292 z-index: 1;
293 font-family: var(--font-display);
294 font-size: 36px;
295 font-weight: 800;
296 letter-spacing: -0.028em;
297 line-height: 1;
298 color: var(--text-strong);
299 }
300 .admin-stat-hint {
301 margin-top: 6px;
302 font-size: 12px;
303 color: var(--text-faint);
304 position: relative;
305 z-index: 1;
306 }
307
308 /* ─── Action grid ─── */
309 .admin-actions {
310 display: grid;
311 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
312 gap: var(--space-2);
313 margin-bottom: var(--space-6);
314 }
315 .admin-action {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 padding: 12px 14px;
320 background: var(--bg-elevated);
321 border: 1px solid var(--border);
322 border-radius: 12px;
323 color: var(--text);
324 text-decoration: none;
325 font-size: 13.5px;
326 font-weight: 500;
327 line-height: 1.25;
328 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
329 cursor: pointer;
330 text-align: left;
331 font-family: inherit;
332 }
333 .admin-action:hover {
334 border-color: var(--border-strong);
335 background: rgba(255,255,255,0.025);
336 transform: translateY(-1px);
337 }
338 .admin-action:focus-visible {
339 outline: none;
340 border-color: var(--border-focus);
341 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
342 }
343 .admin-action .admin-action-icon {
344 display: inline-flex;
345 align-items: center;
346 justify-content: center;
347 width: 30px; height: 30px;
348 border-radius: 8px;
349 background: rgba(255,255,255,0.03);
350 color: var(--text-muted);
351 flex-shrink: 0;
352 box-shadow: inset 0 0 0 1px var(--border);
353 }
354 .admin-action.is-primary {
355 border-color: rgba(140,109,255,0.35);
356 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.10));
357 color: var(--text-strong);
358 }
359 .admin-action.is-primary:hover {
360 border-color: rgba(140,109,255,0.55);
361 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
362 }
363 .admin-action.is-primary .admin-action-icon {
364 background: rgba(140,109,255,0.18);
365 color: #c5b3ff;
366 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
367 }
368 .admin-action-form { display: contents; }
369
370 /* ─── Section header (h3 replacement) ─── */
371 .admin-h3 {
372 display: flex;
373 align-items: baseline;
374 justify-content: space-between;
375 gap: var(--space-3);
376 margin: var(--space-5) 0 var(--space-3);
377 }
378 .admin-h3 h3 {
379 font-family: var(--font-display);
380 font-size: 16px;
381 font-weight: 700;
382 letter-spacing: -0.014em;
383 margin: 0;
384 color: var(--text-strong);
385 }
386 .admin-h3-meta {
387 font-size: 12px;
388 color: var(--text-muted);
389 }
390
391 /* ─── Lists / cards ─── */
392 .admin-list {
393 background: var(--bg-elevated);
394 border: 1px solid var(--border);
395 border-radius: 14px;
396 overflow: hidden;
397 }
398 .admin-list-row {
399 display: flex;
400 align-items: center;
401 justify-content: space-between;
402 gap: var(--space-3);
403 padding: 12px 16px;
404 border-bottom: 1px solid var(--border-subtle);
405 transition: background 120ms ease;
406 }
407 .admin-list-row:last-child { border-bottom: none; }
408 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
409 .admin-list-empty {
410 padding: var(--space-5);
411 text-align: center;
412 color: var(--text-muted);
413 font-size: 13.5px;
414 }
415 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
416 .admin-avatar {
417 width: 30px; height: 30px;
418 border-radius: 9999px;
419 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
420 color: var(--text-strong);
421 display: inline-flex;
422 align-items: center;
423 justify-content: center;
424 font-size: 12px;
425 font-weight: 700;
426 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
427 flex-shrink: 0;
428 text-transform: uppercase;
429 }
430 .admin-avatar.is-admin {
431 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
432 color: #fff;
433 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
434 }
435 .admin-row-text { min-width: 0; }
436 .admin-row-title {
437 font-size: 14px;
438 font-weight: 600;
439 color: var(--text-strong);
440 text-decoration: none;
441 }
442 .admin-row-title:hover { color: var(--accent-hover); }
443 .admin-row-sub {
444 margin-top: 2px;
445 font-size: 12px;
446 color: var(--text-muted);
447 display: flex;
448 gap: 8px;
449 flex-wrap: wrap;
450 align-items: center;
451 }
452 .admin-pill {
453 display: inline-flex;
454 align-items: center;
455 gap: 4px;
456 padding: 2px 8px;
457 border-radius: 9999px;
458 font-size: 10.5px;
459 font-weight: 600;
460 letter-spacing: 0.04em;
461 text-transform: uppercase;
462 }
463 .admin-pill.is-admin {
464 background: rgba(140,109,255,0.16);
465 color: #c5b3ff;
466 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
467 }
468 .admin-pill.is-private {
469 background: rgba(251,191,36,0.10);
470 color: #fde68a;
471 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
472 }
473 .admin-pill.is-public {
474 background: rgba(255,255,255,0.04);
475 color: var(--text-muted);
476 box-shadow: inset 0 0 0 1px var(--border);
477 }
478 .admin-pill.is-on {
479 background: rgba(52,211,153,0.14);
480 color: #6ee7b7;
481 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
482 }
483 .admin-pill.is-off {
484 background: rgba(255,255,255,0.04);
485 color: var(--text-muted);
486 box-shadow: inset 0 0 0 1px var(--border);
487 }
488 .admin-pill .dot {
489 width: 6px; height: 6px;
490 border-radius: 9999px;
491 background: currentColor;
492 }
493
494 /* ─── Inline forms / search ─── */
495 .admin-search {
496 display: flex;
497 gap: 8px;
498 flex-wrap: wrap;
499 align-items: center;
500 margin-bottom: var(--space-4);
501 }
502 .admin-input {
503 width: 320px;
504 max-width: 100%;
505 padding: 9px 12px;
506 font-size: 14px;
507 color: var(--text);
508 background: var(--bg);
509 border: 1px solid var(--border-strong);
510 border-radius: 8px;
511 outline: none;
512 font-family: var(--font-sans);
513 transition: border-color 120ms ease, box-shadow 120ms ease;
514 }
515 .admin-input:focus {
516 border-color: var(--border-focus);
517 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
518 }
519
520 /* ─── Flags form ─── */
521 .admin-card {
522 background: var(--bg-elevated);
523 border: 1px solid var(--border);
524 border-radius: 14px;
525 overflow: hidden;
526 }
527 .admin-card-body { padding: var(--space-5); }
528 .admin-card-foot {
529 padding: var(--space-3) var(--space-5);
530 border-top: 1px solid var(--border);
531 background: rgba(255,255,255,0.012);
532 display: flex;
533 justify-content: flex-end;
534 gap: var(--space-2);
535 align-items: center;
536 flex-wrap: wrap;
537 }
538 .admin-card-foot .admin-foot-hint {
539 margin-right: auto;
540 font-size: 12.5px;
541 color: var(--text-muted);
542 }
543 .admin-field { margin-bottom: var(--space-4); }
544 .admin-field:last-child { margin-bottom: 0; }
545 .admin-field label {
546 display: block;
547 font-family: var(--font-mono);
548 font-size: 12.5px;
549 font-weight: 600;
550 color: var(--text-strong);
551 margin-bottom: 6px;
552 letter-spacing: -0.005em;
553 }
554 .admin-field .admin-input-mono {
555 width: 100%;
556 padding: 9px 12px;
557 font-size: 13.5px;
558 color: var(--text);
559 background: var(--bg);
560 border: 1px solid var(--border-strong);
561 border-radius: 8px;
562 outline: none;
563 font-family: var(--font-mono);
564 transition: border-color 120ms ease, box-shadow 120ms ease;
565 }
566 .admin-field .admin-input-mono:focus {
567 border-color: var(--border-focus);
568 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
569 }
570 .admin-field-hint {
571 font-size: 11.5px;
572 color: var(--text-muted);
573 margin-top: 6px;
574 line-height: 1.45;
575 }
576 .admin-field-hint code {
577 font-family: var(--font-mono);
578 font-size: 11.5px;
579 background: var(--bg-tertiary);
580 padding: 1px 5px;
581 border-radius: 4px;
582 }
583
584 /* ─── Digest forms ─── */
585 .admin-digest-row {
586 display: flex;
587 gap: 8px;
588 align-items: center;
589 flex-wrap: wrap;
590 }
591
592 /* ─── Autopilot specific ─── */
593 .admin-ap-table {
594 width: 100%;
595 border-collapse: collapse;
596 background: var(--bg-elevated);
597 border: 1px solid var(--border);
598 border-radius: 14px;
599 overflow: hidden;
600 }
601 .admin-ap-table thead th {
602 text-align: left;
603 font-size: 11px;
604 font-weight: 600;
605 letter-spacing: 0.08em;
606 text-transform: uppercase;
607 color: var(--text-muted);
608 padding: 10px 14px;
609 background: rgba(255,255,255,0.015);
610 border-bottom: 1px solid var(--border);
611 }
612 .admin-ap-table tbody td {
613 padding: 10px 14px;
614 border-bottom: 1px solid var(--border-subtle);
615 font-size: 13px;
616 color: var(--text);
617 vertical-align: top;
618 }
619 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
620 .admin-ap-table code {
621 font-family: var(--font-mono);
622 font-size: 12px;
623 color: var(--text-strong);
624 }
625 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
626 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
627 .admin-ap-empty {
628 padding: var(--space-5);
629 text-align: center;
630 color: var(--text-muted);
631 font-size: 13.5px;
632 background: var(--bg-elevated);
633 border: 1px dashed var(--border);
634 border-radius: 14px;
635 }
636 .admin-ap-foot {
637 margin-top: var(--space-5);
638 padding: var(--space-3) var(--space-4);
639 border: 1px solid var(--border-subtle);
640 background: rgba(255,255,255,0.015);
641 border-radius: 10px;
642 color: var(--text-muted);
643 font-size: 12.5px;
644 }
645 .admin-ap-foot code {
646 font-family: var(--font-mono);
647 font-size: 12px;
648 background: var(--bg-tertiary);
649 padding: 1px 5px;
650 border-radius: 4px;
651 color: var(--text);
652 }
653
654 /* ─── Misc ─── */
655 .admin-403 {
656 max-width: 540px;
657 margin: var(--space-12) auto;
658 padding: var(--space-6);
659 text-align: center;
660 background: var(--bg-elevated);
661 border: 1px solid var(--border);
662 border-radius: 16px;
663 }
664 .admin-403 h2 {
665 font-family: var(--font-display);
666 font-size: 22px;
667 margin: 0 0 8px;
668 color: var(--text-strong);
669 }
670 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
671`;
672
8929744Claude673/* ─────────────────────────────────────────────────────────────────────────
674 * Per-sub-page scoped CSS — each handler gets its own namespace so they
675 * cannot bleed into each other or back into the shared `.admin-*` panel.
676 *
677 * .adm-users-* /admin/users
678 * .adm-repos-* /admin/repos
679 * .adm-flags-* /admin/flags
680 * .adm-digests-* /admin/digests
681 * .adm-autopilot-* /admin/autopilot
682 *
683 * All five mirror the 2026 design language from /admin and /admin/ops:
684 * - gradient hairline (::before)
685 * - animated radial-gradient orb
686 * - clamp() display headline + gradient-text span
687 * - eyebrow + subtitle
688 * - cards with avatar/icon + mono IDs + action buttons
689 * - filter pills / search bar
690 * - empty state with orb
691 * ───────────────────────────────────────────────────────────────────── */
692const admUsersStyles = `
693 .adm-users-wrap { max-width: 1080px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
694
695 /* Hero */
696 .adm-users-hero {
697 position: relative;
698 margin-bottom: var(--space-5);
699 padding: var(--space-5) var(--space-6);
700 background: var(--bg-elevated);
701 border: 1px solid var(--border);
702 border-radius: 16px;
703 overflow: hidden;
704 }
705 .adm-users-hero::before {
706 content: '';
707 position: absolute;
708 top: 0; left: 0; right: 0;
709 height: 2px;
710 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
711 opacity: 0.7;
712 pointer-events: none;
713 }
714 .adm-users-hero-orb {
715 position: absolute;
716 inset: -20% -10% auto auto;
717 width: 380px; height: 380px;
718 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
719 filter: blur(80px);
720 opacity: 0.7;
721 pointer-events: none;
722 z-index: 0;
723 animation: admUsersOrb 14s ease-in-out infinite;
724 }
725 @keyframes admUsersOrb {
726 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
727 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
728 }
729 @media (prefers-reduced-motion: reduce) {
730 .adm-users-hero-orb { animation: none; }
731 }
732 .adm-users-hero-inner {
733 position: relative;
734 z-index: 1;
735 display: flex;
736 align-items: flex-end;
737 justify-content: space-between;
738 gap: var(--space-4);
739 flex-wrap: wrap;
740 }
741 .adm-users-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
742 .adm-users-eyebrow {
743 font-size: 12px;
744 color: var(--text-muted);
745 margin-bottom: var(--space-2);
746 letter-spacing: 0.02em;
747 display: inline-flex;
748 align-items: center;
749 gap: 8px;
750 }
751 .adm-users-eyebrow-pill {
752 display: inline-flex;
753 align-items: center;
754 justify-content: center;
755 width: 22px; height: 22px;
756 border-radius: 6px;
757 background: rgba(140,109,255,0.14);
758 color: #b69dff;
759 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
760 }
761 .adm-users-title {
762 font-size: clamp(28px, 4vw, 40px);
763 font-family: var(--font-display);
764 font-weight: 800;
765 letter-spacing: -0.028em;
766 line-height: 1.05;
767 margin: 0 0 var(--space-2);
768 color: var(--text-strong);
769 }
770 .adm-users-title-grad {
771 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
772 -webkit-background-clip: text;
773 background-clip: text;
774 -webkit-text-fill-color: transparent;
775 color: transparent;
776 }
777 .adm-users-sub {
778 font-size: 15px;
779 color: var(--text-muted);
780 margin: 0;
781 line-height: 1.5;
782 max-width: 620px;
783 }
784 .adm-users-back {
785 display: inline-flex;
786 align-items: center;
787 gap: 6px;
788 padding: 7px 12px;
789 font-size: 12.5px;
790 color: var(--text-muted);
791 background: rgba(255,255,255,0.02);
792 border: 1px solid var(--border);
793 border-radius: 8px;
794 text-decoration: none;
795 font-weight: 500;
796 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
797 }
798 .adm-users-back:hover {
799 border-color: var(--border-strong);
800 color: var(--text-strong);
801 background: rgba(255,255,255,0.04);
802 }
803
804 /* Filter bar */
805 .adm-users-filterbar {
806 display: flex;
807 gap: var(--space-3);
808 align-items: center;
809 justify-content: space-between;
810 margin-bottom: var(--space-4);
811 flex-wrap: wrap;
812 }
813 .adm-users-search {
814 position: relative;
815 display: flex;
816 align-items: center;
817 gap: 8px;
818 flex: 1;
819 min-width: 280px;
820 }
821 .adm-users-search-ico {
822 position: absolute;
823 left: 12px;
824 top: 50%;
825 transform: translateY(-50%);
826 color: var(--text-muted);
827 pointer-events: none;
828 display: inline-flex;
829 }
830 .adm-users-input {
831 flex: 1;
832 width: 100%;
833 padding: 10px 12px 10px 36px;
834 font-size: 14px;
835 color: var(--text);
836 background: var(--bg);
837 border: 1px solid var(--border-strong);
838 border-radius: 10px;
839 outline: none;
840 font-family: var(--font-sans);
841 transition: border-color 120ms ease, box-shadow 120ms ease;
842 }
843 .adm-users-input:focus {
844 border-color: var(--border-focus);
845 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
846 }
847 .adm-users-pills {
848 display: inline-flex;
849 gap: 6px;
850 flex-wrap: wrap;
851 }
852 .adm-users-pill {
853 display: inline-flex;
854 align-items: center;
855 gap: 6px;
856 padding: 4px 10px;
857 border-radius: 9999px;
858 font-size: 11.5px;
859 font-weight: 600;
860 background: rgba(255,255,255,0.04);
861 color: var(--text-muted);
862 box-shadow: inset 0 0 0 1px var(--border);
863 }
864 .adm-users-pill .dot {
865 width: 6px; height: 6px;
866 border-radius: 9999px;
867 background: currentColor;
868 }
869 .adm-users-pill.is-admin {
870 background: rgba(140,109,255,0.16);
871 color: #c5b3ff;
872 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
873 }
874
875 /* Buttons */
876 .adm-users-btn {
877 display: inline-flex;
878 align-items: center;
879 gap: 6px;
880 padding: 8px 14px;
881 border-radius: 8px;
882 font-size: 13px;
883 font-weight: 600;
884 text-decoration: none;
885 border: 1px solid var(--border-strong);
886 background: rgba(255,255,255,0.02);
887 color: var(--text);
888 cursor: pointer;
889 font: inherit;
890 font-weight: 600;
891 line-height: 1;
892 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
893 }
894 .adm-users-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
895 .adm-users-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
896 .adm-users-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
897 .adm-users-btn-primary {
898 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
899 color: #fff;
900 border-color: transparent;
901 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
902 }
903 .adm-users-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
904 .adm-users-btn-danger {
905 background: transparent;
906 color: #fca5a5;
907 border-color: rgba(248,113,113,0.40);
908 }
909 .adm-users-btn-danger:hover {
910 background: rgba(248,113,113,0.08);
911 border-color: rgba(248,113,113,0.70);
912 color: #fecaca;
913 }
914
915 /* Card grid */
916 .adm-users-grid {
917 display: grid;
918 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
919 gap: var(--space-3);
920 }
921 .adm-users-card {
922 position: relative;
923 padding: var(--space-4);
924 background: var(--bg-elevated);
925 border: 1px solid var(--border);
926 border-radius: 14px;
927 display: flex;
928 flex-direction: column;
929 gap: var(--space-3);
930 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
931 }
932 .adm-users-card:hover {
933 transform: translateY(-2px);
934 border-color: var(--border-strong);
935 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
936 }
937 .adm-users-card.is-admin {
938 border-color: rgba(140,109,255,0.35);
939 background:
940 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
941 var(--bg-elevated);
942 }
943 .adm-users-card-head {
944 display: flex;
945 align-items: center;
946 gap: 12px;
947 }
948 .adm-users-avatar {
949 width: 38px; height: 38px;
950 border-radius: 9999px;
951 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
952 color: var(--text-strong);
953 display: inline-flex;
954 align-items: center;
955 justify-content: center;
956 font-size: 14px;
957 font-weight: 700;
958 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
959 flex-shrink: 0;
960 text-transform: uppercase;
961 }
962 .adm-users-avatar.is-admin {
963 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
964 color: #fff;
965 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
966 }
967 .adm-users-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
968 .adm-users-card-name {
969 font-size: 14.5px;
970 font-weight: 700;
971 color: var(--text-strong);
972 text-decoration: none;
973 letter-spacing: -0.005em;
974 }
975 .adm-users-card-name:hover { color: var(--accent-hover, var(--accent)); }
976 .adm-users-card-mono {
977 font-family: var(--font-mono);
978 font-size: 11px;
979 color: var(--text-muted);
980 background: rgba(255,255,255,0.03);
981 padding: 1px 6px;
982 border-radius: 4px;
983 border: 1px solid var(--border-subtle);
984 width: fit-content;
985 }
986 .adm-users-card-meta {
987 display: flex;
988 flex-direction: column;
989 gap: 6px;
990 font-size: 12.5px;
991 }
992 .adm-users-meta-item { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
993 .adm-users-meta-key {
994 font-family: var(--font-mono);
995 font-size: 10.5px;
996 text-transform: uppercase;
997 letter-spacing: 0.06em;
998 color: var(--text-muted);
999 flex-shrink: 0;
1000 width: 50px;
1001 }
1002 .adm-users-meta-val { color: var(--text); word-break: break-all; min-width: 0; }
1003 .adm-users-card-actions {
1004 display: flex;
1005 gap: 8px;
1006 flex-wrap: wrap;
1007 margin-top: auto;
1008 }
1009 .adm-users-card-actions form { margin: 0; }
1010
1011 /* Empty state */
1012 .adm-users-empty {
1013 position: relative;
1014 padding: var(--space-12) var(--space-6);
1015 border: 1px dashed var(--border);
1016 border-radius: 16px;
1017 background: var(--bg-elevated);
1018 text-align: center;
1019 overflow: hidden;
1020 }
1021 .adm-users-empty-orb {
1022 position: absolute;
1023 inset: 50% auto auto 50%;
1024 transform: translate(-50%, -50%);
1025 width: 320px; height: 320px;
1026 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1027 filter: blur(60px);
1028 pointer-events: none;
1029 z-index: 0;
1030 }
1031 .adm-users-empty-inner { position: relative; z-index: 1; }
1032 .adm-users-empty-icon {
1033 display: inline-flex;
1034 align-items: center;
1035 justify-content: center;
1036 width: 56px; height: 56px;
1037 border-radius: 16px;
1038 background: rgba(140,109,255,0.10);
1039 color: #b69dff;
1040 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1041 margin-bottom: var(--space-3);
1042 }
1043 .adm-users-empty-icon svg { width: 24px; height: 24px; }
1044 .adm-users-empty-title {
1045 font-family: var(--font-display);
1046 font-size: 20px;
1047 font-weight: 700;
1048 letter-spacing: -0.015em;
1049 color: var(--text-strong);
1050 margin-bottom: 6px;
1051 }
1052 .adm-users-empty-sub {
1053 color: var(--text-muted);
1054 font-size: 13.5px;
1055 line-height: 1.5;
1056 }
1057 .adm-users-empty-sub code {
1058 font-family: var(--font-mono);
1059 font-size: 12px;
1060 background: var(--bg-tertiary);
1061 padding: 1px 6px;
1062 border-radius: 4px;
1063 color: var(--text);
1064 }
1065`;
1066
1067const admReposStyles = `
1068 .adm-repos-wrap { max-width: 1080px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
1069
1070 .adm-repos-hero {
1071 position: relative;
1072 margin-bottom: var(--space-5);
1073 padding: var(--space-5) var(--space-6);
1074 background: var(--bg-elevated);
1075 border: 1px solid var(--border);
1076 border-radius: 16px;
1077 overflow: hidden;
1078 }
1079 .adm-repos-hero::before {
1080 content: '';
1081 position: absolute;
1082 top: 0; left: 0; right: 0;
1083 height: 2px;
1084 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1085 opacity: 0.7;
1086 pointer-events: none;
1087 }
1088 .adm-repos-hero-orb {
1089 position: absolute;
1090 inset: -20% -10% auto auto;
1091 width: 380px; height: 380px;
1092 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1093 filter: blur(80px);
1094 opacity: 0.7;
1095 pointer-events: none;
1096 z-index: 0;
1097 animation: admReposOrb 14s ease-in-out infinite;
1098 }
1099 @keyframes admReposOrb {
1100 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1101 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1102 }
1103 @media (prefers-reduced-motion: reduce) {
1104 .adm-repos-hero-orb { animation: none; }
1105 }
1106 .adm-repos-hero-inner {
1107 position: relative;
1108 z-index: 1;
1109 display: flex;
1110 align-items: flex-end;
1111 justify-content: space-between;
1112 gap: var(--space-4);
1113 flex-wrap: wrap;
1114 }
1115 .adm-repos-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1116 .adm-repos-eyebrow {
1117 font-size: 12px;
1118 color: var(--text-muted);
1119 margin-bottom: var(--space-2);
1120 letter-spacing: 0.02em;
1121 display: inline-flex;
1122 align-items: center;
1123 gap: 8px;
1124 }
1125 .adm-repos-eyebrow-pill {
1126 display: inline-flex;
1127 align-items: center;
1128 justify-content: center;
1129 width: 22px; height: 22px;
1130 border-radius: 6px;
1131 background: rgba(140,109,255,0.14);
1132 color: #b69dff;
1133 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1134 }
1135 .adm-repos-title {
1136 font-size: clamp(28px, 4vw, 40px);
1137 font-family: var(--font-display);
1138 font-weight: 800;
1139 letter-spacing: -0.028em;
1140 line-height: 1.05;
1141 margin: 0 0 var(--space-2);
1142 color: var(--text-strong);
1143 }
1144 .adm-repos-title-grad {
1145 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1146 -webkit-background-clip: text;
1147 background-clip: text;
1148 -webkit-text-fill-color: transparent;
1149 color: transparent;
1150 }
1151 .adm-repos-sub {
1152 font-size: 15px;
1153 color: var(--text-muted);
1154 margin: 0;
1155 line-height: 1.5;
1156 max-width: 620px;
1157 }
1158 .adm-repos-back {
1159 display: inline-flex;
1160 align-items: center;
1161 gap: 6px;
1162 padding: 7px 12px;
1163 font-size: 12.5px;
1164 color: var(--text-muted);
1165 background: rgba(255,255,255,0.02);
1166 border: 1px solid var(--border);
1167 border-radius: 8px;
1168 text-decoration: none;
1169 font-weight: 500;
1170 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1171 }
1172 .adm-repos-back:hover {
1173 border-color: var(--border-strong);
1174 color: var(--text-strong);
1175 background: rgba(255,255,255,0.04);
1176 }
1177
1178 .adm-repos-pills {
1179 display: inline-flex;
1180 gap: 6px;
1181 flex-wrap: wrap;
1182 margin-bottom: var(--space-4);
1183 }
1184 .adm-repos-pill {
1185 display: inline-flex;
1186 align-items: center;
1187 gap: 6px;
1188 padding: 4px 10px;
1189 border-radius: 9999px;
1190 font-size: 11.5px;
1191 font-weight: 600;
1192 background: rgba(255,255,255,0.04);
1193 color: var(--text-muted);
1194 box-shadow: inset 0 0 0 1px var(--border);
1195 }
1196 .adm-repos-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1197 .adm-repos-pill.is-private {
1198 background: rgba(251,191,36,0.10);
1199 color: #fde68a;
1200 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
1201 }
1202 .adm-repos-pill.is-public {
1203 background: rgba(52,211,153,0.10);
1204 color: #86efac;
1205 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
1206 }
1207
1208 .adm-repos-btn {
1209 display: inline-flex;
1210 align-items: center;
1211 gap: 6px;
1212 padding: 8px 14px;
1213 border-radius: 8px;
1214 font-size: 13px;
1215 font-weight: 600;
1216 text-decoration: none;
1217 border: 1px solid var(--border-strong);
1218 background: rgba(255,255,255,0.02);
1219 color: var(--text);
1220 cursor: pointer;
1221 font: inherit;
1222 line-height: 1;
1223 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1224 }
1225 .adm-repos-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1226 .adm-repos-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
1227 .adm-repos-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
1228 .adm-repos-btn-danger {
1229 background: transparent;
1230 color: #fca5a5;
1231 border-color: rgba(248,113,113,0.40);
1232 }
1233 .adm-repos-btn-danger:hover {
1234 background: rgba(248,113,113,0.08);
1235 border-color: rgba(248,113,113,0.70);
1236 color: #fecaca;
1237 }
1238
1239 .adm-repos-grid {
1240 display: grid;
1241 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1242 gap: var(--space-3);
1243 }
1244 .adm-repos-card {
1245 position: relative;
1246 padding: var(--space-4);
1247 background: var(--bg-elevated);
1248 border: 1px solid var(--border);
1249 border-radius: 14px;
1250 display: flex;
1251 flex-direction: column;
1252 gap: var(--space-3);
1253 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1254 }
1255 .adm-repos-card:hover {
1256 transform: translateY(-2px);
1257 border-color: var(--border-strong);
1258 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1259 }
1260 .adm-repos-card-head {
1261 display: flex;
1262 align-items: center;
1263 gap: 12px;
1264 }
1265 .adm-repos-icon {
1266 width: 38px; height: 38px;
1267 border-radius: 10px;
1268 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
1269 color: #b69dff;
1270 display: inline-flex;
1271 align-items: center;
1272 justify-content: center;
1273 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1274 flex-shrink: 0;
1275 }
1276 .adm-repos-icon svg { width: 18px; height: 18px; }
1277 .adm-repos-card-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
1278 .adm-repos-card-name {
1279 font-size: 14.5px;
1280 font-weight: 700;
1281 color: var(--text-strong);
1282 text-decoration: none;
1283 letter-spacing: -0.005em;
1284 word-break: break-all;
1285 }
1286 .adm-repos-card-name:hover { color: var(--accent-hover, var(--accent)); }
1287 .adm-repos-card-mono {
1288 font-family: var(--font-mono);
1289 font-size: 11px;
1290 color: var(--text-muted);
1291 background: rgba(255,255,255,0.03);
1292 padding: 1px 6px;
1293 border-radius: 4px;
1294 border: 1px solid var(--border-subtle);
1295 width: fit-content;
1296 }
1297 .adm-repos-card-meta {
1298 display: flex;
1299 gap: var(--space-3);
1300 flex-wrap: wrap;
1301 font-size: 12.5px;
1302 color: var(--text-muted);
1303 }
1304 .adm-repos-meta-item {
1305 display: inline-flex;
1306 align-items: center;
1307 gap: 5px;
1308 }
1309 .adm-repos-meta-item svg { width: 13px; height: 13px; color: var(--text-muted); }
1310 .adm-repos-card-actions {
1311 display: flex;
1312 gap: 8px;
1313 flex-wrap: wrap;
1314 margin-top: auto;
1315 }
1316 .adm-repos-card-actions form { margin: 0; }
1317
1318 .adm-repos-empty {
1319 position: relative;
1320 padding: var(--space-12) var(--space-6);
1321 border: 1px dashed var(--border);
1322 border-radius: 16px;
1323 background: var(--bg-elevated);
1324 text-align: center;
1325 overflow: hidden;
1326 }
1327 .adm-repos-empty-orb {
1328 position: absolute;
1329 inset: 50% auto auto 50%;
1330 transform: translate(-50%, -50%);
1331 width: 320px; height: 320px;
1332 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1333 filter: blur(60px);
1334 pointer-events: none;
1335 z-index: 0;
1336 }
1337 .adm-repos-empty-inner { position: relative; z-index: 1; }
1338 .adm-repos-empty-icon {
1339 display: inline-flex;
1340 align-items: center;
1341 justify-content: center;
1342 width: 56px; height: 56px;
1343 border-radius: 16px;
1344 background: rgba(140,109,255,0.10);
1345 color: #b69dff;
1346 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1347 margin-bottom: var(--space-3);
1348 }
1349 .adm-repos-empty-icon svg { width: 24px; height: 24px; }
1350 .adm-repos-empty-title {
1351 font-family: var(--font-display);
1352 font-size: 20px;
1353 font-weight: 700;
1354 letter-spacing: -0.015em;
1355 color: var(--text-strong);
1356 margin-bottom: 6px;
1357 }
1358 .adm-repos-empty-sub {
1359 color: var(--text-muted);
1360 font-size: 13.5px;
1361 line-height: 1.5;
1362 }
1363`;
1364
1365const admFlagsStyles = `
1366 .adm-flags-wrap { max-width: 880px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
1367
1368 .adm-flags-hero {
1369 position: relative;
1370 margin-bottom: var(--space-5);
1371 padding: var(--space-5) var(--space-6);
1372 background: var(--bg-elevated);
1373 border: 1px solid var(--border);
1374 border-radius: 16px;
1375 overflow: hidden;
1376 }
1377 .adm-flags-hero::before {
1378 content: '';
1379 position: absolute;
1380 top: 0; left: 0; right: 0;
1381 height: 2px;
1382 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1383 opacity: 0.7;
1384 pointer-events: none;
1385 }
1386 .adm-flags-hero-orb {
1387 position: absolute;
1388 inset: -20% -10% auto auto;
1389 width: 380px; height: 380px;
1390 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1391 filter: blur(80px);
1392 opacity: 0.7;
1393 pointer-events: none;
1394 z-index: 0;
1395 animation: admFlagsOrb 14s ease-in-out infinite;
1396 }
1397 @keyframes admFlagsOrb {
1398 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1399 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1400 }
1401 @media (prefers-reduced-motion: reduce) {
1402 .adm-flags-hero-orb { animation: none; }
1403 }
1404 .adm-flags-hero-inner {
1405 position: relative;
1406 z-index: 1;
1407 display: flex;
1408 align-items: flex-end;
1409 justify-content: space-between;
1410 gap: var(--space-4);
1411 flex-wrap: wrap;
1412 }
1413 .adm-flags-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1414 .adm-flags-eyebrow {
1415 font-size: 12px;
1416 color: var(--text-muted);
1417 margin-bottom: var(--space-2);
1418 letter-spacing: 0.02em;
1419 display: inline-flex;
1420 align-items: center;
1421 gap: 8px;
1422 }
1423 .adm-flags-eyebrow-pill {
1424 display: inline-flex;
1425 align-items: center;
1426 justify-content: center;
1427 width: 22px; height: 22px;
1428 border-radius: 6px;
1429 background: rgba(140,109,255,0.14);
1430 color: #b69dff;
1431 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1432 }
1433 .adm-flags-title {
1434 font-size: clamp(28px, 4vw, 40px);
1435 font-family: var(--font-display);
1436 font-weight: 800;
1437 letter-spacing: -0.028em;
1438 line-height: 1.05;
1439 margin: 0 0 var(--space-2);
1440 color: var(--text-strong);
1441 }
1442 .adm-flags-title-grad {
1443 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1444 -webkit-background-clip: text;
1445 background-clip: text;
1446 -webkit-text-fill-color: transparent;
1447 color: transparent;
1448 }
1449 .adm-flags-sub {
1450 font-size: 15px;
1451 color: var(--text-muted);
1452 margin: 0;
1453 line-height: 1.5;
1454 max-width: 620px;
1455 }
1456 .adm-flags-sub code {
1457 font-family: var(--font-mono);
1458 font-size: 13px;
1459 background: var(--bg-tertiary);
1460 padding: 1px 5px;
1461 border-radius: 4px;
1462 color: var(--text);
1463 }
1464 .adm-flags-back {
1465 display: inline-flex;
1466 align-items: center;
1467 gap: 6px;
1468 padding: 7px 12px;
1469 font-size: 12.5px;
1470 color: var(--text-muted);
1471 background: rgba(255,255,255,0.02);
1472 border: 1px solid var(--border);
1473 border-radius: 8px;
1474 text-decoration: none;
1475 font-weight: 500;
1476 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1477 }
1478 .adm-flags-back:hover {
1479 border-color: var(--border-strong);
1480 color: var(--text-strong);
1481 background: rgba(255,255,255,0.04);
1482 }
1483
1484 .adm-flags-card {
1485 background: var(--bg-elevated);
1486 border: 1px solid var(--border);
1487 border-radius: 14px;
1488 overflow: hidden;
1489 }
1490 .adm-flags-card-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1491 .adm-flags-card-foot {
1492 padding: var(--space-3) var(--space-5);
1493 border-top: 1px solid var(--border);
1494 background: rgba(255,255,255,0.012);
1495 display: flex;
1496 justify-content: flex-end;
1497 gap: var(--space-2);
1498 align-items: center;
1499 flex-wrap: wrap;
1500 }
1501 .adm-flags-foot-hint {
1502 margin-right: auto;
1503 font-size: 12.5px;
1504 color: var(--text-muted);
1505 }
1506
1507 .adm-flags-field {
1508 display: flex;
1509 flex-direction: column;
1510 gap: 6px;
1511 padding: var(--space-3);
1512 border: 1px solid var(--border-subtle);
1513 border-radius: 12px;
1514 background: rgba(255,255,255,0.015);
1515 transition: border-color 120ms ease, background 120ms ease;
1516 }
1517 .adm-flags-field:hover { border-color: var(--border); background: rgba(255,255,255,0.025); }
1518 .adm-flags-field-head {
1519 display: flex;
1520 align-items: center;
1521 gap: 8px;
1522 }
1523 .adm-flags-key {
1524 font-family: var(--font-mono);
1525 font-size: 12.5px;
1526 font-weight: 600;
1527 color: var(--text-strong);
1528 letter-spacing: -0.005em;
1529 }
1530 .adm-flags-mono {
1531 font-family: var(--font-mono);
1532 font-size: 10.5px;
1533 color: var(--text-muted);
1534 background: rgba(255,255,255,0.04);
1535 padding: 1px 6px;
1536 border-radius: 4px;
1537 border: 1px solid var(--border-subtle);
1538 }
1539 .adm-flags-input {
1540 width: 100%;
1541 padding: 9px 12px;
1542 font-size: 13.5px;
1543 color: var(--text);
1544 background: var(--bg);
1545 border: 1px solid var(--border-strong);
1546 border-radius: 8px;
1547 outline: none;
1548 font-family: var(--font-mono);
1549 transition: border-color 120ms ease, box-shadow 120ms ease;
1550 box-sizing: border-box;
1551 }
1552 .adm-flags-input:focus {
1553 border-color: var(--border-focus);
1554 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1555 }
1556 .adm-flags-hint {
1557 font-size: 11.5px;
1558 color: var(--text-muted);
1559 margin-top: 2px;
1560 line-height: 1.45;
1561 }
1562 .adm-flags-hint code {
1563 font-family: var(--font-mono);
1564 font-size: 11.5px;
1565 background: var(--bg-tertiary);
1566 padding: 1px 5px;
1567 border-radius: 4px;
1568 color: var(--text);
1569 }
1570
1571 .adm-flags-btn {
1572 display: inline-flex;
1573 align-items: center;
1574 gap: 6px;
1575 padding: 9px 16px;
1576 border-radius: 10px;
1577 font-size: 13px;
1578 font-weight: 600;
1579 text-decoration: none;
1580 border: 1px solid transparent;
1581 cursor: pointer;
1582 font: inherit;
1583 line-height: 1;
1584 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1585 }
1586 .adm-flags-btn-primary {
1587 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1588 color: #fff;
1589 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1590 }
1591 .adm-flags-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
1592`;
1593
1594const admDigestsStyles = `
1595 .adm-digests-wrap { max-width: 1000px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
1596
1597 .adm-digests-hero {
1598 position: relative;
1599 margin-bottom: var(--space-5);
1600 padding: var(--space-5) var(--space-6);
1601 background: var(--bg-elevated);
1602 border: 1px solid var(--border);
1603 border-radius: 16px;
1604 overflow: hidden;
1605 }
1606 .adm-digests-hero::before {
1607 content: '';
1608 position: absolute;
1609 top: 0; left: 0; right: 0;
1610 height: 2px;
1611 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1612 opacity: 0.7;
1613 pointer-events: none;
1614 }
1615 .adm-digests-hero-orb {
1616 position: absolute;
1617 inset: -20% -10% auto auto;
1618 width: 380px; height: 380px;
1619 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1620 filter: blur(80px);
1621 opacity: 0.7;
1622 pointer-events: none;
1623 z-index: 0;
1624 animation: admDigestsOrb 14s ease-in-out infinite;
1625 }
1626 @keyframes admDigestsOrb {
1627 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1628 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1629 }
1630 @media (prefers-reduced-motion: reduce) {
1631 .adm-digests-hero-orb { animation: none; }
1632 }
1633 .adm-digests-hero-inner {
1634 position: relative;
1635 z-index: 1;
1636 display: flex;
1637 align-items: flex-end;
1638 justify-content: space-between;
1639 gap: var(--space-4);
1640 flex-wrap: wrap;
1641 }
1642 .adm-digests-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1643 .adm-digests-eyebrow {
1644 font-size: 12px;
1645 color: var(--text-muted);
1646 margin-bottom: var(--space-2);
1647 letter-spacing: 0.02em;
1648 display: inline-flex;
1649 align-items: center;
1650 gap: 8px;
1651 }
1652 .adm-digests-eyebrow-pill {
1653 display: inline-flex;
1654 align-items: center;
1655 justify-content: center;
1656 width: 22px; height: 22px;
1657 border-radius: 6px;
1658 background: rgba(140,109,255,0.14);
1659 color: #b69dff;
1660 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1661 }
1662 .adm-digests-title {
1663 font-size: clamp(28px, 4vw, 40px);
1664 font-family: var(--font-display);
1665 font-weight: 800;
1666 letter-spacing: -0.028em;
1667 line-height: 1.05;
1668 margin: 0 0 var(--space-2);
1669 color: var(--text-strong);
1670 }
1671 .adm-digests-title-grad {
1672 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1673 -webkit-background-clip: text;
1674 background-clip: text;
1675 -webkit-text-fill-color: transparent;
1676 color: transparent;
1677 }
1678 .adm-digests-sub {
1679 font-size: 15px;
1680 color: var(--text-muted);
1681 margin: 0;
1682 line-height: 1.5;
1683 max-width: 620px;
1684 }
1685 .adm-digests-back {
1686 display: inline-flex;
1687 align-items: center;
1688 gap: 6px;
1689 padding: 7px 12px;
1690 font-size: 12.5px;
1691 color: var(--text-muted);
1692 background: rgba(255,255,255,0.02);
1693 border: 1px solid var(--border);
1694 border-radius: 8px;
1695 text-decoration: none;
1696 font-weight: 500;
1697 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1698 }
1699 .adm-digests-back:hover {
1700 border-color: var(--border-strong);
1701 color: var(--text-strong);
1702 background: rgba(255,255,255,0.04);
1703 }
1704
1705 .adm-digests-banner {
1706 margin-bottom: var(--space-4);
1707 padding: 10px 14px;
1708 border-radius: 10px;
1709 font-size: 13.5px;
1710 border: 1px solid var(--border);
1711 background: rgba(255,255,255,0.025);
1712 color: var(--text);
1713 }
1714 .adm-digests-banner.is-ok {
1715 border-color: rgba(52,211,153,0.40);
1716 background: rgba(52,211,153,0.08);
1717 color: #bbf7d0;
1718 }
1719 .adm-digests-banner.is-error {
1720 border-color: rgba(248,113,113,0.40);
1721 background: rgba(248,113,113,0.08);
1722 color: #fecaca;
1723 }
1724
1725 .adm-digests-pills {
1726 display: inline-flex;
1727 gap: 6px;
1728 flex-wrap: wrap;
1729 margin-bottom: var(--space-4);
1730 }
1731 .adm-digests-pill {
1732 display: inline-flex;
1733 align-items: center;
1734 gap: 6px;
1735 padding: 4px 10px;
1736 border-radius: 9999px;
1737 font-size: 11.5px;
1738 font-weight: 600;
1739 background: rgba(255,255,255,0.04);
1740 color: var(--text-muted);
1741 box-shadow: inset 0 0 0 1px var(--border);
1742 }
1743 .adm-digests-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1744 .adm-digests-pill.is-on {
1745 background: rgba(52,211,153,0.14);
1746 color: #6ee7b7;
1747 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1748 }
1749
1750 .adm-digests-section {
1751 margin-bottom: var(--space-5);
1752 background: var(--bg-elevated);
1753 border: 1px solid var(--border);
1754 border-radius: 14px;
1755 overflow: hidden;
1756 }
1757 .adm-digests-section-head {
1758 padding: var(--space-4) var(--space-5);
1759 border-bottom: 1px solid var(--border);
1760 display: flex;
1761 align-items: center;
1762 gap: 10px;
1763 flex-wrap: wrap;
1764 }
1765 .adm-digests-section-icon {
1766 display: inline-flex;
1767 align-items: center;
1768 justify-content: center;
1769 width: 26px; height: 26px;
1770 border-radius: 8px;
1771 background: rgba(140,109,255,0.12);
1772 color: #b69dff;
1773 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1774 flex-shrink: 0;
1775 }
1776 .adm-digests-section-title {
1777 margin: 0;
1778 font-family: var(--font-display);
1779 font-size: 17px;
1780 font-weight: 700;
1781 letter-spacing: -0.018em;
1782 color: var(--text-strong);
1783 }
1784 .adm-digests-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
1785 .adm-digests-section-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1786
1787 .adm-digests-input {
1788 width: 100%;
1789 padding: 9px 12px;
1790 font-size: 13.5px;
1791 color: var(--text);
1792 background: var(--bg);
1793 border: 1px solid var(--border-strong);
1794 border-radius: 8px;
1795 outline: none;
1796 font-family: var(--font-mono);
1797 transition: border-color 120ms ease, box-shadow 120ms ease;
1798 box-sizing: border-box;
1799 max-width: 280px;
1800 }
1801 .adm-digests-input:focus {
1802 border-color: var(--border-focus);
1803 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1804 }
1805 .adm-digests-form-row {
1806 display: flex;
1807 gap: 8px;
1808 align-items: center;
1809 flex-wrap: wrap;
1810 }
1811
1812 .adm-digests-btn {
1813 display: inline-flex;
1814 align-items: center;
1815 gap: 6px;
1816 padding: 9px 16px;
1817 border-radius: 10px;
1818 font-size: 13px;
1819 font-weight: 600;
1820 text-decoration: none;
1821 border: 1px solid var(--border-strong);
1822 background: rgba(255,255,255,0.02);
1823 color: var(--text);
1824 cursor: pointer;
1825 font: inherit;
1826 line-height: 1;
1827 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1828 }
1829 .adm-digests-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1830 .adm-digests-btn-primary {
1831 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1832 color: #fff;
1833 border-color: transparent;
1834 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1835 }
1836 .adm-digests-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
1837
1838 .adm-digests-section-divider {
1839 border-top: 1px solid var(--border-subtle);
1840 padding-top: var(--space-3);
1841 margin-top: var(--space-2);
1842 }
1843 .adm-digests-divider-hint {
1844 font-size: 12.5px;
1845 color: var(--text-muted);
1846 margin-bottom: 8px;
1847 }
1848
1849 .adm-digests-h3 {
1850 display: flex;
1851 align-items: baseline;
1852 justify-content: space-between;
1853 gap: var(--space-3);
1854 margin: var(--space-5) 0 var(--space-3);
1855 }
1856 .adm-digests-h3 h3 {
1857 font-family: var(--font-display);
1858 font-size: 16px;
1859 font-weight: 700;
1860 letter-spacing: -0.014em;
1861 margin: 0;
1862 color: var(--text-strong);
1863 }
1864 .adm-digests-h3-meta { font-size: 12px; color: var(--text-muted); }
1865
1866 .adm-digests-grid {
1867 display: grid;
1868 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1869 gap: var(--space-3);
1870 }
1871 .adm-digests-card {
1872 padding: var(--space-3) var(--space-4);
1873 background: var(--bg-elevated);
1874 border: 1px solid var(--border);
1875 border-radius: 12px;
1876 display: flex;
1877 align-items: center;
1878 gap: 12px;
1879 transition: transform 160ms ease, border-color 160ms ease;
1880 }
1881 .adm-digests-card:hover { transform: translateY(-1px); border-color: var(--border-strong); }
1882 .adm-digests-avatar {
1883 width: 36px; height: 36px;
1884 border-radius: 9999px;
1885 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
1886 color: var(--text-strong);
1887 display: inline-flex;
1888 align-items: center;
1889 justify-content: center;
1890 font-size: 13px;
1891 font-weight: 700;
1892 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1893 flex-shrink: 0;
1894 text-transform: uppercase;
1895 }
1896 .adm-digests-card-text { min-width: 0; flex: 1; }
1897 .adm-digests-card-name {
1898 font-size: 13.5px;
1899 font-weight: 600;
1900 color: var(--text-strong);
1901 text-decoration: none;
1902 }
1903 .adm-digests-card-name:hover { color: var(--accent-hover, var(--accent)); }
1904 .adm-digests-card-sent {
1905 margin-top: 2px;
1906 font-size: 11.5px;
1907 color: var(--text-muted);
1908 font-family: var(--font-mono);
1909 }
1910
1911 .adm-digests-empty {
1912 position: relative;
1913 padding: var(--space-12) var(--space-6);
1914 border: 1px dashed var(--border);
1915 border-radius: 16px;
1916 background: var(--bg-elevated);
1917 text-align: center;
1918 overflow: hidden;
1919 }
1920 .adm-digests-empty-orb {
1921 position: absolute;
1922 inset: 50% auto auto 50%;
1923 transform: translate(-50%, -50%);
1924 width: 320px; height: 320px;
1925 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1926 filter: blur(60px);
1927 pointer-events: none;
1928 z-index: 0;
1929 }
1930 .adm-digests-empty-inner { position: relative; z-index: 1; }
1931 .adm-digests-empty-icon {
1932 display: inline-flex;
1933 align-items: center;
1934 justify-content: center;
1935 width: 56px; height: 56px;
1936 border-radius: 16px;
1937 background: rgba(140,109,255,0.10);
1938 color: #b69dff;
1939 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1940 margin-bottom: var(--space-3);
1941 }
1942 .adm-digests-empty-icon svg { width: 24px; height: 24px; }
1943 .adm-digests-empty-title {
1944 font-family: var(--font-display);
1945 font-size: 20px;
1946 font-weight: 700;
1947 letter-spacing: -0.015em;
1948 color: var(--text-strong);
1949 margin-bottom: 6px;
1950 }
1951 .adm-digests-empty-sub {
1952 color: var(--text-muted);
1953 font-size: 13.5px;
1954 line-height: 1.5;
1955 }
1956`;
1957
1958const admAutopilotStyles = `
1959 .adm-autopilot-wrap { max-width: 1080px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
1960
1961 .adm-autopilot-hero {
1962 position: relative;
1963 margin-bottom: var(--space-5);
1964 padding: var(--space-5) var(--space-6);
1965 background: var(--bg-elevated);
1966 border: 1px solid var(--border);
1967 border-radius: 16px;
1968 overflow: hidden;
1969 }
1970 .adm-autopilot-hero::before {
1971 content: '';
1972 position: absolute;
1973 top: 0; left: 0; right: 0;
1974 height: 2px;
1975 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1976 opacity: 0.7;
1977 pointer-events: none;
1978 }
1979 .adm-autopilot-hero-orb {
1980 position: absolute;
1981 inset: -20% -10% auto auto;
1982 width: 380px; height: 380px;
1983 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1984 filter: blur(80px);
1985 opacity: 0.7;
1986 pointer-events: none;
1987 z-index: 0;
1988 animation: admAutopilotOrb 14s ease-in-out infinite;
1989 }
1990 @keyframes admAutopilotOrb {
1991 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1992 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1993 }
1994 @media (prefers-reduced-motion: reduce) {
1995 .adm-autopilot-hero-orb { animation: none; }
1996 }
1997 .adm-autopilot-hero-inner {
1998 position: relative;
1999 z-index: 1;
2000 display: flex;
2001 align-items: flex-end;
2002 justify-content: space-between;
2003 gap: var(--space-4);
2004 flex-wrap: wrap;
2005 }
2006 .adm-autopilot-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2007 .adm-autopilot-eyebrow {
2008 font-size: 12px;
2009 color: var(--text-muted);
2010 margin-bottom: var(--space-2);
2011 letter-spacing: 0.02em;
2012 display: inline-flex;
2013 align-items: center;
2014 gap: 8px;
2015 }
2016 .adm-autopilot-eyebrow-pill {
2017 display: inline-flex;
2018 align-items: center;
2019 justify-content: center;
2020 width: 22px; height: 22px;
2021 border-radius: 6px;
2022 background: rgba(140,109,255,0.14);
2023 color: #b69dff;
2024 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
2025 }
2026 .adm-autopilot-title {
2027 font-size: clamp(28px, 4vw, 40px);
2028 font-family: var(--font-display);
2029 font-weight: 800;
2030 letter-spacing: -0.028em;
2031 line-height: 1.05;
2032 margin: 0 0 var(--space-2);
2033 color: var(--text-strong);
2034 }
2035 .adm-autopilot-title-grad {
2036 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2037 -webkit-background-clip: text;
2038 background-clip: text;
2039 -webkit-text-fill-color: transparent;
2040 color: transparent;
2041 }
2042 .adm-autopilot-sub {
2043 font-size: 15px;
2044 color: var(--text-muted);
2045 margin: 0;
2046 line-height: 1.5;
2047 max-width: 620px;
2048 }
2049 .adm-autopilot-back {
2050 display: inline-flex;
2051 align-items: center;
2052 gap: 6px;
2053 padding: 7px 12px;
2054 font-size: 12.5px;
2055 color: var(--text-muted);
2056 background: rgba(255,255,255,0.02);
2057 border: 1px solid var(--border);
2058 border-radius: 8px;
2059 text-decoration: none;
2060 font-weight: 500;
2061 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2062 }
2063 .adm-autopilot-back:hover {
2064 border-color: var(--border-strong);
2065 color: var(--text-strong);
2066 background: rgba(255,255,255,0.04);
2067 }
2068
2069 .adm-autopilot-banner {
2070 margin-bottom: var(--space-4);
2071 padding: 10px 14px;
2072 border-radius: 10px;
2073 font-size: 13.5px;
2074 border: 1px solid var(--border);
2075 background: rgba(255,255,255,0.025);
2076 color: var(--text);
2077 }
2078 .adm-autopilot-banner.is-ok {
2079 border-color: rgba(52,211,153,0.40);
2080 background: rgba(52,211,153,0.08);
2081 color: #bbf7d0;
2082 }
2083 .adm-autopilot-banner.is-error {
2084 border-color: rgba(248,113,113,0.40);
2085 background: rgba(248,113,113,0.08);
2086 color: #fecaca;
2087 }
2088
2089 .adm-autopilot-statgrid {
2090 display: grid;
2091 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2092 gap: var(--space-3);
2093 margin-bottom: var(--space-5);
2094 }
2095 .adm-autopilot-stat {
2096 position: relative;
2097 padding: var(--space-4);
2098 background: var(--bg-elevated);
2099 border: 1px solid var(--border);
2100 border-radius: 14px;
2101 overflow: hidden;
2102 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
2103 }
2104 .adm-autopilot-stat:hover {
2105 transform: translateY(-2px);
2106 border-color: var(--border-strong);
2107 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
2108 }
2109 .adm-autopilot-stat-head {
2110 display: flex;
2111 align-items: center;
2112 justify-content: space-between;
2113 margin-bottom: var(--space-2);
2114 }
2115 .adm-autopilot-stat-label {
2116 font-size: 11px;
2117 font-weight: 600;
2118 letter-spacing: 0.08em;
2119 text-transform: uppercase;
2120 color: var(--text-muted);
2121 }
2122 .adm-autopilot-stat-icon {
2123 display: inline-flex;
2124 align-items: center;
2125 justify-content: center;
2126 width: 26px; height: 26px;
2127 border-radius: 8px;
2128 background: rgba(140,109,255,0.12);
2129 color: #b69dff;
2130 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2131 }
2132 .adm-autopilot-stat-value {
2133 font-family: var(--font-display);
2134 font-size: 32px;
2135 font-weight: 800;
2136 letter-spacing: -0.028em;
2137 line-height: 1;
2138 color: var(--text-strong);
2139 }
2140 .adm-autopilot-stat-value.is-mono {
2141 font-family: var(--font-mono);
2142 font-size: 13px;
2143 line-height: 1.3;
2144 word-break: break-all;
2145 }
2146 .adm-autopilot-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
2147 .adm-autopilot-pill {
2148 display: inline-flex;
2149 align-items: center;
2150 gap: 6px;
2151 padding: 2px 8px;
2152 border-radius: 9999px;
2153 font-size: 10.5px;
2154 font-weight: 600;
2155 letter-spacing: 0.04em;
2156 text-transform: uppercase;
2157 }
2158 .adm-autopilot-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
2159 .adm-autopilot-pill.is-on {
2160 background: rgba(52,211,153,0.14);
2161 color: #6ee7b7;
2162 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2163 }
2164 .adm-autopilot-pill.is-off {
2165 background: rgba(255,255,255,0.04);
2166 color: var(--text-muted);
2167 box-shadow: inset 0 0 0 1px var(--border);
2168 }
2169
2170 .adm-autopilot-actions {
2171 display: flex;
2172 align-items: center;
2173 gap: 12px;
2174 flex-wrap: wrap;
2175 margin-bottom: var(--space-5);
2176 }
2177 .adm-autopilot-actions form { margin: 0; }
2178 .adm-autopilot-action-hint {
2179 color: var(--text-muted);
2180 font-size: 13px;
2181 }
2182
2183 .adm-autopilot-btn {
2184 display: inline-flex;
2185 align-items: center;
2186 gap: 6px;
2187 padding: 9px 16px;
2188 border-radius: 10px;
2189 font-size: 13px;
2190 font-weight: 600;
2191 text-decoration: none;
2192 border: 1px solid transparent;
2193 cursor: pointer;
2194 font: inherit;
2195 line-height: 1;
2196 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
2197 }
2198 .adm-autopilot-btn-primary {
2199 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
2200 color: #fff;
2201 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
2202 }
2203 .adm-autopilot-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
2204
2205 .adm-autopilot-h3 {
2206 display: flex;
2207 align-items: baseline;
2208 justify-content: space-between;
2209 gap: var(--space-3);
2210 margin: 0 0 var(--space-3);
2211 }
2212 .adm-autopilot-h3 h3 {
2213 font-family: var(--font-display);
2214 font-size: 16px;
2215 font-weight: 700;
2216 letter-spacing: -0.014em;
2217 margin: 0;
2218 color: var(--text-strong);
2219 }
2220 .adm-autopilot-h3-meta { font-size: 12px; color: var(--text-muted); }
2221
2222 .adm-autopilot-tasks {
2223 display: grid;
2224 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2225 gap: var(--space-3);
2226 }
2227 .adm-autopilot-task {
2228 padding: var(--space-3) var(--space-4);
2229 background: var(--bg-elevated);
2230 border: 1px solid var(--border);
2231 border-radius: 12px;
2232 display: flex;
2233 flex-direction: column;
2234 gap: 8px;
2235 transition: transform 160ms ease, border-color 160ms ease;
2236 }
2237 .adm-autopilot-task:hover { transform: translateY(-1px); border-color: var(--border-strong); }
2238 .adm-autopilot-task.is-ok { border-color: rgba(52,211,153,0.28); }
2239 .adm-autopilot-task.is-fail { border-color: rgba(248,113,113,0.32); }
2240 .adm-autopilot-task-head {
2241 display: flex;
2242 align-items: center;
2243 gap: 10px;
2244 }
2245 .adm-autopilot-task-light {
2246 flex-shrink: 0;
2247 width: 10px; height: 10px;
2248 border-radius: 9999px;
2249 background: #6b7280;
2250 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
2251 }
2252 .adm-autopilot-task-light.is-ok {
2253 background: #34d399;
2254 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
2255 }
2256 .adm-autopilot-task-light.is-fail {
2257 background: #f87171;
2258 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
2259 animation: admApPulse 1.8s ease-in-out infinite;
2260 }
2261 @keyframes admApPulse {
2262 0%, 100% { opacity: 1; transform: scale(1); }
2263 50% { opacity: 0.7; transform: scale(0.92); }
2264 }
2265 @media (prefers-reduced-motion: reduce) {
2266 .adm-autopilot-task-light.is-fail { animation: none; }
2267 }
2268 .adm-autopilot-task-name {
2269 font-family: var(--font-mono);
2270 font-size: 12.5px;
2271 font-weight: 600;
2272 color: var(--text-strong);
2273 word-break: break-word;
2274 flex: 1;
2275 min-width: 0;
2276 }
2277 .adm-autopilot-task-status {
2278 display: inline-flex;
2279 align-items: center;
2280 gap: 4px;
2281 padding: 2px 8px;
2282 border-radius: 9999px;
2283 font-size: 10.5px;
2284 font-weight: 600;
2285 letter-spacing: 0.04em;
2286 text-transform: uppercase;
2287 flex-shrink: 0;
2288 }
2289 .adm-autopilot-task-status.is-ok {
2290 background: rgba(52,211,153,0.14);
2291 color: #6ee7b7;
2292 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2293 }
2294 .adm-autopilot-task-status.is-fail {
2295 background: rgba(248,113,113,0.12);
2296 color: #fecaca;
2297 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
2298 }
2299 .adm-autopilot-task-meta {
2300 display: flex;
2301 align-items: center;
2302 justify-content: space-between;
2303 gap: 8px;
2304 font-size: 11.5px;
2305 color: var(--text-muted);
2306 font-family: var(--font-mono);
2307 }
2308 .adm-autopilot-task-err {
2309 font-size: 11.5px;
2310 color: #fecaca;
2311 line-height: 1.5;
2312 background: rgba(248,113,113,0.06);
2313 border: 1px solid rgba(248,113,113,0.20);
2314 padding: 6px 8px;
2315 border-radius: 6px;
2316 word-break: break-word;
2317 }
2318
2319 .adm-autopilot-empty {
2320 position: relative;
2321 padding: var(--space-12) var(--space-6);
2322 border: 1px dashed var(--border);
2323 border-radius: 16px;
2324 background: var(--bg-elevated);
2325 text-align: center;
2326 overflow: hidden;
2327 }
2328 .adm-autopilot-empty-orb {
2329 position: absolute;
2330 inset: 50% auto auto 50%;
2331 transform: translate(-50%, -50%);
2332 width: 320px; height: 320px;
2333 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
2334 filter: blur(60px);
2335 pointer-events: none;
2336 z-index: 0;
2337 }
2338 .adm-autopilot-empty-inner { position: relative; z-index: 1; }
2339 .adm-autopilot-empty-icon {
2340 display: inline-flex;
2341 align-items: center;
2342 justify-content: center;
2343 width: 56px; height: 56px;
2344 border-radius: 16px;
2345 background: rgba(140,109,255,0.10);
2346 color: #b69dff;
2347 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2348 margin-bottom: var(--space-3);
2349 }
2350 .adm-autopilot-empty-icon svg { width: 24px; height: 24px; }
2351 .adm-autopilot-empty-title {
2352 font-family: var(--font-display);
2353 font-size: 20px;
2354 font-weight: 700;
2355 letter-spacing: -0.015em;
2356 color: var(--text-strong);
2357 margin-bottom: 6px;
2358 }
2359 .adm-autopilot-empty-sub {
2360 color: var(--text-muted);
2361 font-size: 13.5px;
2362 line-height: 1.5;
2363 }
2364
2365 .adm-autopilot-foot {
2366 margin-top: var(--space-5);
2367 padding: var(--space-3) var(--space-4);
2368 border: 1px solid var(--border-subtle);
2369 background: rgba(255,255,255,0.015);
2370 border-radius: 10px;
2371 color: var(--text-muted);
2372 font-size: 12.5px;
2373 }
2374 .adm-autopilot-foot code {
2375 font-family: var(--font-mono);
2376 font-size: 12px;
2377 background: var(--bg-tertiary);
2378 padding: 1px 5px;
2379 border-radius: 4px;
2380 color: var(--text);
2381 }
2382`;
2383
07f4b70Claude2384/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
2385const Icons = {
2386 shield: (
2387 <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">
2388 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2389 </svg>
2390 ),
2391 users: (
2392 <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">
2393 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
2394 <circle cx="9" cy="7" r="4" />
2395 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
2396 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
2397 </svg>
2398 ),
2399 repo: (
2400 <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">
2401 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
2402 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
2403 </svg>
2404 ),
2405 starShield: (
2406 <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">
2407 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2408 <path d="m9 12 2 2 4-4" />
2409 </svg>
2410 ),
2411 ops: (
2412 <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">
2413 <circle cx="12" cy="12" r="3" />
2414 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
2415 </svg>
2416 ),
2417 pulse: (
2418 <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">
2419 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
2420 </svg>
2421 ),
2422 flag: (
2423 <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">
2424 <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
2425 <line x1="4" y1="22" x2="4" y2="15" />
2426 </svg>
2427 ),
2428 mail: (
2429 <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">
2430 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
2431 <polyline points="22,6 12,13 2,6" />
2432 </svg>
2433 ),
2434 google: (
2435 <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">
2436 <circle cx="12" cy="12" r="10" />
2437 <path d="M12 8v8" /><path d="M8 12h8" />
2438 </svg>
2439 ),
2440 github: (
2441 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2442 <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.69-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.74.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.51-1.47.11-3.06 0 0 .97-.31 3.18 1.18a11 11 0 0 1 2.9-.39c.98 0 1.97.13 2.9.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.77.11 3.06.74.81 1.19 1.84 1.19 3.1 0 4.42-2.69 5.39-5.25 5.68.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
2443 </svg>
2444 ),
2445 sso: (
2446 <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">
2447 <rect x="3" y="11" width="18" height="11" rx="2" />
2448 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2449 </svg>
2450 ),
2451 bot: (
2452 <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">
2453 <rect x="3" y="7" width="18" height="13" rx="2" />
2454 <circle cx="9" cy="13" r="1" />
2455 <circle cx="15" cy="13" r="1" />
2456 <path d="M12 3v4" />
2457 </svg>
2458 ),
2459 refresh: (
2460 <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">
2461 <polyline points="23 4 23 10 17 10" />
2462 <polyline points="1 20 1 14 7 14" />
2463 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
2464 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
2465 </svg>
2466 ),
2467 arrowLeft: (
2468 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2469 <line x1="19" y1="12" x2="5" y2="12" />
2470 <polyline points="12 19 5 12 12 5" />
2471 </svg>
2472 ),
509c376Claude2473 key: (
2474 <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">
2475 <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" />
2476 </svg>
2477 ),
07f4b70Claude2478};
2479
2480/** First-letter avatar helper. */
2481function initials(s: string): string {
2482 return (s || "?").trim().charAt(0).toUpperCase() || "?";
2483}
2484
8f50ed0Claude2485async function gate(c: any): Promise<{ user: any } | Response> {
2486 const user = c.get("user");
2487 if (!user) return c.redirect("/login?next=/admin");
2488 if (!(await isSiteAdmin(user.id))) {
2489 return c.html(
2490 <Layout title="Forbidden" user={user}>
07f4b70Claude2491 <div class="admin-403">
8f50ed0Claude2492 <h2>403 — Not a site admin</h2>
2493 <p>You don't have permission to view this page.</p>
2494 </div>
07f4b70Claude2495 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2496 </Layout>,
2497 403
2498 );
2499 }
2500 return { user };
2501}
2502
2503admin.get("/admin", async (c) => {
2504 const g = await gate(c);
2505 if (g instanceof Response) return g;
2506 const { user } = g;
2507
2508 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
2509 const [rc] = await db
2510 .select({ n: sql<number>`count(*)::int` })
2511 .from(repositories);
2512
2513 const recent = await db
2514 .select({
2515 id: users.id,
2516 username: users.username,
2517 createdAt: users.createdAt,
2518 })
2519 .from(users)
2520 .orderBy(desc(users.createdAt))
2521 .limit(10);
2522
2523 const admins = await listSiteAdmins();
2524
988380aClaude2525 const msg = c.req.query("result") || c.req.query("error");
2526 const isErr = !!c.req.query("error");
2527
07f4b70Claude2528 const userCount = Number(uc?.n || 0);
2529 const repoCount = Number(rc?.n || 0);
2530 const adminCount = admins.length;
2531
8f50ed0Claude2532 return c.html(
2533 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude2534 <div class="admin-wrap">
2535 <section class="admin-hero">
2536 <div class="admin-hero-bg" aria-hidden="true">
2537 <div class="admin-hero-orb" />
2538 </div>
2539 <div class="admin-hero-inner">
2540 <div class="admin-hero-eyebrow">
2541 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
2542 Site administration ·{" "}
2543 <span class="admin-who">{user.username}</span>
2544 </div>
2545 <h2 class="admin-hero-title">
2546 <span class="admin-hero-title-grad">Site admin</span>.
2547 </h2>
2548 <p class="admin-hero-sub">
2549 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
2550 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
2551 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
2552 Operations, flags, digests, and autopilot — all in one place.
2553 </p>
2554 </div>
2555 </section>
8f50ed0Claude2556
07f4b70Claude2557 {msg && (
2558 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
2559 {decodeURIComponent(msg)}
2560 </div>
2561 )}
988380aClaude2562
07f4b70Claude2563 <div class="admin-stat-grid">
2564 <div class="admin-stat">
2565 <div class="admin-stat-head">
2566 <span class="admin-stat-label">Users</span>
2567 <span class="admin-stat-icon">{Icons.users}</span>
2568 </div>
2569 <div class="admin-stat-value">{userCount}</div>
2570 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude2571 </div>
07f4b70Claude2572 <div class="admin-stat">
2573 <div class="admin-stat-head">
2574 <span class="admin-stat-label">Repos</span>
2575 <span class="admin-stat-icon">{Icons.repo}</span>
2576 </div>
2577 <div class="admin-stat-value">{repoCount}</div>
2578 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude2579 </div>
07f4b70Claude2580 <div class="admin-stat">
2581 <div class="admin-stat-head">
2582 <span class="admin-stat-label">Admins</span>
2583 <span class="admin-stat-icon">{Icons.starShield}</span>
2584 </div>
2585 <div class="admin-stat-value">{adminCount}</div>
2586 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude2587 </div>
2588 </div>
2589
07f4b70Claude2590 <div class="admin-actions">
2591 <a href="/admin/ops" class="admin-action is-primary">
2592 <span class="admin-action-icon">{Icons.ops}</span>
2593 Operations
2594 </a>
509c376Claude2595 <a href="/admin/integrations" class="admin-action is-primary">
2596 <span class="admin-action-icon">{Icons.key}</span>
2597 Integrations
2598 </a>
cf793f9Claude2599 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2600 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2601 Health (traffic lights)
2602 </a>
2603 <a href="/admin/deploys" class="admin-action is-primary">
2604 <span class="admin-action-icon">{Icons.ops}</span>
2605 Deploys
2606 </a>
2607 <a href="/admin/diagnose" class="admin-action">
2608 <span class="admin-action-icon">{Icons.pulse}</span>
2609 Diagnose
2610 </a>
2611 <a href="/admin/self-host" class="admin-action">
2612 <span class="admin-action-icon">{Icons.ops}</span>
2613 Self-host status
2614 </a>
2615 <a href="/admin/status" class="admin-action">
2616 <span class="admin-action-icon">{Icons.pulse}</span>
2617 Live activity stream
07f4b70Claude2618 </a>
2619 <a href="/admin/users" class="admin-action">
2620 <span class="admin-action-icon">{Icons.users}</span>
2621 Manage users
2622 </a>
2623 <a href="/admin/repos" class="admin-action">
2624 <span class="admin-action-icon">{Icons.repo}</span>
2625 Manage repos
2626 </a>
2627 <a href="/admin/flags" class="admin-action">
2628 <span class="admin-action-icon">{Icons.flag}</span>
2629 Site flags
2630 </a>
2631 <a href="/admin/digests" class="admin-action">
2632 <span class="admin-action-icon">{Icons.mail}</span>
2633 Email digests
2634 </a>
2635 <a href="/admin/google-oauth" class="admin-action">
2636 <span class="admin-action-icon">{Icons.google}</span>
2637 Sign in with Google
2638 </a>
2639 <a href="/admin/github-oauth" class="admin-action">
2640 <span class="admin-action-icon">{Icons.github}</span>
2641 Sign in with GitHub
2642 </a>
2643 <a href="/admin/sso" class="admin-action">
2644 <span class="admin-action-icon">{Icons.sso}</span>
2645 Enterprise SSO
2646 </a>
2647 <a href="/admin/autopilot" class="admin-action">
2648 <span class="admin-action-icon">{Icons.bot}</span>
2649 Autopilot
2650 </a>
662ce86Claude2651 <a href="/connect/claude" class="admin-action is-primary">
2652 <span class="admin-action-icon">{Icons.bot}</span>
2653 Connect Claude
2654 </a>
07f4b70Claude2655 <form
2656 method="post"
2657 action="/admin/demo/reseed"
2658 class="admin-action-form"
2659 >
2660 <button
2661 class="admin-action"
2662 type="submit"
2663 title="Idempotently (re)create demo user + 3 sample repos"
2664 >
2665 <span class="admin-action-icon">{Icons.refresh}</span>
2666 Reseed demo
2667 </button>
2668 </form>
2669 </div>
8f50ed0Claude2670
07f4b70Claude2671 <div class="admin-h3">
2672 <h3>Recent signups</h3>
2673 <span class="admin-h3-meta">
2674 {recent.length} most-recent
2675 </span>
2676 </div>
2677 <div class="admin-list" style="margin-bottom:20px">
2678 {recent.length === 0 ? (
2679 <div class="admin-list-empty">No users yet.</div>
2680 ) : (
2681 recent.map((u) => (
2682 <div class="admin-list-row">
2683 <div class="admin-list-main">
2684 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
2685 <div class="admin-row-text">
2686 <a href={`/${u.username}`} class="admin-row-title">
2687 {u.username}
2688 </a>
2689 <div class="admin-row-sub">
2690 <span>Joined</span>
2691 <span>
2692 {u.createdAt
2693 ? new Date(u.createdAt as unknown as string).toLocaleString()
2694 : ""}
2695 </span>
2696 </div>
2697 </div>
2698 </div>
2699 </div>
2700 ))
2701 )}
2702 </div>
8f50ed0Claude2703
07f4b70Claude2704 <div class="admin-h3">
2705 <h3>Site admins</h3>
2706 <span class="admin-h3-meta">
2707 {adminCount} active
2708 </span>
2709 </div>
2710 <div class="admin-list">
2711 {admins.length === 0 ? (
2712 <div class="admin-list-empty">
2713 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude2714 </div>
07f4b70Claude2715 ) : (
2716 admins.map((a) => (
2717 <div class="admin-list-row">
2718 <div class="admin-list-main">
2719 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
2720 <div class="admin-row-text">
2721 <a href={`/${a.username}`} class="admin-row-title">
2722 {a.username}
2723 </a>
2724 <div class="admin-row-sub">
2725 <span class="admin-pill is-admin">
2726 <span class="dot" aria-hidden="true" /> Site admin
2727 </span>
2728 <span>
2729 Granted{" "}
2730 {a.grantedAt
2731 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
2732 : "—"}
2733 </span>
2734 </div>
2735 </div>
2736 </div>
2737 </div>
2738 ))
2739 )}
2740 </div>
8f50ed0Claude2741 </div>
07f4b70Claude2742 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2743 </Layout>
2744 );
2745});
2746
2747// ----- Users -----
2748
2749admin.get("/admin/users", async (c) => {
2750 const g = await gate(c);
2751 if (g instanceof Response) return g;
2752 const { user } = g;
2753 const q = c.req.query("q") || "";
2754 const rows = await db
2755 .select({
2756 id: users.id,
2757 username: users.username,
2758 email: users.email,
2759 createdAt: users.createdAt,
2760 })
2761 .from(users)
2762 .where(
2763 q
2764 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
2765 : sql`1=1`
2766 )
2767 .orderBy(desc(users.createdAt))
2768 .limit(200);
2769
2770 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude2771 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude2772
2773 return c.html(
2774 <Layout title="Admin — Users" user={user}>
8929744Claude2775 <div class="adm-users-wrap">
2776 <section class="adm-users-hero">
2777 <div class="adm-users-hero-orb" aria-hidden="true" />
2778 <div class="adm-users-hero-inner">
2779 <div class="adm-users-hero-text">
2780 <div class="adm-users-eyebrow">
2781 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
2782 Site admin · Users
2783 </div>
2784 <h1 class="adm-users-title">
2785 <span class="adm-users-title-grad">Users</span>.
2786 </h1>
2787 <p class="adm-users-sub">
2788 Search, audit, and grant or revoke the site-admin flag.
2789 Showing up to 200 accounts ordered by signup recency.
2790 </p>
2791 </div>
2792 <a href="/admin" class="adm-users-back">
07f4b70Claude2793 {Icons.arrowLeft} Back
2794 </a>
2795 </div>
2796 </section>
2797
8929744Claude2798 <div class="adm-users-filterbar">
2799 <form method="get" action="/admin/users" class="adm-users-search">
2800 <span class="adm-users-search-ico" aria-hidden="true">
2801 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2802 <circle cx="11" cy="11" r="8" />
2803 <line x1="21" y1="21" x2="16.65" y2="16.65" />
2804 </svg>
2805 </span>
2806 <input
2807 type="text"
2808 name="q"
2809 value={q}
2810 placeholder="Search username or email"
2811 aria-label="Search username or email"
2812 class="adm-users-input"
2813 />
2814 <button type="submit" class="adm-users-btn">Search</button>
2815 {q && (
2816 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
2817 )}
2818 </form>
2819 <div class="adm-users-pills">
2820 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
2821 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
2822 </div>
2823 </div>
07f4b70Claude2824
8929744Claude2825 {rows.length === 0 ? (
2826 <div class="adm-users-empty">
2827 <div class="adm-users-empty-orb" aria-hidden="true" />
2828 <div class="adm-users-empty-inner">
2829 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
2830 <div class="adm-users-empty-title">No users found</div>
2831 <div class="adm-users-empty-sub">
2832 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
2833 </div>
2834 </div>
2835 </div>
2836 ) : (
2837 <div class="adm-users-grid">
2838 {rows.map((u) => {
07f4b70Claude2839 const isAdmin = adminIds.has(u.id);
2840 return (
8929744Claude2841 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
2842 <div class="adm-users-card-head">
2843 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude2844 {initials(u.username)}
8f50ed0Claude2845 </span>
8929744Claude2846 <div class="adm-users-card-id">
2847 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
2848 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude2849 </div>
8929744Claude2850 {isAdmin && (
2851 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
2852 )}
2853 </div>
2854 <div class="adm-users-card-meta">
2855 <span class="adm-users-meta-item">
2856 <span class="adm-users-meta-key">Email</span>
2857 <span class="adm-users-meta-val">{u.email}</span>
2858 </span>
2859 {u.createdAt && (
2860 <span class="adm-users-meta-item">
2861 <span class="adm-users-meta-key">Joined</span>
2862 <span class="adm-users-meta-val">
2863 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
2864 </span>
2865 </span>
2866 )}
2867 </div>
2868 <div class="adm-users-card-actions">
2869 <form
2870 method="post"
2871 action={`/admin/users/${u.id}/admin`}
2872 onsubmit={
2873 isAdmin
2874 ? "return confirm('Revoke site admin?')"
2875 : "return confirm('Grant site admin?')"
2876 }
2877 >
2878 <button
2879 type="submit"
2880 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
2881 >
2882 {isAdmin ? "Revoke admin" : "Grant admin"}
2883 </button>
2884 </form>
2885 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
2886 View profile
2887 </a>
07f4b70Claude2888 </div>
8f50ed0Claude2889 </div>
07f4b70Claude2890 );
8929744Claude2891 })}
2892 </div>
2893 )}
8f50ed0Claude2894 </div>
07f4b70Claude2895 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude2896 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude2897 </Layout>
2898 );
2899});
2900
2901admin.post("/admin/users/:id/admin", async (c) => {
2902 const g = await gate(c);
2903 if (g instanceof Response) return g;
2904 const { user } = g;
2905 const id = c.req.param("id");
2906 const admins = await listSiteAdmins();
2907 const isAlready = admins.some((a) => a.userId === id);
2908 if (isAlready) {
2909 await revokeSiteAdmin(id);
2910 await audit({
2911 userId: user.id,
2912 action: "site_admin.revoke",
2913 targetType: "user",
2914 targetId: id,
2915 });
2916 } else {
2917 await grantSiteAdmin(id, user.id);
2918 await audit({
2919 userId: user.id,
2920 action: "site_admin.grant",
2921 targetType: "user",
2922 targetId: id,
2923 });
2924 }
2925 return c.redirect("/admin/users");
2926});
2927
2928// ----- Repos -----
2929
2930admin.get("/admin/repos", async (c) => {
2931 const g = await gate(c);
2932 if (g instanceof Response) return g;
2933 const { user } = g;
2934 const rows = await db
2935 .select({
2936 id: repositories.id,
2937 name: repositories.name,
2938 ownerUsername: users.username,
0316dbbClaude2939 isPrivate: repositories.isPrivate,
8f50ed0Claude2940 createdAt: repositories.createdAt,
2941 starCount: repositories.starCount,
2942 })
2943 .from(repositories)
2944 .innerJoin(users, eq(repositories.ownerId, users.id))
2945 .orderBy(desc(repositories.createdAt))
2946 .limit(200);
2947
8929744Claude2948 const privateCount = rows.filter((r) => r.isPrivate).length;
2949 const publicCount = rows.length - privateCount;
2950
8f50ed0Claude2951 return c.html(
2952 <Layout title="Admin — Repos" user={user}>
8929744Claude2953 <div class="adm-repos-wrap">
2954 <section class="adm-repos-hero">
2955 <div class="adm-repos-hero-orb" aria-hidden="true" />
2956 <div class="adm-repos-hero-inner">
2957 <div class="adm-repos-hero-text">
2958 <div class="adm-repos-eyebrow">
2959 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
2960 Site admin · Repositories
2961 </div>
2962 <h1 class="adm-repos-title">
2963 <span class="adm-repos-title-grad">Repositories</span>.
2964 </h1>
2965 <p class="adm-repos-sub">
2966 Every repository on the platform — public and private.
2967 Delete is irreversible and audit-logged.
2968 </p>
2969 </div>
2970 <a href="/admin" class="adm-repos-back">
07f4b70Claude2971 {Icons.arrowLeft} Back
2972 </a>
2973 </div>
2974 </section>
2975
8929744Claude2976 <div class="adm-repos-pills">
2977 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
2978 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
2979 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
2980 </div>
2981
2982 {rows.length === 0 ? (
2983 <div class="adm-repos-empty">
2984 <div class="adm-repos-empty-orb" aria-hidden="true" />
2985 <div class="adm-repos-empty-inner">
2986 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
2987 <div class="adm-repos-empty-title">No repositories yet</div>
2988 <div class="adm-repos-empty-sub">
2989 When users create their first repos, they'll appear here.
2990 </div>
2991 </div>
2992 </div>
2993 ) : (
2994 <div class="adm-repos-grid">
2995 {rows.map((r) => (
2996 <div class="adm-repos-card">
2997 <div class="adm-repos-card-head">
2998 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
2999 <div class="adm-repos-card-title">
07f4b70Claude3000 <a
3001 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3002 class="adm-repos-card-name"
07f4b70Claude3003 >
3004 {r.ownerUsername}/{r.name}
3005 </a>
8929744Claude3006 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3007 </div>
8929744Claude3008 <span
3009 class={
3010 "adm-repos-pill " +
3011 (r.isPrivate ? "is-private" : "is-public")
3012 }
3013 style="margin-left:auto"
3014 >
3015 <span class="dot" aria-hidden="true" />
3016 {r.isPrivate ? "private" : "public"}
3017 </span>
3018 </div>
3019 <div class="adm-repos-card-meta">
3020 <span class="adm-repos-meta-item">
3021 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3022 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
3023 </svg>
3024 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3025 </span>
3026 {r.createdAt && (
3027 <span class="adm-repos-meta-item">
3028 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3029 <rect x="3" y="4" width="18" height="18" rx="2" />
3030 <line x1="16" y1="2" x2="16" y2="6" />
3031 <line x1="8" y1="2" x2="8" y2="6" />
3032 <line x1="3" y1="10" x2="21" y2="10" />
3033 </svg>
3034 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3035 </span>
3036 )}
3037 </div>
3038 <div class="adm-repos-card-actions">
3039 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3040 Open repo
3041 </a>
3042 <form
3043 method="post"
3044 action={`/admin/repos/${r.id}/delete`}
3045 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3046 >
3047 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3048 Delete
3049 </button>
3050 </form>
8f50ed0Claude3051 </div>
3052 </div>
8929744Claude3053 ))}
3054 </div>
3055 )}
8f50ed0Claude3056 </div>
07f4b70Claude3057 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3058 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3059 </Layout>
3060 );
3061});
3062
3063admin.post("/admin/repos/:id/delete", async (c) => {
3064 const g = await gate(c);
3065 if (g instanceof Response) return g;
3066 const { user } = g;
3067 const id = c.req.param("id");
3068 try {
3069 await db.delete(repositories).where(eq(repositories.id, id));
3070 } catch (err) {
3071 console.error("[admin] repo delete:", err);
3072 }
3073 await audit({
3074 userId: user.id,
3075 action: "admin.repo.delete",
3076 targetType: "repository",
3077 targetId: id,
3078 });
3079 return c.redirect("/admin/repos");
3080});
3081
3082// ----- Flags -----
3083
3084admin.get("/admin/flags", async (c) => {
3085 const g = await gate(c);
3086 if (g instanceof Response) return g;
3087 const { user } = g;
3088
3089 const existing = await listFlags();
3090 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3091 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3092
3093 return c.html(
3094 <Layout title="Admin — Flags" user={user}>
8929744Claude3095 <div class="adm-flags-wrap">
3096 <section class="adm-flags-hero">
3097 <div class="adm-flags-hero-orb" aria-hidden="true" />
3098 <div class="adm-flags-hero-inner">
3099 <div class="adm-flags-hero-text">
3100 <div class="adm-flags-eyebrow">
3101 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3102 Site admin · Feature flags
3103 </div>
3104 <h1 class="adm-flags-title">
3105 <span class="adm-flags-title-grad">Site flags</span>.
3106 </h1>
3107 <p class="adm-flags-sub">
3108 Runtime feature flags surfaced to the rest of the app via{" "}
3109 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3110 </p>
3111 </div>
3112 <a href="/admin" class="adm-flags-back">
07f4b70Claude3113 {Icons.arrowLeft} Back
3114 </a>
3115 </div>
3116 </section>
3117
8929744Claude3118 <form method="post" action="/admin/flags" class="adm-flags-card">
3119 <div class="adm-flags-card-body">
07f4b70Claude3120 {keys.map((k) => {
3121 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3122 const isOverridden =
3123 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3124 return (
8929744Claude3125 <div class="adm-flags-field">
3126 <div class="adm-flags-field-head">
3127 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3128 {isOverridden && (
3129 <span class="adm-flags-mono">overridden</span>
3130 )}
3131 </div>
07f4b70Claude3132 <input
8929744Claude3133 id={`flag-${k}`}
07f4b70Claude3134 type="text"
3135 name={k}
3136 value={current}
3137 aria-label={k}
8929744Claude3138 class="adm-flags-input"
07f4b70Claude3139 />
8929744Claude3140 <div class="adm-flags-hint">
07f4b70Claude3141 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3142 </div>
3143 </div>
3144 );
3145 })}
3146 </div>
8929744Claude3147 <div class="adm-flags-card-foot">
3148 <span class="adm-flags-foot-hint">
07f4b70Claude3149 Saved values overwrite the defaults at runtime.
3150 </span>
8929744Claude3151 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3152 Save changes
07f4b70Claude3153 </button>
3154 </div>
3155 </form>
8f50ed0Claude3156 </div>
07f4b70Claude3157 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3158 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3159 </Layout>
3160 );
3161});
3162
3163admin.post("/admin/flags", async (c) => {
3164 const g = await gate(c);
3165 if (g instanceof Response) return g;
3166 const { user } = g;
3167 const body = await c.req.parseBody();
3168 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3169 for (const k of keys) {
3170 const v = String(body[k] ?? "");
3171 await setFlag(k, v, user.id);
3172 }
3173 await audit({ userId: user.id, action: "admin.flags.save" });
3174 return c.redirect("/admin/flags");
3175});
3176
08420cdClaude3177// ----- Email digests (Block I7) -----
3178
3179admin.get("/admin/digests", async (c) => {
3180 const g = await gate(c);
3181 if (g instanceof Response) return g;
3182 const { user } = g;
3183
3184 const [optedRow] = await db
3185 .select({ n: sql<number>`count(*)::int` })
3186 .from(users)
3187 .where(eq(users.notifyEmailDigestWeekly, true));
3188 const opted = Number(optedRow?.n || 0);
3189
3190 const recentlySent = await db
3191 .select({
3192 id: users.id,
3193 username: users.username,
3194 lastDigestSentAt: users.lastDigestSentAt,
3195 })
3196 .from(users)
3197 .where(sql`${users.lastDigestSentAt} is not null`)
3198 .orderBy(desc(users.lastDigestSentAt))
3199 .limit(20);
3200
3201 const result = c.req.query("result");
3202 const error = c.req.query("error");
3203
3204 return c.html(
3205 <Layout title="Admin — Digests" user={user}>
8929744Claude3206 <div class="adm-digests-wrap">
3207 <section class="adm-digests-hero">
3208 <div class="adm-digests-hero-orb" aria-hidden="true" />
3209 <div class="adm-digests-hero-inner">
3210 <div class="adm-digests-hero-text">
3211 <div class="adm-digests-eyebrow">
3212 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3213 Site admin · Email
3214 </div>
3215 <h1 class="adm-digests-title">
3216 <span class="adm-digests-title-grad">Email digests</span>.
3217 </h1>
3218 <p class="adm-digests-sub">
3219 Manually trigger the weekly digest for every opted-in user
3220 or preview the email for a single account.
3221 </p>
3222 </div>
3223 <a href="/admin" class="adm-digests-back">
07f4b70Claude3224 {Icons.arrowLeft} Back
3225 </a>
3226 </div>
3227 </section>
08420cdClaude3228
07f4b70Claude3229 {result && (
8929744Claude3230 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3231 )}
3232 {error && (
8929744Claude3233 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3234 )}
08420cdClaude3235
8929744Claude3236 <div class="adm-digests-pills">
3237 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3238 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3239 </div>
3240
3241 <section class="adm-digests-section">
3242 <header class="adm-digests-section-head">
3243 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3244 <div>
3245 <h3 class="adm-digests-section-title">Send digests</h3>
3246 <p class="adm-digests-section-sub">
3247 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3248 </p>
08420cdClaude3249 </div>
8929744Claude3250 </header>
3251 <div class="adm-digests-section-body">
3252 <form method="post" action="/admin/digests/run">
07f4b70Claude3253 <button
3254 type="submit"
8929744Claude3255 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3256 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3257 >
8929744Claude3258 {Icons.mail}
07f4b70Claude3259 Send digests now
3260 </button>
3261 </form>
8929744Claude3262 <div class="adm-digests-section-divider">
3263 <div class="adm-digests-divider-hint">
07f4b70Claude3264 Preview / one-off — send the digest to a single user.
3265 </div>
3266 <form
3267 method="post"
3268 action="/admin/digests/preview"
8929744Claude3269 class="adm-digests-form-row"
07f4b70Claude3270 >
3271 <input
3272 type="text"
3273 name="username"
3274 placeholder="username"
3275 required
3276 aria-label="Username"
8929744Claude3277 class="adm-digests-input"
07f4b70Claude3278 />
8929744Claude3279 <button type="submit" class="adm-digests-btn">
07f4b70Claude3280 Send to one user
3281 </button>
3282 </form>
3283 </div>
3284 </div>
8929744Claude3285 </section>
07f4b70Claude3286
8929744Claude3287 <div class="adm-digests-h3">
07f4b70Claude3288 <h3>Recently sent</h3>
8929744Claude3289 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3290 </div>
8929744Claude3291 {recentlySent.length === 0 ? (
3292 <div class="adm-digests-empty">
3293 <div class="adm-digests-empty-orb" aria-hidden="true" />
3294 <div class="adm-digests-empty-inner">
3295 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3296 <div class="adm-digests-empty-title">No digests sent yet</div>
3297 <div class="adm-digests-empty-sub">
3298 When the weekly digest fires, sent recipients will appear here.
3299 </div>
3300 </div>
3301 </div>
3302 ) : (
3303 <div class="adm-digests-grid">
3304 {recentlySent.map((u) => (
3305 <div class="adm-digests-card">
3306 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3307 <div class="adm-digests-card-text">
3308 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3309 <div class="adm-digests-card-sent">
3310 sent {u.lastDigestSentAt
3311 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3312 : "—"}
07f4b70Claude3313 </div>
3314 </div>
3315 </div>
8929744Claude3316 ))}
3317 </div>
3318 )}
08420cdClaude3319 </div>
07f4b70Claude3320 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3321 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3322 </Layout>
3323 );
3324});
3325
3326admin.post("/admin/digests/run", async (c) => {
3327 const g = await gate(c);
3328 if (g instanceof Response) return g;
3329 const { user } = g;
3330 const results = await sendDigestsToAll();
3331 const sent = results.filter((r) => r.ok).length;
3332 const skipped = results.length - sent;
3333 await audit({
3334 userId: user.id,
3335 action: "admin.digests.run",
3336 metadata: { sent, skipped, total: results.length },
3337 });
3338 return c.redirect(
3339 `/admin/digests?result=${encodeURIComponent(
3340 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3341 )}`
3342 );
3343});
3344
3345admin.post("/admin/digests/preview", async (c) => {
3346 const g = await gate(c);
3347 if (g instanceof Response) return g;
3348 const { user } = g;
3349 const body = await c.req.parseBody();
3350 const username = String(body.username || "").trim();
3351 if (!username) {
3352 return c.redirect("/admin/digests?error=Username+required");
3353 }
3354 const [target] = await db
3355 .select({ id: users.id, username: users.username })
3356 .from(users)
3357 .where(eq(users.username, username))
3358 .limit(1);
3359 if (!target) {
3360 return c.redirect("/admin/digests?error=User+not+found");
3361 }
3362 const result = await sendDigestForUser(target.id);
3363 await audit({
3364 userId: user.id,
3365 action: "admin.digests.preview",
3366 targetType: "user",
3367 targetId: target.id,
3368 metadata: {
3369 ok: result.ok,
3370 skipped: "skipped" in result ? result.skipped : null,
3371 },
3372 });
3373 if (result.ok) {
3374 return c.redirect(
3375 `/admin/digests?result=${encodeURIComponent(
3376 `Digest sent to ${target.username}.`
3377 )}`
3378 );
3379 }
3380 return c.redirect(
3381 `/admin/digests?error=${encodeURIComponent(
3382 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3383 )}`
3384 );
3385});
3386
8e9f1d9Claude3387admin.get("/admin/autopilot", async (c) => {
3388 const g = await gate(c);
3389 if (g instanceof Response) return g;
3390 const { user } = g;
3391 const tick = getLastTick();
3392 const total = getTickCount();
3393 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3394 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3395 const intervalMs =
3396 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3397 ? Number(intervalRaw)
3398 : 5 * 60 * 1000;
3399 const msg = c.req.query("result") || c.req.query("error");
3400 const isErr = !!c.req.query("error");
3401 return c.html(
3402 <Layout title="Autopilot — admin" user={user}>
8929744Claude3403 <div class="adm-autopilot-wrap">
3404 <section class="adm-autopilot-hero">
3405 <div class="adm-autopilot-hero-orb" aria-hidden="true" />
3406 <div class="adm-autopilot-hero-inner">
3407 <div class="adm-autopilot-hero-text">
3408 <div class="adm-autopilot-eyebrow">
3409 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3410 Site admin · Maintenance loop
3411 </div>
3412 <h1 class="adm-autopilot-title">
3413 <span class="adm-autopilot-title-grad">Autopilot</span>.
3414 </h1>
3415 <p class="adm-autopilot-sub">
3416 Periodic platform-maintenance loop — mirror sync, merge-queue
3417 progress, weekly digests, advisory rescans, environment
3418 wait-timer release, and scheduled workflow triggers (cron).
3419 </p>
3420 </div>
3421 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3422 {Icons.arrowLeft} Back
3423 </a>
3424 </div>
3425 </section>
3426
8e9f1d9Claude3427 {msg && (
8929744Claude3428 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3429 {decodeURIComponent(msg)}
3430 </div>
3431 )}
07f4b70Claude3432
8929744Claude3433 <div class="adm-autopilot-statgrid">
3434 <div class="adm-autopilot-stat">
3435 <div class="adm-autopilot-stat-head">
3436 <span class="adm-autopilot-stat-label">Status</span>
3437 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3438 <span class="dot" aria-hidden="true" />
3439 {disabled ? "disabled" : "running"}
3440 </span>
3441 </div>
8929744Claude3442 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3443 {disabled ? "disabled" : "running"}
3444 </div>
8929744Claude3445 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3446 </div>
8929744Claude3447 <div class="adm-autopilot-stat">
3448 <div class="adm-autopilot-stat-head">
3449 <span class="adm-autopilot-stat-label">Interval</span>
3450 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3451 </div>
8929744Claude3452 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3453 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3454 </div>
8929744Claude3455 <div class="adm-autopilot-stat">
3456 <div class="adm-autopilot-stat-head">
3457 <span class="adm-autopilot-stat-label">Ticks this process</span>
3458 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3459 </div>
8929744Claude3460 <div class="adm-autopilot-stat-value">{total}</div>
3461 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3462 </div>
8929744Claude3463 <div class="adm-autopilot-stat">
3464 <div class="adm-autopilot-stat-head">
3465 <span class="adm-autopilot-stat-label">Last tick</span>
3466 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3467 </div>
8929744Claude3468 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3469 {tick ? tick.finishedAt : "never"}
3470 </div>
3471 </div>
3472 </div>
07f4b70Claude3473
8929744Claude3474 <div class="adm-autopilot-actions">
3475 <form method="post" action="/admin/autopilot/run">
3476 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3477 {Icons.bot}
3478 Run tick now
3479 </button>
3480 </form>
3481 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3482 Executes all sub-tasks synchronously and records the result.
3483 </span>
8929744Claude3484 </div>
07f4b70Claude3485
8929744Claude3486 <div class="adm-autopilot-h3">
07f4b70Claude3487 <h3>Last tick tasks</h3>
3488 {tick && (
8929744Claude3489 <span class="adm-autopilot-h3-meta">
07f4b70Claude3490 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3491 </span>
3492 )}
3493 </div>
8e9f1d9Claude3494 {tick ? (
8929744Claude3495 <div class="adm-autopilot-tasks">
3496 {tick.tasks.map((t) => (
3497 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3498 <div class="adm-autopilot-task-head">
3499 <span
3500 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3501 aria-label={t.ok ? "ok" : "failed"}
3502 />
3503 <span class="adm-autopilot-task-name">{t.name}</span>
3504 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3505 {t.ok ? "ok" : "failed"}
8929744Claude3506 </span>
3507 </div>
3508 <div class="adm-autopilot-task-meta">
3509 <span>duration</span>
3510 <span>{t.durationMs}ms</span>
3511 </div>
3512 {t.error && (
3513 <div class="adm-autopilot-task-err">{t.error}</div>
3514 )}
3515 </div>
3516 ))}
3517 </div>
8e9f1d9Claude3518 ) : (
8929744Claude3519 <div class="adm-autopilot-empty">
3520 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3521 <div class="adm-autopilot-empty-inner">
3522 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3523 <div class="adm-autopilot-empty-title">No ticks yet</div>
3524 <div class="adm-autopilot-empty-sub">
3525 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3526 </div>
3527 </div>
07f4b70Claude3528 </div>
8e9f1d9Claude3529 )}
8929744Claude3530 <p class="adm-autopilot-foot">
8e9f1d9Claude3531 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3532 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3533 </p>
3534 </div>
07f4b70Claude3535 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3536 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3537 </Layout>
3538 );
3539});
3540
988380aClaude3541admin.post("/admin/demo/reseed", async (c) => {
3542 const g = await gate(c);
3543 if (g instanceof Response) return g;
3544 const { user } = g;
3545 try {
3546 const result = await ensureDemoContent({ force: true });
3547 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
3548 await audit({
3549 userId: user.id,
3550 action: "admin.demo.reseed",
3551 targetType: "user",
3552 targetId: result.demoUser?.id ?? "demo",
3553 metadata: {
3554 createdUser: result.created.user,
3555 createdRepos: result.created.repos,
3556 createdIssues: result.created.issues,
3557 createdPrs: result.created.prs,
3558 errors: result.errors.slice(0, 5),
3559 },
3560 });
3561 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3562 } catch (err) {
3563 const message = err instanceof Error ? err.message : String(err);
3564 return c.redirect(
3565 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3566 );
3567 }
3568});
3569
3570// Public jump-to-demo — redirects to the first demo repo if present,
3571// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3572admin.get("/demo", (c) => {
3573 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3574});
3575
8e9f1d9Claude3576admin.post("/admin/autopilot/run", async (c) => {
3577 const g = await gate(c);
3578 if (g instanceof Response) return g;
3579 const { user } = g;
3580 let summary = "";
3581 try {
3582 const result = await runAutopilotTick();
3583 const ok = result.tasks.filter((t) => t.ok).length;
3584 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3585 await audit({
3586 userId: user.id,
3587 action: "admin.autopilot.run",
3588 targetType: "system",
3589 targetId: "autopilot",
3590 metadata: { ok, total: result.tasks.length },
3591 });
3592 return c.redirect(
3593 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3594 );
3595 } catch (err) {
3596 const message = err instanceof Error ? err.message : String(err);
3597 return c.redirect(
3598 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3599 );
3600 }
3601});
3602
8f50ed0Claude3603export default admin;