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.tsxBlame420 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";
34
35const admin = new Hono<AuthEnv>();
36admin.use("*", softAuth);
37
38async function gate(c: any): Promise<{ user: any } | Response> {
39 const user = c.get("user");
40 if (!user) return c.redirect("/login?next=/admin");
41 if (!(await isSiteAdmin(user.id))) {
42 return c.html(
43 <Layout title="Forbidden" user={user}>
44 <div class="empty-state">
45 <h2>403 — Not a site admin</h2>
46 <p>You don't have permission to view this page.</p>
47 </div>
48 </Layout>,
49 403
50 );
51 }
52 return { user };
53}
54
55admin.get("/admin", async (c) => {
56 const g = await gate(c);
57 if (g instanceof Response) return g;
58 const { user } = g;
59
60 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
61 const [rc] = await db
62 .select({ n: sql<number>`count(*)::int` })
63 .from(repositories);
64
65 const recent = await db
66 .select({
67 id: users.id,
68 username: users.username,
69 createdAt: users.createdAt,
70 })
71 .from(users)
72 .orderBy(desc(users.createdAt))
73 .limit(10);
74
75 const admins = await listSiteAdmins();
76
77 return c.html(
78 <Layout title="Admin — Gluecron" user={user}>
79 <h2>Site admin</h2>
80
81 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px">
82 <div class="panel" style="padding:12px;text-align:center">
83 <div style="font-size:22px;font-weight:700">{Number(uc?.n || 0)}</div>
84 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
85 Users
86 </div>
87 </div>
88 <div class="panel" style="padding:12px;text-align:center">
89 <div style="font-size:22px;font-weight:700">{Number(rc?.n || 0)}</div>
90 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
91 Repos
92 </div>
93 </div>
94 <div class="panel" style="padding:12px;text-align:center">
95 <div style="font-size:22px;font-weight:700">{admins.length}</div>
96 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase">
97 Site admins
98 </div>
99 </div>
100 </div>
101
102 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:20px">
103 <a href="/admin/users" class="btn">
104 Manage users
105 </a>
106 <a href="/admin/repos" class="btn">
107 Manage repos
108 </a>
109 <a href="/admin/flags" class="btn">
110 Site flags
111 </a>
112 </div>
113
114 <h3>Recent signups</h3>
115 <div class="panel" style="margin-bottom:20px">
116 {recent.map((u) => (
117 <div class="panel-item" style="justify-content:space-between">
118 <a href={`/${u.username}`}>{u.username}</a>
119 <span style="font-size:12px;color:var(--text-muted)">
120 {u.createdAt
121 ? new Date(u.createdAt as unknown as string).toLocaleString()
122 : ""}
123 </span>
124 </div>
125 ))}
126 </div>
127
128 <h3>Site admins</h3>
129 <div class="panel">
130 {admins.length === 0 ? (
131 <div class="panel-empty">
132 No admins (bootstrap mode — oldest user is admin).
133 </div>
134 ) : (
135 admins.map((a) => (
136 <div class="panel-item" style="justify-content:space-between">
137 <a href={`/${a.username}`}>{a.username}</a>
138 <span style="font-size:12px;color:var(--text-muted)">
139 Granted{" "}
140 {a.grantedAt
141 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
142 : ""}
143 </span>
144 </div>
145 ))
146 )}
147 </div>
148 </Layout>
149 );
150});
151
152// ----- Users -----
153
154admin.get("/admin/users", async (c) => {
155 const g = await gate(c);
156 if (g instanceof Response) return g;
157 const { user } = g;
158 const q = c.req.query("q") || "";
159 const rows = await db
160 .select({
161 id: users.id,
162 username: users.username,
163 email: users.email,
164 createdAt: users.createdAt,
165 })
166 .from(users)
167 .where(
168 q
169 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
170 : sql`1=1`
171 )
172 .orderBy(desc(users.createdAt))
173 .limit(200);
174
175 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
176
177 return c.html(
178 <Layout title="Admin — Users" user={user}>
179 <h2>Users</h2>
180 <form method="GET" action="/admin/users" style="margin-bottom:16px">
181 <input
182 type="text"
183 name="q"
184 value={q}
185 placeholder="Search username or email"
186 style="width:320px"
187 />{" "}
188 <button type="submit" class="btn">
189 Search
190 </button>
191 <a href="/admin" class="btn" style="margin-left:6px">
192 Back
193 </a>
194 </form>
195 <div class="panel">
196 {rows.length === 0 ? (
197 <div class="panel-empty">No users found.</div>
198 ) : (
199 rows.map((u) => {
200 const isAdmin = adminIds.has(u.id);
201 return (
202 <div class="panel-item" style="justify-content:space-between">
203 <div>
204 <a href={`/${u.username}`} style="font-weight:600">
205 {u.username}
206 </a>{" "}
207 <span style="color:var(--text-muted)">{u.email}</span>
208 {isAdmin && (
209 <span
210 style="margin-left:6px;font-size:11px;background:#8957e5;color:white;padding:2px 6px;border-radius:3px"
211 >
212 ADMIN
213 </span>
214 )}
215 </div>
216 <form
217 method="POST"
218 action={`/admin/users/${u.id}/admin`}
219 onsubmit={
220 isAdmin
221 ? "return confirm('Revoke site admin?')"
222 : "return confirm('Grant site admin?')"
223 }
224 >
225 <button type="submit" class="btn btn-sm">
226 {isAdmin ? "Revoke admin" : "Grant admin"}
227 </button>
228 </form>
229 </div>
230 );
231 })
232 )}
233 </div>
234 </Layout>
235 );
236});
237
238admin.post("/admin/users/:id/admin", async (c) => {
239 const g = await gate(c);
240 if (g instanceof Response) return g;
241 const { user } = g;
242 const id = c.req.param("id");
243 const admins = await listSiteAdmins();
244 const isAlready = admins.some((a) => a.userId === id);
245 if (isAlready) {
246 await revokeSiteAdmin(id);
247 await audit({
248 userId: user.id,
249 action: "site_admin.revoke",
250 targetType: "user",
251 targetId: id,
252 });
253 } else {
254 await grantSiteAdmin(id, user.id);
255 await audit({
256 userId: user.id,
257 action: "site_admin.grant",
258 targetType: "user",
259 targetId: id,
260 });
261 }
262 return c.redirect("/admin/users");
263});
264
265// ----- Repos -----
266
267admin.get("/admin/repos", async (c) => {
268 const g = await gate(c);
269 if (g instanceof Response) return g;
270 const { user } = g;
271 const rows = await db
272 .select({
273 id: repositories.id,
274 name: repositories.name,
275 ownerUsername: users.username,
276 visibility: repositories.visibility,
277 createdAt: repositories.createdAt,
278 starCount: repositories.starCount,
279 })
280 .from(repositories)
281 .innerJoin(users, eq(repositories.ownerId, users.id))
282 .orderBy(desc(repositories.createdAt))
283 .limit(200);
284
285 return c.html(
286 <Layout title="Admin — Repos" user={user}>
287 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
288 <h2>Repositories</h2>
289 <a href="/admin" class="btn btn-sm">
290 Back
291 </a>
292 </div>
293 <div class="panel">
294 {rows.length === 0 ? (
295 <div class="panel-empty">No repositories.</div>
296 ) : (
297 rows.map((r) => (
298 <div class="panel-item" style="justify-content:space-between">
299 <div>
300 <a
301 href={`/${r.ownerUsername}/${r.name}`}
302 style="font-weight:600"
303 >
304 {r.ownerUsername}/{r.name}
305 </a>
306 <span
307 style="margin-left:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase"
308 >
309 {r.visibility}
310 </span>
311 <div style="font-size:12px;color:var(--text-muted);margin-top:2px">
312 {r.starCount} stars ·{" "}
313 {r.createdAt
314 ? new Date(r.createdAt as unknown as string).toLocaleDateString()
315 : ""}
316 </div>
317 </div>
318 <form
319 method="POST"
320 action={`/admin/repos/${r.id}/delete`}
321 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
322 >
323 <button type="submit" class="btn btn-sm btn-danger">
324 Delete
325 </button>
326 </form>
327 </div>
328 ))
329 )}
330 </div>
331 </Layout>
332 );
333});
334
335admin.post("/admin/repos/:id/delete", async (c) => {
336 const g = await gate(c);
337 if (g instanceof Response) return g;
338 const { user } = g;
339 const id = c.req.param("id");
340 try {
341 await db.delete(repositories).where(eq(repositories.id, id));
342 } catch (err) {
343 console.error("[admin] repo delete:", err);
344 }
345 await audit({
346 userId: user.id,
347 action: "admin.repo.delete",
348 targetType: "repository",
349 targetId: id,
350 });
351 return c.redirect("/admin/repos");
352});
353
354// ----- Flags -----
355
356admin.get("/admin/flags", async (c) => {
357 const g = await gate(c);
358 if (g instanceof Response) return g;
359 const { user } = g;
360
361 const existing = await listFlags();
362 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
363 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
364
365 return c.html(
366 <Layout title="Admin — Flags" user={user}>
367 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
368 <h2>Site flags</h2>
369 <a href="/admin" class="btn btn-sm">
370 Back
371 </a>
372 </div>
373 <form
374 method="POST"
375 action="/admin/flags"
376 class="panel"
377 style="padding:16px"
378 >
379 {keys.map((k) => {
380 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
381 return (
382 <div class="form-group">
383 <label>{k}</label>
384 <input
385 type="text"
386 name={k}
387 value={current}
388 style="font-family:var(--font-mono)"
389 />
390 <div
391 style="font-size:11px;color:var(--text-muted);margin-top:2px"
392 >
393 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
394 </div>
395 </div>
396 );
397 })}
398 <button type="submit" class="btn btn-primary">
399 Save
400 </button>
401 </form>
402 </Layout>
403 );
404});
405
406admin.post("/admin/flags", async (c) => {
407 const g = await gate(c);
408 if (g instanceof Response) return g;
409 const { user } = g;
410 const body = await c.req.parseBody();
411 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
412 for (const k of keys) {
413 const v = String(body[k] ?? "");
414 await setFlag(k, v, user.id);
415 }
416 await audit({ userId: user.id, action: "admin.flags.save" });
417 return c.redirect("/admin/flags");
418});
419
420export default admin;