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