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.tsxBlame1771 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 ),
509c376Claude762 key: (
763 <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">
764 <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" />
765 </svg>
766 ),
07f4b70Claude767};
768
769/** First-letter avatar helper. */
770function initials(s: string): string {
771 return (s || "?").trim().charAt(0).toUpperCase() || "?";
772}
773
8f50ed0Claude774async function gate(c: any): Promise<{ user: any } | Response> {
775 const user = c.get("user");
776 if (!user) return c.redirect("/login?next=/admin");
777 if (!(await isSiteAdmin(user.id))) {
778 return c.html(
779 <Layout title="Forbidden" user={user}>
07f4b70Claude780 <div class="admin-403">
8f50ed0Claude781 <h2>403 — Not a site admin</h2>
782 <p>You don't have permission to view this page.</p>
783 </div>
07f4b70Claude784 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude785 </Layout>,
786 403
787 );
788 }
789 return { user };
790}
791
792admin.get("/admin", async (c) => {
793 const g = await gate(c);
794 if (g instanceof Response) return g;
795 const { user } = g;
796
797 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
798 const [rc] = await db
799 .select({ n: sql<number>`count(*)::int` })
800 .from(repositories);
801
802 const recent = await db
803 .select({
804 id: users.id,
805 username: users.username,
806 createdAt: users.createdAt,
807 })
808 .from(users)
809 .orderBy(desc(users.createdAt))
810 .limit(10);
811
812 const admins = await listSiteAdmins();
813
988380aClaude814 const msg = c.req.query("result") || c.req.query("error");
815 const isErr = !!c.req.query("error");
816
07f4b70Claude817 const userCount = Number(uc?.n || 0);
818 const repoCount = Number(rc?.n || 0);
819 const adminCount = admins.length;
820
8f50ed0Claude821 return c.html(
822 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude823 <div class="admin-wrap">
824 <section class="admin-hero">
825 <div class="admin-hero-bg" aria-hidden="true">
826 <div class="admin-hero-orb" />
827 </div>
828 <div class="admin-hero-inner">
829 <div class="admin-hero-eyebrow">
830 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
831 Site administration ·{" "}
832 <span class="admin-who">{user.username}</span>
833 </div>
834 <h2 class="admin-hero-title">
835 <span class="admin-hero-title-grad">Site admin</span>.
836 </h2>
837 <p class="admin-hero-sub">
838 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
839 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
840 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
841 Operations, flags, digests, and autopilot — all in one place.
842 </p>
843 </div>
844 </section>
8f50ed0Claude845
07f4b70Claude846 {msg && (
847 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
848 {decodeURIComponent(msg)}
849 </div>
850 )}
988380aClaude851
07f4b70Claude852 <div class="admin-stat-grid">
853 <div class="admin-stat">
854 <div class="admin-stat-head">
855 <span class="admin-stat-label">Users</span>
856 <span class="admin-stat-icon">{Icons.users}</span>
857 </div>
858 <div class="admin-stat-value">{userCount}</div>
859 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude860 </div>
07f4b70Claude861 <div class="admin-stat">
862 <div class="admin-stat-head">
863 <span class="admin-stat-label">Repos</span>
864 <span class="admin-stat-icon">{Icons.repo}</span>
865 </div>
866 <div class="admin-stat-value">{repoCount}</div>
867 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude868 </div>
07f4b70Claude869 <div class="admin-stat">
870 <div class="admin-stat-head">
871 <span class="admin-stat-label">Admins</span>
872 <span class="admin-stat-icon">{Icons.starShield}</span>
873 </div>
874 <div class="admin-stat-value">{adminCount}</div>
875 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude876 </div>
877 </div>
878
07f4b70Claude879 <div class="admin-actions">
880 <a href="/admin/ops" class="admin-action is-primary">
881 <span class="admin-action-icon">{Icons.ops}</span>
882 Operations
883 </a>
509c376Claude884 <a href="/admin/integrations" class="admin-action is-primary">
885 <span class="admin-action-icon">{Icons.key}</span>
886 Integrations
887 </a>
07f4b70Claude888 <a href="/admin/diagnose" class="admin-action is-primary">
889 <span class="admin-action-icon">{Icons.pulse}</span>
890 Health / Diagnose
891 </a>
892 <a href="/admin/users" class="admin-action">
893 <span class="admin-action-icon">{Icons.users}</span>
894 Manage users
895 </a>
896 <a href="/admin/repos" class="admin-action">
897 <span class="admin-action-icon">{Icons.repo}</span>
898 Manage repos
899 </a>
900 <a href="/admin/flags" class="admin-action">
901 <span class="admin-action-icon">{Icons.flag}</span>
902 Site flags
903 </a>
904 <a href="/admin/digests" class="admin-action">
905 <span class="admin-action-icon">{Icons.mail}</span>
906 Email digests
907 </a>
908 <a href="/admin/google-oauth" class="admin-action">
909 <span class="admin-action-icon">{Icons.google}</span>
910 Sign in with Google
911 </a>
912 <a href="/admin/github-oauth" class="admin-action">
913 <span class="admin-action-icon">{Icons.github}</span>
914 Sign in with GitHub
915 </a>
916 <a href="/admin/sso" class="admin-action">
917 <span class="admin-action-icon">{Icons.sso}</span>
918 Enterprise SSO
919 </a>
920 <a href="/admin/autopilot" class="admin-action">
921 <span class="admin-action-icon">{Icons.bot}</span>
922 Autopilot
923 </a>
662ce86Claude924 <a href="/connect/claude" class="admin-action is-primary">
925 <span class="admin-action-icon">{Icons.bot}</span>
926 Connect Claude
927 </a>
07f4b70Claude928 <form
929 method="post"
930 action="/admin/demo/reseed"
931 class="admin-action-form"
932 >
933 <button
934 class="admin-action"
935 type="submit"
936 title="Idempotently (re)create demo user + 3 sample repos"
937 >
938 <span class="admin-action-icon">{Icons.refresh}</span>
939 Reseed demo
940 </button>
941 </form>
942 </div>
8f50ed0Claude943
07f4b70Claude944 <div class="admin-h3">
945 <h3>Recent signups</h3>
946 <span class="admin-h3-meta">
947 {recent.length} most-recent
948 </span>
949 </div>
950 <div class="admin-list" style="margin-bottom:20px">
951 {recent.length === 0 ? (
952 <div class="admin-list-empty">No users yet.</div>
953 ) : (
954 recent.map((u) => (
955 <div class="admin-list-row">
956 <div class="admin-list-main">
957 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
958 <div class="admin-row-text">
959 <a href={`/${u.username}`} class="admin-row-title">
960 {u.username}
961 </a>
962 <div class="admin-row-sub">
963 <span>Joined</span>
964 <span>
965 {u.createdAt
966 ? new Date(u.createdAt as unknown as string).toLocaleString()
967 : ""}
968 </span>
969 </div>
970 </div>
971 </div>
972 </div>
973 ))
974 )}
975 </div>
8f50ed0Claude976
07f4b70Claude977 <div class="admin-h3">
978 <h3>Site admins</h3>
979 <span class="admin-h3-meta">
980 {adminCount} active
981 </span>
982 </div>
983 <div class="admin-list">
984 {admins.length === 0 ? (
985 <div class="admin-list-empty">
986 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude987 </div>
07f4b70Claude988 ) : (
989 admins.map((a) => (
990 <div class="admin-list-row">
991 <div class="admin-list-main">
992 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
993 <div class="admin-row-text">
994 <a href={`/${a.username}`} class="admin-row-title">
995 {a.username}
996 </a>
997 <div class="admin-row-sub">
998 <span class="admin-pill is-admin">
999 <span class="dot" aria-hidden="true" /> Site admin
1000 </span>
1001 <span>
1002 Granted{" "}
1003 {a.grantedAt
1004 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
1005 : "—"}
1006 </span>
1007 </div>
1008 </div>
1009 </div>
1010 </div>
1011 ))
1012 )}
1013 </div>
8f50ed0Claude1014 </div>
07f4b70Claude1015 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1016 </Layout>
1017 );
1018});
1019
1020// ----- Users -----
1021
1022admin.get("/admin/users", async (c) => {
1023 const g = await gate(c);
1024 if (g instanceof Response) return g;
1025 const { user } = g;
1026 const q = c.req.query("q") || "";
1027 const rows = await db
1028 .select({
1029 id: users.id,
1030 username: users.username,
1031 email: users.email,
1032 createdAt: users.createdAt,
1033 })
1034 .from(users)
1035 .where(
1036 q
1037 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
1038 : sql`1=1`
1039 )
1040 .orderBy(desc(users.createdAt))
1041 .limit(200);
1042
1043 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
1044
1045 return c.html(
1046 <Layout title="Admin — Users" user={user}>
07f4b70Claude1047 <div class="admin-wrap">
1048 <section class="admin-sec-hero">
1049 <div class="admin-sec-hero-text">
1050 <div class="admin-sec-eyebrow">Site admin</div>
1051 <h2 class="admin-sec-title">Users</h2>
1052 <p class="admin-sec-sub">
1053 Search, audit, and grant or revoke the site-admin flag.
1054 Showing up to 200 accounts ordered by signup recency.
1055 </p>
1056 </div>
1057 <div class="admin-sec-hero-actions">
1058 <a href="/admin" class="btn btn-sm">
1059 {Icons.arrowLeft} Back
1060 </a>
1061 </div>
1062 </section>
1063
1064 <form method="get" action="/admin/users" class="admin-search">
1065 <input
1066 type="text"
1067 name="q"
1068 value={q}
1069 placeholder="Search username or email"
1070 aria-label="Search username or email"
1071 class="admin-input"
1072 />
1073 <button type="submit" class="btn">
1074 Search
1075 </button>
1076 {q && (
1077 <a href="/admin/users" class="btn btn-sm">
1078 Clear
1079 </a>
1080 )}
1081 </form>
1082
1083 <div class="admin-list">
1084 {rows.length === 0 ? (
1085 <div class="admin-list-empty">No users found.</div>
1086 ) : (
1087 rows.map((u) => {
1088 const isAdmin = adminIds.has(u.id);
1089 return (
1090 <div class="admin-list-row">
1091 <div class="admin-list-main">
1092 <span class={"admin-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
1093 {initials(u.username)}
8f50ed0Claude1094 </span>
07f4b70Claude1095 <div class="admin-row-text">
1096 <a href={`/${u.username}`} class="admin-row-title">
1097 {u.username}
1098 </a>
1099 {isAdmin && (
1100 <span class="admin-pill is-admin" style="margin-left:8px">
1101 <span class="dot" aria-hidden="true" /> Admin
1102 </span>
1103 )}
1104 <div class="admin-row-sub">
1105 <span>{u.email}</span>
1106 {u.createdAt && (
1107 <span>
1108 ·{" "}
1109 {new Date(
1110 u.createdAt as unknown as string
1111 ).toLocaleDateString()}
1112 </span>
1113 )}
1114 </div>
1115 </div>
1116 </div>
1117 <form
1118 method="post"
1119 action={`/admin/users/${u.id}/admin`}
1120 onsubmit={
1121 isAdmin
1122 ? "return confirm('Revoke site admin?')"
1123 : "return confirm('Grant site admin?')"
1124 }
1125 >
1126 <button type="submit" class="btn btn-sm">
1127 {isAdmin ? "Revoke admin" : "Grant admin"}
1128 </button>
1129 </form>
8f50ed0Claude1130 </div>
07f4b70Claude1131 );
1132 })
1133 )}
1134 </div>
8f50ed0Claude1135 </div>
07f4b70Claude1136 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1137 </Layout>
1138 );
1139});
1140
1141admin.post("/admin/users/:id/admin", async (c) => {
1142 const g = await gate(c);
1143 if (g instanceof Response) return g;
1144 const { user } = g;
1145 const id = c.req.param("id");
1146 const admins = await listSiteAdmins();
1147 const isAlready = admins.some((a) => a.userId === id);
1148 if (isAlready) {
1149 await revokeSiteAdmin(id);
1150 await audit({
1151 userId: user.id,
1152 action: "site_admin.revoke",
1153 targetType: "user",
1154 targetId: id,
1155 });
1156 } else {
1157 await grantSiteAdmin(id, user.id);
1158 await audit({
1159 userId: user.id,
1160 action: "site_admin.grant",
1161 targetType: "user",
1162 targetId: id,
1163 });
1164 }
1165 return c.redirect("/admin/users");
1166});
1167
1168// ----- Repos -----
1169
1170admin.get("/admin/repos", async (c) => {
1171 const g = await gate(c);
1172 if (g instanceof Response) return g;
1173 const { user } = g;
1174 const rows = await db
1175 .select({
1176 id: repositories.id,
1177 name: repositories.name,
1178 ownerUsername: users.username,
0316dbbClaude1179 isPrivate: repositories.isPrivate,
8f50ed0Claude1180 createdAt: repositories.createdAt,
1181 starCount: repositories.starCount,
1182 })
1183 .from(repositories)
1184 .innerJoin(users, eq(repositories.ownerId, users.id))
1185 .orderBy(desc(repositories.createdAt))
1186 .limit(200);
1187
1188 return c.html(
1189 <Layout title="Admin — Repos" user={user}>
07f4b70Claude1190 <div class="admin-wrap">
1191 <section class="admin-sec-hero">
1192 <div class="admin-sec-hero-text">
1193 <div class="admin-sec-eyebrow">Site admin</div>
1194 <h2 class="admin-sec-title">Repositories</h2>
1195 <p class="admin-sec-sub">
1196 Every repository on the platform — public and private.
1197 Delete is irreversible and audit-logged.
1198 </p>
1199 </div>
1200 <div class="admin-sec-hero-actions">
1201 <a href="/admin" class="btn btn-sm">
1202 {Icons.arrowLeft} Back
1203 </a>
1204 </div>
1205 </section>
1206
1207 <div class="admin-list">
1208 {rows.length === 0 ? (
1209 <div class="admin-list-empty">No repositories.</div>
1210 ) : (
1211 rows.map((r) => (
1212 <div class="admin-list-row">
1213 <div class="admin-list-main">
1214 <span class="admin-avatar" aria-hidden="true">
1215 {initials(r.ownerUsername)}
1216 </span>
1217 <div class="admin-row-text">
1218 <a
1219 href={`/${r.ownerUsername}/${r.name}`}
1220 class="admin-row-title"
1221 >
1222 {r.ownerUsername}/{r.name}
1223 </a>
1224 <span
1225 class={
1226 "admin-pill " +
1227 (r.isPrivate ? "is-private" : "is-public")
1228 }
1229 style="margin-left:8px"
1230 >
1231 {r.isPrivate ? "private" : "public"}
1232 </span>
1233 <div class="admin-row-sub">
1234 <span>{r.starCount} star{r.starCount === 1 ? "" : "s"}</span>
1235 <span>·</span>
1236 <span>
1237 {r.createdAt
1238 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
1239 : ""}
1240 </span>
1241 </div>
1242 </div>
8f50ed0Claude1243 </div>
07f4b70Claude1244 <form
1245 method="post"
1246 action={`/admin/repos/${r.id}/delete`}
1247 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
1248 >
1249 <button type="submit" class="btn btn-sm btn-danger">
1250 Delete
1251 </button>
1252 </form>
8f50ed0Claude1253 </div>
07f4b70Claude1254 ))
1255 )}
1256 </div>
8f50ed0Claude1257 </div>
07f4b70Claude1258 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1259 </Layout>
1260 );
1261});
1262
1263admin.post("/admin/repos/:id/delete", async (c) => {
1264 const g = await gate(c);
1265 if (g instanceof Response) return g;
1266 const { user } = g;
1267 const id = c.req.param("id");
1268 try {
1269 await db.delete(repositories).where(eq(repositories.id, id));
1270 } catch (err) {
1271 console.error("[admin] repo delete:", err);
1272 }
1273 await audit({
1274 userId: user.id,
1275 action: "admin.repo.delete",
1276 targetType: "repository",
1277 targetId: id,
1278 });
1279 return c.redirect("/admin/repos");
1280});
1281
1282// ----- Flags -----
1283
1284admin.get("/admin/flags", async (c) => {
1285 const g = await gate(c);
1286 if (g instanceof Response) return g;
1287 const { user } = g;
1288
1289 const existing = await listFlags();
1290 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
1291 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1292
1293 return c.html(
1294 <Layout title="Admin — Flags" user={user}>
07f4b70Claude1295 <div class="admin-wrap">
1296 <section class="admin-sec-hero">
1297 <div class="admin-sec-hero-text">
1298 <div class="admin-sec-eyebrow">Site admin</div>
1299 <h2 class="admin-sec-title">Site flags</h2>
1300 <p class="admin-sec-sub">
1301 Runtime feature flags surfaced to the rest of the app via
1302 <code style="margin:0 4px;padding:1px 5px;border-radius:4px;background:var(--bg-tertiary);font-family:var(--font-mono);font-size:12px">
1303 getFlag()
1304 </code>
1305 — registration lock, site banner, read-only mode, and more.
1306 </p>
1307 </div>
1308 <div class="admin-sec-hero-actions">
1309 <a href="/admin" class="btn btn-sm">
1310 {Icons.arrowLeft} Back
1311 </a>
1312 </div>
1313 </section>
1314
1315 <form method="post" action="/admin/flags" class="admin-card">
1316 <div class="admin-card-body">
1317 {keys.map((k) => {
1318 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
1319 return (
1320 <div class="admin-field">
1321 <label>{k}</label>
1322 <input
1323 type="text"
1324 name={k}
1325 value={current}
1326 aria-label={k}
1327 class="admin-input-mono"
1328 />
1329 <div class="admin-field-hint">
1330 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
1331 </div>
1332 </div>
1333 );
1334 })}
1335 </div>
1336 <div class="admin-card-foot">
1337 <span class="admin-foot-hint">
1338 Saved values overwrite the defaults at runtime.
1339 </span>
1340 <button type="submit" class="btn btn-primary">
1341 Save
1342 </button>
1343 </div>
1344 </form>
8f50ed0Claude1345 </div>
07f4b70Claude1346 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude1347 </Layout>
1348 );
1349});
1350
1351admin.post("/admin/flags", async (c) => {
1352 const g = await gate(c);
1353 if (g instanceof Response) return g;
1354 const { user } = g;
1355 const body = await c.req.parseBody();
1356 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
1357 for (const k of keys) {
1358 const v = String(body[k] ?? "");
1359 await setFlag(k, v, user.id);
1360 }
1361 await audit({ userId: user.id, action: "admin.flags.save" });
1362 return c.redirect("/admin/flags");
1363});
1364
08420cdClaude1365// ----- Email digests (Block I7) -----
1366
1367admin.get("/admin/digests", async (c) => {
1368 const g = await gate(c);
1369 if (g instanceof Response) return g;
1370 const { user } = g;
1371
1372 const [optedRow] = await db
1373 .select({ n: sql<number>`count(*)::int` })
1374 .from(users)
1375 .where(eq(users.notifyEmailDigestWeekly, true));
1376 const opted = Number(optedRow?.n || 0);
1377
1378 const recentlySent = await db
1379 .select({
1380 id: users.id,
1381 username: users.username,
1382 lastDigestSentAt: users.lastDigestSentAt,
1383 })
1384 .from(users)
1385 .where(sql`${users.lastDigestSentAt} is not null`)
1386 .orderBy(desc(users.lastDigestSentAt))
1387 .limit(20);
1388
1389 const result = c.req.query("result");
1390 const error = c.req.query("error");
1391
1392 return c.html(
1393 <Layout title="Admin — Digests" user={user}>
07f4b70Claude1394 <div class="admin-wrap">
1395 <section class="admin-sec-hero">
1396 <div class="admin-sec-hero-text">
1397 <div class="admin-sec-eyebrow">Site admin</div>
1398 <h2 class="admin-sec-title">Email digests</h2>
1399 <p class="admin-sec-sub">
1400 Manually trigger the weekly digest for every opted-in user
1401 or preview the email for a single account.
1402 </p>
1403 </div>
1404 <div class="admin-sec-hero-actions">
1405 <a href="/admin" class="btn btn-sm">
1406 {Icons.arrowLeft} Back
1407 </a>
1408 </div>
1409 </section>
08420cdClaude1410
07f4b70Claude1411 {result && (
1412 <div class="admin-banner is-ok">{decodeURIComponent(result)}</div>
1413 )}
1414 {error && (
1415 <div class="admin-banner is-error">{decodeURIComponent(error)}</div>
1416 )}
08420cdClaude1417
07f4b70Claude1418 <div class="admin-card" style="margin-bottom:var(--space-5)">
1419 <div class="admin-card-body">
1420 <div style="display:flex;align-items:center;gap:10px;margin-bottom:var(--space-3);flex-wrap:wrap">
1421 <span class="admin-pill is-on">
1422 <span class="dot" aria-hidden="true" />
1423 {opted} opted-in
1424 </span>
1425 <span style="font-size:13px;color:var(--text-muted)">
1426 user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
08420cdClaude1427 </span>
1428 </div>
07f4b70Claude1429 <form method="post" action="/admin/digests/run" style="margin-bottom:var(--space-4)">
1430 <button
1431 type="submit"
1432 class="btn btn-primary"
1433 onclick="return confirm('Send weekly digest to all opted-in users now?')"
1434 >
1435 Send digests now
1436 </button>
1437 </form>
1438 <div style="border-top:1px solid var(--border-subtle);padding-top:var(--space-4)">
1439 <div style="font-size:12.5px;color:var(--text-muted);margin-bottom:8px">
1440 Preview / one-off — send the digest to a single user.
1441 </div>
1442 <form
1443 method="post"
1444 action="/admin/digests/preview"
1445 class="admin-digest-row"
1446 >
1447 <input
1448 type="text"
1449 name="username"
1450 placeholder="username"
1451 required
1452 aria-label="Username"
1453 class="admin-input"
1454 style="width:240px"
1455 />
1456 <button type="submit" class="btn btn-sm">
1457 Send to one user
1458 </button>
1459 </form>
1460 </div>
1461 </div>
1462 </div>
1463
1464 <div class="admin-h3">
1465 <h3>Recently sent</h3>
1466 <span class="admin-h3-meta">
1467 last {recentlySent.length}
1468 </span>
1469 </div>
1470 <div class="admin-list">
1471 {recentlySent.length === 0 ? (
1472 <div class="admin-list-empty">No digests have been sent yet.</div>
1473 ) : (
1474 recentlySent.map((u) => (
1475 <div class="admin-list-row">
1476 <div class="admin-list-main">
1477 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
1478 <div class="admin-row-text">
1479 <a href={`/${u.username}`} class="admin-row-title">
1480 {u.username}
1481 </a>
1482 <div class="admin-row-sub">
1483 <span>Sent</span>
1484 <span>
1485 {u.lastDigestSentAt
1486 ? new Date(
1487 u.lastDigestSentAt as unknown as string
1488 ).toLocaleString()
1489 : ""}
1490 </span>
1491 </div>
1492 </div>
1493 </div>
1494 </div>
1495 ))
1496 )}
1497 </div>
08420cdClaude1498 </div>
07f4b70Claude1499 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
08420cdClaude1500 </Layout>
1501 );
1502});
1503
1504admin.post("/admin/digests/run", async (c) => {
1505 const g = await gate(c);
1506 if (g instanceof Response) return g;
1507 const { user } = g;
1508 const results = await sendDigestsToAll();
1509 const sent = results.filter((r) => r.ok).length;
1510 const skipped = results.length - sent;
1511 await audit({
1512 userId: user.id,
1513 action: "admin.digests.run",
1514 metadata: { sent, skipped, total: results.length },
1515 });
1516 return c.redirect(
1517 `/admin/digests?result=${encodeURIComponent(
1518 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
1519 )}`
1520 );
1521});
1522
1523admin.post("/admin/digests/preview", async (c) => {
1524 const g = await gate(c);
1525 if (g instanceof Response) return g;
1526 const { user } = g;
1527 const body = await c.req.parseBody();
1528 const username = String(body.username || "").trim();
1529 if (!username) {
1530 return c.redirect("/admin/digests?error=Username+required");
1531 }
1532 const [target] = await db
1533 .select({ id: users.id, username: users.username })
1534 .from(users)
1535 .where(eq(users.username, username))
1536 .limit(1);
1537 if (!target) {
1538 return c.redirect("/admin/digests?error=User+not+found");
1539 }
1540 const result = await sendDigestForUser(target.id);
1541 await audit({
1542 userId: user.id,
1543 action: "admin.digests.preview",
1544 targetType: "user",
1545 targetId: target.id,
1546 metadata: {
1547 ok: result.ok,
1548 skipped: "skipped" in result ? result.skipped : null,
1549 },
1550 });
1551 if (result.ok) {
1552 return c.redirect(
1553 `/admin/digests?result=${encodeURIComponent(
1554 `Digest sent to ${target.username}.`
1555 )}`
1556 );
1557 }
1558 return c.redirect(
1559 `/admin/digests?error=${encodeURIComponent(
1560 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
1561 )}`
1562 );
1563});
1564
8e9f1d9Claude1565admin.get("/admin/autopilot", async (c) => {
1566 const g = await gate(c);
1567 if (g instanceof Response) return g;
1568 const { user } = g;
1569 const tick = getLastTick();
1570 const total = getTickCount();
1571 const disabled = process.env.AUTOPILOT_DISABLED === "1";
1572 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
1573 const intervalMs =
1574 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
1575 ? Number(intervalRaw)
1576 : 5 * 60 * 1000;
1577 const msg = c.req.query("result") || c.req.query("error");
1578 const isErr = !!c.req.query("error");
1579 return c.html(
1580 <Layout title="Autopilot — admin" user={user}>
07f4b70Claude1581 <div class="admin-wrap" style="padding: var(--space-6) var(--space-4)">
1582 <section class="admin-sec-hero">
1583 <div class="admin-sec-hero-text">
1584 <div class="admin-sec-eyebrow">Site admin</div>
1585 <h2 class="admin-sec-title">Autopilot</h2>
1586 <p class="admin-sec-sub">
1587 Periodic platform-maintenance loop — mirror sync, merge-queue
1588 progress, weekly digests, advisory rescans, environment
1589 wait-timer release, and scheduled workflow triggers (cron).
1590 </p>
1591 </div>
1592 <div class="admin-sec-hero-actions">
1593 <a href="/admin" class="btn btn-sm">
1594 {Icons.arrowLeft} Back
1595 </a>
1596 </div>
1597 </section>
1598
8e9f1d9Claude1599 {msg && (
07f4b70Claude1600 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude1601 {decodeURIComponent(msg)}
1602 </div>
1603 )}
07f4b70Claude1604
1605 <div class="admin-stat-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))">
1606 <div class="admin-stat">
1607 <div class="admin-stat-head">
1608 <span class="admin-stat-label">Status</span>
1609 <span class={"admin-pill " + (disabled ? "is-off" : "is-on")}>
1610 <span class="dot" aria-hidden="true" />
1611 {disabled ? "disabled" : "running"}
1612 </span>
1613 </div>
1614 <div class="admin-stat-value" style="font-size:22px">
8e9f1d9Claude1615 {disabled ? "disabled" : "running"}
1616 </div>
1617 </div>
07f4b70Claude1618 <div class="admin-stat">
1619 <div class="admin-stat-head">
1620 <span class="admin-stat-label">Interval</span>
1621 </div>
1622 <div class="admin-stat-value">{Math.round(intervalMs / 1000)}s</div>
1623 <div class="admin-stat-hint">between ticks</div>
8e9f1d9Claude1624 </div>
07f4b70Claude1625 <div class="admin-stat">
1626 <div class="admin-stat-head">
1627 <span class="admin-stat-label">Ticks this process</span>
1628 </div>
1629 <div class="admin-stat-value">{total}</div>
1630 <div class="admin-stat-hint">since boot</div>
8e9f1d9Claude1631 </div>
07f4b70Claude1632 <div class="admin-stat">
1633 <div class="admin-stat-head">
1634 <span class="admin-stat-label">Last tick</span>
1635 </div>
1636 <div
1637 class="admin-stat-value"
1638 style="font-size: 14px; font-family: var(--font-mono); line-height: 1.3"
1639 >
8e9f1d9Claude1640 {tick ? tick.finishedAt : "never"}
1641 </div>
1642 </div>
1643 </div>
07f4b70Claude1644
8e9f1d9Claude1645 <form
1646 method="post"
1647 action="/admin/autopilot/run"
07f4b70Claude1648 style="margin-bottom: var(--space-5); display:flex; align-items:center; gap:12px; flex-wrap:wrap"
8e9f1d9Claude1649 >
1650 <button class="btn btn-primary" type="submit">
1651 Run tick now
1652 </button>
07f4b70Claude1653 <span style="color: var(--text-muted); font-size: 13px">
8e9f1d9Claude1654 Executes all sub-tasks synchronously and records the result.
1655 </span>
1656 </form>
07f4b70Claude1657
1658 <div class="admin-h3">
1659 <h3>Last tick tasks</h3>
1660 {tick && (
1661 <span class="admin-h3-meta">
1662 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
1663 </span>
1664 )}
1665 </div>
8e9f1d9Claude1666 {tick ? (
07f4b70Claude1667 <table class="admin-ap-table">
8e9f1d9Claude1668 <thead>
1669 <tr>
07f4b70Claude1670 <th>Task</th>
1671 <th>Status</th>
8e9f1d9Claude1672 <th style="text-align: right">Duration</th>
07f4b70Claude1673 <th>Error</th>
8e9f1d9Claude1674 </tr>
1675 </thead>
1676 <tbody>
1677 {tick.tasks.map((t) => (
1678 <tr>
1679 <td>
1680 <code>{t.name}</code>
1681 </td>
07f4b70Claude1682 <td class={t.ok ? "admin-ap-status-ok" : "admin-ap-status-fail"}>
8e9f1d9Claude1683 {t.ok ? "ok" : "failed"}
1684 </td>
1685 <td style="text-align: right">{t.durationMs}ms</td>
07f4b70Claude1686 <td style="color: var(--text-muted); font-size: 12.5px">
8e9f1d9Claude1687 {t.error || ""}
1688 </td>
1689 </tr>
1690 ))}
1691 </tbody>
1692 </table>
1693 ) : (
07f4b70Claude1694 <div class="admin-ap-empty">
8e9f1d9Claude1695 No ticks have run yet. The first tick fires after the interval
1696 elapses. Click "Run tick now" to fire one immediately.
07f4b70Claude1697 </div>
8e9f1d9Claude1698 )}
07f4b70Claude1699 <p class="admin-ap-foot">
8e9f1d9Claude1700 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
1701 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
1702 </p>
1703 </div>
07f4b70Claude1704 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8e9f1d9Claude1705 </Layout>
1706 );
1707});
1708
988380aClaude1709admin.post("/admin/demo/reseed", async (c) => {
1710 const g = await gate(c);
1711 if (g instanceof Response) return g;
1712 const { user } = g;
1713 try {
1714 const result = await ensureDemoContent({ force: true });
1715 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}` : ""}`;
1716 await audit({
1717 userId: user.id,
1718 action: "admin.demo.reseed",
1719 targetType: "user",
1720 targetId: result.demoUser?.id ?? "demo",
1721 metadata: {
1722 createdUser: result.created.user,
1723 createdRepos: result.created.repos,
1724 createdIssues: result.created.issues,
1725 createdPrs: result.created.prs,
1726 errors: result.errors.slice(0, 5),
1727 },
1728 });
1729 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
1730 } catch (err) {
1731 const message = err instanceof Error ? err.message : String(err);
1732 return c.redirect(
1733 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
1734 );
1735 }
1736});
1737
1738// Public jump-to-demo — redirects to the first demo repo if present,
1739// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
1740admin.get("/demo", (c) => {
1741 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
1742});
1743
8e9f1d9Claude1744admin.post("/admin/autopilot/run", async (c) => {
1745 const g = await gate(c);
1746 if (g instanceof Response) return g;
1747 const { user } = g;
1748 let summary = "";
1749 try {
1750 const result = await runAutopilotTick();
1751 const ok = result.tasks.filter((t) => t.ok).length;
1752 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
1753 await audit({
1754 userId: user.id,
1755 action: "admin.autopilot.run",
1756 targetType: "system",
1757 targetId: "autopilot",
1758 metadata: { ok, total: result.tasks.length },
1759 });
1760 return c.redirect(
1761 `/admin/autopilot?result=${encodeURIComponent(summary)}`
1762 );
1763 } catch (err) {
1764 const message = err instanceof Error ? err.message : String(err);
1765 return c.redirect(
1766 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
1767 );
1768 }
1769});
1770
8f50ed0Claude1771export default admin;