import { Hono } from "hono"; import { BaseLayout } from "../layouts/base.js"; import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js"; import { getSessionWithPermissions, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles, } from "../lib/session.js"; import { isProgrammingError } from "../lib/errors.js"; import { logger } from "../lib/logger.js"; // ─── Types ───────────────────────────────────────────────────────────────── interface MemberEntry { did: string; handle: string; role: string; roleUri: string | null; joinedAt: string | null; } interface RoleEntry { id: string; name: string; uri: string; priority: number; } interface CategoryEntry { id: string; did: string; uri: string; name: string; description: string | null; sortOrder: number | null; } interface BoardEntry { id: string; name: string; description: string | null; sortOrder: number | null; categoryUri: string; uri: string; } // ─── Helpers ─────────────────────────────────────────────────────────────── function formatJoinedDate(isoString: string | null): string { if (!isoString) return "—"; const d = new Date(isoString); if (isNaN(d.getTime())) return "—"; return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } // ─── Components ──────────────────────────────────────────────────────────── function MemberRow({ member, roles, showRoleControls, errorMsg = null, }: { member: MemberEntry; roles: RoleEntry[]; showRoleControls: boolean; errorMsg?: string | null; }) { return ( {member.handle} {member.role} {formatJoinedDate(member.joinedAt)} {showRoleControls ? (
{errorMsg && {errorMsg}}
) : ( errorMsg && ( {errorMsg} ) )} ); } // ─── Private Helpers ──────────────────────────────────────────────────────── /** * Extracts the error message from an AppView error response. * Falls back to the provided default if JSON parsing fails. */ async function extractAppviewError(res: Response, fallback: string): Promise { try { const data = (await res.json()) as { error?: string }; return data.error ?? fallback; } catch { return fallback; } } /** * Parses a sort order value from a form field string. * Returns 0 for empty/missing values, null for invalid values (negative or non-integer). */ function parseSortOrder(value: unknown): number | null { if (typeof value !== "string" || value.trim() === "") return 0; const n = Number(value); return Number.isInteger(n) && n >= 0 ? n : null; } // ─── Routes ──────────────────────────────────────────────────────────────── export function createAdminRoutes(appviewUrl: string) { const app = new Hono(); // ─── Structure Page Components ────────────────────────────────────────── function StructureBoardRow({ board }: { board: BoardEntry }) { const dialogId = `confirm-delete-board-${board.id}`; return (
{board.name} sortOrder: {board.sortOrder ?? 0}
Edit {board.name}

Delete board "{board.name}"? This cannot be undone.

); } function StructureCategorySection({ category, boards, }: { category: CategoryEntry; boards: BoardEntry[]; }) { const dialogId = `confirm-delete-category-${category.id}`; return (
{category.name} sortOrder: {category.sortOrder ?? 0}
Edit {category.name}

Delete category "{category.name}"? All boards must be removed first.

{boards.map((board) => ( ))}
+ Add Board
); } // ── GET /admin ──────────────────────────────────────────────────────────── app.get("/admin", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) { return c.redirect("/login"); } if (!hasAnyAdminPermission(auth)) { return c.html(

You don't have permission to access the admin panel.

, 403 ); } const showMembers = canManageMembers(auth); const showStructure = canManageCategories(auth); const showModLog = canViewModLog(auth); return c.html( ); }); // ── GET /admin/members ──────────────────────────────────────────────────── app.get("/admin/members", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) { return c.redirect("/login"); } if (!canManageMembers(auth)) { return c.html(

