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.tsxBlame731 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`.
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";
22import { softAuth, requireAuth } from "../middleware/auth";
23import 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";
8f50ed0Claude40
41const admin = new Hono<AuthEnv>();
42admin.use("*", softAuth);
43
44async function gate(c: any): Promise<{ user: any } | Response> {
45 const user = c.get("user");
46 if (!user) return c.redirect("/login?next=/admin");
47 if (!(await isSiteAdmin(user.id))) {
48 return c.html(
49 <Layout title="Forbidden" user={user}>
50 <div class="empty-state">
51 <h2>403 — Not a site admin</h2>
52 <p>You don't have permission to view this page.</p>
53 </div>
54 </Layout>,
55 403
56 );
57 }
58 return { user };
59}
60
61admin.get("/admin", async (c) => {
62 const g = await gate(c);
63 if (g instanceof Response) return g;
64 const { user } = g;
65
66 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
67 const [rc] = await db
68 .select({ n: sql<number>`count(*)::int` })
69 .from(repositories);
70
71 const recent = await db
72 .select({
73 id: users.id,
74 username: users.username,
75 createdAt: users.createdAt,
76 })
77 .from(users)
78 .orderBy(desc(users.createdAt))
79 .limit(10);
80
81 const admins = await listSiteAdmins();
82
83 return c.html(
84 <Layout title="Admin — Gluecron" user={user}>
85 <h2>Site admin</h2>
86
87 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
88 <div class="panel" style="padding:12px;text-align:center">
89 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
90 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
91 Users
92 </div>
93 </div>
94 <div class="panel" style="padding:12px;text-align:center">
95 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
96 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
97 Repos
98 </div>
99 </div>
100 <div class="panel" style="padding:12px;text-align:center">
101 <div style="font-size:22px;font-weight:700">{admins.length}</div>
102 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
103 Site admins
104 </div>
105 </div>
106 </div>
107
edf7c36Claude108 <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px">
8f50ed0Claude109 <a href="/admin/users" class="btn">
110 Manage users
111 </a>
112 <a href="/admin/repos" class="btn">
113 Manage repos
114 </a>
115 <a href="/admin/flags" class="btn">
116 Site flags
117 </a>
08420cdClaude118 <a href="/admin/digests" class="btn">
119 Email digests
120 </a>
edf7c36Claude121 <a href="/admin/sso" class="btn">
122 Enterprise SSO
123 </a>
8f50ed0Claude124 </div>
125
126 <h3>Recent signups</h3>
127 <div class="panel" style="margin-bottom:20px">
128 {recent.map((u) => (
129 <div class="panel-item" style="justify-content:space-between">
130 <a href={`/${u.username}`}>{u.username}</a>
131 <span style="font-size:12px;color:var(--text-muted)">
132 {u.createdAt
133 ? new Date(u.createdAt as unknown as string).toLocaleString()
134 : ""}
135 </span>
136 </div>
137 ))}
138 </div>
139
140 <h3>Site admins</h3>
141 <div class="panel">
142 {admins.length === 0 ? (
143 <div class="panel-empty">
144 No admins (bootstrap mode — oldest user is admin).
145 </div>
146 ) : (
147 admins.map((a) => (
148 <div class="panel-item" style="justify-content:space-between">
149 <a href={`/${a.username}`}>{a.username}</a>
150 <span style="font-size:12px;color:var(--text-muted)">
151 Granted{" "}
152 {a.grantedAt
153 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
154 : ""}
155 </span>
156 </div>
157 ))
158 )}
159 </div>
160 </Layout>
161 );
162});
163
164// ----- Users -----
165
166admin.get("/admin/users", async (c) => {
167 const g = await gate(c);
168 if (g instanceof Response) return g;
169 const { user } = g;
170 const q = c.req.query("q") || "";
171 const rows = await db
172 .select({
173 id: users.id,
174 username: users.username,
175 email: users.email,
176 createdAt: users.createdAt,
177 })
178 .from(users)
179 .where(
180 q
181 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
182 : sql`1=1`
183 )
184 .orderBy(desc(users.createdAt))
185 .limit(200);
186
187 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
188
189 return c.html(
190 <Layout title="Admin — Users" user={user}>
191 <h2>Users</h2>
b9968e3Claude192 <form method="get" action="/admin/users" style="margin-bottom:16px">
8f50ed0Claude193 <input
194 type="text"
195 name="q"
196 value={q}
197 placeholder="Search username or email"
198 style="width:320px"
199 />{" "}
200 <button type="submit" class="btn">
201 Search
202 </button>
203 <a href="/admin" class="btn" style="margin-left:6px">
204 Back
205 </a>
206 </form>
207 <div class="panel">
208 {rows.length === 0 ? (
209 <div class="panel-empty">No users found.</div>
210 ) : (
211 rows.map((u) => {
212 const isAdmin = adminIds.has(u.id);
213 return (
214 <div class="panel-item" style="justify-content:space-between">
215 <div>
216 <a href={`/${u.username}`} style="font-weight:600">
217 {u.username}
218 </a>{" "}
219 <span style="color:var(--text-muted)">{u.email}</span>
220 {isAdmin && (
221 <span
222 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
223 >
224 ADMIN
225 </span>
226 )}
227 </div>
228 <form
b9968e3Claude229 method="post"
8f50ed0Claude230 action={`/admin/users/${u.id}/admin`}
231 onsubmit={
232 isAdmin
233 ? "return confirm('Revoke site admin?')"
234 : "return confirm('Grant site admin?')"
235 }
236 >
237 <button type="submit" class="btn btn-sm">
238 {isAdmin ? "Revoke admin" : "Grant admin"}
239 </button>
240 </form>
241 </div>
242 );
243 })
244 )}
245 </div>
246 </Layout>
247 );
248});
249
250admin.post("/admin/users/:id/admin", async (c) => {
251 const g = await gate(c);
252 if (g instanceof Response) return g;
253 const { user } = g;
254 const id = c.req.param("id");
255 const admins = await listSiteAdmins();
256 const isAlready = admins.some((a) => a.userId === id);
257 if (isAlready) {
258 await revokeSiteAdmin(id);
259 await audit({
260 userId: user.id,
261 action: "site_admin.revoke",
262 targetType: "user",
263 targetId: id,
264 });
265 } else {
266 await grantSiteAdmin(id, user.id);
267 await audit({
268 userId: user.id,
269 action: "site_admin.grant",
270 targetType: "user",
271 targetId: id,
272 });
273 }
274 return c.redirect("/admin/users");
275});
276
277// ----- Repos -----
278
279admin.get("/admin/repos", async (c) => {
280 const g = await gate(c);
281 if (g instanceof Response) return g;
282 const { user } = g;
283 const rows = await db
284 .select({
285 id: repositories.id,
286 name: repositories.name,
287 ownerUsername: users.username,
0316dbbClaude288 isPrivate: repositories.isPrivate,
8f50ed0Claude289 createdAt: repositories.createdAt,
290 starCount: repositories.starCount,
291 })
292 .from(repositories)
293 .innerJoin(users, eq(repositories.ownerId, users.id))
294 .orderBy(desc(repositories.createdAt))
295 .limit(200);
296
297 return c.html(
298 <Layout title="Admin — Repos" user={user}>
299 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
300 <h2>Repositories</h2>
301 <a href="/admin" class="btn btn-sm">
302 Back
303 </a>
304 </div>
305 <div class="panel">
306 {rows.length === 0 ? (
307 <div class="panel-empty">No repositories.</div>
308 ) : (
309 rows.map((r) => (
310 <div class="panel-item" style="justify-content:space-between">
311 <div>
312 <a
313 href={`/${r.ownerUsername}/${r.name}`}
314 style="font-weight:600"
315 >
316 {r.ownerUsername}/{r.name}
317 </a>
318 <span
319 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
320 >
0316dbbClaude321 {r.isPrivate ? "private" : "public"}
8f50ed0Claude322 </span>
323 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
324 {r.starCount} stars ·{" "}
325 {r.createdAt
326 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
327 : ""}
328 </div>
329 </div>
330 <form
b9968e3Claude331 method="post"
8f50ed0Claude332 action={`/admin/repos/${r.id}/delete`}
333 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
334 >
335 <button type="submit" class="btn btn-sm btn-danger">
336 Delete
337 </button>
338 </form>
339 </div>
340 ))
341 )}
342 </div>
343 </Layout>
344 );
345});
346
347admin.post("/admin/repos/:id/delete", async (c) => {
348 const g = await gate(c);
349 if (g instanceof Response) return g;
350 const { user } = g;
351 const id = c.req.param("id");
352 try {
353 await db.delete(repositories).where(eq(repositories.id, id));
354 } catch (err) {
355 console.error("[admin] repo delete:", err);
356 }
357 await audit({
358 userId: user.id,
359 action: "admin.repo.delete",
360 targetType: "repository",
361 targetId: id,
362 });
363 return c.redirect("/admin/repos");
364});
365
366// ----- Flags -----
367
368admin.get("/admin/flags", async (c) => {
369 const g = await gate(c);
370 if (g instanceof Response) return g;
371 const { user } = g;
372
373 const existing = await listFlags();
374 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
375 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
376
377 return c.html(
378 <Layout title="Admin — Flags" user={user}>
379 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
380 <h2>Site flags</h2>
381 <a href="/admin" class="btn btn-sm">
382 Back
383 </a>
384 </div>
385 <form
b9968e3Claude386 method="post"
8f50ed0Claude387 action="/admin/flags"
388 class="panel"
389 style="padding:16px"
390 >
391 {keys.map((k) => {
392 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
393 return (
394 <div class="form-group">
395 <label>{k}</label>
396 <input
397 type="text"
398 name={k}
399 value={current}
400 style="font-family:var(--font-mono)"
401 />
402 <div
403 style="font-size:11px;color:var(--text-muted);margin-top:2px"
404 >
405 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
406 </div>
407 </div>
408 );
409 })}
410 <button type="submit" class="btn btn-primary">
411 Save
412 </button>
413 </form>
414 </Layout>
415 );
416});
417
418admin.post("/admin/flags", async (c) => {
419 const g = await gate(c);
420 if (g instanceof Response) return g;
421 const { user } = g;
422 const body = await c.req.parseBody();
423 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
424 for (const k of keys) {
425 const v = String(body[k] ?? "");
426 await setFlag(k, v, user.id);
427 }
428 await audit({ userId: user.id, action: "admin.flags.save" });
429 return c.redirect("/admin/flags");
430});
431
08420cdClaude432// ----- Email digests (Block I7) -----
433
434admin.get("/admin/digests", async (c) => {
435 const g = await gate(c);
436 if (g instanceof Response) return g;
437 const { user } = g;
438
439 const [optedRow] = await db
440 .select({ n: sql<number>`count(*)::int` })
441 .from(users)
442 .where(eq(users.notifyEmailDigestWeekly, true));
443 const opted = Number(optedRow?.n || 0);
444
445 const recentlySent = await db
446 .select({
447 id: users.id,
448 username: users.username,
449 lastDigestSentAt: users.lastDigestSentAt,
450 })
451 .from(users)
452 .where(sql`${users.lastDigestSentAt} is not null`)
453 .orderBy(desc(users.lastDigestSentAt))
454 .limit(20);
455
456 const result = c.req.query("result");
457 const error = c.req.query("error");
458
459 return c.html(
460 <Layout title="Admin — Digests" user={user}>
461 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
462 <h2>Email digests</h2>
463 <a href="/admin" class="btn btn-sm">
464 Back
465 </a>
466 </div>
467
468 {result && (
469 <div class="auth-success">{decodeURIComponent(result)}</div>
470 )}
471 {error && (
472 <div class="auth-error">{decodeURIComponent(error)}</div>
473 )}
474
475 <div class="panel" style="padding:16px;margin-bottom:20px">
476 <div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">
477 {opted} user{opted === 1 ? "" : "s"} opted into the weekly digest.
478 </div>
b9968e3Claude479 <form method="post" action="/admin/digests/run" style="margin-bottom:8px">
08420cdClaude480 <button
481 type="submit"
482 class="btn btn-primary"
483 onclick="return confirm('Send weekly digest to all opted-in users now?')"
484 >
485 Send digests now
486 </button>
487 </form>
b9968e3Claude488 <form method="post" action="/admin/digests/preview" style="display:flex;gap:6px;align-items:center">
08420cdClaude489 <input
490 type="text"
491 name="username"
492 placeholder="username"
493 required
494 style="width:240px"
495 />
496 <button type="submit" class="btn btn-sm">
497 Send to one user
498 </button>
499 </form>
500 </div>
501
502 <h3>Recently sent</h3>
503 <div class="panel">
504 {recentlySent.length === 0 ? (
505 <div class="panel-empty">No digests have been sent yet.</div>
506 ) : (
507 recentlySent.map((u) => (
508 <div class="panel-item" style="justify-content:space-between">
509 <a href={`/${u.username}`}>{u.username}</a>
510 <span style="font-size:12px;color:var(--text-muted)">
511 {u.lastDigestSentAt
512 ? new Date(
513 u.lastDigestSentAt as unknown as string
514 ).toLocaleString()
515 : ""}
516 </span>
517 </div>
518 ))
519 )}
520 </div>
521 </Layout>
522 );
523});
524
525admin.post("/admin/digests/run", async (c) => {
526 const g = await gate(c);
527 if (g instanceof Response) return g;
528 const { user } = g;
529 const results = await sendDigestsToAll();
530 const sent = results.filter((r) => r.ok).length;
531 const skipped = results.length - sent;
532 await audit({
533 userId: user.id,
534 action: "admin.digests.run",
535 metadata: { sent, skipped, total: results.length },
536 });
537 return c.redirect(
538 `/admin/digests?result=${encodeURIComponent(
539 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
540 )}`
541 );
542});
543
544admin.post("/admin/digests/preview", async (c) => {
545 const g = await gate(c);
546 if (g instanceof Response) return g;
547 const { user } = g;
548 const body = await c.req.parseBody();
549 const username = String(body.username || "").trim();
550 if (!username) {
551 return c.redirect("/admin/digests?error=Username+required");
552 }
553 const [target] = await db
554 .select({ id: users.id, username: users.username })
555 .from(users)
556 .where(eq(users.username, username))
557 .limit(1);
558 if (!target) {
559 return c.redirect("/admin/digests?error=User+not+found");
560 }
561 const result = await sendDigestForUser(target.id);
562 await audit({
563 userId: user.id,
564 action: "admin.digests.preview",
565 targetType: "user",
566 targetId: target.id,
567 metadata: {
568 ok: result.ok,
569 skipped: "skipped" in result ? result.skipped : null,
570 },
571 });
572 if (result.ok) {
573 return c.redirect(
574 `/admin/digests?result=${encodeURIComponent(
575 `Digest sent to ${target.username}.`
576 )}`
577 );
578 }
579 return c.redirect(
580 `/admin/digests?error=${encodeURIComponent(
581 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
582 )}`
583 );
584});
585
8e9f1d9Claude586admin.get("/admin/autopilot", async (c) => {
587 const g = await gate(c);
588 if (g instanceof Response) return g;
589 const { user } = g;
590 const tick = getLastTick();
591 const total = getTickCount();
592 const disabled = process.env.AUTOPILOT_DISABLED === "1";
593 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
594 const intervalMs =
595 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
596 ? Number(intervalRaw)
597 : 5 * 60 * 1000;
598 const msg = c.req.query("result") || c.req.query("error");
599 const isErr = !!c.req.query("error");
600 return c.html(
601 <Layout title="Autopilot — admin" user={user}>
602 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
603 <h1 style="margin-bottom: 8px">Autopilot</h1>
604 <p style="color: var(--text-muted); margin-bottom: 24px">
605 Periodic platform-maintenance loop — mirror sync, merge-queue
606 progress, weekly digests, advisory rescans.
607 </p>
608 {msg && (
609 <div
610 class={isErr ? "auth-error" : "banner"}
611 style="margin-bottom: 16px"
612 >
613 {decodeURIComponent(msg)}
614 </div>
615 )}
616 <div
617 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px"
618 >
619 <div class="stat-card">
620 <div class="stat-label">Status</div>
621 <div class="stat-value">
622 {disabled ? "disabled" : "running"}
623 </div>
624 </div>
625 <div class="stat-card">
626 <div class="stat-label">Interval</div>
627 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
628 </div>
629 <div class="stat-card">
630 <div class="stat-label">Ticks this process</div>
631 <div class="stat-value">{total}</div>
632 </div>
633 <div class="stat-card">
634 <div class="stat-label">Last tick</div>
635 <div class="stat-value" style="font-size: 14px">
636 {tick ? tick.finishedAt : "never"}
637 </div>
638 </div>
639 </div>
640 <form
641 method="post"
642 action="/admin/autopilot/run"
643 style="margin-bottom: 24px"
644 >
645 <button class="btn btn-primary" type="submit">
646 Run tick now
647 </button>
648 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
649 Executes all sub-tasks synchronously and records the result.
650 </span>
651 </form>
652 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
653 {tick ? (
654 <table class="table" style="width: 100%">
655 <thead>
656 <tr>
657 <th style="text-align: left">Task</th>
658 <th style="text-align: left">Status</th>
659 <th style="text-align: right">Duration</th>
660 <th style="text-align: left">Error</th>
661 </tr>
662 </thead>
663 <tbody>
664 {tick.tasks.map((t) => (
665 <tr>
666 <td>
667 <code>{t.name}</code>
668 </td>
669 <td
670 style={
671 t.ok
672 ? "color: var(--green)"
673 : "color: var(--red)"
674 }
675 >
676 {t.ok ? "ok" : "failed"}
677 </td>
678 <td style="text-align: right">{t.durationMs}ms</td>
679 <td style="color: var(--text-muted); font-size: 13px">
680 {t.error || ""}
681 </td>
682 </tr>
683 ))}
684 </tbody>
685 </table>
686 ) : (
687 <p style="color: var(--text-muted)">
688 No ticks have run yet. The first tick fires after the interval
689 elapses. Click "Run tick now" to fire one immediately.
690 </p>
691 )}
692 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
693 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
694 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
695 </p>
696 </div>
697 </Layout>
698 );
699});
700
701admin.post("/admin/autopilot/run", async (c) => {
702 const g = await gate(c);
703 if (g instanceof Response) return g;
704 const { user } = g;
705 let summary = "";
706 try {
707 const result = await runAutopilotTick();
708 const ok = result.tasks.filter((t) => t.ok).length;
709 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
710 await audit({
711 userId: user.id,
712 action: "admin.autopilot.run",
713 targetType: "system",
714 targetId: "autopilot",
715 metadata: { ok, total: result.tasks.length },
716 });
717 return c.redirect(
718 `/admin/autopilot?result=${encodeURIComponent(summary)}`
719 );
720 } catch (err) {
721 const message = err instanceof Error ? err.message : String(err);
722 return c.redirect(
723 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
724 );
725 }
726});
727
08420cdClaude728// Keep requireAuth import used even if some routes don't reference it here.
729void requireAuth;
730
8f50ed0Claude731export default admin;