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

admin.tsx

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

admin.tsxBlame1758 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
24import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
25import { db } from "../db";
26import { repositories, users } from "../db/schema";
27import { Layout } from "../views/layout";
5618f9aClaude28import { softAuth } from "../middleware/auth";
8f50ed0Claude29import type { AuthEnv } from "../middleware/auth";
30import {
31 grantSiteAdmin,
32 isSiteAdmin,
33 KNOWN_FLAGS,
34 listFlags,
35 listSiteAdmins,
36 revokeSiteAdmin,
37 setFlag,
38} from "../lib/admin";
39import { audit } from "../lib/notify";
08420cdClaude40import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude41import {
42 getLastTick,
43 getTickCount,
44 runAutopilotTick,
45} from "../lib/autopilot";
988380aClaude46import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude47
48const admin = new Hono<AuthEnv>();
49admin.use("*", softAuth);
50
07f4b70Claude51/* ─────────────────────────────────────────────────────────────────────────
52 * Scoped CSS — every class prefixed `.admin-` so the block cannot bleed
53 * into other surfaces. Pattern mirrors dashboard-hero (commit a004c46),
54 * repo-home (commit 544d842), and settings polish (commit 98eb360).
55 * ───────────────────────────────────────────────────────────────────── */
56const adminStyles = `
57 .admin-wrap { max-width: 1080px; margin: 0 auto; }
58
59 /* ─── Hero (main dashboard) ─── */
60 .admin-hero {
61 position: relative;
62 margin-bottom: var(--space-6);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .admin-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
75 opacity: 0.7;
76 pointer-events: none;
77 }
78 .admin-hero-bg {
79 position: absolute;
80 inset: -20% -10% auto auto;
81 width: 380px; height: 380px;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .admin-hero-orb {
86 position: absolute;
87 inset: 0;
88 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
89 filter: blur(80px);
90 opacity: 0.7;
91 animation: adminHeroOrb 14s ease-in-out infinite;
92 }
93 @keyframes adminHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .admin-hero-orb { animation: none; }
99 }
100 .admin-hero-inner {
101 position: relative;
102 z-index: 1;
103 max-width: 720px;
104 }
105 .admin-hero-eyebrow {
106 font-size: 12px;
107 color: var(--text-muted);
108 margin-bottom: var(--space-2);
109 letter-spacing: 0.02em;
110 text-transform: none;
111 display: inline-flex;
112 align-items: center;
113 gap: 8px;
114 }
115 .admin-hero-eyebrow .admin-shield {
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 width: 18px; height: 18px;
120 border-radius: 6px;
121 background: rgba(140,109,255,0.14);
122 color: #b69dff;
123 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
124 }
125 .admin-hero-eyebrow .admin-who {
126 color: var(--accent);
127 font-weight: 600;
128 }
129 .admin-hero-title {
130 font-size: clamp(28px, 4vw, 40px);
131 font-family: var(--font-display);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 var(--space-2);
136 color: var(--text-strong);
137 }
138 .admin-hero-title .gradient-text,
139 .admin-hero-title-grad {
140 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
141 -webkit-background-clip: text;
142 background-clip: text;
143 -webkit-text-fill-color: transparent;
144 color: transparent;
145 }
146 .admin-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 620px;
152 }
153
154 /* ─── Section hero (sub-pages) ─── */
155 .admin-sec-hero {
156 position: relative;
157 margin-bottom: var(--space-5);
158 padding: var(--space-4) var(--space-5);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 overflow: hidden;
163 display: flex;
164 align-items: flex-end;
165 justify-content: space-between;
166 gap: var(--space-4);
167 flex-wrap: wrap;
168 }
169 .admin-sec-hero::before {
170 content: '';
171 position: absolute;
172 top: 0; left: 0; right: 0;
173 height: 2px;
174 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
175 opacity: 0.6;
176 pointer-events: none;
177 }
178 .admin-sec-hero-text { flex: 1; min-width: 240px; }
179 .admin-sec-eyebrow {
180 font-size: 11px;
181 font-weight: 600;
182 letter-spacing: 0.08em;
183 text-transform: uppercase;
184 color: var(--accent);
185 margin-bottom: 6px;
186 }
187 .admin-sec-title {
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.8vw, 28px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 margin: 0 0 4px;
194 color: var(--text-strong);
195 }
196 .admin-sec-sub {
197 font-size: 13.5px;
198 color: var(--text-muted);
199 margin: 0;
200 line-height: 1.5;
201 max-width: 620px;
202 }
203 .admin-sec-hero-actions {
204 display: flex;
205 gap: var(--space-2);
206 flex-wrap: wrap;
207 }
208
209 /* ─── Banners ─── */
210 .admin-banner {
211 margin-bottom: var(--space-4);
212 padding: 10px 14px;
213 border-radius: 10px;
214 font-size: 13.5px;
215 border: 1px solid var(--border);
216 background: rgba(255,255,255,0.025);
217 color: var(--text);
218 }
219 .admin-banner.is-error {
220 border-color: rgba(248,113,113,0.40);
221 background: rgba(248,113,113,0.08);
222 color: #fecaca;
223 }
224 .admin-banner.is-ok {
225 border-color: rgba(52,211,153,0.40);
226 background: rgba(52,211,153,0.08);
227 color: #bbf7d0;
228 }
229
230 /* ─── Stat grid ─── */
231 .admin-stat-grid {
232 display: grid;
233 grid-template-columns: repeat(3, 1fr);
234 gap: var(--space-3);
235 margin-bottom: var(--space-5);
236 }
237 @media (max-width: 720px) {
238 .admin-stat-grid { grid-template-columns: 1fr; }
239 }
240 .admin-stat {
241 position: relative;
242 padding: var(--space-4) var(--space-4);
243 background: var(--bg-elevated);
244 border: 1px solid var(--border);
245 border-radius: 14px;
246 overflow: hidden;
247 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
248 }
249 .admin-stat::after {
250 content: '';
251 position: absolute;
252 inset: 0;
253 border-radius: inherit;
254 background: linear-gradient(135deg, rgba(140,109,255,0.05), rgba(54,197,214,0.04));
255 opacity: 0;
256 pointer-events: none;
257 transition: opacity 200ms ease;
258 }
259 .admin-stat:hover {
260 transform: translateY(-2px);
261 border-color: var(--border-strong);
262 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
263 }
264 .admin-stat:hover::after { opacity: 1; }
265 .admin-stat-head {
266 display: flex;
267 align-items: center;
268 justify-content: space-between;
269 margin-bottom: var(--space-2);
270 position: relative;
271 z-index: 1;
272 }
273 .admin-stat-label {
274 font-size: 11px;
275 font-weight: 600;
276 letter-spacing: 0.08em;
277 text-transform: uppercase;
278 color: var(--text-muted);
279 }
280 .admin-stat-icon {
281 display: inline-flex;
282 align-items: center;
283 justify-content: center;
284 width: 26px; height: 26px;
285 border-radius: 8px;
286 background: rgba(140,109,255,0.12);
287 color: #b69dff;
288 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
289 }
290 .admin-stat-value {
291 position: relative;
292 z-index: 1;
293 font-family: var(--font-display);
294 font-size: 36px;
295 font-weight: 800;
296 letter-spacing: -0.028em;
297 line-height: 1;
298 color: var(--text-strong);
299 }
300 .admin-stat-hint {
301 margin-top: 6px;
302 font-size: 12px;
303 color: var(--text-faint);
304 position: relative;
305 z-index: 1;
306 }
307
308 /* ─── Action grid ─── */
309 .admin-actions {
310 display: grid;
311 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
312 gap: var(--space-2);
313 margin-bottom: var(--space-6);
314 }
315 .admin-action {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 padding: 12px 14px;
320 background: var(--bg-elevated);
321 border: 1px solid var(--border);
322 border-radius: 12px;
323 color: var(--text);
324 text-decoration: none;
325 font-size: 13.5px;
326 font-weight: 500;
327 line-height: 1.25;
328 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
329 cursor: pointer;
330 text-align: left;
331 font-family: inherit;
332 }
333 .admin-action:hover {
334 border-color: var(--border-strong);
335 background: rgba(255,255,255,0.025);
336 transform: translateY(-1px);
337 }
338 .admin-action:focus-visible {
339 outline: none;
340 border-color: var(--border-focus);
341 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
342 }
343 .admin-action .admin-action-icon {
344 display: inline-flex;
345 align-items: center;
346 justify-content: center;
347 width: 30px; height: 30px;
348 border-radius: 8px;
349 background: rgba(255,255,255,0.03);
350 color: var(--text-muted);
351 flex-shrink: 0;
352 box-shadow: inset 0 0 0 1px var(--border);
353 }
354 .admin-action.is-primary {
355 border-color: rgba(140,109,255,0.35);
356 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.10));
357 color: var(--text-strong);
358 }
359 .admin-action.is-primary:hover {
360 border-color: rgba(140,109,255,0.55);
361 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
362 }
363 .admin-action.is-primary .admin-action-icon {
364 background: rgba(140,109,255,0.18);
365 color: #c5b3ff;
366 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
367 }
368 .admin-action-form { display: contents; }
369
370 /* ─── Section header (h3 replacement) ─── */
371 .admin-h3 {
372 display: flex;
373 align-items: baseline;
374 justify-content: space-between;
375 gap: var(--space-3);
376 margin: var(--space-5) 0 var(--space-3);
377 }
378 .admin-h3 h3 {
379 font-family: var(--font-display);
380 font-size: 16px;
381 font-weight: 700;
382 letter-spacing: -0.014em;
383 margin: 0;
384 color: var(--text-strong);
385 }
386 .admin-h3-meta {
387 font-size: 12px;
388 color: var(--text-muted);
389 }
390
391 /* ─── Lists / cards ─── */
392 .admin-list {
393 background: var(--bg-elevated);
394 border: 1px solid var(--border);
395 border-radius: 14px;
396 overflow: hidden;
397 }
398 .admin-list-row {
399 display: flex;
400 align-items: center;
401 justify-content: space-between;
402 gap: var(--space-3);
403 padding: 12px 16px;
404 border-bottom: 1px solid var(--border-subtle);
405 transition: background 120ms ease;
406 }
407 .admin-list-row:last-child { border-bottom: none; }
408 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
409 .admin-list-empty {
410 padding: var(--space-5);
411 text-align: center;
412 color: var(--text-muted);
413 font-size: 13.5px;
414 }
415 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
416 .admin-avatar {
417 width: 30px; height: 30px;
418 border-radius: 9999px;
419 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.22));
420 color: var(--text-strong);
421 display: inline-flex;
422 align-items: center;
423 justify-content: center;
424 font-size: 12px;
425 font-weight: 700;
426 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
427 flex-shrink: 0;
428 text-transform: uppercase;
429 }
430 .admin-avatar.is-admin {
431 background: linear-gradient(135deg, rgba(140,109,255,0.50), rgba(54,197,214,0.35));
432 color: #fff;
433 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.55), 0 0 12px rgba(140,109,255,0.25);
434 }
435 .admin-row-text { min-width: 0; }
436 .admin-row-title {
437 font-size: 14px;
438 font-weight: 600;
439 color: var(--text-strong);
440 text-decoration: none;
441 }
442 .admin-row-title:hover { color: var(--accent-hover); }
443 .admin-row-sub {
444 margin-top: 2px;
445 font-size: 12px;
446 color: var(--text-muted);
447 display: flex;
448 gap: 8px;
449 flex-wrap: wrap;
450 align-items: center;
451 }
452 .admin-pill {
453 display: inline-flex;
454 align-items: center;
455 gap: 4px;
456 padding: 2px 8px;
457 border-radius: 9999px;
458 font-size: 10.5px;
459 font-weight: 600;
460 letter-spacing: 0.04em;
461 text-transform: uppercase;
462 }
463 .admin-pill.is-admin {
464 background: rgba(140,109,255,0.16);
465 color: #c5b3ff;
466 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
467 }
468 .admin-pill.is-private {
469 background: rgba(251,191,36,0.10);
470 color: #fde68a;
471 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
472 }
473 .admin-pill.is-public {
474 background: rgba(255,255,255,0.04);
475 color: var(--text-muted);
476 box-shadow: inset 0 0 0 1px var(--border);
477 }
478 .admin-pill.is-on {
479 background: rgba(52,211,153,0.14);
480 color: #6ee7b7;
481 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
482 }
483 .admin-pill.is-off {
484 background: rgba(255,255,255,0.04);
485 color: var(--text-muted);
486 box-shadow: inset 0 0 0 1px var(--border);
487 }
488 .admin-pill .dot {
489 width: 6px; height: 6px;
490 border-radius: 9999px;
491 background: currentColor;
492 }
493
494 /* ─── Inline forms / search ─── */
495 .admin-search {
496 display: flex;
497 gap: 8px;
498 flex-wrap: wrap;
499 align-items: center;
500 margin-bottom: var(--space-4);
501 }
502 .admin-input {
503 width: 320px;
504 max-width: 100%;
505 padding: 9px 12px;
506 font-size: 14px;
507 color: var(--text);
508 background: var(--bg);
509 border: 1px solid var(--border-strong);
510 border-radius: 8px;
511 outline: none;
512 font-family: var(--font-sans);
513 transition: border-color 120ms ease, box-shadow 120ms ease;
514 }
515 .admin-input:focus {
516 border-color: var(--border-focus);
517 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
518 }
519
520 /* ─── Flags form ─── */
521 .admin-card {
522 background: var(--bg-elevated);
523 border: 1px solid var(--border);
524 border-radius: 14px;
525 overflow: hidden;
526 }
527 .admin-card-body { padding: var(--space-5); }
528 .admin-card-foot {
529 padding: var(--space-3) var(--space-5);
530 border-top: 1px solid var(--border);
531 background: rgba(255,255,255,0.012);
532 display: flex;
533 justify-content: flex-end;
534 gap: var(--space-2);
535 align-items: center;
536 flex-wrap: wrap;
537 }
538 .admin-card-foot .admin-foot-hint {
539 margin-right: auto;
540 font-size: 12.5px;
541 color: var(--text-muted);
542 }
543 .admin-field { margin-bottom: var(--space-4); }
544 .admin-field:last-child { margin-bottom: 0; }
545 .admin-field label {
546 display: block;
547 font-family: var(--font-mono);
548 font-size: 12.5px;
549 font-weight: 600;
550 color: var(--text-strong);
551 margin-bottom: 6px;
552 letter-spacing: -0.005em;
553 }
554 .admin-field .admin-input-mono {
555 width: 100%;
556 padding: 9px 12px;
557 font-size: 13.5px;
558 color: var(--text);
559 background: var(--bg);
560 border: 1px solid var(--border-strong);
561 border-radius: 8px;
562 outline: none;
563 font-family: var(--font-mono);
564 transition: border-color 120ms ease, box-shadow 120ms ease;
565 }
566 .admin-field .admin-input-mono:focus {
567 border-color: var(--border-focus);
568 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
569 }
570 .admin-field-hint {
571 font-size: 11.5px;
572 color: var(--text-muted);
573 margin-top: 6px;
574 line-height: 1.45;
575 }
576 .admin-field-hint code {
577 font-family: var(--font-mono);
578 font-size: 11.5px;
579 background: var(--bg-tertiary);
580 padding: 1px 5px;
581 border-radius: 4px;
582 }
583
584 /* ─── Digest forms ─── */
585 .admin-digest-row {
586 display: flex;
587 gap: 8px;
588 align-items: center;
589 flex-wrap: wrap;
590 }
591
592 /* ─── Autopilot specific ─── */
593 .admin-ap-table {
594 width: 100%;
595 border-collapse: collapse;
596 background: var(--bg-elevated);
597 border: 1px solid var(--border);
598 border-radius: 14px;
599 overflow: hidden;
600 }
601 .admin-ap-table thead th {
602 text-align: left;
603 font-size: 11px;
604 font-weight: 600;
605 letter-spacing: 0.08em;
606 text-transform: uppercase;
607 color: var(--text-muted);
608 padding: 10px 14px;
609 background: rgba(255,255,255,0.015);
610 border-bottom: 1px solid var(--border);
611 }
612 .admin-ap-table tbody td {
613 padding: 10px 14px;
614 border-bottom: 1px solid var(--border-subtle);
615 font-size: 13px;
616 color: var(--text);
617 vertical-align: top;
618 }
619 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
620 .admin-ap-table code {
621 font-family: var(--font-mono);
622 font-size: 12px;
623 color: var(--text-strong);
624 }
625 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
626 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
627 .admin-ap-empty {
628 padding: var(--space-5);
629 text-align: center;
630 color: var(--text-muted);
631 font-size: 13.5px;
632 background: var(--bg-elevated);
633 border: 1px dashed var(--border);
634 border-radius: 14px;
635 }
636 .admin-ap-foot {
637 margin-top: var(--space-5);
638 padding: var(--space-3) var(--space-4);
639 border: 1px solid var(--border-subtle);
640 background: rgba(255,255,255,0.015);
641 border-radius: 10px;
642 color: var(--text-muted);
643 font-size: 12.5px;
644 }
645 .admin-ap-foot code {
646 font-family: var(--font-mono);
647 font-size: 12px;
648 background: var(--bg-tertiary);
649 padding: 1px 5px;
650 border-radius: 4px;
651 color: var(--text);
652 }
653
654 /* ─── Misc ─── */
655 .admin-403 {
656 max-width: 540px;
657 margin: var(--space-12) auto;
658 padding: var(--space-6);
659 text-align: center;
660 background: var(--bg-elevated);
661 border: 1px solid var(--border);
662 border-radius: 16px;
663 }
664 .admin-403 h2 {
665 font-family: var(--font-display);
666 font-size: 22px;
667 margin: 0 0 8px;
668 color: var(--text-strong);
669 }
670 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
671`;
672
673/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
674const Icons = {
675 shield: (
676 <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">
677 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
678 </svg>
679 ),
680 users: (
681 <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">
682 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
683 <circle cx="9" cy="7" r="4" />
684 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
685 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
686 </svg>
687 ),
688 repo: (
689 <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">
690 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
691 <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" />
692 </svg>
693 ),
694 starShield: (
695 <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">
696 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
697 <path d="m9 12 2 2 4-4" />
698 </svg>
699 ),
700 ops: (
701 <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">
702 <circle cx="12" cy="12" r="3" />
703 <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" />
704 </svg>
705 ),
706 pulse: (
707 <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">
708 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
709 </svg>
710 ),
711 flag: (
712 <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">
713 <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" />
714 <line x1="4" y1="22" x2="4" y2="15" />
715 </svg>
716 ),
717 mail: (
718 <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">
719 <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" />
720 <polyline points="22,6 12,13 2,6" />
721 </svg>
722 ),
723 google: (
724 <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">
725 <circle cx="12" cy="12" r="10" />
726 <path d="M12 8v8" /><path d="M8 12h8" />
727 </svg>
728 ),
729 github: (
730 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
731 <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" />
732 </svg>
733 ),
734 sso: (
735 <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">
736 <rect x="3" y="11" width="18" height="11" rx="2" />
737 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
738 </svg>
739 ),
740 bot: (
741 <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">
742 <rect x="3" y="7" width="18" height="13" rx="2" />
743 <circle cx="9" cy="13" r="1" />
744 <circle cx="15" cy="13" r="1" />
745 <path d="M12 3v4" />
746 </svg>
747 ),
748 refresh: (
749 <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">
750 <polyline points="23 4 23 10 17 10" />
751 <polyline points="1 20 1 14 7 14" />
752 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
753 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
754 </svg>
755 ),
756 arrowLeft: (
757 <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">
758 <line x1="19" y1="12" x2="5" y2="12" />
759 <polyline points="12 19 5 12 12 5" />
760 </svg>
761 ),
762};
763
764/** First-letter avatar helper. */
765function initials(s: string): string {
766 return (s || "?").trim().charAt(0).toUpperCase() || "?";
767}
768
8f50ed0Claude769async function gate(c: any): Promise<{ user: any } | Response> {
770 const user = c.get("user");
771 if (!user) return c.redirect("/login?next=/admin");
772 if (!(await isSiteAdmin(user.id))) {
773 return c.html(
774 <Layout title="Forbidden" user={user}>
07f4b70Claude775 <div class="admin-403">
8f50ed0Claude776 <h2>403 — Not a site admin</h2>
777 <p>You don't have permission to view this page.</p>
778 </div>
07f4b70Claude779 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude780 </Layout>,
781 403
782 );
783 }
784 return { user };
785}
786
787admin.get("/admin", async (c) => {
788 const g = await gate(c);
789 if (g instanceof Response) return g;
790 const { user } = g;
791
792 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
793 const [rc] = await db
794 .select({ n: sql<number>`count(*)::int` })
795 .from(repositories);
796
797 const recent = await db
798 .select({
799 id: users.id,
800 username: users.username,
801 createdAt: users.createdAt,
802 })
803 .from(users)
804 .orderBy(desc(users.createdAt))
805 .limit(10);
806
807 const admins = await listSiteAdmins();
808
988380aClaude809 const msg = c.req.query("result") || c.req.query("error");
810 const isErr = !!c.req.query("error");
811
07f4b70Claude812 const userCount = Number(uc?.n || 0);
813 const repoCount = Number(rc?.n || 0);
814 const adminCount = admins.length;
815
8f50ed0Claude816 return c.html(
817 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude818 <div class="admin-wrap">
819 <section class="admin-hero">
820 <div class="admin-hero-bg" aria-hidden="true">
821 <div class="admin-hero-orb" />
822 </div>
823 <div class="admin-hero-inner">
824 <div class="admin-hero-eyebrow">
825 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
826 Site administration ·{" "}
827 <span class="admin-who">{user.username}</span>
828 </div>
829 <h2 class="admin-hero-title">
830 <span class="admin-hero-title-grad">Site admin</span>.
831 </h2>
832 <p class="admin-hero-sub">
833 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
834 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
835 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
836 Operations, flags, digests, and autopilot — all in one place.
837 </p>
838 </div>
839 </section>
8f50ed0Claude840
07f4b70Claude841 {msg && (
842 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
843 {decodeURIComponent(msg)}
844 </div>
845 )}
988380aClaude846
07f4b70Claude847 <div class="admin-stat-grid">
848 <div class="admin-stat">
849 <div class="admin-stat-head">
850 <span class="admin-stat-label">Users</span>
851 <span class="admin-stat-icon">{Icons.users}</span>
852 </div>
853 <div class="admin-stat-value">{userCount}</div>
854 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude855 </div>
07f4b70Claude856 <div class="admin-stat">
857 <div class="admin-stat-head">
858 <span class="admin-stat-label">Repos</span>
859 <span class="admin-stat-icon">{Icons.repo}</span>
860 </div>
861 <div class="admin-stat-value">{repoCount}</div>
862 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude863 </div>
07f4b70Claude864 <div class="admin-stat">
865 <div class="admin-stat-head">
866 <span class="admin-stat-label">Admins</span>
867 <span class="admin-stat-icon">{Icons.starShield}</span>
868 </div>
869 <div class="admin-stat-value">{adminCount}</div>
870 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude871 </div>
872 </div>
873
07f4b70Claude874 <div class="admin-actions">
875 <a href="/admin/ops" class="admin-action is-primary">
876 <span class="admin-action-icon">{Icons.ops}</span>
877 Operations
878 </a>
879 <a href="/admin/diagnose" class="admin-action is-primary">
880 <span class="admin-action-icon">{Icons.pulse}</span>
881 Health / Diagnose
882 </a>
883 <a href="/admin/users" class="admin-action">
884 <span class="admin-action-icon">{Icons.users}</span>
885 Manage users
886 </a>
887 <a href="/admin/repos" class="admin-action">
888 <span class="admin-action-icon">{Icons.repo}</span>
889 Manage repos
890 </a>
891 <a href="/admin/flags" class="admin-action">
892 <span class="admin-action-icon">{Icons.flag}</span>
893 Site flags
894 </a>
895 <a href="/admin/digests" class="admin-action">
896 <span class="admin-action-icon">{Icons.mail}</span>
897 Email digests
898 </a>
899 <a href="/admin/google-oauth" class="admin-action">
900 <span class="admin-action-icon">{Icons.google}</span>
901 Sign in with Google
902 </a>
903 <a href="/admin/github-oauth" class="admin-action">
904 <span class="admin-action-icon">{Icons.github}</span>
905 Sign in with GitHub
906 </a>
907 <a href="/admin/sso" class="admin-action">
908 <span class="admin-action-icon">{Icons.sso}</span>
909 Enterprise SSO
910 </a>
911 <a href="/admin/autopilot" class="admin-action">
912 <span class="admin-action-icon">{Icons.bot}</span>
913 Autopilot
914 </a>
915 <form
916 method="post"
917 action="/admin/demo/reseed"
918 class="admin-action-form"
919 >
920 <button
921 class="admin-action"
922 type="submit"
923 title="Idempotently (re)create demo user + 3 sample repos"
924 >
925 <span class="admin-action-icon">{Icons.refresh}</span>
926 Reseed demo
927 </button>
928 </form>
929 </div>
8f50ed0Claude930
07f4b70Claude931 <div class="admin-h3">
932 <h3>Recent signups</h3>
933 <span class="admin-h3-meta">
934 {recent.length} most-recent
935 </span>
936 </div>
937 <div class="admin-list" style="margin-bottom:20px">
938 {recent.length === 0 ? (
939 <div class="admin-list-empty">No users yet.</div>
940 ) : (
941 recent.map((u) => (
942 <div class="admin-list-row">
943 <div class="admin-list-main">
944 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
945 <div class="admin-row-text">
946 <a href={`/${u.username}`} class="admin-row-title">
947 {u.username}
948 </a>
949 <div class="admin-row-sub">
950 <span>Joined</span>
951 <span>
952 {u.createdAt
953 ? new Date(u.createdAt as unknown as string).toLocaleString()
954 : ""}
955 </span>
956 </div>
957 </div>
958 </div>
959 </div>
960 ))
961 )}
962 </div>
8f50ed0Claude963
07f4b70Claude964 <div class="admin-h3">
965 <h3>Site admins</h3>
966 <span class="admin-h3-meta">
967 {adminCount} active
968 </span>
969 </div>
970 <div class="admin-list">
971 {admins.length === 0 ? (
972 <div class="admin-list-empty">
973 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude974 </div>
07f4b70Claude975 ) : (
976 admins.map((a) => (
977 <div class="admin-list-row">
978 <div class="admin-list-main">
979 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
980 <div class="admin-row-text">
981 <a href={`/${a.username}`} class="admin-row-title">
982 {a.username}
983 </a>
984 <div class="admin-row-sub">
985 <span class="admin-pill is-admin">
986 <span class="dot" aria-hidden="true" /> Site admin
987 </span>
988 <span>
989 Granted{" "}
990 {a.grantedAt
991 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
992 : "—"}
993 </span>
994 </div>
995 </div>
996 </div>
997 </div>
998 ))
999 )}
1000 </div>
8f50ed0Claude1001 </div>
07f4b70Claude1002 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1003 </Layout>
1004 );
1005});
1006
1007// ----- Users -----
1008
1009admin.get("/admin/users", async (c) => {
1010 const g = await gate(c);
1011 if (g instanceof Response) return g;
1012 const { user } = g;
1013 const q = c.req.query("q") || "";
1014 const rows = await db
1015 .select({
1016 id: users.id,
1017 username: users.username,
1018 email: users.email,
1019 createdAt: users.createdAt,
1020 })
1021 .from(users)
1022 .where(
1023 q
1024 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
1025 : sql`1=1`
1026 )
1027 .orderBy(desc(users.createdAt))
1028 .limit(200);
1029
1030 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
1031
1032 return c.html(
1033 <Layout title="Admin — Users" user={user}>
07f4b70Claude1034 <div class="admin-wrap">
1035 <section class="admin-sec-hero">
1036 <div class="admin-sec-hero-text">
1037 <div class="admin-sec-eyebrow">Site admin</div>
1038 <h2 class="admin-sec-title">Users</h2>
1039 <p class="admin-sec-sub">
1040 Search, audit, and grant or revoke the site-admin flag.
1041 Showing up to 200 accounts ordered by signup recency.
1042 </p>
1043 </div>
1044 <div class="admin-sec-hero-actions">
1045 <a href="/admin" class="btn btn-sm">
1046 {Icons.arrowLeft} Back
1047 </a>
1048 </div>
1049 </section>
1050
1051 <form method="get" action="/admin/users" class="admin-search">
1052 <input
1053 type="text"
1054 name="q"
1055 value={q}
1056 placeholder="Search username or email"
1057 aria-label="Search username or email"
1058 class="admin-input"
1059 />
1060 <button type="submit" class="btn">
1061 Search
1062 </button>
1063 {q && (
1064 <a href="/admin/users" class="btn btn-sm">
1065 Clear
1066 </a>
1067 )}
1068 </form>
1069
1070 <div class="admin-list">
1071 {rows.length === 0 ? (
1072 <div class="admin-list-empty">No users found.</div>
1073 ) : (
1074 rows.map((u) => {
1075 const isAdmin = adminIds.has(u.id);
1076 return (
1077 <div class="admin-list-row">
1078 <div class="admin-list-main">
1079 <span class={"admin-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
1080 {initials(u.username)}
8f50ed0Claude1081 </span>
07f4b70Claude1082 <div class="admin-row-text">
1083 <a href={`/${u.username}`} class="admin-row-title">
1084 {u.username}
1085 </a>
1086 {isAdmin && (
1087 <span class="admin-pill is-admin" style="margin-left:8px">
1088 <span class="dot" aria-hidden="true" /> Admin
1089 </span>
1090 )}
1091 <div class="admin-row-sub">
1092 <span>{u.email}</span>
1093 {u.createdAt && (
1094 <span>
1095 ·{" "}
1096 {new Date(
1097 u.createdAt as unknown as string
1098 ).toLocaleDateString()}
1099 </span>
1100 )}
1101 </div>
1102 </div>
1103 </div>
1104 <form
1105 method="post"
1106 action={`/admin/users/${u.id}/admin`}
1107 onsubmit={
1108 isAdmin
1109 ? "return confirm('Revoke site admin?')"
1110 : "return confirm('Grant site admin?')"
1111 }
1112 >
1113 <button type="submit" class="btn btn-sm">
1114 {isAdmin ? "Revoke admin" : "Grant admin"}
1115 </button>
1116 </form>
8f50ed0Claude1117 </div>
07f4b70Claude1118 );
1119 })
1120 )}
1121 </div>
8f50ed0Claude1122 </div>
07f4b70Claude1123 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1124 </Layout>
1125 );
1126});
1127
1128admin.post("/admin/users/:id/admin", async (c) => {
1129 const g = await gate(c);
1130 if (g instanceof Response) return g;
1131 const { user } = g;
1132 const id = c.req.param("id");
1133 const admins = await listSiteAdmins();
1134 const isAlready = admins.some((a) => a.userId === id);
1135 if (isAlready) {
1136 await revokeSiteAdmin(id);
1137 await audit({
1138 userId: user.id,
1139 action: "site_admin.revoke",
1140 targetType: "user",
1141 targetId: id,
1142 });
1143 } else {
1144 await grantSiteAdmin(id, user.id);
1145 await audit({
1146 userId: user.id,
1147 action: "site_admin.grant",
1148 targetType: "user",
1149 targetId: id,
1150 });
1151 }
1152 return c.redirect("/admin/users");
1153});
1154
1155// ----- Repos -----
1156
1157admin.get("/admin/repos", async (c) => {
1158 const g = await gate(c);
1159 if (g instanceof Response) return g;
1160 const { user } = g;
1161 const rows = await db
1162 .select({
1163 id: repositories.id,
1164 name: repositories.name,
1165 ownerUsername: users.username,
0316dbbClaude1166 isPrivate: repositories.isPrivate,
8f50ed0Claude1167 createdAt: repositories.createdAt,
1168 starCount: repositories.starCount,
1169 })
1170 .from(repositories)
1171 .innerJoin(users, eq(repositories.ownerId, users.id))
1172 .orderBy(desc(repositories.createdAt))
1173 .limit(200);
1174
1175 return c.html(
1176 <Layout title="Admin — Repos" user={user}>
07f4b70Claude1177 <div class="admin-wrap">
1178 <section class="admin-sec-hero">
1179 <div class="admin-sec-hero-text">
1180 <div class="admin-sec-eyebrow">Site admin</div>
1181 <h2 class="admin-sec-title">Repositories</h2>
1182 <p class="admin-sec-sub">
1183 Every repository on the platform — public and private.
1184 Delete is irreversible and audit-logged.
1185 </p>
1186 </div>
1187 <div class="admin-sec-hero-actions">
1188 <a href="/admin" class="btn btn-sm">
1189 {Icons.arrowLeft} Back
1190 </a>
1191 </div>
1192 </section>
1193
1194 <div class="admin-list">
1195 {rows.length === 0 ? (
1196 <div class="admin-list-empty">No repositories.</div>
1197 ) : (
1198 rows.map((r) => (
1199 <div class="admin-list-row">
1200 <div class="admin-list-main">
1201 <span class="admin-avatar" aria-hidden="true">
1202 {initials(r.ownerUsername)}
1203 </span>
1204 <div class="admin-row-text">
1205 <a
1206 href={`/${r.ownerUsername}/${r.name}`}
1207 class="admin-row-title"
1208 >
1209 {r.ownerUsername}/{r.name}
1210 </a>
1211 <span
1212 class={
1213 "admin-pill " +
1214 (r.isPrivate ? "is-private" : "is-public")
1215 }
1216 style="margin-left:8px"
1217 >
1218 {r.isPrivate ? "private" : "public"}
1219 </span>
1220 <div class="admin-row-sub">
1221 <span>{r.starCount} star{r.starCount === 1 ? "" : "s"}</span>
1222 <span>·</span>
1223 <span>
1224 {r.createdAt
1225 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
1226 : ""}
1227 </span>
1228 </div>
1229 </div>
8f50ed0Claude1230 </div>
07f4b70Claude1231 <form
1232 method="post"
1233 action={`/admin/repos/${r.id}/delete`}
1234 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
1235 >
1236 <button type="submit" class="btn btn-sm btn-danger">
1237 Delete
1238 </button>
1239 </form>
8f50ed0Claude1240 </div>
07f4b70Claude1241 ))
1242 )}
1243 </div>
8f50ed0Claude1244 </div>
07f4b70Claude1245 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1246 </Layout>
1247 );
1248});
1249
1250admin.post("/admin/repos/:id/delete", async (c) => {
1251 const g = await gate(c);
1252 if (g instanceof Response) return g;
1253 const { user } = g;
1254 const id = c.req.param("id");
1255 try {
1256 await db.delete(repositories).where(eq(repositories.id, id));
1257 } catch (err) {
1258 console.error("[admin] repo delete:", err);
1259 }
1260 await audit({
1261 userId: user.id,
1262 action: "admin.repo.delete",
1263 targetType: "repository",
1264 targetId: id,
1265 });
1266 return c.redirect("/admin/repos");
1267});
1268
1269// ----- Flags -----
1270
1271admin.get("/admin/flags", async (c) => {
1272 const g = await gate(c);
1273 if (g instanceof Response) return g;
1274 const { user } = g;
1275
1276 const existing = await listFlags();
1277 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
1278 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1279
1280 return c.html(
1281 <Layout title="Admin — Flags" user={user}>
07f4b70Claude1282 <div class="admin-wrap">
1283 <section class="admin-sec-hero">
1284 <div class="admin-sec-hero-text">
1285 <div class="admin-sec-eyebrow">Site admin</div>
1286 <h2 class="admin-sec-title">Site flags</h2>
1287 <p class="admin-sec-sub">
1288 Runtime feature flags surfaced to the rest of the app via
1289 <code style="margin:0 4px;padding:1px 5px;border-radius:4px;background:var(--bg-tertiary);font-family:var(--font-mono);font-size:12px">
1290 getFlag()
1291 </code>
1292 — registration lock, site banner, read-only mode, and more.
1293 </p>
1294 </div>
1295 <div class="admin-sec-hero-actions">
1296 <a href="/admin" class="btn btn-sm">
1297 {Icons.arrowLeft} Back
1298 </a>
1299 </div>
1300 </section>
1301
1302 <form method="post" action="/admin/flags" class="admin-card">
1303 <div class="admin-card-body">
1304 {keys.map((k) => {
1305 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
1306 return (
1307 <div class="admin-field">
1308 <label>{k}</label>
1309 <input
1310 type="text"
1311 name={k}
1312 value={current}
1313 aria-label={k}
1314 class="admin-input-mono"
1315 />
1316 <div class="admin-field-hint">
1317 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
1318 </div>
1319 </div>
1320 );
1321 })}
1322 </div>
1323 <div class="admin-card-foot">
1324 <span class="admin-foot-hint">
1325 Saved values overwrite the defaults at runtime.
1326 </span>
1327 <button type="submit" class="btn btn-primary">
1328 Save
1329 </button>
1330 </div>
1331 </form>
8f50ed0Claude1332 </div>
07f4b70Claude1333 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1334 </Layout>
1335 );
1336});
1337
1338admin.post("/admin/flags", async (c) => {
1339 const g = await gate(c);
1340 if (g instanceof Response) return g;
1341 const { user } = g;
1342 const body = await c.req.parseBody();
1343 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1344 for (const k of keys) {
1345 const v = String(body[k] ?? "");
1346 await setFlag(k, v, user.id);
1347 }
1348 await audit({ userId: user.id, action: "admin.flags.save" });
1349 return c.redirect("/admin/flags");
1350});
1351
08420cdClaude1352// ----- Email digests (Block I7) -----
1353
1354admin.get("/admin/digests", async (c) => {
1355 const g = await gate(c);
1356 if (g instanceof Response) return g;
1357 const { user } = g;
1358
1359 const [optedRow] = await db
1360 .select({ n: sql<number>`count(*)::int` })
1361 .from(users)
1362 .where(eq(users.notifyEmailDigestWeekly, true));
1363 const opted = Number(optedRow?.n || 0);
1364
1365 const recentlySent = await db
1366 .select({
1367 id: users.id,
1368 username: users.username,
1369 lastDigestSentAt: users.lastDigestSentAt,
1370 })
1371 .from(users)
1372 .where(sql`${users.lastDigestSentAt} is not null`)
1373 .orderBy(desc(users.lastDigestSentAt))
1374 .limit(20);
1375
1376 const result = c.req.query("result");
1377 const error = c.req.query("error");
1378
1379 return c.html(
1380 <Layout title="Admin — Digests" user={user}>
07f4b70Claude1381 <div class="admin-wrap">
1382 <section class="admin-sec-hero">
1383 <div class="admin-sec-hero-text">
1384 <div class="admin-sec-eyebrow">Site admin</div>
1385 <h2 class="admin-sec-title">Email digests</h2>
1386 <p class="admin-sec-sub">
1387 Manually trigger the weekly digest for every opted-in user
1388 or preview the email for a single account.
1389 </p>
1390 </div>
1391 <div class="admin-sec-hero-actions">
1392 <a href="/admin" class="btn btn-sm">
1393 {Icons.arrowLeft} Back
1394 </a>
1395 </div>
1396 </section>
08420cdClaude1397
07f4b70Claude1398 {result && (
1399 <div class="admin-banner is-ok">{decodeURIComponent(result)}</div>
1400 )}
1401 {error && (
1402 <div class="admin-banner is-error">{decodeURIComponent(error)}</div>
1403 )}
08420cdClaude1404
07f4b70Claude1405 <div class="admin-card" style="margin-bottom:var(--space-5)">
1406 <div class="admin-card-body">
1407 <div style="display:flex;align-items:center;gap:10px;margin-bottom:var(--space-3);flex-wrap:wrap">
1408 <span class="admin-pill is-on">
1409 <span class="dot" aria-hidden="true" />
1410 {opted} opted-in
1411 </span>
1412 <span style="font-size:13px;color:var(--text-muted)">
1413 user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
08420cdClaude1414 </span>
1415 </div>
07f4b70Claude1416 <form method="post" action="/admin/digests/run" style="margin-bottom:var(--space-4)">
1417 <button
1418 type="submit"
1419 class="btn btn-primary"
1420 onclick="return confirm('Send weekly digest to all opted-in users now?')"
1421 >
1422 Send digests now
1423 </button>
1424 </form>
1425 <div style="border-top:1px solid var(--border-subtle);padding-top:var(--space-4)">
1426 <div style="font-size:12.5px;color:var(--text-muted);margin-bottom:8px">
1427 Preview / one-off — send the digest to a single user.
1428 </div>
1429 <form
1430 method="post"
1431 action="/admin/digests/preview"
1432 class="admin-digest-row"
1433 >
1434 <input
1435 type="text"
1436 name="username"
1437 placeholder="username"
1438 required
1439 aria-label="Username"
1440 class="admin-input"
1441 style="width:240px"
1442 />
1443 <button type="submit" class="btn btn-sm">
1444 Send to one user
1445 </button>
1446 </form>
1447 </div>
1448 </div>
1449 </div>
1450
1451 <div class="admin-h3">
1452 <h3>Recently sent</h3>
1453 <span class="admin-h3-meta">
1454 last {recentlySent.length}
1455 </span>
1456 </div>
1457 <div class="admin-list">
1458 {recentlySent.length === 0 ? (
1459 <div class="admin-list-empty">No digests have been sent yet.</div>
1460 ) : (
1461 recentlySent.map((u) => (
1462 <div class="admin-list-row">
1463 <div class="admin-list-main">
1464 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
1465 <div class="admin-row-text">
1466 <a href={`/${u.username}`} class="admin-row-title">
1467 {u.username}
1468 </a>
1469 <div class="admin-row-sub">
1470 <span>Sent</span>
1471 <span>
1472 {u.lastDigestSentAt
1473 ? new Date(
1474 u.lastDigestSentAt as unknown as string
1475 ).toLocaleString()
1476 : ""}
1477 </span>
1478 </div>
1479 </div>
1480 </div>
1481 </div>
1482 ))
1483 )}
1484 </div>
08420cdClaude1485 </div>
07f4b70Claude1486 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
08420cdClaude1487 </Layout>
1488 );
1489});
1490
1491admin.post("/admin/digests/run", async (c) => {
1492 const g = await gate(c);
1493 if (g instanceof Response) return g;
1494 const { user } = g;
1495 const results = await sendDigestsToAll();
1496 const sent = results.filter((r) => r.ok).length;
1497 const skipped = results.length - sent;
1498 await audit({
1499 userId: user.id,
1500 action: "admin.digests.run",
1501 metadata: { sent, skipped, total: results.length },
1502 });
1503 return c.redirect(
1504 `/admin/digests?result=${encodeURIComponent(
1505 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
1506 )}`
1507 );
1508});
1509
1510admin.post("/admin/digests/preview", async (c) => {
1511 const g = await gate(c);
1512 if (g instanceof Response) return g;
1513 const { user } = g;
1514 const body = await c.req.parseBody();
1515 const username = String(body.username || "").trim();
1516 if (!username) {
1517 return c.redirect("/admin/digests?error=Username+required");
1518 }
1519 const [target] = await db
1520 .select({ id: users.id, username: users.username })
1521 .from(users)
1522 .where(eq(users.username, username))
1523 .limit(1);
1524 if (!target) {
1525 return c.redirect("/admin/digests?error=User+not+found");
1526 }
1527 const result = await sendDigestForUser(target.id);
1528 await audit({
1529 userId: user.id,
1530 action: "admin.digests.preview",
1531 targetType: "user",
1532 targetId: target.id,
1533 metadata: {
1534 ok: result.ok,
1535 skipped: "skipped" in result ? result.skipped : null,
1536 },
1537 });
1538 if (result.ok) {
1539 return c.redirect(
1540 `/admin/digests?result=${encodeURIComponent(
1541 `Digest sent to ${target.username}.`
1542 )}`
1543 );
1544 }
1545 return c.redirect(
1546 `/admin/digests?error=${encodeURIComponent(
1547 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
1548 )}`
1549 );
1550});
1551
8e9f1d9Claude1552admin.get("/admin/autopilot", async (c) => {
1553 const g = await gate(c);
1554 if (g instanceof Response) return g;
1555 const { user } = g;
1556 const tick = getLastTick();
1557 const total = getTickCount();
1558 const disabled = process.env.AUTOPILOT_DISABLED === "1";
1559 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
1560 const intervalMs =
1561 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
1562 ? Number(intervalRaw)
1563 : 5 * 60 * 1000;
1564 const msg = c.req.query("result") || c.req.query("error");
1565 const isErr = !!c.req.query("error");
1566 return c.html(
1567 <Layout title="Autopilot — admin" user={user}>
07f4b70Claude1568 <div class="admin-wrap" style="padding: var(--space-6) var(--space-4)">
1569 <section class="admin-sec-hero">
1570 <div class="admin-sec-hero-text">
1571 <div class="admin-sec-eyebrow">Site admin</div>
1572 <h2 class="admin-sec-title">Autopilot</h2>
1573 <p class="admin-sec-sub">
1574 Periodic platform-maintenance loop — mirror sync, merge-queue
1575 progress, weekly digests, advisory rescans, environment
1576 wait-timer release, and scheduled workflow triggers (cron).
1577 </p>
1578 </div>
1579 <div class="admin-sec-hero-actions">
1580 <a href="/admin" class="btn btn-sm">
1581 {Icons.arrowLeft} Back
1582 </a>
1583 </div>
1584 </section>
1585
8e9f1d9Claude1586 {msg && (
07f4b70Claude1587 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude1588 {decodeURIComponent(msg)}
1589 </div>
1590 )}
07f4b70Claude1591
1592 <div class="admin-stat-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))">
1593 <div class="admin-stat">
1594 <div class="admin-stat-head">
1595 <span class="admin-stat-label">Status</span>
1596 <span class={"admin-pill " + (disabled ? "is-off" : "is-on")}>
1597 <span class="dot" aria-hidden="true" />
1598 {disabled ? "disabled" : "running"}
1599 </span>
1600 </div>
1601 <div class="admin-stat-value" style="font-size:22px">
8e9f1d9Claude1602 {disabled ? "disabled" : "running"}
1603 </div>
1604 </div>
07f4b70Claude1605 <div class="admin-stat">
1606 <div class="admin-stat-head">
1607 <span class="admin-stat-label">Interval</span>
1608 </div>
1609 <div class="admin-stat-value">{Math.round(intervalMs / 1000)}s</div>
1610 <div class="admin-stat-hint">between ticks</div>
8e9f1d9Claude1611 </div>
07f4b70Claude1612 <div class="admin-stat">
1613 <div class="admin-stat-head">
1614 <span class="admin-stat-label">Ticks this process</span>
1615 </div>
1616 <div class="admin-stat-value">{total}</div>
1617 <div class="admin-stat-hint">since boot</div>
8e9f1d9Claude1618 </div>
07f4b70Claude1619 <div class="admin-stat">
1620 <div class="admin-stat-head">
1621 <span class="admin-stat-label">Last tick</span>
1622 </div>
1623 <div
1624 class="admin-stat-value"
1625 style="font-size: 14px; font-family: var(--font-mono); line-height: 1.3"
1626 >
8e9f1d9Claude1627 {tick ? tick.finishedAt : "never"}
1628 </div>
1629 </div>
1630 </div>
07f4b70Claude1631
8e9f1d9Claude1632 <form
1633 method="post"
1634 action="/admin/autopilot/run"
07f4b70Claude1635 style="margin-bottom: var(--space-5); display:flex; align-items:center; gap:12px; flex-wrap:wrap"
8e9f1d9Claude1636 >
1637 <button class="btn btn-primary" type="submit">
1638 Run tick now
1639 </button>
07f4b70Claude1640 <span style="color: var(--text-muted); font-size: 13px">
8e9f1d9Claude1641 Executes all sub-tasks synchronously and records the result.
1642 </span>
1643 </form>
07f4b70Claude1644
1645 <div class="admin-h3">
1646 <h3>Last tick tasks</h3>
1647 {tick && (
1648 <span class="admin-h3-meta">
1649 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
1650 </span>
1651 )}
1652 </div>
8e9f1d9Claude1653 {tick ? (
07f4b70Claude1654 <table class="admin-ap-table">
8e9f1d9Claude1655 <thead>
1656 <tr>
07f4b70Claude1657 <th>Task</th>
1658 <th>Status</th>
8e9f1d9Claude1659 <th style="text-align: right">Duration</th>
07f4b70Claude1660 <th>Error</th>
8e9f1d9Claude1661 </tr>
1662 </thead>
1663 <tbody>
1664 {tick.tasks.map((t) => (
1665 <tr>
1666 <td>
1667 <code>{t.name}</code>
1668 </td>
07f4b70Claude1669 <td class={t.ok ? "admin-ap-status-ok" : "admin-ap-status-fail"}>
8e9f1d9Claude1670 {t.ok ? "ok" : "failed"}
1671 </td>
1672 <td style="text-align: right">{t.durationMs}ms</td>
07f4b70Claude1673 <td style="color: var(--text-muted); font-size: 12.5px">
8e9f1d9Claude1674 {t.error || ""}
1675 </td>
1676 </tr>
1677 ))}
1678 </tbody>
1679 </table>
1680 ) : (
07f4b70Claude1681 <div class="admin-ap-empty">
8e9f1d9Claude1682 No ticks have run yet. The first tick fires after the interval
1683 elapses. Click "Run tick now" to fire one immediately.
07f4b70Claude1684 </div>
8e9f1d9Claude1685 )}
07f4b70Claude1686 <p class="admin-ap-foot">
8e9f1d9Claude1687 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
1688 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
1689 </p>
1690 </div>
07f4b70Claude1691 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8e9f1d9Claude1692 </Layout>
1693 );
1694});
1695
988380aClaude1696admin.post("/admin/demo/reseed", async (c) => {
1697 const g = await gate(c);
1698 if (g instanceof Response) return g;
1699 const { user } = g;
1700 try {
1701 const result = await ensureDemoContent({ force: true });
1702 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}` : ""}`;
1703 await audit({
1704 userId: user.id,
1705 action: "admin.demo.reseed",
1706 targetType: "user",
1707 targetId: result.demoUser?.id ?? "demo",
1708 metadata: {
1709 createdUser: result.created.user,
1710 createdRepos: result.created.repos,
1711 createdIssues: result.created.issues,
1712 createdPrs: result.created.prs,
1713 errors: result.errors.slice(0, 5),
1714 },
1715 });
1716 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
1717 } catch (err) {
1718 const message = err instanceof Error ? err.message : String(err);
1719 return c.redirect(
1720 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
1721 );
1722 }
1723});
1724
1725// Public jump-to-demo — redirects to the first demo repo if present,
1726// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
1727admin.get("/demo", (c) => {
1728 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
1729});
1730
8e9f1d9Claude1731admin.post("/admin/autopilot/run", async (c) => {
1732 const g = await gate(c);
1733 if (g instanceof Response) return g;
1734 const { user } = g;
1735 let summary = "";
1736 try {
1737 const result = await runAutopilotTick();
1738 const ok = result.tasks.filter((t) => t.ok).length;
1739 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
1740 await audit({
1741 userId: user.id,
1742 action: "admin.autopilot.run",
1743 targetType: "system",
1744 targetId: "autopilot",
1745 metadata: { ok, total: result.tasks.length },
1746 });
1747 return c.redirect(
1748 `/admin/autopilot?result=${encodeURIComponent(summary)}`
1749 );
1750 } catch (err) {
1751 const message = err instanceof Error ? err.message : String(err);
1752 return c.redirect(
1753 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
1754 );
1755 }
1756});
1757
8f50ed0Claude1758export default admin;