You don't have permission to manage members.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; const showRoleControls = canManageRoles(auth); let membersRes: Response; let rolesRes: Response | null = null; try { [membersRes, rolesRes] = await Promise.all([ fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }), showRoleControls ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } }) : Promise.resolve(null), ]); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error fetching members", { operation: "GET /admin/members", error: error instanceof Error ? error.message : String(error), }); return c.html( , 503 ); } if (!membersRes.ok) { if (membersRes.status === 401) { return c.redirect("/login"); } logger.error("AppView returned error for members list", { operation: "GET /admin/members", status: membersRes.status, }); return c.html( , 500 ); } const membersData = (await membersRes.json()) as { members: MemberEntry[]; isTruncated: boolean; }; let rolesData: { roles: RoleEntry[] } | null = null; if (rolesRes?.ok) { try { rolesData = (await rolesRes.json()) as { roles: RoleEntry[] }; } catch (error) { if (!(error instanceof SyntaxError)) throw error; logger.error("Malformed JSON from AppView roles response", { operation: "GET /admin/members", }); } } else if (rolesRes) { logger.error("AppView returned error for roles list", { operation: "GET /admin/members", status: rolesRes.status, }); } const members = membersData.members; const roles = rolesData?.roles ?? []; const isTruncated = membersData.isTruncated; const title = `Members (${members.length}${isTruncated ? "+" : ""})`; return c.html( {members.length === 0 ? ( ) : (
{showRoleControls && } {members.map((member) => ( ))}
Handle Role JoinedAssign Role
)}
); }); // ── POST /admin/members/:did/role (HTMX proxy) ──────────────────────────── app.post("/admin/members/:did/role", async (c) => { // Permission gate — must come before body parsing const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) { return c.html( You must be logged in to perform this action. , 401 ); } if (!canManageRoles(auth)) { return c.html( You don't have permission to assign roles. , 403 ); } const targetDid = c.req.param("did"); const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Failed to parse form body", { operation: "POST /admin/members/:did/role", targetDid, }); return c.html( Invalid form submission. ); } const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : ""; const handle = typeof body.handle === "string" ? body.handle : targetDid; const joinedAt = typeof body.joinedAt === "string" && body.joinedAt ? body.joinedAt : null; const currentRole = typeof body.currentRole === "string" ? body.currentRole : ""; const currentRoleUri = typeof body.currentRoleUri === "string" && body.currentRoleUri ? body.currentRoleUri : null; const showRoleControls = canManageRoles(auth); let roles: RoleEntry[] = []; try { const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]"; roles = JSON.parse(rolesJson) as RoleEntry[]; } catch (error) { if (!(error instanceof SyntaxError)) throw error; logger.warn("Malformed rolesJson in POST body", { operation: "POST /admin/members/:did/role", targetDid, }); return c.html( ); } if (!roleUri) { return c.html( ); } if (!targetDid.startsWith("did:")) { return c.html( ); } let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/members/${targetDid}/role`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie, }, body: JSON.stringify({ roleUri }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error proxying role assignment", { operation: "POST /admin/members/:did/role", targetDid, error: error instanceof Error ? error.message : String(error), }); return c.html( ); } if (appviewRes.ok) { let data: { roleAssigned: string; targetDid: string }; try { data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string }; } catch (error) { if (!(error instanceof SyntaxError)) throw error; logger.error("Malformed JSON from AppView role assignment response", { operation: "POST /admin/members/:did/role", targetDid, }); return c.html( ); } const newRoleName = data.roleAssigned || currentRole; return c.html( ); } let errorMsg: string; if (appviewRes.status === 403) { errorMsg = "Cannot assign a role with equal or higher authority than your own."; } else if (appviewRes.status === 404) { errorMsg = "Member or role not found."; } else if (appviewRes.status === 401) { errorMsg = "Your session has expired. Please log in again."; } else { logger.error("AppView returned error for role assignment", { operation: "POST /admin/members/:did/role", targetDid, status: appviewRes.status, }); errorMsg = "Something went wrong. Please try again."; } return c.html( ); }); // ── GET /admin/structure ───────────────────────────────────────────────── app.get("/admin/structure", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) { return c.redirect("/login"); } if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; const errorMsg = c.req.query("error") ?? null; let categoriesRes: Response; try { categoriesRes = await fetch(`${appviewUrl}/api/categories`, { headers: { Cookie: cookie }, }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error fetching categories for structure page", { operation: "GET /admin/structure", error: error instanceof Error ? error.message : String(error), }); return c.html( , 503 ); } if (!categoriesRes.ok) { if (categoriesRes.status === 401) { return c.redirect("/login"); } logger.error("AppView returned error for categories list", { operation: "GET /admin/structure", status: categoriesRes.status, }); return c.html( , 500 ); } const categoriesData = (await categoriesRes.json()) as { categories: CategoryEntry[] }; const catList = categoriesData.categories; // Fetch boards for each category in parallel (N+1 pattern — same as home.tsx) let boardsPerCategory: BoardEntry[][]; try { boardsPerCategory = await Promise.all( catList.map((cat) => fetch(`${appviewUrl}/api/categories/${cat.id}/boards`, { headers: { Cookie: cookie }, }) .then((r) => r.json() as Promise<{ boards: BoardEntry[] }>) .then((data) => data.boards) .catch((error) => { if (isProgrammingError(error)) throw error; logger.error("Failed to fetch boards for category", { operation: "GET /admin/structure", categoryId: cat.id, error: error instanceof Error ? error.message : String(error), }); return [] as BoardEntry[]; }) ) ); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Failed to fetch boards for all categories", { operation: "GET /admin/structure", error: error instanceof Error ? error.message : String(error), }); boardsPerCategory = catList.map(() => []); } const structure = catList.map((cat, i) => ({ category: cat, boards: boardsPerCategory[i] ?? [], })); return c.html( {errorMsg &&
{errorMsg}
}
{structure.length === 0 ? ( ) : ( structure.map(({ category, boards }) => ( )) )}

