WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { Hono } from "hono";
2import { BaseLayout } from "../layouts/base.js";
3import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js";
4import {
5 getSessionWithPermissions,
6 hasAnyAdminPermission,
7 canManageMembers,
8 canManageCategories,
9 canViewModLog,
10 canManageRoles,
11} from "../lib/session.js";
12import { isProgrammingError } from "../lib/errors.js";
13import { logger } from "../lib/logger.js";
14
15// ─── Types ─────────────────────────────────────────────────────────────────
16
17interface MemberEntry {
18 did: string;
19 handle: string;
20 role: string;
21 roleUri: string | null;
22 joinedAt: string | null;
23}
24
25interface RoleEntry {
26 id: string;
27 name: string;
28 uri: string;
29 priority: number;
30}
31
32interface CategoryEntry {
33 id: string;
34 did: string;
35 uri: string;
36 name: string;
37 description: string | null;
38 sortOrder: number | null;
39}
40
41interface BoardEntry {
42 id: string;
43 name: string;
44 description: string | null;
45 sortOrder: number | null;
46 categoryUri: string;
47 uri: string;
48}
49
50interface ModLogEntry {
51 id: string;
52 action: string;
53 moderatorDid: string;
54 moderatorHandle: string;
55 subjectDid: string | null;
56 subjectHandle: string | null;
57 subjectPostUri: string | null;
58 reason: string | null;
59 createdAt: string;
60}
61
62const ACTION_LABELS: Record<string, string> = {
63 "space.atbb.modAction.ban": "Ban",
64 "space.atbb.modAction.unban": "Unban",
65 "space.atbb.modAction.lock": "Lock",
66 "space.atbb.modAction.unlock": "Unlock",
67 "space.atbb.modAction.delete": "Hide",
68 "space.atbb.modAction.undelete": "Unhide",
69};
70
71// ─── Helpers ───────────────────────────────────────────────────────────────
72
73function formatJoinedDate(isoString: string | null): string {
74 if (!isoString) return "—";
75 const d = new Date(isoString);
76 if (isNaN(d.getTime())) return "—";
77 return d.toLocaleDateString("en-US", {
78 month: "short",
79 day: "numeric",
80 year: "numeric",
81 });
82}
83
84function formatModLogDate(isoString: string): string {
85 const d = new Date(isoString);
86 if (isNaN(d.getTime())) return "—";
87 return d.toLocaleString("en-US", {
88 year: "numeric",
89 month: "2-digit",
90 day: "2-digit",
91 hour: "2-digit",
92 minute: "2-digit",
93 hour12: false,
94 });
95}
96
97// ─── Components ────────────────────────────────────────────────────────────
98
99function MemberRow({
100 member,
101 roles,
102 showRoleControls,
103 errorMsg = null,
104}: {
105 member: MemberEntry;
106 roles: RoleEntry[];
107 showRoleControls: boolean;
108 errorMsg?: string | null;
109}) {
110 return (
111 <tr>
112 <td>{member.handle}</td>
113 <td>
114 <span class="role-badge">{member.role}</span>
115 </td>
116 <td>{formatJoinedDate(member.joinedAt)}</td>
117 {showRoleControls ? (
118 <td>
119 <form
120 hx-post={`/admin/members/${member.did}/role`}
121 hx-target="closest tr"
122 hx-swap="outerHTML"
123 >
124 <input type="hidden" name="handle" value={member.handle} />
125 <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} />
126 <input type="hidden" name="currentRole" value={member.role} />
127 <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} />
128 <input type="hidden" name="rolesJson" value={JSON.stringify(roles)} />
129 <div class="member-row__assign-form">
130 <label class="sr-only" for={`role-${member.did}`}>
131 Assign role to {member.handle}
132 </label>
133 <select id={`role-${member.did}`} name="roleUri">
134 {roles.map((role) => (
135 <option value={role.uri} selected={member.roleUri === role.uri}>
136 {role.name}
137 </option>
138 ))}
139 </select>
140 <button type="submit" class="btn btn-primary">
141 Assign
142 </button>
143 </div>
144 {errorMsg && <span class="member-row__error">{errorMsg}</span>}
145 </form>
146 </td>
147 ) : (
148 errorMsg && (
149 <td>
150 <span class="member-row__error">{errorMsg}</span>
151 </td>
152 )
153 )}
154 </tr>
155 );
156}
157
158function ModLogRow({ entry }: { entry: ModLogEntry }) {
159 const label = ACTION_LABELS[entry.action] ?? entry.action;
160 const subject = entry.subjectPostUri
161 ? entry.subjectPostUri
162 : (entry.subjectHandle ?? entry.subjectDid ?? "—");
163
164 return (
165 <tr>
166 <td class="modlog-table__time">{formatModLogDate(entry.createdAt)}</td>
167 <td class="modlog-table__moderator">{entry.moderatorHandle}</td>
168 <td class="modlog-table__action">
169 <span class={`modlog-action-badge modlog-action-badge--${label.toLowerCase()}`}>
170 {label}
171 </span>
172 </td>
173 <td class="modlog-table__subject">{subject}</td>
174 <td class="modlog-table__reason">{entry.reason ?? "—"}</td>
175 </tr>
176 );
177}
178
179// ─── Private Helpers ────────────────────────────────────────────────────────
180
181/**
182 * Extracts the error message from an AppView error response.
183 * Falls back to the provided default if JSON parsing fails.
184 */
185async function extractAppviewError(res: Response, fallback: string): Promise<string> {
186 try {
187 const data = (await res.json()) as { error?: string };
188 return data.error ?? fallback;
189 } catch {
190 return fallback;
191 }
192}
193
194/**
195 * Parses a sort order value from a form field string.
196 * Returns 0 for empty/missing values, null for invalid values (negative or non-integer).
197 */
198function parseSortOrder(value: unknown): number | null {
199 if (typeof value !== "string" || value.trim() === "") return 0;
200 const n = Number(value);
201 return Number.isInteger(n) && n >= 0 ? n : null;
202}
203
204// ─── Routes ────────────────────────────────────────────────────────────────
205
206export function createAdminRoutes(appviewUrl: string) {
207 const app = new Hono();
208
209 // ─── Structure Page Components ──────────────────────────────────────────
210
211 function StructureBoardRow({ board }: { board: BoardEntry }) {
212 const dialogId = `confirm-delete-board-${board.id}`;
213 return (
214 <div class="structure-board">
215 <div class="structure-board__header">
216 <span class="structure-board__name">{board.name}</span>
217 <span class="structure-board__meta">sortOrder: {board.sortOrder ?? 0}</span>
218 <div class="structure-board__actions">
219 <button
220 type="button"
221 class="btn btn-secondary btn-sm"
222 onclick={`document.getElementById('edit-board-${board.id}').open=!document.getElementById('edit-board-${board.id}').open`}
223 >
224 Edit
225 </button>
226 <button
227 type="button"
228 class="btn btn-danger btn-sm"
229 onclick={`document.getElementById('${dialogId}').showModal()`}
230 >
231 Delete
232 </button>
233 </div>
234 </div>
235 <details id={`edit-board-${board.id}`} class="structure-edit-form">
236 <summary class="sr-only">Edit {board.name}</summary>
237 <form method="post" action={`/admin/structure/boards/${board.id}/edit`} class="structure-edit-form__body">
238 <div class="form-group">
239 <label for={`edit-board-name-${board.id}`}>Name</label>
240 <input id={`edit-board-name-${board.id}`} type="text" name="name" value={board.name} required />
241 </div>
242 <div class="form-group">
243 <label for={`edit-board-desc-${board.id}`}>Description</label>
244 <textarea id={`edit-board-desc-${board.id}`} name="description">{board.description ?? ""}</textarea>
245 </div>
246 <div class="form-group">
247 <label for={`edit-board-sort-${board.id}`}>Sort Order</label>
248 <input id={`edit-board-sort-${board.id}`} type="number" name="sortOrder" min="0" value={String(board.sortOrder ?? 0)} />
249 </div>
250 <button type="submit" class="btn btn-primary">Save Changes</button>
251 </form>
252 </details>
253 <dialog id={dialogId} class="structure-confirm-dialog">
254 <p>Delete board "{board.name}"? This cannot be undone.</p>
255 <form method="post" action={`/admin/structure/boards/${board.id}/delete`} class="dialog-actions">
256 <button type="submit" class="btn btn-danger">Delete</button>
257 <button
258 type="button"
259 class="btn btn-secondary"
260 onclick={`document.getElementById('${dialogId}').close()`}
261 >
262 Cancel
263 </button>
264 </form>
265 </dialog>
266 </div>
267 );
268 }
269
270 function StructureCategorySection({
271 category,
272 boards,
273 }: {
274 category: CategoryEntry;
275 boards: BoardEntry[];
276 }) {
277 const dialogId = `confirm-delete-category-${category.id}`;
278 return (
279 <div class="structure-category">
280 <div class="structure-category__header">
281 <span class="structure-category__name">{category.name}</span>
282 <span class="structure-category__meta">sortOrder: {category.sortOrder ?? 0}</span>
283 <div class="structure-category__actions">
284 <button
285 type="button"
286 class="btn btn-secondary btn-sm"
287 onclick={`document.getElementById('edit-category-${category.id}').open=!document.getElementById('edit-category-${category.id}').open`}
288 >
289 Edit
290 </button>
291 <button
292 type="button"
293 class="btn btn-danger btn-sm"
294 onclick={`document.getElementById('${dialogId}').showModal()`}
295 >
296 Delete
297 </button>
298 </div>
299 </div>
300
301 <details id={`edit-category-${category.id}`} class="structure-edit-form">
302 <summary class="sr-only">Edit {category.name}</summary>
303 <form method="post" action={`/admin/structure/categories/${category.id}/edit`} class="structure-edit-form__body">
304 <div class="form-group">
305 <label for={`edit-cat-name-${category.id}`}>Name</label>
306 <input id={`edit-cat-name-${category.id}`} type="text" name="name" value={category.name} required />
307 </div>
308 <div class="form-group">
309 <label for={`edit-cat-desc-${category.id}`}>Description</label>
310 <textarea id={`edit-cat-desc-${category.id}`} name="description">{category.description ?? ""}</textarea>
311 </div>
312 <div class="form-group">
313 <label for={`edit-cat-sort-${category.id}`}>Sort Order</label>
314 <input id={`edit-cat-sort-${category.id}`} type="number" name="sortOrder" min="0" value={String(category.sortOrder ?? 0)} />
315 </div>
316 <button type="submit" class="btn btn-primary">Save Changes</button>
317 </form>
318 </details>
319
320 <dialog id={dialogId} class="structure-confirm-dialog">
321 <p>Delete category "{category.name}"? All boards must be removed first.</p>
322 <form method="post" action={`/admin/structure/categories/${category.id}/delete`} class="dialog-actions">
323 <button type="submit" class="btn btn-danger">Delete</button>
324 <button
325 type="button"
326 class="btn btn-secondary"
327 onclick={`document.getElementById('${dialogId}').close()`}
328 >
329 Cancel
330 </button>
331 </form>
332 </dialog>
333
334 <div class="structure-boards">
335 {boards.map((board) => (
336 <StructureBoardRow board={board} />
337 ))}
338 <details class="structure-add-board">
339 <summary class="structure-add-board__trigger">+ Add Board</summary>
340 <form method="post" action="/admin/structure/boards" class="structure-edit-form__body">
341 <input type="hidden" name="categoryUri" value={category.uri} />
342 <div class="form-group">
343 <label for={`new-board-name-${category.id}`}>Name</label>
344 <input id={`new-board-name-${category.id}`} type="text" name="name" required />
345 </div>
346 <div class="form-group">
347 <label for={`new-board-desc-${category.id}`}>Description</label>
348 <textarea id={`new-board-desc-${category.id}`} name="description"></textarea>
349 </div>
350 <div class="form-group">
351 <label for={`new-board-sort-${category.id}`}>Sort Order</label>
352 <input id={`new-board-sort-${category.id}`} type="number" name="sortOrder" min="0" value="0" />
353 </div>
354 <button type="submit" class="btn btn-primary">Add Board</button>
355 </form>
356 </details>
357 </div>
358 </div>
359 );
360 }
361
362 // ── GET /admin ────────────────────────────────────────────────────────────
363
364 app.get("/admin", async (c) => {
365 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
366
367 if (!auth.authenticated) {
368 return c.redirect("/login");
369 }
370
371 if (!hasAnyAdminPermission(auth)) {
372 return c.html(
373 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
374 <PageHeader title="Access Denied" />
375 <p>You don't have permission to access the admin panel.</p>
376 </BaseLayout>,
377 403
378 );
379 }
380
381 const showMembers = canManageMembers(auth);
382 const showStructure = canManageCategories(auth);
383 const showModLog = canViewModLog(auth);
384
385 return c.html(
386 <BaseLayout title="Admin Panel — atBB Forum" auth={auth}>
387 <PageHeader title="Admin Panel" />
388 <div class="admin-nav-grid">
389 {showMembers && (
390 <a href="/admin/members" class="admin-nav-card">
391 <Card>
392 <p class="admin-nav-card__icon" aria-hidden="true">👥</p>
393 <p class="admin-nav-card__title">Members</p>
394 <p class="admin-nav-card__description">View and assign member roles</p>
395 </Card>
396 </a>
397 )}
398 {showStructure && (
399 <a href="/admin/structure" class="admin-nav-card">
400 <Card>
401 <p class="admin-nav-card__icon" aria-hidden="true">📁</p>
402 <p class="admin-nav-card__title">Structure</p>
403 <p class="admin-nav-card__description">Manage categories and boards</p>
404 </Card>
405 </a>
406 )}
407 {showModLog && (
408 <a href="/admin/modlog" class="admin-nav-card">
409 <Card>
410 <p class="admin-nav-card__icon" aria-hidden="true">📋</p>
411 <p class="admin-nav-card__title">Mod Log</p>
412 <p class="admin-nav-card__description">Audit trail of moderation actions</p>
413 </Card>
414 </a>
415 )}
416 </div>
417 </BaseLayout>
418 );
419 });
420
421 // ── GET /admin/members ────────────────────────────────────────────────────
422
423 app.get("/admin/members", async (c) => {
424 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
425
426 if (!auth.authenticated) {
427 return c.redirect("/login");
428 }
429
430 if (!canManageMembers(auth)) {
431 return c.html(
432 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
433 <PageHeader title="Members" />
434 <p>You don't have permission to manage members.</p>
435 </BaseLayout>,
436 403
437 );
438 }
439
440 const cookie = c.req.header("cookie") ?? "";
441 const showRoleControls = canManageRoles(auth);
442
443 let membersRes: Response;
444 let rolesRes: Response | null = null;
445
446 try {
447 [membersRes, rolesRes] = await Promise.all([
448 fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }),
449 showRoleControls
450 ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } })
451 : Promise.resolve(null),
452 ]);
453 } catch (error) {
454 if (isProgrammingError(error)) throw error;
455 logger.error("Network error fetching members", {
456 operation: "GET /admin/members",
457 error: error instanceof Error ? error.message : String(error),
458 });
459 return c.html(
460 <BaseLayout title="Members — atBB Forum" auth={auth}>
461 <PageHeader title="Members" />
462 <ErrorDisplay
463 message="Unable to load members"
464 detail="The forum is temporarily unavailable. Please try again."
465 />
466 </BaseLayout>,
467 503
468 );
469 }
470
471 if (!membersRes.ok) {
472 if (membersRes.status === 401) {
473 return c.redirect("/login");
474 }
475 logger.error("AppView returned error for members list", {
476 operation: "GET /admin/members",
477 status: membersRes.status,
478 });
479 return c.html(
480 <BaseLayout title="Members — atBB Forum" auth={auth}>
481 <PageHeader title="Members" />
482 <ErrorDisplay
483 message="Something went wrong"
484 detail="Could not load member list. Please try again."
485 />
486 </BaseLayout>,
487 500
488 );
489 }
490
491 const membersData = (await membersRes.json()) as {
492 members: MemberEntry[];
493 isTruncated: boolean;
494 };
495 let rolesData: { roles: RoleEntry[] } | null = null;
496 if (rolesRes?.ok) {
497 try {
498 rolesData = (await rolesRes.json()) as { roles: RoleEntry[] };
499 } catch (error) {
500 if (!(error instanceof SyntaxError)) throw error;
501 logger.error("Malformed JSON from AppView roles response", {
502 operation: "GET /admin/members",
503 });
504 }
505 } else if (rolesRes) {
506 logger.error("AppView returned error for roles list", {
507 operation: "GET /admin/members",
508 status: rolesRes.status,
509 });
510 }
511
512 const members = membersData.members;
513 const roles = rolesData?.roles ?? [];
514 const isTruncated = membersData.isTruncated;
515 const title = `Members (${members.length}${isTruncated ? "+" : ""})`;
516
517 return c.html(
518 <BaseLayout title="Members — atBB Forum" auth={auth}>
519 <PageHeader title={title} />
520 {members.length === 0 ? (
521 <EmptyState message="No members yet" />
522 ) : (
523 <div class="card">
524 <table class="admin-member-table">
525 <thead>
526 <tr>
527 <th scope="col">Handle</th>
528 <th scope="col">Role</th>
529 <th scope="col">Joined</th>
530 {showRoleControls && <th scope="col">Assign Role</th>}
531 </tr>
532 </thead>
533 <tbody>
534 {members.map((member) => (
535 <MemberRow
536 member={member}
537 roles={roles}
538 showRoleControls={showRoleControls}
539 />
540 ))}
541 </tbody>
542 </table>
543 </div>
544 )}
545 </BaseLayout>
546 );
547 });
548
549 // ── POST /admin/members/:did/role (HTMX proxy) ────────────────────────────
550
551 app.post("/admin/members/:did/role", async (c) => {
552 // Permission gate — must come before body parsing
553 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
554 if (!auth.authenticated) {
555 return c.html(
556 <tr>
557 <td colspan={4}>
558 <span class="member-row__error">You must be logged in to perform this action.</span>
559 </td>
560 </tr>,
561 401
562 );
563 }
564 if (!canManageRoles(auth)) {
565 return c.html(
566 <tr>
567 <td colspan={4}>
568 <span class="member-row__error">You don't have permission to assign roles.</span>
569 </td>
570 </tr>,
571 403
572 );
573 }
574
575 const targetDid = c.req.param("did");
576 const cookie = c.req.header("cookie") ?? "";
577
578 let body: Record<string, string | File>;
579 try {
580 body = await c.req.parseBody();
581 } catch (error) {
582 if (isProgrammingError(error)) throw error;
583 logger.error("Failed to parse form body", {
584 operation: "POST /admin/members/:did/role",
585 targetDid,
586 });
587 return c.html(
588 <tr>
589 <td colspan={4}>
590 <span class="member-row__error">Invalid form submission.</span>
591 </td>
592 </tr>
593 );
594 }
595
596 const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : "";
597 const handle = typeof body.handle === "string" ? body.handle : targetDid;
598 const joinedAt = typeof body.joinedAt === "string" && body.joinedAt ? body.joinedAt : null;
599 const currentRole = typeof body.currentRole === "string" ? body.currentRole : "";
600 const currentRoleUri =
601 typeof body.currentRoleUri === "string" && body.currentRoleUri
602 ? body.currentRoleUri
603 : null;
604 const showRoleControls = canManageRoles(auth);
605
606 let roles: RoleEntry[] = [];
607 try {
608 const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]";
609 roles = JSON.parse(rolesJson) as RoleEntry[];
610 } catch (error) {
611 if (!(error instanceof SyntaxError)) throw error;
612 logger.warn("Malformed rolesJson in POST body", {
613 operation: "POST /admin/members/:did/role",
614 targetDid,
615 });
616 return c.html(
617 <MemberRow
618 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
619 roles={[]}
620 showRoleControls={canManageRoles(auth)}
621 errorMsg="Role data was corrupted. Please reload the page."
622 />
623 );
624 }
625
626 if (!roleUri) {
627 return c.html(
628 <MemberRow
629 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
630 roles={roles}
631 showRoleControls={showRoleControls}
632 errorMsg="Please select a role."
633 />
634 );
635 }
636
637 if (!targetDid.startsWith("did:")) {
638 return c.html(
639 <MemberRow
640 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
641 roles={roles}
642 showRoleControls={showRoleControls}
643 errorMsg="Invalid member identifier."
644 />
645 );
646 }
647
648 let appviewRes: Response;
649 try {
650 appviewRes = await fetch(`${appviewUrl}/api/admin/members/${targetDid}/role`, {
651 method: "POST",
652 headers: {
653 "Content-Type": "application/json",
654 Cookie: cookie,
655 },
656 body: JSON.stringify({ roleUri }),
657 });
658 } catch (error) {
659 if (isProgrammingError(error)) throw error;
660 logger.error("Network error proxying role assignment", {
661 operation: "POST /admin/members/:did/role",
662 targetDid,
663 error: error instanceof Error ? error.message : String(error),
664 });
665 return c.html(
666 <MemberRow
667 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
668 roles={roles}
669 showRoleControls={showRoleControls}
670 errorMsg="Forum temporarily unavailable. Please try again."
671 />
672 );
673 }
674
675 if (appviewRes.ok) {
676 let data: { roleAssigned: string; targetDid: string };
677 try {
678 data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string };
679 } catch (error) {
680 if (!(error instanceof SyntaxError)) throw error;
681 logger.error("Malformed JSON from AppView role assignment response", {
682 operation: "POST /admin/members/:did/role",
683 targetDid,
684 });
685 return c.html(
686 <MemberRow
687 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
688 roles={roles}
689 showRoleControls={showRoleControls}
690 errorMsg="Something went wrong. Please try again."
691 />
692 );
693 }
694 const newRoleName = data.roleAssigned || currentRole;
695 return c.html(
696 <MemberRow
697 member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }}
698 roles={roles}
699 showRoleControls={showRoleControls}
700 />
701 );
702 }
703
704 let errorMsg: string;
705 if (appviewRes.status === 403) {
706 errorMsg = "Cannot assign a role with equal or higher authority than your own.";
707 } else if (appviewRes.status === 404) {
708 errorMsg = "Member or role not found.";
709 } else if (appviewRes.status === 401) {
710 errorMsg = "Your session has expired. Please log in again.";
711 } else {
712 logger.error("AppView returned error for role assignment", {
713 operation: "POST /admin/members/:did/role",
714 targetDid,
715 status: appviewRes.status,
716 });
717 errorMsg = "Something went wrong. Please try again.";
718 }
719
720 return c.html(
721 <MemberRow
722 member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
723 roles={roles}
724 showRoleControls={showRoleControls}
725 errorMsg={errorMsg}
726 />
727 );
728 });
729
730 // ── GET /admin/structure ─────────────────────────────────────────────────
731
732 app.get("/admin/structure", async (c) => {
733 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
734
735 if (!auth.authenticated) {
736 return c.redirect("/login");
737 }
738
739 if (!canManageCategories(auth)) {
740 return c.html(
741 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
742 <PageHeader title="Forum Structure" />
743 <p>You don't have permission to manage forum structure.</p>
744 </BaseLayout>,
745 403
746 );
747 }
748
749 const cookie = c.req.header("cookie") ?? "";
750 const errorMsg = c.req.query("error") ?? null;
751
752 let categoriesRes: Response;
753 try {
754 categoriesRes = await fetch(`${appviewUrl}/api/categories`, {
755 headers: { Cookie: cookie },
756 });
757 } catch (error) {
758 if (isProgrammingError(error)) throw error;
759 logger.error("Network error fetching categories for structure page", {
760 operation: "GET /admin/structure",
761 error: error instanceof Error ? error.message : String(error),
762 });
763 return c.html(
764 <BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
765 <PageHeader title="Forum Structure" />
766 <ErrorDisplay
767 message="Unable to load forum structure"
768 detail="The forum is temporarily unavailable. Please try again."
769 />
770 </BaseLayout>,
771 503
772 );
773 }
774
775 if (!categoriesRes.ok) {
776 if (categoriesRes.status === 401) {
777 return c.redirect("/login");
778 }
779 logger.error("AppView returned error for categories list", {
780 operation: "GET /admin/structure",
781 status: categoriesRes.status,
782 });
783 return c.html(
784 <BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
785 <PageHeader title="Forum Structure" />
786 <ErrorDisplay
787 message="Something went wrong"
788 detail="Could not load forum structure. Please try again."
789 />
790 </BaseLayout>,
791 500
792 );
793 }
794
795 const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] };
796 const catList = categoriesData.categories;
797
798 // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx)
799 let boardsPerCategory: BoardEntry[][];
800 try {
801 boardsPerCategory = await Promise.all(
802 catList.map((cat) =>
803 fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, {
804 headers: { Cookie: cookie },
805 })
806 .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>)
807 .then((data) => data.boards)
808 .catch((error) => {
809 if (isProgrammingError(error)) throw error;
810 logger.error("Failed to fetch boards for category", {
811 operation: "GET /admin/structure",
812 categoryId: cat.id,
813 error: error instanceof Error ? error.message : String(error),
814 });
815 return [] as BoardEntry[];
816 })
817 )
818 );
819 } catch (error) {
820 if (isProgrammingError(error)) throw error;
821 logger.error("Failed to fetch boards for all categories", {
822 operation: "GET /admin/structure",
823 error: error instanceof Error ? error.message : String(error),
824 });
825 boardsPerCategory = catList.map(() => []);
826 }
827
828 const structure = catList.map((cat, i) => ({
829 category: cat,
830 boards: boardsPerCategory[i] ?? [],
831 }));
832
833 return c.html(
834 <BaseLayout title="Forum Structure — atBB Forum" auth={auth}>
835 <PageHeader title="Forum Structure" />
836 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
837 <div class="structure-page">
838 {structure.length === 0 ? (
839 <EmptyState message="No categories yet" />
840 ) : (
841 structure.map(({ category, boards }) => (
842 <StructureCategorySection category={category} boards={boards} />
843 ))
844 )}
845 <div class="structure-add-category card">
846 <h3>Add Category</h3>
847 <form method="post" action="/admin/structure/categories">
848 <div class="form-group">
849 <label for="new-cat-name">Name</label>
850 <input id="new-cat-name" type="text" name="name" required />
851 </div>
852 <div class="form-group">
853 <label for="new-cat-desc">Description</label>
854 <textarea id="new-cat-desc" name="description"></textarea>
855 </div>
856 <div class="form-group">
857 <label for="new-cat-sort">Sort Order</label>
858 <input id="new-cat-sort" type="number" name="sortOrder" min="0" value="0" />
859 </div>
860 <button type="submit" class="btn btn-primary">Add Category</button>
861 </form>
862 </div>
863 </div>
864 </BaseLayout>
865 );
866 });
867
868 // ── POST /admin/structure/categories ─────────────────────────────────────
869
870 app.post("/admin/structure/categories", async (c) => {
871 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
872 if (!auth.authenticated) return c.redirect("/login");
873 if (!canManageCategories(auth)) {
874 return c.html(
875 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
876 <PageHeader title="Forum Structure" />
877 <p>You don't have permission to manage forum structure.</p>
878 </BaseLayout>,
879 403
880 );
881 }
882
883 const cookie = c.req.header("cookie") ?? "";
884
885 let body: Record<string, string | File>;
886 try {
887 body = await c.req.parseBody();
888 } catch (error) {
889 if (isProgrammingError(error)) throw error;
890 return c.redirect(
891 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
892 302
893 );
894 }
895
896 const name = typeof body.name === "string" ? body.name.trim() : "";
897 if (!name) {
898 return c.redirect(
899 `/admin/structure?error=${encodeURIComponent("Category name is required.")}`,
900 302
901 );
902 }
903
904 const description = typeof body.description === "string" ? body.description.trim() || null : null;
905 const sortOrder = parseSortOrder(body.sortOrder);
906 if (sortOrder === null) {
907 return c.redirect(
908 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
909 302
910 );
911 }
912
913 let appviewRes: Response;
914 try {
915 appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, {
916 method: "POST",
917 headers: { "Content-Type": "application/json", Cookie: cookie },
918 body: JSON.stringify({ name, description, sortOrder }),
919 });
920 } catch (error) {
921 if (isProgrammingError(error)) throw error;
922 logger.error("Network error creating category", {
923 operation: "POST /admin/structure/categories",
924 error: error instanceof Error ? error.message : String(error),
925 });
926 return c.redirect(
927 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
928 302
929 );
930 }
931
932 if (!appviewRes.ok) {
933 const msg = await extractAppviewError(appviewRes, "Failed to create category. Please try again.");
934 logger.error("AppView error creating category", {
935 operation: "POST /admin/structure/categories",
936 status: appviewRes.status,
937 });
938 return c.redirect(
939 `/admin/structure?error=${encodeURIComponent(msg)}`,
940 302
941 );
942 }
943
944 return c.redirect("/admin/structure", 302);
945 });
946
947 // ── POST /admin/structure/categories/:id/edit ─────────────────────────────
948
949 app.post("/admin/structure/categories/:id/edit", async (c) => {
950 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
951 if (!auth.authenticated) return c.redirect("/login");
952 if (!canManageCategories(auth)) {
953 return c.html(
954 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
955 <PageHeader title="Forum Structure" />
956 <p>You don't have permission to manage forum structure.</p>
957 </BaseLayout>,
958 403
959 );
960 }
961
962 const categoryId = c.req.param("id");
963 const cookie = c.req.header("cookie") ?? "";
964
965 let body: Record<string, string | File>;
966 try {
967 body = await c.req.parseBody();
968 } catch (error) {
969 if (isProgrammingError(error)) throw error;
970 return c.redirect(
971 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
972 302
973 );
974 }
975
976 const name = typeof body.name === "string" ? body.name.trim() : "";
977 if (!name) {
978 return c.redirect(
979 `/admin/structure?error=${encodeURIComponent("Category name is required.")}`,
980 302
981 );
982 }
983
984 const description = typeof body.description === "string" ? body.description.trim() || null : null;
985 const sortOrder = parseSortOrder(body.sortOrder);
986 if (sortOrder === null) {
987 return c.redirect(
988 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
989 302
990 );
991 }
992
993 let appviewRes: Response;
994 try {
995 appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, {
996 method: "PUT",
997 headers: { "Content-Type": "application/json", Cookie: cookie },
998 body: JSON.stringify({ name, description, sortOrder }),
999 });
1000 } catch (error) {
1001 if (isProgrammingError(error)) throw error;
1002 logger.error("Network error editing category", {
1003 operation: "POST /admin/structure/categories/:id/edit",
1004 categoryId,
1005 error: error instanceof Error ? error.message : String(error),
1006 });
1007 return c.redirect(
1008 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1009 302
1010 );
1011 }
1012
1013 if (!appviewRes.ok) {
1014 const msg = await extractAppviewError(appviewRes, "Failed to update category. Please try again.");
1015 logger.error("AppView error editing category", {
1016 operation: "POST /admin/structure/categories/:id/edit",
1017 categoryId,
1018 status: appviewRes.status,
1019 });
1020 return c.redirect(
1021 `/admin/structure?error=${encodeURIComponent(msg)}`,
1022 302
1023 );
1024 }
1025
1026 return c.redirect("/admin/structure", 302);
1027 });
1028
1029 // ── POST /admin/structure/categories/:id/delete ───────────────────────────
1030
1031 app.post("/admin/structure/categories/:id/delete", async (c) => {
1032 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1033 if (!auth.authenticated) return c.redirect("/login");
1034 if (!canManageCategories(auth)) {
1035 return c.html(
1036 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
1037 <PageHeader title="Forum Structure" />
1038 <p>You don't have permission to manage forum structure.</p>
1039 </BaseLayout>,
1040 403
1041 );
1042 }
1043
1044 const categoryId = c.req.param("id");
1045 const cookie = c.req.header("cookie") ?? "";
1046
1047 let appviewRes: Response;
1048 try {
1049 appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, {
1050 method: "DELETE",
1051 headers: { Cookie: cookie },
1052 });
1053 } catch (error) {
1054 if (isProgrammingError(error)) throw error;
1055 logger.error("Network error deleting category", {
1056 operation: "POST /admin/structure/categories/:id/delete",
1057 categoryId,
1058 error: error instanceof Error ? error.message : String(error),
1059 });
1060 return c.redirect(
1061 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1062 302
1063 );
1064 }
1065
1066 if (!appviewRes.ok) {
1067 const msg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again.");
1068 logger.error("AppView error deleting category", {
1069 operation: "POST /admin/structure/categories/:id/delete",
1070 categoryId,
1071 status: appviewRes.status,
1072 });
1073 return c.redirect(
1074 `/admin/structure?error=${encodeURIComponent(msg)}`,
1075 302
1076 );
1077 }
1078
1079 return c.redirect("/admin/structure", 302);
1080 });
1081
1082 // ── POST /admin/structure/boards ──────────────────────────────────────────
1083
1084 app.post("/admin/structure/boards", async (c) => {
1085 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1086 if (!auth.authenticated) return c.redirect("/login");
1087 if (!canManageCategories(auth)) {
1088 return c.html(
1089 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
1090 <PageHeader title="Forum Structure" />
1091 <p>You don't have permission to manage forum structure.</p>
1092 </BaseLayout>,
1093 403
1094 );
1095 }
1096
1097 const cookie = c.req.header("cookie") ?? "";
1098
1099 let body: Record<string, string | File>;
1100 try {
1101 body = await c.req.parseBody();
1102 } catch (error) {
1103 if (isProgrammingError(error)) throw error;
1104 return c.redirect(
1105 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
1106 302
1107 );
1108 }
1109
1110 const name = typeof body.name === "string" ? body.name.trim() : "";
1111 if (!name) {
1112 return c.redirect(
1113 `/admin/structure?error=${encodeURIComponent("Board name is required.")}`,
1114 302
1115 );
1116 }
1117
1118 const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : "";
1119 if (!categoryUri) {
1120 return c.redirect(
1121 `/admin/structure?error=${encodeURIComponent("Category is required to create a board.")}`,
1122 302
1123 );
1124 }
1125
1126 const description = typeof body.description === "string" ? body.description.trim() || null : null;
1127 const sortOrder = parseSortOrder(body.sortOrder);
1128 if (sortOrder === null) {
1129 return c.redirect(
1130 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
1131 302
1132 );
1133 }
1134
1135 let appviewRes: Response;
1136 try {
1137 appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, {
1138 method: "POST",
1139 headers: { "Content-Type": "application/json", Cookie: cookie },
1140 body: JSON.stringify({ name, description, sortOrder, categoryUri }),
1141 });
1142 } catch (error) {
1143 if (isProgrammingError(error)) throw error;
1144 logger.error("Network error creating board", {
1145 operation: "POST /admin/structure/boards",
1146 error: error instanceof Error ? error.message : String(error),
1147 });
1148 return c.redirect(
1149 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1150 302
1151 );
1152 }
1153
1154 if (!appviewRes.ok) {
1155 const msg = await extractAppviewError(appviewRes, "Failed to create board. Please try again.");
1156 logger.error("AppView error creating board", {
1157 operation: "POST /admin/structure/boards",
1158 status: appviewRes.status,
1159 });
1160 return c.redirect(
1161 `/admin/structure?error=${encodeURIComponent(msg)}`,
1162 302
1163 );
1164 }
1165
1166 return c.redirect("/admin/structure", 302);
1167 });
1168
1169 // ── POST /admin/structure/boards/:id/edit ─────────────────────────────────
1170
1171 app.post("/admin/structure/boards/:id/edit", async (c) => {
1172 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1173 if (!auth.authenticated) return c.redirect("/login");
1174 if (!canManageCategories(auth)) {
1175 return c.html(
1176 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
1177 <PageHeader title="Forum Structure" />
1178 <p>You don't have permission to manage forum structure.</p>
1179 </BaseLayout>,
1180 403
1181 );
1182 }
1183
1184 const boardId = c.req.param("id");
1185 const cookie = c.req.header("cookie") ?? "";
1186
1187 let body: Record<string, string | File>;
1188 try {
1189 body = await c.req.parseBody();
1190 } catch (error) {
1191 if (isProgrammingError(error)) throw error;
1192 return c.redirect(
1193 `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`,
1194 302
1195 );
1196 }
1197
1198 const name = typeof body.name === "string" ? body.name.trim() : "";
1199 if (!name) {
1200 return c.redirect(
1201 `/admin/structure?error=${encodeURIComponent("Board name is required.")}`,
1202 302
1203 );
1204 }
1205
1206 const description = typeof body.description === "string" ? body.description.trim() || null : null;
1207 const sortOrder = parseSortOrder(body.sortOrder);
1208 if (sortOrder === null) {
1209 return c.redirect(
1210 `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`,
1211 302
1212 );
1213 }
1214
1215 let appviewRes: Response;
1216 try {
1217 appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, {
1218 method: "PUT",
1219 headers: { "Content-Type": "application/json", Cookie: cookie },
1220 body: JSON.stringify({ name, description, sortOrder }),
1221 });
1222 } catch (error) {
1223 if (isProgrammingError(error)) throw error;
1224 logger.error("Network error editing board", {
1225 operation: "POST /admin/structure/boards/:id/edit",
1226 boardId,
1227 error: error instanceof Error ? error.message : String(error),
1228 });
1229 return c.redirect(
1230 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1231 302
1232 );
1233 }
1234
1235 if (!appviewRes.ok) {
1236 const msg = await extractAppviewError(appviewRes, "Failed to update board. Please try again.");
1237 logger.error("AppView error editing board", {
1238 operation: "POST /admin/structure/boards/:id/edit",
1239 boardId,
1240 status: appviewRes.status,
1241 });
1242 return c.redirect(
1243 `/admin/structure?error=${encodeURIComponent(msg)}`,
1244 302
1245 );
1246 }
1247
1248 return c.redirect("/admin/structure", 302);
1249 });
1250
1251 // ── POST /admin/structure/boards/:id/delete ───────────────────────────────
1252
1253 app.post("/admin/structure/boards/:id/delete", async (c) => {
1254 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1255 if (!auth.authenticated) return c.redirect("/login");
1256 if (!canManageCategories(auth)) {
1257 return c.html(
1258 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
1259 <PageHeader title="Forum Structure" />
1260 <p>You don't have permission to manage forum structure.</p>
1261 </BaseLayout>,
1262 403
1263 );
1264 }
1265
1266 const boardId = c.req.param("id");
1267 const cookie = c.req.header("cookie") ?? "";
1268
1269 let appviewRes: Response;
1270 try {
1271 appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, {
1272 method: "DELETE",
1273 headers: { Cookie: cookie },
1274 });
1275 } catch (error) {
1276 if (isProgrammingError(error)) throw error;
1277 logger.error("Network error deleting board", {
1278 operation: "POST /admin/structure/boards/:id/delete",
1279 boardId,
1280 error: error instanceof Error ? error.message : String(error),
1281 });
1282 return c.redirect(
1283 `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
1284 302
1285 );
1286 }
1287
1288 if (!appviewRes.ok) {
1289 const msg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again.");
1290 logger.error("AppView error deleting board", {
1291 operation: "POST /admin/structure/boards/:id/delete",
1292 boardId,
1293 status: appviewRes.status,
1294 });
1295 return c.redirect(
1296 `/admin/structure?error=${encodeURIComponent(msg)}`,
1297 302
1298 );
1299 }
1300
1301 return c.redirect("/admin/structure", 302);
1302 });
1303
1304 // ── GET /admin/modlog ─────────────────────────────────────────────────────
1305
1306 app.get("/admin/modlog", async (c) => {
1307 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
1308
1309 if (!auth.authenticated) {
1310 return c.redirect("/login");
1311 }
1312
1313 if (!canViewModLog(auth)) {
1314 return c.html(
1315 <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
1316 <PageHeader title="Mod Action Log" />
1317 <p>You don't have permission to view the mod action log.</p>
1318 </BaseLayout>,
1319 403
1320 );
1321 }
1322
1323 const rawOffset = c.req.query("offset");
1324 const offset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
1325 ? parseInt(rawOffset, 10)
1326 : 0;
1327 const limit = 50;
1328
1329 const cookie = c.req.header("cookie") ?? "";
1330
1331 let modlogRes: Response;
1332 try {
1333 modlogRes = await fetch(
1334 `${appviewUrl}/api/admin/modlog?limit=${limit}&offset=${offset}`,
1335 { headers: { Cookie: cookie } }
1336 );
1337 } catch (error) {
1338 if (isProgrammingError(error)) throw error;
1339 logger.error("Network error fetching mod action log", {
1340 operation: "GET /admin/modlog",
1341 error: error instanceof Error ? error.message : String(error),
1342 });
1343 return c.html(
1344 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
1345 <PageHeader title="Mod Action Log" />
1346 <ErrorDisplay
1347 message="Unable to load mod action log"
1348 detail="The forum is temporarily unavailable. Please try again."
1349 />
1350 </BaseLayout>,
1351 503
1352 );
1353 }
1354
1355 if (!modlogRes.ok) {
1356 if (modlogRes.status === 401) {
1357 return c.redirect("/login");
1358 }
1359 logger.error("AppView returned error for mod action log", {
1360 operation: "GET /admin/modlog",
1361 status: modlogRes.status,
1362 });
1363 return c.html(
1364 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
1365 <PageHeader title="Mod Action Log" />
1366 <ErrorDisplay
1367 message="Something went wrong"
1368 detail="Could not load mod action log. Please try again."
1369 />
1370 </BaseLayout>,
1371 500
1372 );
1373 }
1374
1375 let data: { actions: ModLogEntry[]; total: number; offset: number; limit: number };
1376 try {
1377 data = (await modlogRes.json()) as {
1378 actions: ModLogEntry[];
1379 total: number;
1380 offset: number;
1381 limit: number;
1382 };
1383 } catch (error) {
1384 if (!(error instanceof SyntaxError)) throw error;
1385 logger.error("Malformed JSON from AppView mod action log response", {
1386 operation: "GET /admin/modlog",
1387 });
1388 return c.html(
1389 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
1390 <PageHeader title="Mod Action Log" />
1391 <ErrorDisplay
1392 message="Something went wrong"
1393 detail="Could not load mod action log. Please try again."
1394 />
1395 </BaseLayout>,
1396 500
1397 );
1398 }
1399
1400 const { actions, total } = data;
1401 const totalPages = total === 0 ? 1 : Math.ceil(total / limit);
1402 const currentPage = Math.floor(offset / limit) + 1;
1403 const hasPrev = offset > 0;
1404 const hasNext = offset + limit < total;
1405
1406 return c.html(
1407 <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
1408 <PageHeader title="Mod Action Log" />
1409 {actions.length === 0 ? (
1410 <EmptyState message="No moderation actions yet" />
1411 ) : (
1412 <div class="card">
1413 <table class="modlog-table">
1414 <thead>
1415 <tr>
1416 <th scope="col">Time</th>
1417 <th scope="col">Moderator</th>
1418 <th scope="col">Action</th>
1419 <th scope="col">Subject</th>
1420 <th scope="col">Reason</th>
1421 </tr>
1422 </thead>
1423 <tbody>
1424 {actions.map((entry) => (
1425 <ModLogRow entry={entry} />
1426 ))}
1427 </tbody>
1428 </table>
1429 </div>
1430 )}
1431 <div class="modlog-pagination">
1432 {hasPrev && (
1433 <a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary">
1434 ← Previous
1435 </a>
1436 )}
1437 <span class="modlog-pagination__indicator">
1438 Page {currentPage} of {totalPages}
1439 </span>
1440 {hasNext && (
1441 <a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary">
1442 Next →
1443 </a>
1444 )}
1445 </div>
1446 </BaseLayout>
1447 );
1448 });
1449
1450 return app;
1451}