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.tsxBlame804 lines · 4 contributors
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`.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
5618f9aClaude22import { softAuth } from "../middleware/auth";
8f50ed0Claude23import type { AuthEnv } from "../middleware/auth";
24import {
25 grantSiteAdmin,
26 isSiteAdmin,
27 KNOWN_FLAGS,
28 listFlags,
29 listSiteAdmins,
30 revokeSiteAdmin,
31 setFlag,
32} from "../lib/admin";
33import { audit } from "../lib/notify";
08420cdClaude34import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude35import {
36 getLastTick,
37 getTickCount,
38 runAutopilotTick,
39} from "../lib/autopilot";
988380aClaude40import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude41
42const admin = new Hono<AuthEnv>();
43admin.use("*", softAuth);
44
45async function gate(c: any): Promise<{ user: any } | Response> {
46 const user = c.get("user");
47 if (!user) return c.redirect("/login?next=/admin");
48 if (!(await isSiteAdmin(user.id))) {
49 return c.html(
50 <Layout title="Forbidden" user={user}>
51 <div class="empty-state">
52 <h2>403 — Not a site admin</h2>
53 <p>You don't have permission to view this page.</p>
54 </div>
55 </Layout>,
56 403
57 );
58 }
59 return { user };
60}
61
62admin.get("/admin", async (c) => {
63 const g = await gate(c);
64 if (g instanceof Response) return g;
65 const { user } = g;
66
67 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
68 const [rc] = await db
69 .select({ n: sql<number>`count(*)::int` })
70 .from(repositories);
71
72 const recent = await db
73 .select({
74 id: users.id,
75 username: users.username,
76 createdAt: users.createdAt,
77 })
78 .from(users)
79 .orderBy(desc(users.createdAt))
80 .limit(10);
81
82 const admins = await listSiteAdmins();
83
988380aClaude84 const msg = c.req.query("result") || c.req.query("error");
85 const isErr = !!c.req.query("error");
86
8f50ed0Claude87 return c.html(
88 <Layout title="Admin — Gluecron" user={user}>
89 <h2>Site admin</h2>
90
988380aClaude91 {msg && (
92 <div
93 class={isErr ? "auth-error" : "banner"}
94 style="margin-bottom:16px"
95 >
96 {decodeURIComponent(msg)}
97 </div>
98 )}
99
dc26881CC LABS App100 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-5)">
101 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude102 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
103 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
104 Users
105 </div>
106 </div>
dc26881CC LABS App107 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude108 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
109 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
110 Repos
111 </div>
112 </div>
dc26881CC LABS App113 <div class="panel" style="padding:var(--space-3);text-align:center">
8f50ed0Claude114 <div style="font-size:22px;font-weight:700">{admins.length}</div>
115 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
116 Site admins
117 </div>
118 </div>
119 </div>
120
dc26881CC LABS App121 <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-2);margin-bottom:var(--space-5)">
9dd96b9Test User122 <a href="/admin/ops" class="btn btn-primary">
123 Operations
124 </a>
826eccfTest User125 <a href="/admin/diagnose" class="btn">
115c66bClaude126 Health / Diagnose
826eccfTest User127 </a>
8f50ed0Claude128 <a href="/admin/users" class="btn">
129 Manage users
130 </a>
131 <a href="/admin/repos" class="btn">
132 Manage repos
133 </a>
134 <a href="/admin/flags" class="btn">
135 Site flags
136 </a>
08420cdClaude137 <a href="/admin/digests" class="btn">
138 Email digests
139 </a>
582cdacClaude140 <a href="/admin/google-oauth" class="btn">
141 Sign in with Google
142 </a>
143 <a href="/admin/github-oauth" class="btn">
144 Sign in with GitHub
145 </a>
edf7c36Claude146 <a href="/admin/sso" class="btn">
147 Enterprise SSO
148 </a>
988380aClaude149 <a href="/admin/autopilot" class="btn">
150 Autopilot
151 </a>
152 <form
153 method="post"
154 action="/admin/demo/reseed"
155 style="display:contents"
156 >
157 <button class="btn" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
158 Reseed demo
159 </button>
160 </form>
8f50ed0Claude161 </div>
162
163 <h3>Recent signups</h3>
164 <div class="panel" style="margin-bottom:20px">
165 {recent.map((u) => (
166 <div class="panel-item" style="justify-content:space-between">
167 <a href={`/${u.username}`}>{u.username}</a>
168 <span style="font-size:12px;color:var(--text-muted)">
169 {u.createdAt
170 ? new Date(u.createdAt as unknown as string).toLocaleString()
171 : ""}
172 </span>
173 </div>
174 ))}
175 </div>
176
177 <h3>Site admins</h3>
178 <div class="panel">
179 {admins.length === 0 ? (
180 <div class="panel-empty">
181 No admins (bootstrap mode — oldest user is admin).
182 </div>
183 ) : (
184 admins.map((a) => (
185 <div class="panel-item" style="justify-content:space-between">
186 <a href={`/${a.username}`}>{a.username}</a>
187 <span style="font-size:12px;color:var(--text-muted)">
188 Granted{" "}
189 {a.grantedAt
190 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
191 : ""}
192 </span>
193 </div>
194 ))
195 )}
196 </div>
197 </Layout>
198 );
199});
200
201// ----- Users -----
202
203admin.get("/admin/users", async (c) => {
204 const g = await gate(c);
205 if (g instanceof Response) return g;
206 const { user } = g;
207 const q = c.req.query("q") || "";
208 const rows = await db
209 .select({
210 id: users.id,
211 username: users.username,
212 email: users.email,
213 createdAt: users.createdAt,
214 })
215 .from(users)
216 .where(
217 q
218 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
219 : sql`1=1`
220 )
221 .orderBy(desc(users.createdAt))
222 .limit(200);
223
224 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
225
226 return c.html(
227 <Layout title="Admin — Users" user={user}>
228 <h2>Users</h2>
b9968e3Claude229 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude230 <input
231 type="text"
232 name="q"
233 value={q}
234 placeholder="Search username or email"
2c3ba6ecopilot-swe-agent[bot]235 aria-label="Search username or email"
8f50ed0Claude236 style="width:320px"
237 />{" "}
238 <button type="submit" class="btn">
239 Search
240 </button>
241 <a href="/admin" class="btn" style="margin-left:6px">
242 Back
243 </a>
244 </form>
245 <div class="panel">
246 {rows.length === 0 ? (
247 <div class="panel-empty">No users found.</div>
248 ) : (
249 rows.map((u) => {
250 const isAdmin = adminIds.has(u.id);
251 return (
252 <div class="panel-item" style="justify-content:space-between">
253 <div>
254 <a href={`/${u.username}`} style="font-weight:600">
255 {u.username}
256 </a>{" "}
257 <span style="color:var(--text-muted)">{u.email}</span>
258 {isAdmin && (
259 <span
260 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
261 >
262 ADMIN
263 </span>
264 )}
265 </div>
266 <form
b9968e3Claude267 method="post"
8f50ed0Claude268 action={`/admin/users/${u.id}/admin`}
269 onsubmit={
270 isAdmin
271 ? "return confirm('Revoke site admin?')"
272 : "return confirm('Grant site admin?')"
273 }
274 >
275 <button type="submit" class="btn btn-sm">
276 {isAdmin ? "Revoke admin" : "Grant admin"}
277 </button>
278 </form>
279 </div>
280 );
281 })
282 )}
283 </div>
284 </Layout>
285 );
286});
287
288admin.post("/admin/users/:id/admin", async (c) => {
289 const g = await gate(c);
290 if (g instanceof Response) return g;
291 const { user } = g;
292 const id = c.req.param("id");
293 const admins = await listSiteAdmins();
294 const isAlready = admins.some((a) => a.userId === id);
295 if (isAlready) {
296 await revokeSiteAdmin(id);
297 await audit({
298 userId: user.id,
299 action: "site_admin.revoke",
300 targetType: "user",
301 targetId: id,
302 });
303 } else {
304 await grantSiteAdmin(id, user.id);
305 await audit({
306 userId: user.id,
307 action: "site_admin.grant",
308 targetType: "user",
309 targetId: id,
310 });
311 }
312 return c.redirect("/admin/users");
313});
314
315// ----- Repos -----
316
317admin.get("/admin/repos", async (c) => {
318 const g = await gate(c);
319 if (g instanceof Response) return g;
320 const { user } = g;
321 const rows = await db
322 .select({
323 id: repositories.id,
324 name: repositories.name,
325 ownerUsername: users.username,
0316dbbClaude326 isPrivate: repositories.isPrivate,
8f50ed0Claude327 createdAt: repositories.createdAt,
328 starCount: repositories.starCount,
329 })
330 .from(repositories)
331 .innerJoin(users, eq(repositories.ownerId, users.id))
332 .orderBy(desc(repositories.createdAt))
333 .limit(200);
334
335 return c.html(
336 <Layout title="Admin — Repos" user={user}>
337 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
338 <h2>Repositories</h2>
339 <a href="/admin" class="btn btn-sm">
340 Back
341 </a>
342 </div>
343 <div class="panel">
344 {rows.length === 0 ? (
345 <div class="panel-empty">No repositories.</div>
346 ) : (
347 rows.map((r) => (
348 <div class="panel-item" style="justify-content:space-between">
349 <div>
350 <a
351 href={`/${r.ownerUsername}/${r.name}`}
352 style="font-weight:600"
353 >
354 {r.ownerUsername}/{r.name}
355 </a>
356 <span
357 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
358 >
0316dbbClaude359 {r.isPrivate ? "private" : "public"}
8f50ed0Claude360 </span>
361 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
362 {r.starCount} stars ·{" "}
363 {r.createdAt
364 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
365 : ""}
366 </div>
367 </div>
368 <form
b9968e3Claude369 method="post"
8f50ed0Claude370 action={`/admin/repos/${r.id}/delete`}
371 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
372 >
373 <button type="submit" class="btn btn-sm btn-danger">
374 Delete
375 </button>
376 </form>
377 </div>
378 ))
379 )}
380 </div>
381 </Layout>
382 );
383});
384
385admin.post("/admin/repos/:id/delete", async (c) => {
386 const g = await gate(c);
387 if (g instanceof Response) return g;
388 const { user } = g;
389 const id = c.req.param("id");
390 try {
391 await db.delete(repositories).where(eq(repositories.id, id));
392 } catch (err) {
393 console.error("[admin] repo delete:", err);
394 }
395 await audit({
396 userId: user.id,
397 action: "admin.repo.delete",
398 targetType: "repository",
399 targetId: id,
400 });
401 return c.redirect("/admin/repos");
402});
403
404// ----- Flags -----
405
406admin.get("/admin/flags", async (c) => {
407 const g = await gate(c);
408 if (g instanceof Response) return g;
409 const { user } = g;
410
411 const existing = await listFlags();
412 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
413 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
414
415 return c.html(
416 <Layout title="Admin — Flags" user={user}>
417 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
418 <h2>Site flags</h2>
419 <a href="/admin" class="btn btn-sm">
420 Back
421 </a>
422 </div>
423 <form
b9968e3Claude424 method="post"
8f50ed0Claude425 action="/admin/flags"
426 class="panel"
dc26881CC LABS App427 style="padding:var(--space-4)"
8f50ed0Claude428 >
429 {keys.map((k) => {
430 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
431 return (
432 <div class="form-group">
433 <label>{k}</label>
434 <input
435 type="text"
436 name={k}
437 value={current}
2c3ba6ecopilot-swe-agent[bot]438 aria-label={k}
8f50ed0Claude439 style="font-family:var(--font-mono)"
440 />
441 <div
442 style="font-size:11px;color:var(--text-muted);margin-top:2px"
443 >
444 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
445 </div>
446 </div>
447 );
448 })}
449 <button type="submit" class="btn btn-primary">
450 Save
451 </button>
452 </form>
453 </Layout>
454 );
455});
456
457admin.post("/admin/flags", async (c) => {
458 const g = await gate(c);
459 if (g instanceof Response) return g;
460 const { user } = g;
461 const body = await c.req.parseBody();
462 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
463 for (const k of keys) {
464 const v = String(body[k] ?? "");
465 await setFlag(k, v, user.id);
466 }
467 await audit({ userId: user.id, action: "admin.flags.save" });
468 return c.redirect("/admin/flags");
469});
470
08420cdClaude471// ----- Email digests (Block I7) -----
472
473admin.get("/admin/digests", async (c) => {
474 const g = await gate(c);
475 if (g instanceof Response) return g;
476 const { user } = g;
477
478 const [optedRow] = await db
479 .select({ n: sql<number>`count(*)::int` })
480 .from(users)
481 .where(eq(users.notifyEmailDigestWeekly, true));
482 const opted = Number(optedRow?.n || 0);
483
484 const recentlySent = await db
485 .select({
486 id: users.id,
487 username: users.username,
488 lastDigestSentAt: users.lastDigestSentAt,
489 })
490 .from(users)
491 .where(sql`${users.lastDigestSentAt} is not null`)
492 .orderBy(desc(users.lastDigestSentAt))
493 .limit(20);
494
495 const result = c.req.query("result");
496 const error = c.req.query("error");
497
498 return c.html(
499 <Layout title="Admin — Digests" user={user}>
500 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
501 <h2>Email digests</h2>
502 <a href="/admin" class="btn btn-sm">
503 Back
504 </a>
505 </div>
506
507 {result && (
508 <div class="auth-success">{decodeURIComponent(result)}</div>
509 )}
510 {error && (
511 <div class="auth-error">{decodeURIComponent(error)}</div>
512 )}
513
dc26881CC LABS App514 <div class="panel" style="padding:var(--space-4);margin-bottom:var(--space-5)">
08420cdClaude515 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
516 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
517 </div>
b9968e3Claude518 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude519 <button
520 type="submit"
521 class="btn btn-primary"
522 onclick="return confirm('Send weekly digest to all opted-in users now?')"
523 >
524 Send digests now
525 </button>
526 </form>
b9968e3Claude527 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude528 <input
529 type="text"
530 name="username"
531 placeholder="username"
532 required
2c3ba6ecopilot-swe-agent[bot]533 aria-label="Username"
08420cdClaude534 style="width:240px"
535 />
536 <button type="submit" class="btn btn-sm">
537 Send to one user
538 </button>
539 </form>
540 </div>
541
542 <h3>Recently sent</h3>
543 <div class="panel">
544 {recentlySent.length === 0 ? (
545 <div class="panel-empty">No digests have been sent yet.</div>
546 ) : (
547 recentlySent.map((u) => (
548 <div class="panel-item" style="justify-content:space-between">
549 <a href={`/${u.username}`}>{u.username}</a>
550 <span style="font-size:12px;color:var(--text-muted)">
551 {u.lastDigestSentAt
552 ? new Date(
553 u.lastDigestSentAt as unknown as string
554 ).toLocaleString()
555 : ""}
556 </span>
557 </div>
558 ))
559 )}
560 </div>
561 </Layout>
562 );
563});
564
565admin.post("/admin/digests/run", async (c) => {
566 const g = await gate(c);
567 if (g instanceof Response) return g;
568 const { user } = g;
569 const results = await sendDigestsToAll();
570 const sent = results.filter((r) => r.ok).length;
571 const skipped = results.length - sent;
572 await audit({
573 userId: user.id,
574 action: "admin.digests.run",
575 metadata: { sent, skipped, total: results.length },
576 });
577 return c.redirect(
578 `/admin/digests?result=${encodeURIComponent(
579 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
580 )}`
581 );
582});
583
584admin.post("/admin/digests/preview", async (c) => {
585 const g = await gate(c);
586 if (g instanceof Response) return g;
587 const { user } = g;
588 const body = await c.req.parseBody();
589 const username = String(body.username || "").trim();
590 if (!username) {
591 return c.redirect("/admin/digests?error=Username+required");
592 }
593 const [target] = await db
594 .select({ id: users.id, username: users.username })
595 .from(users)
596 .where(eq(users.username, username))
597 .limit(1);
598 if (!target) {
599 return c.redirect("/admin/digests?error=User+not+found");
600 }
601 const result = await sendDigestForUser(target.id);
602 await audit({
603 userId: user.id,
604 action: "admin.digests.preview",
605 targetType: "user",
606 targetId: target.id,
607 metadata: {
608 ok: result.ok,
609 skipped: "skipped" in result ? result.skipped : null,
610 },
611 });
612 if (result.ok) {
613 return c.redirect(
614 `/admin/digests?result=${encodeURIComponent(
615 `Digest sent to ${target.username}.`
616 )}`
617 );
618 }
619 return c.redirect(
620 `/admin/digests?error=${encodeURIComponent(
621 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
622 )}`
623 );
624});
625
8e9f1d9Claude626admin.get("/admin/autopilot", async (c) => {
627 const g = await gate(c);
628 if (g instanceof Response) return g;
629 const { user } = g;
630 const tick = getLastTick();
631 const total = getTickCount();
632 const disabled = process.env.AUTOPILOT_DISABLED === "1";
633 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
634 const intervalMs =
635 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
636 ? Number(intervalRaw)
637 : 5 * 60 * 1000;
638 const msg = c.req.query("result") || c.req.query("error");
639 const isErr = !!c.req.query("error");
640 return c.html(
641 <Layout title="Autopilot — admin" user={user}>
dc26881CC LABS App642 <div style="max-width: 960px; margin: 0 auto; padding: var(--space-6) var(--space-4)">
8e9f1d9Claude643 <h1 style="margin-bottom: 8px">Autopilot</h1>
644 <p style="color: var(--text-muted); margin-bottom: 24px">
645 Periodic platform-maintenance loop — mirror sync, merge-queue
87edc73Claude646 progress, weekly digests, advisory rescans, environment
647 wait-timer release, scheduled workflow triggers (cron).
8e9f1d9Claude648 </p>
649 {msg && (
650 <div
651 class={isErr ? "auth-error" : "banner"}
652 style="margin-bottom: 16px"
653 >
654 {decodeURIComponent(msg)}
655 </div>
656 )}
657 <div
dc26881CC LABS App658 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-6)"
8e9f1d9Claude659 >
660 <div class="stat-card">
661 <div class="stat-label">Status</div>
662 <div class="stat-value">
663 {disabled ? "disabled" : "running"}
664 </div>
665 </div>
666 <div class="stat-card">
667 <div class="stat-label">Interval</div>
668 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
669 </div>
670 <div class="stat-card">
671 <div class="stat-label">Ticks this process</div>
672 <div class="stat-value">{total}</div>
673 </div>
674 <div class="stat-card">
675 <div class="stat-label">Last tick</div>
676 <div class="stat-value" style="font-size: 14px">
677 {tick ? tick.finishedAt : "never"}
678 </div>
679 </div>
680 </div>
681 <form
682 method="post"
683 action="/admin/autopilot/run"
684 style="margin-bottom: 24px"
685 >
686 <button class="btn btn-primary" type="submit">
687 Run tick now
688 </button>
689 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
690 Executes all sub-tasks synchronously and records the result.
691 </span>
692 </form>
693 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
694 {tick ? (
695 <table class="table" style="width: 100%">
696 <thead>
697 <tr>
698 <th style="text-align: left">Task</th>
699 <th style="text-align: left">Status</th>
700 <th style="text-align: right">Duration</th>
701 <th style="text-align: left">Error</th>
702 </tr>
703 </thead>
704 <tbody>
705 {tick.tasks.map((t) => (
706 <tr>
707 <td>
708 <code>{t.name}</code>
709 </td>
710 <td
711 style={
712 t.ok
713 ? "color: var(--green)"
714 : "color: var(--red)"
715 }
716 >
717 {t.ok ? "ok" : "failed"}
718 </td>
719 <td style="text-align: right">{t.durationMs}ms</td>
720 <td style="color: var(--text-muted); font-size: 13px">
721 {t.error || ""}
722 </td>
723 </tr>
724 ))}
725 </tbody>
726 </table>
727 ) : (
728 <p style="color: var(--text-muted)">
729 No ticks have run yet. The first tick fires after the interval
730 elapses. Click "Run tick now" to fire one immediately.
731 </p>
732 )}
733 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
734 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
735 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
736 </p>
737 </div>
738 </Layout>
739 );
740});
741
988380aClaude742admin.post("/admin/demo/reseed", async (c) => {
743 const g = await gate(c);
744 if (g instanceof Response) return g;
745 const { user } = g;
746 try {
747 const result = await ensureDemoContent({ force: true });
748 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}` : ""}`;
749 await audit({
750 userId: user.id,
751 action: "admin.demo.reseed",
752 targetType: "user",
753 targetId: result.demoUser?.id ?? "demo",
754 metadata: {
755 createdUser: result.created.user,
756 createdRepos: result.created.repos,
757 createdIssues: result.created.issues,
758 createdPrs: result.created.prs,
759 errors: result.errors.slice(0, 5),
760 },
761 });
762 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
763 } catch (err) {
764 const message = err instanceof Error ? err.message : String(err);
765 return c.redirect(
766 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
767 );
768 }
769});
770
771// Public jump-to-demo — redirects to the first demo repo if present,
772// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
773admin.get("/demo", (c) => {
774 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
775});
776
8e9f1d9Claude777admin.post("/admin/autopilot/run", async (c) => {
778 const g = await gate(c);
779 if (g instanceof Response) return g;
780 const { user } = g;
781 let summary = "";
782 try {
783 const result = await runAutopilotTick();
784 const ok = result.tasks.filter((t) => t.ok).length;
785 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
786 await audit({
787 userId: user.id,
788 action: "admin.autopilot.run",
789 targetType: "system",
790 targetId: "autopilot",
791 metadata: { ok, total: result.tasks.length },
792 });
793 return c.redirect(
794 `/admin/autopilot?result=${encodeURIComponent(summary)}`
795 );
796 } catch (err) {
797 const message = err instanceof Error ? err.message : String(err);
798 return c.redirect(
799 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
800 );
801 }
802});
803
8f50ed0Claude804export default admin;