Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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.tsxBlame3690 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 = `
eed4684Claude57 .admin-wrap { max-width: 1680px; margin: 0 auto; }
07f4b70Claude58
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; }
f1dc7c7Claude239 .admin-hero { padding: var(--space-4); }
240 .admin-actions { grid-template-columns: 1fr; }
241 .admin-action { min-height: 44px; padding: 14px; }
242 .admin-list-row { flex-direction: column; align-items: stretch; padding: 14px; }
243 .admin-search { flex-direction: column; align-items: stretch; }
244 .admin-search .admin-input { width: 100%; }
245 .admin-card-body { padding: var(--space-4); }
246 .admin-card-foot { padding: var(--space-3) var(--space-4); justify-content: flex-start; }
247 .admin-ap-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
07f4b70Claude248 }
249 .admin-stat {
250 position: relative;
251 padding: var(--space-4) var(--space-4);
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 14px;
255 overflow: hidden;
256 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
257 }
258 .admin-stat::after {
259 content: '';
260 position: absolute;
261 inset: 0;
262 border-radius: inherit;
263 background: linear-gradient(135deg, rgba(140,109,255,0.05), rgba(54,197,214,0.04));
264 opacity: 0;
265 pointer-events: none;
266 transition: opacity 200ms ease;
267 }
268 .admin-stat:hover {
269 transform: translateY(-2px);
270 border-color: var(--border-strong);
271 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
272 }
273 .admin-stat:hover::after { opacity: 1; }
274 .admin-stat-head {
275 display: flex;
276 align-items: center;
277 justify-content: space-between;
278 margin-bottom: var(--space-2);
279 position: relative;
280 z-index: 1;
281 }
282 .admin-stat-label {
283 font-size: 11px;
284 font-weight: 600;
285 letter-spacing: 0.08em;
286 text-transform: uppercase;
287 color: var(--text-muted);
288 }
289 .admin-stat-icon {
290 display: inline-flex;
291 align-items: center;
292 justify-content: center;
293 width: 26px; height: 26px;
294 border-radius: 8px;
295 background: rgba(140,109,255,0.12);
296 color: #b69dff;
297 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
298 }
299 .admin-stat-value {
300 position: relative;
301 z-index: 1;
302 font-family: var(--font-display);
303 font-size: 36px;
304 font-weight: 800;
305 letter-spacing: -0.028em;
306 line-height: 1;
307 color: var(--text-strong);
308 }
309 .admin-stat-hint {
310 margin-top: 6px;
311 font-size: 12px;
312 color: var(--text-faint);
313 position: relative;
314 z-index: 1;
315 }
316
317 /* ─── Action grid ─── */
318 .admin-actions {
319 display: grid;
320 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
321 gap: var(--space-2);
322 margin-bottom: var(--space-6);
323 }
324 .admin-action {
325 display: flex;
326 align-items: center;
327 gap: 10px;
328 padding: 12px 14px;
329 background: var(--bg-elevated);
330 border: 1px solid var(--border);
331 border-radius: 12px;
332 color: var(--text);
333 text-decoration: none;
334 font-size: 13.5px;
335 font-weight: 500;
336 line-height: 1.25;
337 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
338 cursor: pointer;
339 text-align: left;
340 font-family: inherit;
341 }
342 .admin-action:hover {
343 border-color: var(--border-strong);
344 background: rgba(255,255,255,0.025);
345 transform: translateY(-1px);
346 }
347 .admin-action:focus-visible {
348 outline: none;
349 border-color: var(--border-focus);
350 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
351 }
352 .admin-action .admin-action-icon {
353 display: inline-flex;
354 align-items: center;
355 justify-content: center;
356 width: 30px; height: 30px;
357 border-radius: 8px;
358 background: rgba(255,255,255,0.03);
359 color: var(--text-muted);
360 flex-shrink: 0;
361 box-shadow: inset 0 0 0 1px var(--border);
362 }
363 .admin-action.is-primary {
364 border-color: rgba(140,109,255,0.35);
365 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.10));
366 color: var(--text-strong);
367 }
368 .admin-action.is-primary:hover {
369 border-color: rgba(140,109,255,0.55);
370 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
371 }
372 .admin-action.is-primary .admin-action-icon {
373 background: rgba(140,109,255,0.18);
374 color: #c5b3ff;
375 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
376 }
377 .admin-action-form { display: contents; }
378
379 /* ─── Section header (h3 replacement) ─── */
380 .admin-h3 {
381 display: flex;
382 align-items: baseline;
383 justify-content: space-between;
384 gap: var(--space-3);
385 margin: var(--space-5) 0 var(--space-3);
386 }
387 .admin-h3 h3 {
388 font-family: var(--font-display);
389 font-size: 16px;
390 font-weight: 700;
391 letter-spacing: -0.014em;
392 margin: 0;
393 color: var(--text-strong);
394 }
395 .admin-h3-meta {
396 font-size: 12px;
397 color: var(--text-muted);
398 }
399
400 /* ─── Lists / cards ─── */
401 .admin-list {
402 background: var(--bg-elevated);
403 border: 1px solid var(--border);
404 border-radius: 14px;
405 overflow: hidden;
406 }
407 .admin-list-row {
408 display: flex;
409 align-items: center;
410 justify-content: space-between;
411 gap: var(--space-3);
412 padding: 12px 16px;
413 border-bottom: 1px solid var(--border-subtle);
414 transition: background 120ms ease;
415 }
416 .admin-list-row:last-child { border-bottom: none; }
417 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
418 .admin-list-empty {
419 padding: var(--space-5);
420 text-align: center;
421 color: var(--text-muted);
422 font-size: 13.5px;
423 }
424 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
425 .admin-avatar {
426 width: 30px; height: 30px;
427 border-radius: 9999px;
428 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
429 color: var(--text-strong);
430 display: inline-flex;
431 align-items: center;
432 justify-content: center;
433 font-size: 12px;
434 font-weight: 700;
435 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
436 flex-shrink: 0;
437 text-transform: uppercase;
438 }
439 .admin-avatar.is-admin {
440 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
441 color: #fff;
442 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
443 }
444 .admin-row-text { min-width: 0; }
445 .admin-row-title {
446 font-size: 14px;
447 font-weight: 600;
448 color: var(--text-strong);
449 text-decoration: none;
450 }
451 .admin-row-title:hover { color: var(--accent-hover); }
452 .admin-row-sub {
453 margin-top: 2px;
454 font-size: 12px;
455 color: var(--text-muted);
456 display: flex;
457 gap: 8px;
458 flex-wrap: wrap;
459 align-items: center;
460 }
461 .admin-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 4px;
465 padding: 2px 8px;
466 border-radius: 9999px;
467 font-size: 10.5px;
468 font-weight: 600;
469 letter-spacing: 0.04em;
470 text-transform: uppercase;
471 }
472 .admin-pill.is-admin {
473 background: rgba(140,109,255,0.16);
474 color: #c5b3ff;
475 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
476 }
477 .admin-pill.is-private {
478 background: rgba(251,191,36,0.10);
479 color: #fde68a;
480 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
481 }
482 .admin-pill.is-public {
483 background: rgba(255,255,255,0.04);
484 color: var(--text-muted);
485 box-shadow: inset 0 0 0 1px var(--border);
486 }
487 .admin-pill.is-on {
488 background: rgba(52,211,153,0.14);
489 color: #6ee7b7;
490 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
491 }
492 .admin-pill.is-off {
493 background: rgba(255,255,255,0.04);
494 color: var(--text-muted);
495 box-shadow: inset 0 0 0 1px var(--border);
496 }
497 .admin-pill .dot {
498 width: 6px; height: 6px;
499 border-radius: 9999px;
500 background: currentColor;
501 }
502
503 /* ─── Inline forms / search ─── */
504 .admin-search {
505 display: flex;
506 gap: 8px;
507 flex-wrap: wrap;
508 align-items: center;
509 margin-bottom: var(--space-4);
510 }
511 .admin-input {
512 width: 320px;
513 max-width: 100%;
514 padding: 9px 12px;
515 font-size: 14px;
516 color: var(--text);
517 background: var(--bg);
518 border: 1px solid var(--border-strong);
519 border-radius: 8px;
520 outline: none;
521 font-family: var(--font-sans);
522 transition: border-color 120ms ease, box-shadow 120ms ease;
523 }
524 .admin-input:focus {
525 border-color: var(--border-focus);
526 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
527 }
528
529 /* ─── Flags form ─── */
530 .admin-card {
531 background: var(--bg-elevated);
532 border: 1px solid var(--border);
533 border-radius: 14px;
534 overflow: hidden;
535 }
536 .admin-card-body { padding: var(--space-5); }
537 .admin-card-foot {
538 padding: var(--space-3) var(--space-5);
539 border-top: 1px solid var(--border);
540 background: rgba(255,255,255,0.012);
541 display: flex;
542 justify-content: flex-end;
543 gap: var(--space-2);
544 align-items: center;
545 flex-wrap: wrap;
546 }
547 .admin-card-foot .admin-foot-hint {
548 margin-right: auto;
549 font-size: 12.5px;
550 color: var(--text-muted);
551 }
552 .admin-field { margin-bottom: var(--space-4); }
553 .admin-field:last-child { margin-bottom: 0; }
554 .admin-field label {
555 display: block;
556 font-family: var(--font-mono);
557 font-size: 12.5px;
558 font-weight: 600;
559 color: var(--text-strong);
560 margin-bottom: 6px;
561 letter-spacing: -0.005em;
562 }
563 .admin-field .admin-input-mono {
564 width: 100%;
565 padding: 9px 12px;
566 font-size: 13.5px;
567 color: var(--text);
568 background: var(--bg);
569 border: 1px solid var(--border-strong);
570 border-radius: 8px;
571 outline: none;
572 font-family: var(--font-mono);
573 transition: border-color 120ms ease, box-shadow 120ms ease;
574 }
575 .admin-field .admin-input-mono:focus {
576 border-color: var(--border-focus);
577 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
578 }
579 .admin-field-hint {
580 font-size: 11.5px;
581 color: var(--text-muted);
582 margin-top: 6px;
583 line-height: 1.45;
584 }
585 .admin-field-hint code {
586 font-family: var(--font-mono);
587 font-size: 11.5px;
588 background: var(--bg-tertiary);
589 padding: 1px 5px;
590 border-radius: 4px;
591 }
592
593 /* ─── Digest forms ─── */
594 .admin-digest-row {
595 display: flex;
596 gap: 8px;
597 align-items: center;
598 flex-wrap: wrap;
599 }
600
601 /* ─── Autopilot specific ─── */
602 .admin-ap-table {
603 width: 100%;
604 border-collapse: collapse;
605 background: var(--bg-elevated);
606 border: 1px solid var(--border);
607 border-radius: 14px;
608 overflow: hidden;
609 }
610 .admin-ap-table thead th {
611 text-align: left;
612 font-size: 11px;
613 font-weight: 600;
614 letter-spacing: 0.08em;
615 text-transform: uppercase;
616 color: var(--text-muted);
617 padding: 10px 14px;
618 background: rgba(255,255,255,0.015);
619 border-bottom: 1px solid var(--border);
620 }
621 .admin-ap-table tbody td {
622 padding: 10px 14px;
623 border-bottom: 1px solid var(--border-subtle);
624 font-size: 13px;
625 color: var(--text);
626 vertical-align: top;
627 }
628 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
629 .admin-ap-table code {
630 font-family: var(--font-mono);
631 font-size: 12px;
632 color: var(--text-strong);
633 }
634 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
635 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
636 .admin-ap-empty {
637 padding: var(--space-5);
638 text-align: center;
639 color: var(--text-muted);
640 font-size: 13.5px;
641 background: var(--bg-elevated);
642 border: 1px dashed var(--border);
643 border-radius: 14px;
644 }
645 .admin-ap-foot {
646 margin-top: var(--space-5);
647 padding: var(--space-3) var(--space-4);
648 border: 1px solid var(--border-subtle);
649 background: rgba(255,255,255,0.015);
650 border-radius: 10px;
651 color: var(--text-muted);
652 font-size: 12.5px;
653 }
654 .admin-ap-foot code {
655 font-family: var(--font-mono);
656 font-size: 12px;
657 background: var(--bg-tertiary);
658 padding: 1px 5px;
659 border-radius: 4px;
660 color: var(--text);
661 }
662
663 /* ─── Misc ─── */
664 .admin-403 {
665 max-width: 540px;
666 margin: var(--space-12) auto;
667 padding: var(--space-6);
668 text-align: center;
669 background: var(--bg-elevated);
670 border: 1px solid var(--border);
671 border-radius: 16px;
672 }
673 .admin-403 h2 {
674 font-family: var(--font-display);
675 font-size: 22px;
676 margin: 0 0 8px;
677 color: var(--text-strong);
678 }
679 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
680`;
681
8929744Claude682/* ─────────────────────────────────────────────────────────────────────────
683 * Per-sub-page scoped CSS — each handler gets its own namespace so they
684 * cannot bleed into each other or back into the shared `.admin-*` panel.
685 *
686 * .adm-users-* /admin/users
687 * .adm-repos-* /admin/repos
688 * .adm-flags-* /admin/flags
689 * .adm-digests-* /admin/digests
690 * .adm-autopilot-* /admin/autopilot
691 *
692 * All five mirror the 2026 design language from /admin and /admin/ops:
693 * - gradient hairline (::before)
694 * - animated radial-gradient orb
695 * - clamp() display headline + gradient-text span
696 * - eyebrow + subtitle
697 * - cards with avatar/icon + mono IDs + action buttons
698 * - filter pills / search bar
699 * - empty state with orb
700 * ───────────────────────────────────────────────────────────────────── */
701const admUsersStyles = `
eed4684Claude702 .adm-users-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude703
704 /* Hero */
705 .adm-users-hero {
706 position: relative;
707 margin-bottom: var(--space-5);
708 padding: var(--space-5) var(--space-6);
709 background: var(--bg-elevated);
710 border: 1px solid var(--border);
711 border-radius: 16px;
712 overflow: hidden;
713 }
714 .adm-users-hero::before {
715 content: '';
716 position: absolute;
717 top: 0; left: 0; right: 0;
718 height: 2px;
719 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
720 opacity: 0.7;
721 pointer-events: none;
722 }
723 .adm-users-hero-orb {
724 position: absolute;
725 inset: -20% -10% auto auto;
726 width: 380px; height: 380px;
727 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
728 filter: blur(80px);
729 opacity: 0.7;
730 pointer-events: none;
731 z-index: 0;
732 animation: admUsersOrb 14s ease-in-out infinite;
733 }
734 @keyframes admUsersOrb {
735 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
736 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
737 }
738 @media (prefers-reduced-motion: reduce) {
739 .adm-users-hero-orb { animation: none; }
740 }
741 .adm-users-hero-inner {
742 position: relative;
743 z-index: 1;
744 display: flex;
745 align-items: flex-end;
746 justify-content: space-between;
747 gap: var(--space-4);
748 flex-wrap: wrap;
749 }
750 .adm-users-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
751 .adm-users-eyebrow {
752 font-size: 12px;
753 color: var(--text-muted);
754 margin-bottom: var(--space-2);
755 letter-spacing: 0.02em;
756 display: inline-flex;
757 align-items: center;
758 gap: 8px;
759 }
760 .adm-users-eyebrow-pill {
761 display: inline-flex;
762 align-items: center;
763 justify-content: center;
764 width: 22px; height: 22px;
765 border-radius: 6px;
766 background: rgba(140,109,255,0.14);
767 color: #b69dff;
768 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
769 }
770 .adm-users-title {
771 font-size: clamp(28px, 4vw, 40px);
772 font-family: var(--font-display);
773 font-weight: 800;
774 letter-spacing: -0.028em;
775 line-height: 1.05;
776 margin: 0 0 var(--space-2);
777 color: var(--text-strong);
778 }
779 .adm-users-title-grad {
780 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
781 -webkit-background-clip: text;
782 background-clip: text;
783 -webkit-text-fill-color: transparent;
784 color: transparent;
785 }
786 .adm-users-sub {
787 font-size: 15px;
788 color: var(--text-muted);
789 margin: 0;
790 line-height: 1.5;
791 max-width: 620px;
792 }
793 .adm-users-back {
794 display: inline-flex;
795 align-items: center;
796 gap: 6px;
797 padding: 7px 12px;
798 font-size: 12.5px;
799 color: var(--text-muted);
800 background: rgba(255,255,255,0.02);
801 border: 1px solid var(--border);
802 border-radius: 8px;
803 text-decoration: none;
804 font-weight: 500;
805 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
806 }
807 .adm-users-back:hover {
808 border-color: var(--border-strong);
809 color: var(--text-strong);
810 background: rgba(255,255,255,0.04);
811 }
812
813 /* Filter bar */
814 .adm-users-filterbar {
815 display: flex;
816 gap: var(--space-3);
817 align-items: center;
818 justify-content: space-between;
819 margin-bottom: var(--space-4);
820 flex-wrap: wrap;
821 }
822 .adm-users-search {
823 position: relative;
824 display: flex;
825 align-items: center;
826 gap: 8px;
827 flex: 1;
828 min-width: 280px;
829 }
830 .adm-users-search-ico {
831 position: absolute;
832 left: 12px;
833 top: 50%;
834 transform: translateY(-50%);
835 color: var(--text-muted);
836 pointer-events: none;
837 display: inline-flex;
838 }
839 .adm-users-input {
840 flex: 1;
841 width: 100%;
842 padding: 10px 12px 10px 36px;
843 font-size: 14px;
844 color: var(--text);
845 background: var(--bg);
846 border: 1px solid var(--border-strong);
847 border-radius: 10px;
848 outline: none;
849 font-family: var(--font-sans);
850 transition: border-color 120ms ease, box-shadow 120ms ease;
851 }
852 .adm-users-input:focus {
853 border-color: var(--border-focus);
854 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
855 }
856 .adm-users-pills {
857 display: inline-flex;
858 gap: 6px;
859 flex-wrap: wrap;
860 }
861 .adm-users-pill {
862 display: inline-flex;
863 align-items: center;
864 gap: 6px;
865 padding: 4px 10px;
866 border-radius: 9999px;
867 font-size: 11.5px;
868 font-weight: 600;
869 background: rgba(255,255,255,0.04);
870 color: var(--text-muted);
871 box-shadow: inset 0 0 0 1px var(--border);
872 }
873 .adm-users-pill .dot {
874 width: 6px; height: 6px;
875 border-radius: 9999px;
876 background: currentColor;
877 }
878 .adm-users-pill.is-admin {
879 background: rgba(140,109,255,0.16);
880 color: #c5b3ff;
881 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
882 }
883
884 /* Buttons */
885 .adm-users-btn {
886 display: inline-flex;
887 align-items: center;
888 gap: 6px;
889 padding: 8px 14px;
890 border-radius: 8px;
891 font-size: 13px;
892 font-weight: 600;
893 text-decoration: none;
894 border: 1px solid var(--border-strong);
895 background: rgba(255,255,255,0.02);
896 color: var(--text);
897 cursor: pointer;
898 font: inherit;
899 font-weight: 600;
900 line-height: 1;
901 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
902 }
903 .adm-users-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
904 .adm-users-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
905 .adm-users-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
906 .adm-users-btn-primary {
907 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
908 color: #fff;
909 border-color: transparent;
910 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
911 }
912 .adm-users-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
913 .adm-users-btn-danger {
914 background: transparent;
915 color: #fca5a5;
916 border-color: rgba(248,113,113,0.40);
917 }
918 .adm-users-btn-danger:hover {
919 background: rgba(248,113,113,0.08);
920 border-color: rgba(248,113,113,0.70);
921 color: #fecaca;
922 }
923
924 /* Card grid */
925 .adm-users-grid {
926 display: grid;
927 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
928 gap: var(--space-3);
929 }
930 .adm-users-card {
931 position: relative;
932 padding: var(--space-4);
933 background: var(--bg-elevated);
934 border: 1px solid var(--border);
935 border-radius: 14px;
936 display: flex;
937 flex-direction: column;
938 gap: var(--space-3);
939 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
940 }
941 .adm-users-card:hover {
942 transform: translateY(-2px);
943 border-color: var(--border-strong);
944 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
945 }
946 .adm-users-card.is-admin {
947 border-color: rgba(140,109,255,0.35);
948 background:
949 linear-gradient(180deg, rgba(140,109,255,0.04), transparent 60%),
950 var(--bg-elevated);
951 }
952 .adm-users-card-head {
953 display: flex;
954 align-items: center;
955 gap: 12px;
956 }
957 .adm-users-avatar {
958 width: 38px; height: 38px;
959 border-radius: 9999px;
960 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
961 color: var(--text-strong);
962 display: inline-flex;
963 align-items: center;
964 justify-content: center;
965 font-size: 14px;
966 font-weight: 700;
967 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
968 flex-shrink: 0;
969 text-transform: uppercase;
970 }
971 .adm-users-avatar.is-admin {
972 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
973 color: #fff;
974 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
975 }
976 .adm-users-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
977 .adm-users-card-name {
978 font-size: 14.5px;
979 font-weight: 700;
980 color: var(--text-strong);
981 text-decoration: none;
982 letter-spacing: -0.005em;
983 }
984 .adm-users-card-name:hover { color: var(--accent-hover, var(--accent)); }
985 .adm-users-card-mono {
986 font-family: var(--font-mono);
987 font-size: 11px;
988 color: var(--text-muted);
989 background: rgba(255,255,255,0.03);
990 padding: 1px 6px;
991 border-radius: 4px;
992 border: 1px solid var(--border-subtle);
993 width: fit-content;
994 }
995 .adm-users-card-meta {
996 display: flex;
997 flex-direction: column;
998 gap: 6px;
999 font-size: 12.5px;
1000 }
1001 .adm-users-meta-item { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
1002 .adm-users-meta-key {
1003 font-family: var(--font-mono);
1004 font-size: 10.5px;
1005 text-transform: uppercase;
1006 letter-spacing: 0.06em;
1007 color: var(--text-muted);
1008 flex-shrink: 0;
1009 width: 50px;
1010 }
1011 .adm-users-meta-val { color: var(--text); word-break: break-all; min-width: 0; }
1012 .adm-users-card-actions {
1013 display: flex;
1014 gap: 8px;
1015 flex-wrap: wrap;
1016 margin-top: auto;
1017 }
1018 .adm-users-card-actions form { margin: 0; }
1019
1020 /* Empty state */
1021 .adm-users-empty {
1022 position: relative;
1023 padding: var(--space-12) var(--space-6);
1024 border: 1px dashed var(--border);
1025 border-radius: 16px;
1026 background: var(--bg-elevated);
1027 text-align: center;
1028 overflow: hidden;
1029 }
1030 .adm-users-empty-orb {
1031 position: absolute;
1032 inset: 50% auto auto 50%;
1033 transform: translate(-50%, -50%);
1034 width: 320px; height: 320px;
1035 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1036 filter: blur(60px);
1037 pointer-events: none;
1038 z-index: 0;
1039 }
1040 .adm-users-empty-inner { position: relative; z-index: 1; }
1041 .adm-users-empty-icon {
1042 display: inline-flex;
1043 align-items: center;
1044 justify-content: center;
1045 width: 56px; height: 56px;
1046 border-radius: 16px;
1047 background: rgba(140,109,255,0.10);
1048 color: #b69dff;
1049 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1050 margin-bottom: var(--space-3);
1051 }
1052 .adm-users-empty-icon svg { width: 24px; height: 24px; }
1053 .adm-users-empty-title {
1054 font-family: var(--font-display);
1055 font-size: 20px;
1056 font-weight: 700;
1057 letter-spacing: -0.015em;
1058 color: var(--text-strong);
1059 margin-bottom: 6px;
1060 }
1061 .adm-users-empty-sub {
1062 color: var(--text-muted);
1063 font-size: 13.5px;
1064 line-height: 1.5;
1065 }
1066 .adm-users-empty-sub code {
1067 font-family: var(--font-mono);
1068 font-size: 12px;
1069 background: var(--bg-tertiary);
1070 padding: 1px 6px;
1071 border-radius: 4px;
1072 color: var(--text);
1073 }
f1dc7c7Claude1074
1075 @media (max-width: 720px) {
1076 .adm-users-wrap { padding: var(--space-4) var(--space-3); }
1077 .adm-users-hero { padding: var(--space-4); }
1078 .adm-users-grid { grid-template-columns: 1fr; }
1079 }
8929744Claude1080`;
1081
1082const admReposStyles = `
eed4684Claude1083 .adm-repos-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1084
1085 .adm-repos-hero {
1086 position: relative;
1087 margin-bottom: var(--space-5);
1088 padding: var(--space-5) var(--space-6);
1089 background: var(--bg-elevated);
1090 border: 1px solid var(--border);
1091 border-radius: 16px;
1092 overflow: hidden;
1093 }
1094 .adm-repos-hero::before {
1095 content: '';
1096 position: absolute;
1097 top: 0; left: 0; right: 0;
1098 height: 2px;
1099 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1100 opacity: 0.7;
1101 pointer-events: none;
1102 }
1103 .adm-repos-hero-orb {
1104 position: absolute;
1105 inset: -20% -10% auto auto;
1106 width: 380px; height: 380px;
1107 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1108 filter: blur(80px);
1109 opacity: 0.7;
1110 pointer-events: none;
1111 z-index: 0;
1112 animation: admReposOrb 14s ease-in-out infinite;
1113 }
1114 @keyframes admReposOrb {
1115 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1116 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1117 }
1118 @media (prefers-reduced-motion: reduce) {
1119 .adm-repos-hero-orb { animation: none; }
1120 }
1121 .adm-repos-hero-inner {
1122 position: relative;
1123 z-index: 1;
1124 display: flex;
1125 align-items: flex-end;
1126 justify-content: space-between;
1127 gap: var(--space-4);
1128 flex-wrap: wrap;
1129 }
1130 .adm-repos-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1131 .adm-repos-eyebrow {
1132 font-size: 12px;
1133 color: var(--text-muted);
1134 margin-bottom: var(--space-2);
1135 letter-spacing: 0.02em;
1136 display: inline-flex;
1137 align-items: center;
1138 gap: 8px;
1139 }
1140 .adm-repos-eyebrow-pill {
1141 display: inline-flex;
1142 align-items: center;
1143 justify-content: center;
1144 width: 22px; height: 22px;
1145 border-radius: 6px;
1146 background: rgba(140,109,255,0.14);
1147 color: #b69dff;
1148 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1149 }
1150 .adm-repos-title {
1151 font-size: clamp(28px, 4vw, 40px);
1152 font-family: var(--font-display);
1153 font-weight: 800;
1154 letter-spacing: -0.028em;
1155 line-height: 1.05;
1156 margin: 0 0 var(--space-2);
1157 color: var(--text-strong);
1158 }
1159 .adm-repos-title-grad {
1160 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1161 -webkit-background-clip: text;
1162 background-clip: text;
1163 -webkit-text-fill-color: transparent;
1164 color: transparent;
1165 }
1166 .adm-repos-sub {
1167 font-size: 15px;
1168 color: var(--text-muted);
1169 margin: 0;
1170 line-height: 1.5;
1171 max-width: 620px;
1172 }
1173 .adm-repos-back {
1174 display: inline-flex;
1175 align-items: center;
1176 gap: 6px;
1177 padding: 7px 12px;
1178 font-size: 12.5px;
1179 color: var(--text-muted);
1180 background: rgba(255,255,255,0.02);
1181 border: 1px solid var(--border);
1182 border-radius: 8px;
1183 text-decoration: none;
1184 font-weight: 500;
1185 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1186 }
1187 .adm-repos-back:hover {
1188 border-color: var(--border-strong);
1189 color: var(--text-strong);
1190 background: rgba(255,255,255,0.04);
1191 }
1192
1193 .adm-repos-pills {
1194 display: inline-flex;
1195 gap: 6px;
1196 flex-wrap: wrap;
1197 margin-bottom: var(--space-4);
1198 }
1199 .adm-repos-pill {
1200 display: inline-flex;
1201 align-items: center;
1202 gap: 6px;
1203 padding: 4px 10px;
1204 border-radius: 9999px;
1205 font-size: 11.5px;
1206 font-weight: 600;
1207 background: rgba(255,255,255,0.04);
1208 color: var(--text-muted);
1209 box-shadow: inset 0 0 0 1px var(--border);
1210 }
1211 .adm-repos-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1212 .adm-repos-pill.is-private {
1213 background: rgba(251,191,36,0.10);
1214 color: #fde68a;
1215 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
1216 }
1217 .adm-repos-pill.is-public {
1218 background: rgba(52,211,153,0.10);
1219 color: #86efac;
1220 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
1221 }
1222
1223 .adm-repos-btn {
1224 display: inline-flex;
1225 align-items: center;
1226 gap: 6px;
1227 padding: 8px 14px;
1228 border-radius: 8px;
1229 font-size: 13px;
1230 font-weight: 600;
1231 text-decoration: none;
1232 border: 1px solid var(--border-strong);
1233 background: rgba(255,255,255,0.02);
1234 color: var(--text);
1235 cursor: pointer;
1236 font: inherit;
1237 line-height: 1;
1238 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1239 }
1240 .adm-repos-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1241 .adm-repos-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
1242 .adm-repos-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
1243 .adm-repos-btn-danger {
1244 background: transparent;
1245 color: #fca5a5;
1246 border-color: rgba(248,113,113,0.40);
1247 }
1248 .adm-repos-btn-danger:hover {
1249 background: rgba(248,113,113,0.08);
1250 border-color: rgba(248,113,113,0.70);
1251 color: #fecaca;
1252 }
1253
1254 .adm-repos-grid {
1255 display: grid;
1256 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1257 gap: var(--space-3);
1258 }
1259 .adm-repos-card {
1260 position: relative;
1261 padding: var(--space-4);
1262 background: var(--bg-elevated);
1263 border: 1px solid var(--border);
1264 border-radius: 14px;
1265 display: flex;
1266 flex-direction: column;
1267 gap: var(--space-3);
1268 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1269 }
1270 .adm-repos-card:hover {
1271 transform: translateY(-2px);
1272 border-color: var(--border-strong);
1273 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1274 }
1275 .adm-repos-card-head {
1276 display: flex;
1277 align-items: center;
1278 gap: 12px;
1279 }
1280 .adm-repos-icon {
1281 width: 38px; height: 38px;
1282 border-radius: 10px;
1283 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
1284 color: #b69dff;
1285 display: inline-flex;
1286 align-items: center;
1287 justify-content: center;
1288 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1289 flex-shrink: 0;
1290 }
1291 .adm-repos-icon svg { width: 18px; height: 18px; }
1292 .adm-repos-card-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
1293 .adm-repos-card-name {
1294 font-size: 14.5px;
1295 font-weight: 700;
1296 color: var(--text-strong);
1297 text-decoration: none;
1298 letter-spacing: -0.005em;
1299 word-break: break-all;
1300 }
1301 .adm-repos-card-name:hover { color: var(--accent-hover, var(--accent)); }
1302 .adm-repos-card-mono {
1303 font-family: var(--font-mono);
1304 font-size: 11px;
1305 color: var(--text-muted);
1306 background: rgba(255,255,255,0.03);
1307 padding: 1px 6px;
1308 border-radius: 4px;
1309 border: 1px solid var(--border-subtle);
1310 width: fit-content;
1311 }
1312 .adm-repos-card-meta {
1313 display: flex;
1314 gap: var(--space-3);
1315 flex-wrap: wrap;
1316 font-size: 12.5px;
1317 color: var(--text-muted);
1318 }
1319 .adm-repos-meta-item {
1320 display: inline-flex;
1321 align-items: center;
1322 gap: 5px;
1323 }
1324 .adm-repos-meta-item svg { width: 13px; height: 13px; color: var(--text-muted); }
1325 .adm-repos-card-actions {
1326 display: flex;
1327 gap: 8px;
1328 flex-wrap: wrap;
1329 margin-top: auto;
1330 }
1331 .adm-repos-card-actions form { margin: 0; }
1332
1333 .adm-repos-empty {
1334 position: relative;
1335 padding: var(--space-12) var(--space-6);
1336 border: 1px dashed var(--border);
1337 border-radius: 16px;
1338 background: var(--bg-elevated);
1339 text-align: center;
1340 overflow: hidden;
1341 }
1342 .adm-repos-empty-orb {
1343 position: absolute;
1344 inset: 50% auto auto 50%;
1345 transform: translate(-50%, -50%);
1346 width: 320px; height: 320px;
1347 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1348 filter: blur(60px);
1349 pointer-events: none;
1350 z-index: 0;
1351 }
1352 .adm-repos-empty-inner { position: relative; z-index: 1; }
1353 .adm-repos-empty-icon {
1354 display: inline-flex;
1355 align-items: center;
1356 justify-content: center;
1357 width: 56px; height: 56px;
1358 border-radius: 16px;
1359 background: rgba(140,109,255,0.10);
1360 color: #b69dff;
1361 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1362 margin-bottom: var(--space-3);
1363 }
1364 .adm-repos-empty-icon svg { width: 24px; height: 24px; }
1365 .adm-repos-empty-title {
1366 font-family: var(--font-display);
1367 font-size: 20px;
1368 font-weight: 700;
1369 letter-spacing: -0.015em;
1370 color: var(--text-strong);
1371 margin-bottom: 6px;
1372 }
1373 .adm-repos-empty-sub {
1374 color: var(--text-muted);
1375 font-size: 13.5px;
1376 line-height: 1.5;
1377 }
f1dc7c7Claude1378
1379 @media (max-width: 720px) {
1380 .adm-repos-wrap { padding: var(--space-4) var(--space-3); }
1381 .adm-repos-hero { padding: var(--space-4); }
1382 .adm-repos-grid { grid-template-columns: 1fr; }
1383 }
8929744Claude1384`;
1385
1386const admFlagsStyles = `
eed4684Claude1387 .adm-flags-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1388
1389 .adm-flags-hero {
1390 position: relative;
1391 margin-bottom: var(--space-5);
1392 padding: var(--space-5) var(--space-6);
1393 background: var(--bg-elevated);
1394 border: 1px solid var(--border);
1395 border-radius: 16px;
1396 overflow: hidden;
1397 }
1398 .adm-flags-hero::before {
1399 content: '';
1400 position: absolute;
1401 top: 0; left: 0; right: 0;
1402 height: 2px;
1403 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1404 opacity: 0.7;
1405 pointer-events: none;
1406 }
1407 .adm-flags-hero-orb {
1408 position: absolute;
1409 inset: -20% -10% auto auto;
1410 width: 380px; height: 380px;
1411 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1412 filter: blur(80px);
1413 opacity: 0.7;
1414 pointer-events: none;
1415 z-index: 0;
1416 animation: admFlagsOrb 14s ease-in-out infinite;
1417 }
1418 @keyframes admFlagsOrb {
1419 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1420 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1421 }
1422 @media (prefers-reduced-motion: reduce) {
1423 .adm-flags-hero-orb { animation: none; }
1424 }
1425 .adm-flags-hero-inner {
1426 position: relative;
1427 z-index: 1;
1428 display: flex;
1429 align-items: flex-end;
1430 justify-content: space-between;
1431 gap: var(--space-4);
1432 flex-wrap: wrap;
1433 }
1434 .adm-flags-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1435 .adm-flags-eyebrow {
1436 font-size: 12px;
1437 color: var(--text-muted);
1438 margin-bottom: var(--space-2);
1439 letter-spacing: 0.02em;
1440 display: inline-flex;
1441 align-items: center;
1442 gap: 8px;
1443 }
1444 .adm-flags-eyebrow-pill {
1445 display: inline-flex;
1446 align-items: center;
1447 justify-content: center;
1448 width: 22px; height: 22px;
1449 border-radius: 6px;
1450 background: rgba(140,109,255,0.14);
1451 color: #b69dff;
1452 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1453 }
1454 .adm-flags-title {
1455 font-size: clamp(28px, 4vw, 40px);
1456 font-family: var(--font-display);
1457 font-weight: 800;
1458 letter-spacing: -0.028em;
1459 line-height: 1.05;
1460 margin: 0 0 var(--space-2);
1461 color: var(--text-strong);
1462 }
1463 .adm-flags-title-grad {
1464 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1465 -webkit-background-clip: text;
1466 background-clip: text;
1467 -webkit-text-fill-color: transparent;
1468 color: transparent;
1469 }
1470 .adm-flags-sub {
1471 font-size: 15px;
1472 color: var(--text-muted);
1473 margin: 0;
1474 line-height: 1.5;
1475 max-width: 620px;
1476 }
1477 .adm-flags-sub code {
1478 font-family: var(--font-mono);
1479 font-size: 13px;
1480 background: var(--bg-tertiary);
1481 padding: 1px 5px;
1482 border-radius: 4px;
1483 color: var(--text);
1484 }
1485 .adm-flags-back {
1486 display: inline-flex;
1487 align-items: center;
1488 gap: 6px;
1489 padding: 7px 12px;
1490 font-size: 12.5px;
1491 color: var(--text-muted);
1492 background: rgba(255,255,255,0.02);
1493 border: 1px solid var(--border);
1494 border-radius: 8px;
1495 text-decoration: none;
1496 font-weight: 500;
1497 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1498 }
1499 .adm-flags-back:hover {
1500 border-color: var(--border-strong);
1501 color: var(--text-strong);
1502 background: rgba(255,255,255,0.04);
1503 }
1504
1505 .adm-flags-card {
1506 background: var(--bg-elevated);
1507 border: 1px solid var(--border);
1508 border-radius: 14px;
1509 overflow: hidden;
1510 }
1511 .adm-flags-card-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1512 .adm-flags-card-foot {
1513 padding: var(--space-3) var(--space-5);
1514 border-top: 1px solid var(--border);
1515 background: rgba(255,255,255,0.012);
1516 display: flex;
1517 justify-content: flex-end;
1518 gap: var(--space-2);
1519 align-items: center;
1520 flex-wrap: wrap;
1521 }
1522 .adm-flags-foot-hint {
1523 margin-right: auto;
1524 font-size: 12.5px;
1525 color: var(--text-muted);
1526 }
1527
1528 .adm-flags-field {
1529 display: flex;
1530 flex-direction: column;
1531 gap: 6px;
1532 padding: var(--space-3);
1533 border: 1px solid var(--border-subtle);
1534 border-radius: 12px;
1535 background: rgba(255,255,255,0.015);
1536 transition: border-color 120ms ease, background 120ms ease;
1537 }
1538 .adm-flags-field:hover { border-color: var(--border); background: rgba(255,255,255,0.025); }
1539 .adm-flags-field-head {
1540 display: flex;
1541 align-items: center;
1542 gap: 8px;
1543 }
1544 .adm-flags-key {
1545 font-family: var(--font-mono);
1546 font-size: 12.5px;
1547 font-weight: 600;
1548 color: var(--text-strong);
1549 letter-spacing: -0.005em;
1550 }
1551 .adm-flags-mono {
1552 font-family: var(--font-mono);
1553 font-size: 10.5px;
1554 color: var(--text-muted);
1555 background: rgba(255,255,255,0.04);
1556 padding: 1px 6px;
1557 border-radius: 4px;
1558 border: 1px solid var(--border-subtle);
1559 }
1560 .adm-flags-input {
1561 width: 100%;
1562 padding: 9px 12px;
1563 font-size: 13.5px;
1564 color: var(--text);
1565 background: var(--bg);
1566 border: 1px solid var(--border-strong);
1567 border-radius: 8px;
1568 outline: none;
1569 font-family: var(--font-mono);
1570 transition: border-color 120ms ease, box-shadow 120ms ease;
1571 box-sizing: border-box;
1572 }
1573 .adm-flags-input:focus {
1574 border-color: var(--border-focus);
1575 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1576 }
1577 .adm-flags-hint {
1578 font-size: 11.5px;
1579 color: var(--text-muted);
1580 margin-top: 2px;
1581 line-height: 1.45;
1582 }
1583 .adm-flags-hint code {
1584 font-family: var(--font-mono);
1585 font-size: 11.5px;
1586 background: var(--bg-tertiary);
1587 padding: 1px 5px;
1588 border-radius: 4px;
1589 color: var(--text);
1590 }
1591
1592 .adm-flags-btn {
1593 display: inline-flex;
1594 align-items: center;
1595 gap: 6px;
1596 padding: 9px 16px;
1597 border-radius: 10px;
1598 font-size: 13px;
1599 font-weight: 600;
1600 text-decoration: none;
1601 border: 1px solid transparent;
1602 cursor: pointer;
1603 font: inherit;
1604 line-height: 1;
1605 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1606 }
1607 .adm-flags-btn-primary {
1608 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1609 color: #fff;
1610 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1611 }
1612 .adm-flags-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
f1dc7c7Claude1613
1614 @media (max-width: 720px) {
1615 .adm-flags-wrap { padding: var(--space-4) var(--space-3); }
1616 .adm-flags-hero { padding: var(--space-4); }
1617 }
8929744Claude1618`;
1619
1620const admDigestsStyles = `
eed4684Claude1621 .adm-digests-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1622
1623 .adm-digests-hero {
1624 position: relative;
1625 margin-bottom: var(--space-5);
1626 padding: var(--space-5) var(--space-6);
1627 background: var(--bg-elevated);
1628 border: 1px solid var(--border);
1629 border-radius: 16px;
1630 overflow: hidden;
1631 }
1632 .adm-digests-hero::before {
1633 content: '';
1634 position: absolute;
1635 top: 0; left: 0; right: 0;
1636 height: 2px;
1637 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1638 opacity: 0.7;
1639 pointer-events: none;
1640 }
1641 .adm-digests-hero-orb {
1642 position: absolute;
1643 inset: -20% -10% auto auto;
1644 width: 380px; height: 380px;
1645 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1646 filter: blur(80px);
1647 opacity: 0.7;
1648 pointer-events: none;
1649 z-index: 0;
1650 animation: admDigestsOrb 14s ease-in-out infinite;
1651 }
1652 @keyframes admDigestsOrb {
1653 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1654 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1655 }
1656 @media (prefers-reduced-motion: reduce) {
1657 .adm-digests-hero-orb { animation: none; }
1658 }
1659 .adm-digests-hero-inner {
1660 position: relative;
1661 z-index: 1;
1662 display: flex;
1663 align-items: flex-end;
1664 justify-content: space-between;
1665 gap: var(--space-4);
1666 flex-wrap: wrap;
1667 }
1668 .adm-digests-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1669 .adm-digests-eyebrow {
1670 font-size: 12px;
1671 color: var(--text-muted);
1672 margin-bottom: var(--space-2);
1673 letter-spacing: 0.02em;
1674 display: inline-flex;
1675 align-items: center;
1676 gap: 8px;
1677 }
1678 .adm-digests-eyebrow-pill {
1679 display: inline-flex;
1680 align-items: center;
1681 justify-content: center;
1682 width: 22px; height: 22px;
1683 border-radius: 6px;
1684 background: rgba(140,109,255,0.14);
1685 color: #b69dff;
1686 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1687 }
1688 .adm-digests-title {
1689 font-size: clamp(28px, 4vw, 40px);
1690 font-family: var(--font-display);
1691 font-weight: 800;
1692 letter-spacing: -0.028em;
1693 line-height: 1.05;
1694 margin: 0 0 var(--space-2);
1695 color: var(--text-strong);
1696 }
1697 .adm-digests-title-grad {
1698 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1699 -webkit-background-clip: text;
1700 background-clip: text;
1701 -webkit-text-fill-color: transparent;
1702 color: transparent;
1703 }
1704 .adm-digests-sub {
1705 font-size: 15px;
1706 color: var(--text-muted);
1707 margin: 0;
1708 line-height: 1.5;
1709 max-width: 620px;
1710 }
1711 .adm-digests-back {
1712 display: inline-flex;
1713 align-items: center;
1714 gap: 6px;
1715 padding: 7px 12px;
1716 font-size: 12.5px;
1717 color: var(--text-muted);
1718 background: rgba(255,255,255,0.02);
1719 border: 1px solid var(--border);
1720 border-radius: 8px;
1721 text-decoration: none;
1722 font-weight: 500;
1723 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1724 }
1725 .adm-digests-back:hover {
1726 border-color: var(--border-strong);
1727 color: var(--text-strong);
1728 background: rgba(255,255,255,0.04);
1729 }
1730
1731 .adm-digests-banner {
1732 margin-bottom: var(--space-4);
1733 padding: 10px 14px;
1734 border-radius: 10px;
1735 font-size: 13.5px;
1736 border: 1px solid var(--border);
1737 background: rgba(255,255,255,0.025);
1738 color: var(--text);
1739 }
1740 .adm-digests-banner.is-ok {
1741 border-color: rgba(52,211,153,0.40);
1742 background: rgba(52,211,153,0.08);
1743 color: #bbf7d0;
1744 }
1745 .adm-digests-banner.is-error {
1746 border-color: rgba(248,113,113,0.40);
1747 background: rgba(248,113,113,0.08);
1748 color: #fecaca;
1749 }
1750
1751 .adm-digests-pills {
1752 display: inline-flex;
1753 gap: 6px;
1754 flex-wrap: wrap;
1755 margin-bottom: var(--space-4);
1756 }
1757 .adm-digests-pill {
1758 display: inline-flex;
1759 align-items: center;
1760 gap: 6px;
1761 padding: 4px 10px;
1762 border-radius: 9999px;
1763 font-size: 11.5px;
1764 font-weight: 600;
1765 background: rgba(255,255,255,0.04);
1766 color: var(--text-muted);
1767 box-shadow: inset 0 0 0 1px var(--border);
1768 }
1769 .adm-digests-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1770 .adm-digests-pill.is-on {
1771 background: rgba(52,211,153,0.14);
1772 color: #6ee7b7;
1773 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1774 }
1775
1776 .adm-digests-section {
1777 margin-bottom: var(--space-5);
1778 background: var(--bg-elevated);
1779 border: 1px solid var(--border);
1780 border-radius: 14px;
1781 overflow: hidden;
1782 }
1783 .adm-digests-section-head {
1784 padding: var(--space-4) var(--space-5);
1785 border-bottom: 1px solid var(--border);
1786 display: flex;
1787 align-items: center;
1788 gap: 10px;
1789 flex-wrap: wrap;
1790 }
1791 .adm-digests-section-icon {
1792 display: inline-flex;
1793 align-items: center;
1794 justify-content: center;
1795 width: 26px; height: 26px;
1796 border-radius: 8px;
1797 background: rgba(140,109,255,0.12);
1798 color: #b69dff;
1799 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1800 flex-shrink: 0;
1801 }
1802 .adm-digests-section-title {
1803 margin: 0;
1804 font-family: var(--font-display);
1805 font-size: 17px;
1806 font-weight: 700;
1807 letter-spacing: -0.018em;
1808 color: var(--text-strong);
1809 }
1810 .adm-digests-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
1811 .adm-digests-section-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1812
1813 .adm-digests-input {
1814 width: 100%;
1815 padding: 9px 12px;
1816 font-size: 13.5px;
1817 color: var(--text);
1818 background: var(--bg);
1819 border: 1px solid var(--border-strong);
1820 border-radius: 8px;
1821 outline: none;
1822 font-family: var(--font-mono);
1823 transition: border-color 120ms ease, box-shadow 120ms ease;
1824 box-sizing: border-box;
1825 max-width: 280px;
1826 }
1827 .adm-digests-input:focus {
1828 border-color: var(--border-focus);
1829 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1830 }
1831 .adm-digests-form-row {
1832 display: flex;
1833 gap: 8px;
1834 align-items: center;
1835 flex-wrap: wrap;
1836 }
1837
1838 .adm-digests-btn {
1839 display: inline-flex;
1840 align-items: center;
1841 gap: 6px;
1842 padding: 9px 16px;
1843 border-radius: 10px;
1844 font-size: 13px;
1845 font-weight: 600;
1846 text-decoration: none;
1847 border: 1px solid var(--border-strong);
1848 background: rgba(255,255,255,0.02);
1849 color: var(--text);
1850 cursor: pointer;
1851 font: inherit;
1852 line-height: 1;
1853 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1854 }
1855 .adm-digests-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); color: var(--text-strong); }
1856 .adm-digests-btn-primary {
1857 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1858 color: #fff;
1859 border-color: transparent;
1860 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1861 }
1862 .adm-digests-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
1863
1864 .adm-digests-section-divider {
1865 border-top: 1px solid var(--border-subtle);
1866 padding-top: var(--space-3);
1867 margin-top: var(--space-2);
1868 }
1869 .adm-digests-divider-hint {
1870 font-size: 12.5px;
1871 color: var(--text-muted);
1872 margin-bottom: 8px;
1873 }
1874
1875 .adm-digests-h3 {
1876 display: flex;
1877 align-items: baseline;
1878 justify-content: space-between;
1879 gap: var(--space-3);
1880 margin: var(--space-5) 0 var(--space-3);
1881 }
1882 .adm-digests-h3 h3 {
1883 font-family: var(--font-display);
1884 font-size: 16px;
1885 font-weight: 700;
1886 letter-spacing: -0.014em;
1887 margin: 0;
1888 color: var(--text-strong);
1889 }
1890 .adm-digests-h3-meta { font-size: 12px; color: var(--text-muted); }
1891
1892 .adm-digests-grid {
1893 display: grid;
1894 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1895 gap: var(--space-3);
1896 }
1897 .adm-digests-card {
1898 padding: var(--space-3) var(--space-4);
1899 background: var(--bg-elevated);
1900 border: 1px solid var(--border);
1901 border-radius: 12px;
1902 display: flex;
1903 align-items: center;
1904 gap: 12px;
1905 transition: transform 160ms ease, border-color 160ms ease;
1906 }
1907 .adm-digests-card:hover { transform: translateY(-1px); border-color: var(--border-strong); }
1908 .adm-digests-avatar {
1909 width: 36px; height: 36px;
1910 border-radius: 9999px;
1911 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
1912 color: var(--text-strong);
1913 display: inline-flex;
1914 align-items: center;
1915 justify-content: center;
1916 font-size: 13px;
1917 font-weight: 700;
1918 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1919 flex-shrink: 0;
1920 text-transform: uppercase;
1921 }
1922 .adm-digests-card-text { min-width: 0; flex: 1; }
1923 .adm-digests-card-name {
1924 font-size: 13.5px;
1925 font-weight: 600;
1926 color: var(--text-strong);
1927 text-decoration: none;
1928 }
1929 .adm-digests-card-name:hover { color: var(--accent-hover, var(--accent)); }
1930 .adm-digests-card-sent {
1931 margin-top: 2px;
1932 font-size: 11.5px;
1933 color: var(--text-muted);
1934 font-family: var(--font-mono);
1935 }
1936
1937 .adm-digests-empty {
1938 position: relative;
1939 padding: var(--space-12) var(--space-6);
1940 border: 1px dashed var(--border);
1941 border-radius: 16px;
1942 background: var(--bg-elevated);
1943 text-align: center;
1944 overflow: hidden;
1945 }
1946 .adm-digests-empty-orb {
1947 position: absolute;
1948 inset: 50% auto auto 50%;
1949 transform: translate(-50%, -50%);
1950 width: 320px; height: 320px;
1951 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
1952 filter: blur(60px);
1953 pointer-events: none;
1954 z-index: 0;
1955 }
1956 .adm-digests-empty-inner { position: relative; z-index: 1; }
1957 .adm-digests-empty-icon {
1958 display: inline-flex;
1959 align-items: center;
1960 justify-content: center;
1961 width: 56px; height: 56px;
1962 border-radius: 16px;
1963 background: rgba(140,109,255,0.10);
1964 color: #b69dff;
1965 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1966 margin-bottom: var(--space-3);
1967 }
1968 .adm-digests-empty-icon svg { width: 24px; height: 24px; }
1969 .adm-digests-empty-title {
1970 font-family: var(--font-display);
1971 font-size: 20px;
1972 font-weight: 700;
1973 letter-spacing: -0.015em;
1974 color: var(--text-strong);
1975 margin-bottom: 6px;
1976 }
1977 .adm-digests-empty-sub {
1978 color: var(--text-muted);
1979 font-size: 13.5px;
1980 line-height: 1.5;
1981 }
f1dc7c7Claude1982
1983 @media (max-width: 720px) {
1984 .adm-digests-wrap { padding: var(--space-4) var(--space-3); }
1985 .adm-digests-hero { padding: var(--space-4); }
1986 .adm-digests-grid { grid-template-columns: 1fr; }
1987 }
8929744Claude1988`;
1989
1990const admAutopilotStyles = `
eed4684Claude1991 .adm-autopilot-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1992
1993 .adm-autopilot-hero {
1994 position: relative;
1995 margin-bottom: var(--space-5);
1996 padding: var(--space-5) var(--space-6);
1997 background: var(--bg-elevated);
1998 border: 1px solid var(--border);
1999 border-radius: 16px;
2000 overflow: hidden;
2001 }
2002 .adm-autopilot-hero::before {
2003 content: '';
2004 position: absolute;
2005 top: 0; left: 0; right: 0;
2006 height: 2px;
2007 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2008 opacity: 0.7;
2009 pointer-events: none;
2010 }
2011 .adm-autopilot-hero-orb {
2012 position: absolute;
2013 inset: -20% -10% auto auto;
2014 width: 380px; height: 380px;
2015 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
2016 filter: blur(80px);
2017 opacity: 0.7;
2018 pointer-events: none;
2019 z-index: 0;
2020 animation: admAutopilotOrb 14s ease-in-out infinite;
2021 }
2022 @keyframes admAutopilotOrb {
2023 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2024 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2025 }
2026 @media (prefers-reduced-motion: reduce) {
2027 .adm-autopilot-hero-orb { animation: none; }
2028 }
2029 .adm-autopilot-hero-inner {
2030 position: relative;
2031 z-index: 1;
2032 display: flex;
2033 align-items: flex-end;
2034 justify-content: space-between;
2035 gap: var(--space-4);
2036 flex-wrap: wrap;
2037 }
2038 .adm-autopilot-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2039 .adm-autopilot-eyebrow {
2040 font-size: 12px;
2041 color: var(--text-muted);
2042 margin-bottom: var(--space-2);
2043 letter-spacing: 0.02em;
2044 display: inline-flex;
2045 align-items: center;
2046 gap: 8px;
2047 }
2048 .adm-autopilot-eyebrow-pill {
2049 display: inline-flex;
2050 align-items: center;
2051 justify-content: center;
2052 width: 22px; height: 22px;
2053 border-radius: 6px;
2054 background: rgba(140,109,255,0.14);
2055 color: #b69dff;
2056 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
2057 }
2058 .adm-autopilot-title {
2059 font-size: clamp(28px, 4vw, 40px);
2060 font-family: var(--font-display);
2061 font-weight: 800;
2062 letter-spacing: -0.028em;
2063 line-height: 1.05;
2064 margin: 0 0 var(--space-2);
2065 color: var(--text-strong);
2066 }
2067 .adm-autopilot-title-grad {
2068 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
2069 -webkit-background-clip: text;
2070 background-clip: text;
2071 -webkit-text-fill-color: transparent;
2072 color: transparent;
2073 }
2074 .adm-autopilot-sub {
2075 font-size: 15px;
2076 color: var(--text-muted);
2077 margin: 0;
2078 line-height: 1.5;
2079 max-width: 620px;
2080 }
2081 .adm-autopilot-back {
2082 display: inline-flex;
2083 align-items: center;
2084 gap: 6px;
2085 padding: 7px 12px;
2086 font-size: 12.5px;
2087 color: var(--text-muted);
2088 background: rgba(255,255,255,0.02);
2089 border: 1px solid var(--border);
2090 border-radius: 8px;
2091 text-decoration: none;
2092 font-weight: 500;
2093 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2094 }
2095 .adm-autopilot-back:hover {
2096 border-color: var(--border-strong);
2097 color: var(--text-strong);
2098 background: rgba(255,255,255,0.04);
2099 }
2100
2101 .adm-autopilot-banner {
2102 margin-bottom: var(--space-4);
2103 padding: 10px 14px;
2104 border-radius: 10px;
2105 font-size: 13.5px;
2106 border: 1px solid var(--border);
2107 background: rgba(255,255,255,0.025);
2108 color: var(--text);
2109 }
2110 .adm-autopilot-banner.is-ok {
2111 border-color: rgba(52,211,153,0.40);
2112 background: rgba(52,211,153,0.08);
2113 color: #bbf7d0;
2114 }
2115 .adm-autopilot-banner.is-error {
2116 border-color: rgba(248,113,113,0.40);
2117 background: rgba(248,113,113,0.08);
2118 color: #fecaca;
2119 }
2120
2121 .adm-autopilot-statgrid {
2122 display: grid;
2123 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2124 gap: var(--space-3);
2125 margin-bottom: var(--space-5);
2126 }
2127 .adm-autopilot-stat {
2128 position: relative;
2129 padding: var(--space-4);
2130 background: var(--bg-elevated);
2131 border: 1px solid var(--border);
2132 border-radius: 14px;
2133 overflow: hidden;
2134 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
2135 }
2136 .adm-autopilot-stat:hover {
2137 transform: translateY(-2px);
2138 border-color: var(--border-strong);
2139 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
2140 }
2141 .adm-autopilot-stat-head {
2142 display: flex;
2143 align-items: center;
2144 justify-content: space-between;
2145 margin-bottom: var(--space-2);
2146 }
2147 .adm-autopilot-stat-label {
2148 font-size: 11px;
2149 font-weight: 600;
2150 letter-spacing: 0.08em;
2151 text-transform: uppercase;
2152 color: var(--text-muted);
2153 }
2154 .adm-autopilot-stat-icon {
2155 display: inline-flex;
2156 align-items: center;
2157 justify-content: center;
2158 width: 26px; height: 26px;
2159 border-radius: 8px;
2160 background: rgba(140,109,255,0.12);
2161 color: #b69dff;
2162 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2163 }
2164 .adm-autopilot-stat-value {
2165 font-family: var(--font-display);
2166 font-size: 32px;
2167 font-weight: 800;
2168 letter-spacing: -0.028em;
2169 line-height: 1;
2170 color: var(--text-strong);
2171 }
2172 .adm-autopilot-stat-value.is-mono {
2173 font-family: var(--font-mono);
2174 font-size: 13px;
2175 line-height: 1.3;
2176 word-break: break-all;
2177 }
2178 .adm-autopilot-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
2179 .adm-autopilot-pill {
2180 display: inline-flex;
2181 align-items: center;
2182 gap: 6px;
2183 padding: 2px 8px;
2184 border-radius: 9999px;
2185 font-size: 10.5px;
2186 font-weight: 600;
2187 letter-spacing: 0.04em;
2188 text-transform: uppercase;
2189 }
2190 .adm-autopilot-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
2191 .adm-autopilot-pill.is-on {
2192 background: rgba(52,211,153,0.14);
2193 color: #6ee7b7;
2194 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2195 }
2196 .adm-autopilot-pill.is-off {
2197 background: rgba(255,255,255,0.04);
2198 color: var(--text-muted);
2199 box-shadow: inset 0 0 0 1px var(--border);
2200 }
2201
2202 .adm-autopilot-actions {
2203 display: flex;
2204 align-items: center;
2205 gap: 12px;
2206 flex-wrap: wrap;
2207 margin-bottom: var(--space-5);
2208 }
2209 .adm-autopilot-actions form { margin: 0; }
2210 .adm-autopilot-action-hint {
2211 color: var(--text-muted);
2212 font-size: 13px;
2213 }
2214
2215 .adm-autopilot-btn {
2216 display: inline-flex;
2217 align-items: center;
2218 gap: 6px;
2219 padding: 9px 16px;
2220 border-radius: 10px;
2221 font-size: 13px;
2222 font-weight: 600;
2223 text-decoration: none;
2224 border: 1px solid transparent;
2225 cursor: pointer;
2226 font: inherit;
2227 line-height: 1;
2228 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
2229 }
2230 .adm-autopilot-btn-primary {
2231 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
2232 color: #fff;
2233 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
2234 }
2235 .adm-autopilot-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(140,109,255,0.55); }
2236
2237 .adm-autopilot-h3 {
2238 display: flex;
2239 align-items: baseline;
2240 justify-content: space-between;
2241 gap: var(--space-3);
2242 margin: 0 0 var(--space-3);
2243 }
2244 .adm-autopilot-h3 h3 {
2245 font-family: var(--font-display);
2246 font-size: 16px;
2247 font-weight: 700;
2248 letter-spacing: -0.014em;
2249 margin: 0;
2250 color: var(--text-strong);
2251 }
2252 .adm-autopilot-h3-meta { font-size: 12px; color: var(--text-muted); }
2253
2254 .adm-autopilot-tasks {
2255 display: grid;
2256 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2257 gap: var(--space-3);
2258 }
2259 .adm-autopilot-task {
2260 padding: var(--space-3) var(--space-4);
2261 background: var(--bg-elevated);
2262 border: 1px solid var(--border);
2263 border-radius: 12px;
2264 display: flex;
2265 flex-direction: column;
2266 gap: 8px;
2267 transition: transform 160ms ease, border-color 160ms ease;
2268 }
2269 .adm-autopilot-task:hover { transform: translateY(-1px); border-color: var(--border-strong); }
2270 .adm-autopilot-task.is-ok { border-color: rgba(52,211,153,0.28); }
2271 .adm-autopilot-task.is-fail { border-color: rgba(248,113,113,0.32); }
2272 .adm-autopilot-task-head {
2273 display: flex;
2274 align-items: center;
2275 gap: 10px;
2276 }
2277 .adm-autopilot-task-light {
2278 flex-shrink: 0;
2279 width: 10px; height: 10px;
2280 border-radius: 9999px;
2281 background: #6b7280;
2282 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
2283 }
2284 .adm-autopilot-task-light.is-ok {
2285 background: #34d399;
2286 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
2287 }
2288 .adm-autopilot-task-light.is-fail {
2289 background: #f87171;
2290 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
2291 animation: admApPulse 1.8s ease-in-out infinite;
2292 }
2293 @keyframes admApPulse {
2294 0%, 100% { opacity: 1; transform: scale(1); }
2295 50% { opacity: 0.7; transform: scale(0.92); }
2296 }
2297 @media (prefers-reduced-motion: reduce) {
2298 .adm-autopilot-task-light.is-fail { animation: none; }
2299 }
2300 .adm-autopilot-task-name {
2301 font-family: var(--font-mono);
2302 font-size: 12.5px;
2303 font-weight: 600;
2304 color: var(--text-strong);
2305 word-break: break-word;
2306 flex: 1;
2307 min-width: 0;
2308 }
2309 .adm-autopilot-task-status {
2310 display: inline-flex;
2311 align-items: center;
2312 gap: 4px;
2313 padding: 2px 8px;
2314 border-radius: 9999px;
2315 font-size: 10.5px;
2316 font-weight: 600;
2317 letter-spacing: 0.04em;
2318 text-transform: uppercase;
2319 flex-shrink: 0;
2320 }
2321 .adm-autopilot-task-status.is-ok {
2322 background: rgba(52,211,153,0.14);
2323 color: #6ee7b7;
2324 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2325 }
2326 .adm-autopilot-task-status.is-fail {
2327 background: rgba(248,113,113,0.12);
2328 color: #fecaca;
2329 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
2330 }
2331 .adm-autopilot-task-meta {
2332 display: flex;
2333 align-items: center;
2334 justify-content: space-between;
2335 gap: 8px;
2336 font-size: 11.5px;
2337 color: var(--text-muted);
2338 font-family: var(--font-mono);
2339 }
2340 .adm-autopilot-task-err {
2341 font-size: 11.5px;
2342 color: #fecaca;
2343 line-height: 1.5;
2344 background: rgba(248,113,113,0.06);
2345 border: 1px solid rgba(248,113,113,0.20);
2346 padding: 6px 8px;
2347 border-radius: 6px;
2348 word-break: break-word;
2349 }
2350
2351 .adm-autopilot-empty {
2352 position: relative;
2353 padding: var(--space-12) var(--space-6);
2354 border: 1px dashed var(--border);
2355 border-radius: 16px;
2356 background: var(--bg-elevated);
2357 text-align: center;
2358 overflow: hidden;
2359 }
2360 .adm-autopilot-empty-orb {
2361 position: absolute;
2362 inset: 50% auto auto 50%;
2363 transform: translate(-50%, -50%);
2364 width: 320px; height: 320px;
2365 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
2366 filter: blur(60px);
2367 pointer-events: none;
2368 z-index: 0;
2369 }
2370 .adm-autopilot-empty-inner { position: relative; z-index: 1; }
2371 .adm-autopilot-empty-icon {
2372 display: inline-flex;
2373 align-items: center;
2374 justify-content: center;
2375 width: 56px; height: 56px;
2376 border-radius: 16px;
2377 background: rgba(140,109,255,0.10);
2378 color: #b69dff;
2379 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
2380 margin-bottom: var(--space-3);
2381 }
2382 .adm-autopilot-empty-icon svg { width: 24px; height: 24px; }
2383 .adm-autopilot-empty-title {
2384 font-family: var(--font-display);
2385 font-size: 20px;
2386 font-weight: 700;
2387 letter-spacing: -0.015em;
2388 color: var(--text-strong);
2389 margin-bottom: 6px;
2390 }
2391 .adm-autopilot-empty-sub {
2392 color: var(--text-muted);
2393 font-size: 13.5px;
2394 line-height: 1.5;
2395 }
2396
2397 .adm-autopilot-foot {
2398 margin-top: var(--space-5);
2399 padding: var(--space-3) var(--space-4);
2400 border: 1px solid var(--border-subtle);
2401 background: rgba(255,255,255,0.015);
2402 border-radius: 10px;
2403 color: var(--text-muted);
2404 font-size: 12.5px;
2405 }
2406 .adm-autopilot-foot code {
2407 font-family: var(--font-mono);
2408 font-size: 12px;
2409 background: var(--bg-tertiary);
2410 padding: 1px 5px;
2411 border-radius: 4px;
2412 color: var(--text);
2413 }
f1dc7c7Claude2414
2415 @media (max-width: 720px) {
2416 .adm-autopilot-wrap { padding: var(--space-4) var(--space-3); }
2417 .adm-autopilot-hero { padding: var(--space-4); }
2418 .adm-autopilot-statgrid { grid-template-columns: 1fr 1fr; }
2419 .adm-autopilot-tasks { grid-template-columns: 1fr; }
2420 }
8929744Claude2421`;
2422
07f4b70Claude2423/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
2424const Icons = {
2425 shield: (
2426 <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">
2427 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2428 </svg>
2429 ),
2430 users: (
2431 <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">
2432 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
2433 <circle cx="9" cy="7" r="4" />
2434 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
2435 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
2436 </svg>
2437 ),
2438 repo: (
2439 <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">
2440 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
2441 <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" />
2442 </svg>
2443 ),
2444 starShield: (
2445 <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">
2446 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2447 <path d="m9 12 2 2 4-4" />
2448 </svg>
2449 ),
2450 ops: (
2451 <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">
2452 <circle cx="12" cy="12" r="3" />
2453 <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" />
2454 </svg>
2455 ),
2456 pulse: (
2457 <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">
2458 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
2459 </svg>
2460 ),
2461 flag: (
2462 <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">
2463 <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" />
2464 <line x1="4" y1="22" x2="4" y2="15" />
2465 </svg>
2466 ),
2467 mail: (
2468 <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">
2469 <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" />
2470 <polyline points="22,6 12,13 2,6" />
2471 </svg>
2472 ),
2473 google: (
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 <circle cx="12" cy="12" r="10" />
2476 <path d="M12 8v8" /><path d="M8 12h8" />
2477 </svg>
2478 ),
2479 github: (
2480 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2481 <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" />
2482 </svg>
2483 ),
2484 sso: (
2485 <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">
2486 <rect x="3" y="11" width="18" height="11" rx="2" />
2487 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2488 </svg>
2489 ),
2490 bot: (
2491 <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">
2492 <rect x="3" y="7" width="18" height="13" rx="2" />
2493 <circle cx="9" cy="13" r="1" />
2494 <circle cx="15" cy="13" r="1" />
2495 <path d="M12 3v4" />
2496 </svg>
2497 ),
2498 refresh: (
2499 <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">
2500 <polyline points="23 4 23 10 17 10" />
2501 <polyline points="1 20 1 14 7 14" />
2502 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
2503 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
2504 </svg>
2505 ),
2506 arrowLeft: (
2507 <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">
2508 <line x1="19" y1="12" x2="5" y2="12" />
2509 <polyline points="12 19 5 12 12 5" />
2510 </svg>
2511 ),
509c376Claude2512 key: (
2513 <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">
2514 <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" />
2515 </svg>
2516 ),
07f4b70Claude2517};
2518
2519/** First-letter avatar helper. */
2520function initials(s: string): string {
2521 return (s || "?").trim().charAt(0).toUpperCase() || "?";
2522}
2523
8f50ed0Claude2524async function gate(c: any): Promise<{ user: any } | Response> {
2525 const user = c.get("user");
2526 if (!user) return c.redirect("/login?next=/admin");
2527 if (!(await isSiteAdmin(user.id))) {
2528 return c.html(
2529 <Layout title="Forbidden" user={user}>
07f4b70Claude2530 <div class="admin-403">
8f50ed0Claude2531 <h2>403 — Not a site admin</h2>
2532 <p>You don't have permission to view this page.</p>
2533 </div>
07f4b70Claude2534 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2535 </Layout>,
2536 403
2537 );
2538 }
2539 return { user };
2540}
2541
2542admin.get("/admin", async (c) => {
2543 const g = await gate(c);
2544 if (g instanceof Response) return g;
2545 const { user } = g;
2546
2547 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
2548 const [rc] = await db
2549 .select({ n: sql<number>`count(*)::int` })
2550 .from(repositories);
2551
2552 const recent = await db
2553 .select({
2554 id: users.id,
2555 username: users.username,
2556 createdAt: users.createdAt,
2557 })
2558 .from(users)
2559 .orderBy(desc(users.createdAt))
2560 .limit(10);
2561
2562 const admins = await listSiteAdmins();
2563
988380aClaude2564 const msg = c.req.query("result") || c.req.query("error");
2565 const isErr = !!c.req.query("error");
2566
07f4b70Claude2567 const userCount = Number(uc?.n || 0);
2568 const repoCount = Number(rc?.n || 0);
2569 const adminCount = admins.length;
2570
8f50ed0Claude2571 return c.html(
2572 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude2573 <div class="admin-wrap">
2574 <section class="admin-hero">
2575 <div class="admin-hero-bg" aria-hidden="true">
2576 <div class="admin-hero-orb" />
2577 </div>
2578 <div class="admin-hero-inner">
2579 <div class="admin-hero-eyebrow">
2580 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
2581 Site administration ·{" "}
2582 <span class="admin-who">{user.username}</span>
2583 </div>
2584 <h2 class="admin-hero-title">
2585 <span class="admin-hero-title-grad">Site admin</span>.
2586 </h2>
2587 <p class="admin-hero-sub">
2588 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
2589 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
2590 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
2591 Operations, flags, digests, and autopilot — all in one place.
2592 </p>
2593 </div>
2594 </section>
8f50ed0Claude2595
07f4b70Claude2596 {msg && (
2597 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
2598 {decodeURIComponent(msg)}
2599 </div>
2600 )}
988380aClaude2601
07f4b70Claude2602 <div class="admin-stat-grid">
2603 <div class="admin-stat">
2604 <div class="admin-stat-head">
2605 <span class="admin-stat-label">Users</span>
2606 <span class="admin-stat-icon">{Icons.users}</span>
2607 </div>
2608 <div class="admin-stat-value">{userCount}</div>
2609 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude2610 </div>
07f4b70Claude2611 <div class="admin-stat">
2612 <div class="admin-stat-head">
2613 <span class="admin-stat-label">Repos</span>
2614 <span class="admin-stat-icon">{Icons.repo}</span>
2615 </div>
2616 <div class="admin-stat-value">{repoCount}</div>
2617 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude2618 </div>
07f4b70Claude2619 <div class="admin-stat">
2620 <div class="admin-stat-head">
2621 <span class="admin-stat-label">Admins</span>
2622 <span class="admin-stat-icon">{Icons.starShield}</span>
2623 </div>
2624 <div class="admin-stat-value">{adminCount}</div>
2625 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude2626 </div>
2627 </div>
2628
07f4b70Claude2629 <div class="admin-actions">
2630 <a href="/admin/ops" class="admin-action is-primary">
2631 <span class="admin-action-icon">{Icons.ops}</span>
2632 Operations
2633 </a>
509c376Claude2634 <a href="/admin/integrations" class="admin-action is-primary">
2635 <span class="admin-action-icon">{Icons.key}</span>
2636 Integrations
2637 </a>
cf793f9Claude2638 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2639 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2640 Health (traffic lights)
2641 </a>
2642 <a href="/admin/deploys" class="admin-action is-primary">
2643 <span class="admin-action-icon">{Icons.ops}</span>
2644 Deploys
2645 </a>
2646 <a href="/admin/diagnose" class="admin-action">
2647 <span class="admin-action-icon">{Icons.pulse}</span>
2648 Diagnose
2649 </a>
2650 <a href="/admin/self-host" class="admin-action">
2651 <span class="admin-action-icon">{Icons.ops}</span>
2652 Self-host status
2653 </a>
2654 <a href="/admin/status" class="admin-action">
2655 <span class="admin-action-icon">{Icons.pulse}</span>
2656 Live activity stream
07f4b70Claude2657 </a>
2658 <a href="/admin/users" class="admin-action">
2659 <span class="admin-action-icon">{Icons.users}</span>
2660 Manage users
2661 </a>
2662 <a href="/admin/repos" class="admin-action">
2663 <span class="admin-action-icon">{Icons.repo}</span>
2664 Manage repos
2665 </a>
2666 <a href="/admin/flags" class="admin-action">
2667 <span class="admin-action-icon">{Icons.flag}</span>
2668 Site flags
2669 </a>
2670 <a href="/admin/digests" class="admin-action">
2671 <span class="admin-action-icon">{Icons.mail}</span>
2672 Email digests
2673 </a>
2674 <a href="/admin/google-oauth" class="admin-action">
2675 <span class="admin-action-icon">{Icons.google}</span>
2676 Sign in with Google
2677 </a>
2678 <a href="/admin/github-oauth" class="admin-action">
2679 <span class="admin-action-icon">{Icons.github}</span>
2680 Sign in with GitHub
2681 </a>
2682 <a href="/admin/sso" class="admin-action">
2683 <span class="admin-action-icon">{Icons.sso}</span>
2684 Enterprise SSO
2685 </a>
c6018a5Claude2686 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
07f4b70Claude2687 <span class="admin-action-icon">{Icons.bot}</span>
2688 Autopilot
2689 </a>
c6018a5Claude2690 <a href="/admin/diagnose" class="admin-action" title="Live status of the AI CI healer, patch generator, and proactive monitor">
2691 <span class="admin-action-icon">{Icons.bot}</span>
2692 AI background tasks
2693 </a>
662ce86Claude2694 <a href="/connect/claude" class="admin-action is-primary">
2695 <span class="admin-action-icon">{Icons.bot}</span>
2696 Connect Claude
2697 </a>
07f4b70Claude2698 <form
2699 method="post"
2700 action="/admin/demo/reseed"
2701 class="admin-action-form"
2702 >
2703 <button
2704 class="admin-action"
2705 type="submit"
2706 title="Idempotently (re)create demo user + 3 sample repos"
2707 >
2708 <span class="admin-action-icon">{Icons.refresh}</span>
2709 Reseed demo
2710 </button>
2711 </form>
2712 </div>
8f50ed0Claude2713
07f4b70Claude2714 <div class="admin-h3">
2715 <h3>Recent signups</h3>
2716 <span class="admin-h3-meta">
2717 {recent.length} most-recent
2718 </span>
2719 </div>
2720 <div class="admin-list" style="margin-bottom:20px">
2721 {recent.length === 0 ? (
2722 <div class="admin-list-empty">No users yet.</div>
2723 ) : (
2724 recent.map((u) => (
2725 <div class="admin-list-row">
2726 <div class="admin-list-main">
2727 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
2728 <div class="admin-row-text">
2729 <a href={`/${u.username}`} class="admin-row-title">
2730 {u.username}
2731 </a>
2732 <div class="admin-row-sub">
2733 <span>Joined</span>
2734 <span>
2735 {u.createdAt
2736 ? new Date(u.createdAt as unknown as string).toLocaleString()
2737 : ""}
2738 </span>
2739 </div>
2740 </div>
2741 </div>
2742 </div>
2743 ))
2744 )}
2745 </div>
8f50ed0Claude2746
07f4b70Claude2747 <div class="admin-h3">
2748 <h3>Site admins</h3>
2749 <span class="admin-h3-meta">
2750 {adminCount} active
2751 </span>
2752 </div>
2753 <div class="admin-list">
2754 {admins.length === 0 ? (
2755 <div class="admin-list-empty">
2756 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude2757 </div>
07f4b70Claude2758 ) : (
2759 admins.map((a) => (
2760 <div class="admin-list-row">
2761 <div class="admin-list-main">
2762 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
2763 <div class="admin-row-text">
2764 <a href={`/${a.username}`} class="admin-row-title">
2765 {a.username}
2766 </a>
2767 <div class="admin-row-sub">
2768 <span class="admin-pill is-admin">
2769 <span class="dot" aria-hidden="true" /> Site admin
2770 </span>
2771 <span>
2772 Granted{" "}
2773 {a.grantedAt
2774 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
2775 : "—"}
2776 </span>
2777 </div>
2778 </div>
2779 </div>
2780 </div>
2781 ))
2782 )}
2783 </div>
8f50ed0Claude2784 </div>
07f4b70Claude2785 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2786 </Layout>
2787 );
2788});
2789
2790// ----- Users -----
2791
2792admin.get("/admin/users", async (c) => {
2793 const g = await gate(c);
2794 if (g instanceof Response) return g;
2795 const { user } = g;
2796 const q = c.req.query("q") || "";
2797 const rows = await db
2798 .select({
2799 id: users.id,
2800 username: users.username,
2801 email: users.email,
2802 createdAt: users.createdAt,
2803 })
2804 .from(users)
2805 .where(
2806 q
2807 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
2808 : sql`1=1`
2809 )
2810 .orderBy(desc(users.createdAt))
2811 .limit(200);
2812
2813 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude2814 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude2815
2816 return c.html(
2817 <Layout title="Admin — Users" user={user}>
8929744Claude2818 <div class="adm-users-wrap">
2819 <section class="adm-users-hero">
2820 <div class="adm-users-hero-orb" aria-hidden="true" />
2821 <div class="adm-users-hero-inner">
2822 <div class="adm-users-hero-text">
2823 <div class="adm-users-eyebrow">
2824 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
2825 Site admin · Users
2826 </div>
2827 <h1 class="adm-users-title">
2828 <span class="adm-users-title-grad">Users</span>.
2829 </h1>
2830 <p class="adm-users-sub">
2831 Search, audit, and grant or revoke the site-admin flag.
2832 Showing up to 200 accounts ordered by signup recency.
2833 </p>
2834 </div>
2835 <a href="/admin" class="adm-users-back">
07f4b70Claude2836 {Icons.arrowLeft} Back
2837 </a>
2838 </div>
2839 </section>
2840
8929744Claude2841 <div class="adm-users-filterbar">
2842 <form method="get" action="/admin/users" class="adm-users-search">
2843 <span class="adm-users-search-ico" aria-hidden="true">
2844 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2845 <circle cx="11" cy="11" r="8" />
2846 <line x1="21" y1="21" x2="16.65" y2="16.65" />
2847 </svg>
2848 </span>
2849 <input
2850 type="text"
2851 name="q"
2852 value={q}
2853 placeholder="Search username or email"
2854 aria-label="Search username or email"
2855 class="adm-users-input"
2856 />
2857 <button type="submit" class="adm-users-btn">Search</button>
2858 {q && (
2859 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
2860 )}
2861 </form>
2862 <div class="adm-users-pills">
2863 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
2864 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
2865 </div>
2866 </div>
07f4b70Claude2867
8929744Claude2868 {rows.length === 0 ? (
2869 <div class="adm-users-empty">
2870 <div class="adm-users-empty-orb" aria-hidden="true" />
2871 <div class="adm-users-empty-inner">
2872 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
2873 <div class="adm-users-empty-title">No users found</div>
2874 <div class="adm-users-empty-sub">
2875 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
2876 </div>
2877 </div>
2878 </div>
2879 ) : (
2880 <div class="adm-users-grid">
2881 {rows.map((u) => {
07f4b70Claude2882 const isAdmin = adminIds.has(u.id);
2883 return (
8929744Claude2884 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
2885 <div class="adm-users-card-head">
2886 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude2887 {initials(u.username)}
8f50ed0Claude2888 </span>
8929744Claude2889 <div class="adm-users-card-id">
2890 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
2891 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude2892 </div>
8929744Claude2893 {isAdmin && (
2894 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
2895 )}
2896 </div>
2897 <div class="adm-users-card-meta">
2898 <span class="adm-users-meta-item">
2899 <span class="adm-users-meta-key">Email</span>
2900 <span class="adm-users-meta-val">{u.email}</span>
2901 </span>
2902 {u.createdAt && (
2903 <span class="adm-users-meta-item">
2904 <span class="adm-users-meta-key">Joined</span>
2905 <span class="adm-users-meta-val">
2906 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
2907 </span>
2908 </span>
2909 )}
2910 </div>
2911 <div class="adm-users-card-actions">
2912 <form
2913 method="post"
2914 action={`/admin/users/${u.id}/admin`}
2915 onsubmit={
2916 isAdmin
2917 ? "return confirm('Revoke site admin?')"
2918 : "return confirm('Grant site admin?')"
2919 }
2920 >
2921 <button
2922 type="submit"
2923 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
2924 >
2925 {isAdmin ? "Revoke admin" : "Grant admin"}
2926 </button>
2927 </form>
2928 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
2929 View profile
2930 </a>
07f4b70Claude2931 </div>
8f50ed0Claude2932 </div>
07f4b70Claude2933 );
8929744Claude2934 })}
2935 </div>
2936 )}
8f50ed0Claude2937 </div>
07f4b70Claude2938 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude2939 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude2940 </Layout>
2941 );
2942});
2943
2944admin.post("/admin/users/:id/admin", async (c) => {
2945 const g = await gate(c);
2946 if (g instanceof Response) return g;
2947 const { user } = g;
2948 const id = c.req.param("id");
2949 const admins = await listSiteAdmins();
2950 const isAlready = admins.some((a) => a.userId === id);
2951 if (isAlready) {
2952 await revokeSiteAdmin(id);
2953 await audit({
2954 userId: user.id,
2955 action: "site_admin.revoke",
2956 targetType: "user",
2957 targetId: id,
2958 });
2959 } else {
2960 await grantSiteAdmin(id, user.id);
2961 await audit({
2962 userId: user.id,
2963 action: "site_admin.grant",
2964 targetType: "user",
2965 targetId: id,
2966 });
2967 }
2968 return c.redirect("/admin/users");
2969});
2970
2971// ----- Repos -----
2972
2973admin.get("/admin/repos", async (c) => {
2974 const g = await gate(c);
2975 if (g instanceof Response) return g;
2976 const { user } = g;
2977 const rows = await db
2978 .select({
2979 id: repositories.id,
2980 name: repositories.name,
2981 ownerUsername: users.username,
0316dbbClaude2982 isPrivate: repositories.isPrivate,
8f50ed0Claude2983 createdAt: repositories.createdAt,
2984 starCount: repositories.starCount,
2985 })
2986 .from(repositories)
2987 .innerJoin(users, eq(repositories.ownerId, users.id))
2988 .orderBy(desc(repositories.createdAt))
2989 .limit(200);
2990
8929744Claude2991 const privateCount = rows.filter((r) => r.isPrivate).length;
2992 const publicCount = rows.length - privateCount;
2993
8f50ed0Claude2994 return c.html(
2995 <Layout title="Admin — Repos" user={user}>
8929744Claude2996 <div class="adm-repos-wrap">
2997 <section class="adm-repos-hero">
2998 <div class="adm-repos-hero-orb" aria-hidden="true" />
2999 <div class="adm-repos-hero-inner">
3000 <div class="adm-repos-hero-text">
3001 <div class="adm-repos-eyebrow">
3002 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3003 Site admin · Repositories
3004 </div>
3005 <h1 class="adm-repos-title">
3006 <span class="adm-repos-title-grad">Repositories</span>.
3007 </h1>
3008 <p class="adm-repos-sub">
3009 Every repository on the platform — public and private.
3010 Delete is irreversible and audit-logged.
3011 </p>
3012 </div>
3013 <a href="/admin" class="adm-repos-back">
07f4b70Claude3014 {Icons.arrowLeft} Back
3015 </a>
3016 </div>
3017 </section>
3018
8929744Claude3019 <div class="adm-repos-pills">
3020 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3021 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3022 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3023 </div>
3024
3025 {rows.length === 0 ? (
3026 <div class="adm-repos-empty">
3027 <div class="adm-repos-empty-orb" aria-hidden="true" />
3028 <div class="adm-repos-empty-inner">
3029 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3030 <div class="adm-repos-empty-title">No repositories yet</div>
3031 <div class="adm-repos-empty-sub">
3032 When users create their first repos, they'll appear here.
3033 </div>
3034 </div>
3035 </div>
3036 ) : (
3037 <div class="adm-repos-grid">
3038 {rows.map((r) => (
3039 <div class="adm-repos-card">
3040 <div class="adm-repos-card-head">
3041 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3042 <div class="adm-repos-card-title">
07f4b70Claude3043 <a
3044 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3045 class="adm-repos-card-name"
07f4b70Claude3046 >
3047 {r.ownerUsername}/{r.name}
3048 </a>
8929744Claude3049 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3050 </div>
8929744Claude3051 <span
3052 class={
3053 "adm-repos-pill " +
3054 (r.isPrivate ? "is-private" : "is-public")
3055 }
3056 style="margin-left:auto"
3057 >
3058 <span class="dot" aria-hidden="true" />
3059 {r.isPrivate ? "private" : "public"}
3060 </span>
3061 </div>
3062 <div class="adm-repos-card-meta">
3063 <span class="adm-repos-meta-item">
3064 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3065 <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" />
3066 </svg>
3067 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3068 </span>
3069 {r.createdAt && (
3070 <span class="adm-repos-meta-item">
3071 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3072 <rect x="3" y="4" width="18" height="18" rx="2" />
3073 <line x1="16" y1="2" x2="16" y2="6" />
3074 <line x1="8" y1="2" x2="8" y2="6" />
3075 <line x1="3" y1="10" x2="21" y2="10" />
3076 </svg>
3077 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3078 </span>
3079 )}
3080 </div>
3081 <div class="adm-repos-card-actions">
3082 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3083 Open repo
3084 </a>
3085 <form
3086 method="post"
3087 action={`/admin/repos/${r.id}/delete`}
3088 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3089 >
3090 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3091 Delete
3092 </button>
3093 </form>
8f50ed0Claude3094 </div>
3095 </div>
8929744Claude3096 ))}
3097 </div>
3098 )}
8f50ed0Claude3099 </div>
07f4b70Claude3100 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3101 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3102 </Layout>
3103 );
3104});
3105
3106admin.post("/admin/repos/:id/delete", async (c) => {
3107 const g = await gate(c);
3108 if (g instanceof Response) return g;
3109 const { user } = g;
3110 const id = c.req.param("id");
3111 try {
3112 await db.delete(repositories).where(eq(repositories.id, id));
3113 } catch (err) {
3114 console.error("[admin] repo delete:", err);
3115 }
3116 await audit({
3117 userId: user.id,
3118 action: "admin.repo.delete",
3119 targetType: "repository",
3120 targetId: id,
3121 });
3122 return c.redirect("/admin/repos");
3123});
3124
3125// ----- Flags -----
3126
3127admin.get("/admin/flags", async (c) => {
3128 const g = await gate(c);
3129 if (g instanceof Response) return g;
3130 const { user } = g;
3131
3132 const existing = await listFlags();
3133 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3134 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3135
3136 return c.html(
3137 <Layout title="Admin — Flags" user={user}>
8929744Claude3138 <div class="adm-flags-wrap">
3139 <section class="adm-flags-hero">
3140 <div class="adm-flags-hero-orb" aria-hidden="true" />
3141 <div class="adm-flags-hero-inner">
3142 <div class="adm-flags-hero-text">
3143 <div class="adm-flags-eyebrow">
3144 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3145 Site admin · Feature flags
3146 </div>
3147 <h1 class="adm-flags-title">
3148 <span class="adm-flags-title-grad">Site flags</span>.
3149 </h1>
3150 <p class="adm-flags-sub">
3151 Runtime feature flags surfaced to the rest of the app via{" "}
3152 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3153 </p>
3154 </div>
3155 <a href="/admin" class="adm-flags-back">
07f4b70Claude3156 {Icons.arrowLeft} Back
3157 </a>
3158 </div>
3159 </section>
3160
8929744Claude3161 <form method="post" action="/admin/flags" class="adm-flags-card">
3162 <div class="adm-flags-card-body">
07f4b70Claude3163 {keys.map((k) => {
3164 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3165 const isOverridden =
3166 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3167 return (
8929744Claude3168 <div class="adm-flags-field">
3169 <div class="adm-flags-field-head">
3170 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3171 {isOverridden && (
3172 <span class="adm-flags-mono">overridden</span>
3173 )}
3174 </div>
07f4b70Claude3175 <input
8929744Claude3176 id={`flag-${k}`}
07f4b70Claude3177 type="text"
3178 name={k}
3179 value={current}
3180 aria-label={k}
8929744Claude3181 class="adm-flags-input"
07f4b70Claude3182 />
8929744Claude3183 <div class="adm-flags-hint">
07f4b70Claude3184 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3185 </div>
3186 </div>
3187 );
3188 })}
3189 </div>
8929744Claude3190 <div class="adm-flags-card-foot">
3191 <span class="adm-flags-foot-hint">
07f4b70Claude3192 Saved values overwrite the defaults at runtime.
3193 </span>
8929744Claude3194 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3195 Save changes
07f4b70Claude3196 </button>
3197 </div>
3198 </form>
8f50ed0Claude3199 </div>
07f4b70Claude3200 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3201 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3202 </Layout>
3203 );
3204});
3205
3206admin.post("/admin/flags", async (c) => {
3207 const g = await gate(c);
3208 if (g instanceof Response) return g;
3209 const { user } = g;
3210 const body = await c.req.parseBody();
3211 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3212 for (const k of keys) {
3213 const v = String(body[k] ?? "");
3214 await setFlag(k, v, user.id);
3215 }
3216 await audit({ userId: user.id, action: "admin.flags.save" });
3217 return c.redirect("/admin/flags");
3218});
3219
08420cdClaude3220// ----- Email digests (Block I7) -----
3221
3222admin.get("/admin/digests", async (c) => {
3223 const g = await gate(c);
3224 if (g instanceof Response) return g;
3225 const { user } = g;
3226
3227 const [optedRow] = await db
3228 .select({ n: sql<number>`count(*)::int` })
3229 .from(users)
3230 .where(eq(users.notifyEmailDigestWeekly, true));
3231 const opted = Number(optedRow?.n || 0);
3232
3233 const recentlySent = await db
3234 .select({
3235 id: users.id,
3236 username: users.username,
3237 lastDigestSentAt: users.lastDigestSentAt,
3238 })
3239 .from(users)
3240 .where(sql`${users.lastDigestSentAt} is not null`)
3241 .orderBy(desc(users.lastDigestSentAt))
3242 .limit(20);
3243
3244 const result = c.req.query("result");
3245 const error = c.req.query("error");
3246
3247 return c.html(
3248 <Layout title="Admin — Digests" user={user}>
8929744Claude3249 <div class="adm-digests-wrap">
3250 <section class="adm-digests-hero">
3251 <div class="adm-digests-hero-orb" aria-hidden="true" />
3252 <div class="adm-digests-hero-inner">
3253 <div class="adm-digests-hero-text">
3254 <div class="adm-digests-eyebrow">
3255 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3256 Site admin · Email
3257 </div>
3258 <h1 class="adm-digests-title">
3259 <span class="adm-digests-title-grad">Email digests</span>.
3260 </h1>
3261 <p class="adm-digests-sub">
3262 Manually trigger the weekly digest for every opted-in user
3263 or preview the email for a single account.
3264 </p>
3265 </div>
3266 <a href="/admin" class="adm-digests-back">
07f4b70Claude3267 {Icons.arrowLeft} Back
3268 </a>
3269 </div>
3270 </section>
08420cdClaude3271
07f4b70Claude3272 {result && (
8929744Claude3273 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3274 )}
3275 {error && (
8929744Claude3276 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3277 )}
08420cdClaude3278
8929744Claude3279 <div class="adm-digests-pills">
3280 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3281 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3282 </div>
3283
3284 <section class="adm-digests-section">
3285 <header class="adm-digests-section-head">
3286 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3287 <div>
3288 <h3 class="adm-digests-section-title">Send digests</h3>
3289 <p class="adm-digests-section-sub">
3290 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3291 </p>
08420cdClaude3292 </div>
8929744Claude3293 </header>
3294 <div class="adm-digests-section-body">
3295 <form method="post" action="/admin/digests/run">
07f4b70Claude3296 <button
3297 type="submit"
8929744Claude3298 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3299 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3300 >
8929744Claude3301 {Icons.mail}
07f4b70Claude3302 Send digests now
3303 </button>
3304 </form>
8929744Claude3305 <div class="adm-digests-section-divider">
3306 <div class="adm-digests-divider-hint">
07f4b70Claude3307 Preview / one-off — send the digest to a single user.
3308 </div>
3309 <form
3310 method="post"
3311 action="/admin/digests/preview"
8929744Claude3312 class="adm-digests-form-row"
07f4b70Claude3313 >
3314 <input
3315 type="text"
3316 name="username"
3317 placeholder="username"
3318 required
3319 aria-label="Username"
8929744Claude3320 class="adm-digests-input"
07f4b70Claude3321 />
8929744Claude3322 <button type="submit" class="adm-digests-btn">
07f4b70Claude3323 Send to one user
3324 </button>
3325 </form>
3326 </div>
3327 </div>
8929744Claude3328 </section>
07f4b70Claude3329
8929744Claude3330 <div class="adm-digests-h3">
07f4b70Claude3331 <h3>Recently sent</h3>
8929744Claude3332 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3333 </div>
8929744Claude3334 {recentlySent.length === 0 ? (
3335 <div class="adm-digests-empty">
3336 <div class="adm-digests-empty-orb" aria-hidden="true" />
3337 <div class="adm-digests-empty-inner">
3338 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3339 <div class="adm-digests-empty-title">No digests sent yet</div>
3340 <div class="adm-digests-empty-sub">
3341 When the weekly digest fires, sent recipients will appear here.
3342 </div>
3343 </div>
3344 </div>
3345 ) : (
3346 <div class="adm-digests-grid">
3347 {recentlySent.map((u) => (
3348 <div class="adm-digests-card">
3349 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3350 <div class="adm-digests-card-text">
3351 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3352 <div class="adm-digests-card-sent">
3353 sent {u.lastDigestSentAt
3354 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3355 : "—"}
07f4b70Claude3356 </div>
3357 </div>
3358 </div>
8929744Claude3359 ))}
3360 </div>
3361 )}
08420cdClaude3362 </div>
07f4b70Claude3363 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3364 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3365 </Layout>
3366 );
3367});
3368
3369admin.post("/admin/digests/run", async (c) => {
3370 const g = await gate(c);
3371 if (g instanceof Response) return g;
3372 const { user } = g;
3373 const results = await sendDigestsToAll();
3374 const sent = results.filter((r) => r.ok).length;
3375 const skipped = results.length - sent;
3376 await audit({
3377 userId: user.id,
3378 action: "admin.digests.run",
3379 metadata: { sent, skipped, total: results.length },
3380 });
3381 return c.redirect(
3382 `/admin/digests?result=${encodeURIComponent(
3383 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3384 )}`
3385 );
3386});
3387
3388admin.post("/admin/digests/preview", async (c) => {
3389 const g = await gate(c);
3390 if (g instanceof Response) return g;
3391 const { user } = g;
3392 const body = await c.req.parseBody();
3393 const username = String(body.username || "").trim();
3394 if (!username) {
3395 return c.redirect("/admin/digests?error=Username+required");
3396 }
3397 const [target] = await db
3398 .select({ id: users.id, username: users.username })
3399 .from(users)
3400 .where(eq(users.username, username))
3401 .limit(1);
3402 if (!target) {
3403 return c.redirect("/admin/digests?error=User+not+found");
3404 }
3405 const result = await sendDigestForUser(target.id);
3406 await audit({
3407 userId: user.id,
3408 action: "admin.digests.preview",
3409 targetType: "user",
3410 targetId: target.id,
3411 metadata: {
3412 ok: result.ok,
3413 skipped: "skipped" in result ? result.skipped : null,
3414 },
3415 });
3416 if (result.ok) {
3417 return c.redirect(
3418 `/admin/digests?result=${encodeURIComponent(
3419 `Digest sent to ${target.username}.`
3420 )}`
3421 );
3422 }
3423 return c.redirect(
3424 `/admin/digests?error=${encodeURIComponent(
3425 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3426 )}`
3427 );
3428});
3429
b5dd694Claude3430const AUTOPILOT_TASK_CATALOG = [
3431 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3432 { name: "merge-queue", desc: "Advance serialised merge queues" },
3433 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3434 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3435 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3436 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3437 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3438 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3439 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3440 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
b5dd694Claude3441] as const;
3442
8e9f1d9Claude3443admin.get("/admin/autopilot", async (c) => {
3444 const g = await gate(c);
3445 if (g instanceof Response) return g;
3446 const { user } = g;
3447 const tick = getLastTick();
3448 const total = getTickCount();
3449 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3450 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3451 const intervalMs =
3452 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3453 ? Number(intervalRaw)
3454 : 5 * 60 * 1000;
3455 const msg = c.req.query("result") || c.req.query("error");
3456 const isErr = !!c.req.query("error");
3457 return c.html(
3458 <Layout title="Autopilot — admin" user={user}>
8929744Claude3459 <div class="adm-autopilot-wrap">
3460 <section class="adm-autopilot-hero">
3461 <div class="adm-autopilot-hero-orb" aria-hidden="true" />
3462 <div class="adm-autopilot-hero-inner">
3463 <div class="adm-autopilot-hero-text">
3464 <div class="adm-autopilot-eyebrow">
3465 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3466 Site admin · Maintenance loop
3467 </div>
3468 <h1 class="adm-autopilot-title">
3469 <span class="adm-autopilot-title-grad">Autopilot</span>.
3470 </h1>
3471 <p class="adm-autopilot-sub">
3472 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3473 progress, weekly digests, advisory rescans, wait-timer release,
3474 scheduled workflows (cron), AI-gated auto-merge sweep, and
3475 ai:build issue → PR dispatch.
8929744Claude3476 </p>
3477 </div>
3478 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3479 {Icons.arrowLeft} Back
3480 </a>
3481 </div>
3482 </section>
3483
8e9f1d9Claude3484 {msg && (
8929744Claude3485 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3486 {decodeURIComponent(msg)}
3487 </div>
3488 )}
07f4b70Claude3489
8929744Claude3490 <div class="adm-autopilot-statgrid">
3491 <div class="adm-autopilot-stat">
3492 <div class="adm-autopilot-stat-head">
3493 <span class="adm-autopilot-stat-label">Status</span>
3494 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3495 <span class="dot" aria-hidden="true" />
3496 {disabled ? "disabled" : "running"}
3497 </span>
3498 </div>
8929744Claude3499 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3500 {disabled ? "disabled" : "running"}
3501 </div>
8929744Claude3502 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3503 </div>
8929744Claude3504 <div class="adm-autopilot-stat">
3505 <div class="adm-autopilot-stat-head">
3506 <span class="adm-autopilot-stat-label">Interval</span>
3507 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3508 </div>
8929744Claude3509 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3510 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3511 </div>
8929744Claude3512 <div class="adm-autopilot-stat">
3513 <div class="adm-autopilot-stat-head">
3514 <span class="adm-autopilot-stat-label">Ticks this process</span>
3515 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3516 </div>
8929744Claude3517 <div class="adm-autopilot-stat-value">{total}</div>
3518 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3519 </div>
8929744Claude3520 <div class="adm-autopilot-stat">
3521 <div class="adm-autopilot-stat-head">
3522 <span class="adm-autopilot-stat-label">Last tick</span>
3523 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3524 </div>
8929744Claude3525 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3526 {tick ? tick.finishedAt : "never"}
3527 </div>
3528 </div>
3529 </div>
07f4b70Claude3530
8929744Claude3531 <div class="adm-autopilot-actions">
3532 <form method="post" action="/admin/autopilot/run">
3533 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3534 {Icons.bot}
3535 Run tick now
3536 </button>
3537 </form>
3538 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3539 Executes all sub-tasks synchronously and records the result.
3540 </span>
8929744Claude3541 </div>
07f4b70Claude3542
8929744Claude3543 <div class="adm-autopilot-h3">
07f4b70Claude3544 <h3>Last tick tasks</h3>
3545 {tick && (
8929744Claude3546 <span class="adm-autopilot-h3-meta">
07f4b70Claude3547 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3548 </span>
3549 )}
3550 </div>
8e9f1d9Claude3551 {tick ? (
8929744Claude3552 <div class="adm-autopilot-tasks">
3553 {tick.tasks.map((t) => (
3554 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3555 <div class="adm-autopilot-task-head">
3556 <span
3557 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3558 aria-label={t.ok ? "ok" : "failed"}
3559 />
3560 <span class="adm-autopilot-task-name">{t.name}</span>
3561 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3562 {t.ok ? "ok" : "failed"}
8929744Claude3563 </span>
3564 </div>
3565 <div class="adm-autopilot-task-meta">
3566 <span>duration</span>
3567 <span>{t.durationMs}ms</span>
3568 </div>
3569 {t.error && (
3570 <div class="adm-autopilot-task-err">{t.error}</div>
3571 )}
3572 </div>
3573 ))}
3574 </div>
8e9f1d9Claude3575 ) : (
8929744Claude3576 <div class="adm-autopilot-empty">
3577 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3578 <div class="adm-autopilot-empty-inner">
3579 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3580 <div class="adm-autopilot-empty-title">No ticks yet</div>
3581 <div class="adm-autopilot-empty-sub">
3582 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3583 </div>
3584 </div>
07f4b70Claude3585 </div>
8e9f1d9Claude3586 )}
b5dd694Claude3587 <div class="adm-autopilot-h3" style="margin-top:28px">
3588 <h3>Configured tasks</h3>
44ed968Claude3589 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3590 </div>
3591 <div class="adm-autopilot-tasks">
3592 {AUTOPILOT_TASK_CATALOG.map((t) => {
3593 const last = tick?.tasks.find((r) => r.name === t.name);
3594 return (
3595 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3596 <div class="adm-autopilot-task-head">
3597 <span
3598 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3599 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3600 />
3601 <span class="adm-autopilot-task-name">{t.name}</span>
3602 {last && (
3603 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3604 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3605 </span>
3606 )}
3607 </div>
3608 <div class="adm-autopilot-task-meta">
3609 <span>{t.desc}</span>
3610 </div>
3611 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3612 </div>
3613 );
3614 })}
3615 </div>
3616
8929744Claude3617 <p class="adm-autopilot-foot">
8e9f1d9Claude3618 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3619 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3620 </p>
3621 </div>
07f4b70Claude3622 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3623 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3624 </Layout>
3625 );
3626});
3627
988380aClaude3628admin.post("/admin/demo/reseed", async (c) => {
3629 const g = await gate(c);
3630 if (g instanceof Response) return g;
3631 const { user } = g;
3632 try {
3633 const result = await ensureDemoContent({ force: true });
3634 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}` : ""}`;
3635 await audit({
3636 userId: user.id,
3637 action: "admin.demo.reseed",
3638 targetType: "user",
3639 targetId: result.demoUser?.id ?? "demo",
3640 metadata: {
3641 createdUser: result.created.user,
3642 createdRepos: result.created.repos,
3643 createdIssues: result.created.issues,
3644 createdPrs: result.created.prs,
3645 errors: result.errors.slice(0, 5),
3646 },
3647 });
3648 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3649 } catch (err) {
3650 const message = err instanceof Error ? err.message : String(err);
3651 return c.redirect(
3652 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3653 );
3654 }
3655});
3656
3657// Public jump-to-demo — redirects to the first demo repo if present,
3658// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3659admin.get("/demo", (c) => {
3660 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3661});
3662
8e9f1d9Claude3663admin.post("/admin/autopilot/run", async (c) => {
3664 const g = await gate(c);
3665 if (g instanceof Response) return g;
3666 const { user } = g;
3667 let summary = "";
3668 try {
3669 const result = await runAutopilotTick();
3670 const ok = result.tasks.filter((t) => t.ok).length;
3671 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3672 await audit({
3673 userId: user.id,
3674 action: "admin.autopilot.run",
3675 targetType: "system",
3676 targetId: "autopilot",
3677 metadata: { ok, total: result.tasks.length },
3678 });
3679 return c.redirect(
3680 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3681 );
3682 } catch (err) {
3683 const message = err instanceof Error ? err.message : String(err);
3684 return c.redirect(
3685 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3686 );
3687 }
3688});
3689
8f50ed0Claude3690export default admin;