Add Category

); }); // ── POST /admin/structure/categories ───────────────────────────────────── app.post("/admin/structure/categories", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 ); } const description = typeof body.description === "string" ? body.description.trim() || null : null; const sortOrder = parseSortOrder(body.sortOrder); if (sortOrder === null) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 302 ); } let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/categories`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, description, sortOrder }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error creating category", { operation: "POST /admin/structure/categories", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to create category. Please try again."); logger.error("AppView error creating category", { operation: "POST /admin/structure/categories", status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); // ── POST /admin/structure/categories/:id/edit ───────────────────────────── app.post("/admin/structure/categories/:id/edit", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const categoryId = c.req.param("id"); const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Category name is required.")}`, 302 ); } const description = typeof body.description === "string" ? body.description.trim() || null : null; const sortOrder = parseSortOrder(body.sortOrder); if (sortOrder === null) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 302 ); } let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, description, sortOrder }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error editing category", { operation: "POST /admin/structure/categories/:id/edit", categoryId, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to update category. Please try again."); logger.error("AppView error editing category", { operation: "POST /admin/structure/categories/:id/edit", categoryId, status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); // ── POST /admin/structure/categories/:id/delete ─────────────────────────── app.post("/admin/structure/categories/:id/delete", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const categoryId = c.req.param("id"); const cookie = c.req.header("cookie") ?? ""; let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/categories/${categoryId}`, { method: "DELETE", headers: { Cookie: cookie }, }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error deleting category", { operation: "POST /admin/structure/categories/:id/delete", categoryId, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to delete category. Please try again."); logger.error("AppView error deleting category", { operation: "POST /admin/structure/categories/:id/delete", categoryId, status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); // ── POST /admin/structure/boards ────────────────────────────────────────── app.post("/admin/structure/boards", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 ); } const categoryUri = typeof body.categoryUri === "string" ? body.categoryUri.trim() : ""; if (!categoryUri) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Category is required to create a board.")}`, 302 ); } const description = typeof body.description === "string" ? body.description.trim() || null : null; const sortOrder = parseSortOrder(body.sortOrder); if (sortOrder === null) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 302 ); } let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/boards`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, description, sortOrder, categoryUri }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error creating board", { operation: "POST /admin/structure/boards", error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to create board. Please try again."); logger.error("AppView error creating board", { operation: "POST /admin/structure/boards", status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); // ── POST /admin/structure/boards/:id/edit ───────────────────────────────── app.post("/admin/structure/boards/:id/edit", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const boardId = c.req.param("id"); const cookie = c.req.header("cookie") ?? ""; let body: Record; try { body = await c.req.parseBody(); } catch (error) { if (isProgrammingError(error)) throw error; return c.redirect( `/admin/structure?error=${encodeURIComponent("Invalid form submission.")}`, 302 ); } const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Board name is required.")}`, 302 ); } const description = typeof body.description === "string" ? body.description.trim() || null : null; const sortOrder = parseSortOrder(body.sortOrder); if (sortOrder === null) { return c.redirect( `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 302 ); } let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name, description, sortOrder }), }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error editing board", { operation: "POST /admin/structure/boards/:id/edit", boardId, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to update board. Please try again."); logger.error("AppView error editing board", { operation: "POST /admin/structure/boards/:id/edit", boardId, status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); // ── POST /admin/structure/boards/:id/delete ─────────────────────────────── app.post("/admin/structure/boards/:id/delete", async (c) => { const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); if (!auth.authenticated) return c.redirect("/login"); if (!canManageCategories(auth)) { return c.html(

You don't have permission to manage forum structure.

, 403 ); } const boardId = c.req.param("id"); const cookie = c.req.header("cookie") ?? ""; let appviewRes: Response; try { appviewRes = await fetch(`${appviewUrl}/api/admin/boards/${boardId}`, { method: "DELETE", headers: { Cookie: cookie }, }); } catch (error) { if (isProgrammingError(error)) throw error; logger.error("Network error deleting board", { operation: "POST /admin/structure/boards/:id/delete", boardId, error: error instanceof Error ? error.message : String(error), }); return c.redirect( `/admin/structure?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 302 ); } if (!appviewRes.ok) { const msg = await extractAppviewError(appviewRes, "Failed to delete board. Please try again."); logger.error("AppView error deleting board", { operation: "POST /admin/structure/boards/:id/delete", boardId, status: appviewRes.status, }); return c.redirect( `/admin/structure?error=${encodeURIComponent(msg)}`, 302 ); } return c.redirect("/admin/structure", 302); }); return app; }