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

feat(web): admin mod action log page — /admin/modlog (ATB-48) (#82)

* feat(web): admin mod action log page — /admin/modlog (ATB-48)

* docs: ATB-48 modlog UI implementation plan and completion notes

* fix(web): wrap modlogRes.json() in try-catch for non-JSON AppView responses (ATB-48)

A proxy returning HTML with HTTP 200 would cause Response.json() to throw
SyntaxError, which isProgrammingError() re-throws, producing an unhandled crash
instead of a 500 error page. Wrap with the same pattern used in the members
and role-assignment handlers.

authored by

Malpercio and committed by
GitHub
912e65f2 80496123

+1299
+339
apps/web/src/routes/__tests__/admin.test.tsx
··· 1705 expect(location).toContain("error="); 1706 }); 1707 });
··· 1705 expect(location).toContain("error="); 1706 }); 1707 }); 1708 + 1709 + describe("createAdminRoutes — GET /admin/modlog", () => { 1710 + beforeEach(() => { 1711 + vi.stubGlobal("fetch", mockFetch); 1712 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1713 + vi.resetModules(); 1714 + }); 1715 + 1716 + afterEach(() => { 1717 + vi.unstubAllGlobals(); 1718 + vi.unstubAllEnvs(); 1719 + mockFetch.mockReset(); 1720 + }); 1721 + 1722 + function mockResponse(body: unknown, ok = true, status = 200) { 1723 + return { 1724 + ok, 1725 + status, 1726 + statusText: ok ? "OK" : "Error", 1727 + json: () => Promise.resolve(body), 1728 + }; 1729 + } 1730 + 1731 + function setupSession(permissions: string[]) { 1732 + mockFetch.mockResolvedValueOnce( 1733 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1734 + ); 1735 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1736 + } 1737 + 1738 + async function loadAdminRoutes() { 1739 + const { createAdminRoutes } = await import("../admin.js"); 1740 + return createAdminRoutes("http://localhost:3000"); 1741 + } 1742 + 1743 + const SAMPLE_ACTIONS = [ 1744 + { 1745 + id: "1", 1746 + action: "space.atbb.modAction.ban", 1747 + moderatorDid: "did:plc:alice", 1748 + moderatorHandle: "alice.bsky.social", 1749 + subjectDid: "did:plc:bob", 1750 + subjectHandle: "bob.bsky.social", 1751 + subjectPostUri: null, 1752 + reason: "Spam", 1753 + createdAt: "2026-02-26T12:01:00.000Z", 1754 + }, 1755 + { 1756 + id: "2", 1757 + action: "space.atbb.modAction.delete", 1758 + moderatorDid: "did:plc:alice", 1759 + moderatorHandle: "alice.bsky.social", 1760 + subjectDid: null, 1761 + subjectHandle: null, 1762 + subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123", 1763 + reason: "Inappropriate", 1764 + createdAt: "2026-02-26T11:30:00.000Z", 1765 + }, 1766 + ]; 1767 + 1768 + // ── Auth & permission gates ────────────────────────────────────────────── 1769 + 1770 + it("redirects unauthenticated users to /login", async () => { 1771 + const routes = await loadAdminRoutes(); 1772 + const res = await routes.request("/admin/modlog"); 1773 + expect(res.status).toBe(302); 1774 + expect(res.headers.get("location")).toBe("/login"); 1775 + }); 1776 + 1777 + it("returns 403 for user without any mod permission", async () => { 1778 + setupSession(["space.atbb.permission.manageCategories"]); 1779 + const routes = await loadAdminRoutes(); 1780 + const res = await routes.request("/admin/modlog", { 1781 + headers: { cookie: "atbb_session=token" }, 1782 + }); 1783 + expect(res.status).toBe(403); 1784 + const html = await res.text(); 1785 + expect(html).toContain("permission"); 1786 + }); 1787 + 1788 + it("allows access for moderatePosts permission", async () => { 1789 + setupSession(["space.atbb.permission.moderatePosts"]); 1790 + mockFetch.mockResolvedValueOnce( 1791 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1792 + ); 1793 + const routes = await loadAdminRoutes(); 1794 + const res = await routes.request("/admin/modlog", { 1795 + headers: { cookie: "atbb_session=token" }, 1796 + }); 1797 + expect(res.status).toBe(200); 1798 + }); 1799 + 1800 + it("allows access for banUsers permission", async () => { 1801 + setupSession(["space.atbb.permission.banUsers"]); 1802 + mockFetch.mockResolvedValueOnce( 1803 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1804 + ); 1805 + const routes = await loadAdminRoutes(); 1806 + const res = await routes.request("/admin/modlog", { 1807 + headers: { cookie: "atbb_session=token" }, 1808 + }); 1809 + expect(res.status).toBe(200); 1810 + }); 1811 + 1812 + it("allows access for lockTopics permission", async () => { 1813 + setupSession(["space.atbb.permission.lockTopics"]); 1814 + mockFetch.mockResolvedValueOnce( 1815 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1816 + ); 1817 + const routes = await loadAdminRoutes(); 1818 + const res = await routes.request("/admin/modlog", { 1819 + headers: { cookie: "atbb_session=token" }, 1820 + }); 1821 + expect(res.status).toBe(200); 1822 + }); 1823 + 1824 + // ── Table rendering ────────────────────────────────────────────────────── 1825 + 1826 + it("renders table with moderator handle and action label", async () => { 1827 + setupSession(["space.atbb.permission.banUsers"]); 1828 + mockFetch.mockResolvedValueOnce( 1829 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1830 + ); 1831 + const routes = await loadAdminRoutes(); 1832 + const res = await routes.request("/admin/modlog", { 1833 + headers: { cookie: "atbb_session=token" }, 1834 + }); 1835 + const html = await res.text(); 1836 + expect(html).toContain("alice.bsky.social"); 1837 + expect(html).toContain("Ban"); 1838 + expect(html).toContain("bob.bsky.social"); 1839 + expect(html).toContain("Spam"); 1840 + }); 1841 + 1842 + it("maps space.atbb.modAction.delete to 'Hide' label", async () => { 1843 + setupSession(["space.atbb.permission.moderatePosts"]); 1844 + mockFetch.mockResolvedValueOnce( 1845 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1846 + ); 1847 + const routes = await loadAdminRoutes(); 1848 + const res = await routes.request("/admin/modlog", { 1849 + headers: { cookie: "atbb_session=token" }, 1850 + }); 1851 + const html = await res.text(); 1852 + expect(html).toContain("Hide"); 1853 + }); 1854 + 1855 + it("shows post URI in subject column for post-targeting actions", async () => { 1856 + setupSession(["space.atbb.permission.moderatePosts"]); 1857 + mockFetch.mockResolvedValueOnce( 1858 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1859 + ); 1860 + const routes = await loadAdminRoutes(); 1861 + const res = await routes.request("/admin/modlog", { 1862 + headers: { cookie: "atbb_session=token" }, 1863 + }); 1864 + const html = await res.text(); 1865 + expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123"); 1866 + }); 1867 + 1868 + it("shows handle in subject column for user-targeting actions", async () => { 1869 + setupSession(["space.atbb.permission.banUsers"]); 1870 + mockFetch.mockResolvedValueOnce( 1871 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1872 + ); 1873 + const routes = await loadAdminRoutes(); 1874 + const res = await routes.request("/admin/modlog", { 1875 + headers: { cookie: "atbb_session=token" }, 1876 + }); 1877 + const html = await res.text(); 1878 + expect(html).toContain("bob.bsky.social"); 1879 + }); 1880 + 1881 + it("shows empty state when no actions", async () => { 1882 + setupSession(["space.atbb.permission.banUsers"]); 1883 + mockFetch.mockResolvedValueOnce( 1884 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1885 + ); 1886 + const routes = await loadAdminRoutes(); 1887 + const res = await routes.request("/admin/modlog", { 1888 + headers: { cookie: "atbb_session=token" }, 1889 + }); 1890 + const html = await res.text(); 1891 + expect(html).toContain("No moderation actions"); 1892 + }); 1893 + 1894 + // ── Pagination ─────────────────────────────────────────────────────────── 1895 + 1896 + it("renders 'Page 1 of 2' indicator for 51 total actions", async () => { 1897 + setupSession(["space.atbb.permission.banUsers"]); 1898 + mockFetch.mockResolvedValueOnce( 1899 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1900 + ); 1901 + const routes = await loadAdminRoutes(); 1902 + const res = await routes.request("/admin/modlog", { 1903 + headers: { cookie: "atbb_session=token" }, 1904 + }); 1905 + const html = await res.text(); 1906 + expect(html).toContain("Page 1 of 2"); 1907 + }); 1908 + 1909 + it("shows Next link when more pages exist", async () => { 1910 + setupSession(["space.atbb.permission.banUsers"]); 1911 + mockFetch.mockResolvedValueOnce( 1912 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1913 + ); 1914 + const routes = await loadAdminRoutes(); 1915 + const res = await routes.request("/admin/modlog", { 1916 + headers: { cookie: "atbb_session=token" }, 1917 + }); 1918 + const html = await res.text(); 1919 + expect(html).toContain('href="/admin/modlog?offset=50"'); 1920 + expect(html).toContain("Next"); 1921 + }); 1922 + 1923 + it("hides Next link on last page", async () => { 1924 + setupSession(["space.atbb.permission.banUsers"]); 1925 + mockFetch.mockResolvedValueOnce( 1926 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1927 + ); 1928 + const routes = await loadAdminRoutes(); 1929 + const res = await routes.request("/admin/modlog?offset=50", { 1930 + headers: { cookie: "atbb_session=token" }, 1931 + }); 1932 + const html = await res.text(); 1933 + expect(html).not.toContain('href="/admin/modlog?offset=100"'); 1934 + }); 1935 + 1936 + it("shows Previous link when not on first page", async () => { 1937 + setupSession(["space.atbb.permission.banUsers"]); 1938 + mockFetch.mockResolvedValueOnce( 1939 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1940 + ); 1941 + const routes = await loadAdminRoutes(); 1942 + const res = await routes.request("/admin/modlog?offset=50", { 1943 + headers: { cookie: "atbb_session=token" }, 1944 + }); 1945 + const html = await res.text(); 1946 + expect(html).toContain('href="/admin/modlog?offset=0"'); 1947 + expect(html).toContain("Previous"); 1948 + }); 1949 + 1950 + it("hides Previous link on first page", async () => { 1951 + setupSession(["space.atbb.permission.banUsers"]); 1952 + mockFetch.mockResolvedValueOnce( 1953 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1954 + ); 1955 + const routes = await loadAdminRoutes(); 1956 + const res = await routes.request("/admin/modlog", { 1957 + headers: { cookie: "atbb_session=token" }, 1958 + }); 1959 + const html = await res.text(); 1960 + expect(html).not.toContain('href="/admin/modlog?offset=-50"'); 1961 + expect(html).not.toContain("Previous"); 1962 + }); 1963 + 1964 + it("passes offset query param to AppView", async () => { 1965 + setupSession(["space.atbb.permission.banUsers"]); 1966 + mockFetch.mockResolvedValueOnce( 1967 + mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 }) 1968 + ); 1969 + const routes = await loadAdminRoutes(); 1970 + await routes.request("/admin/modlog?offset=50", { 1971 + headers: { cookie: "atbb_session=token" }, 1972 + }); 1973 + // Third fetch call (index 2) is the modlog API call 1974 + const modlogCall = mockFetch.mock.calls[2]; 1975 + expect(modlogCall[0]).toContain("offset=50"); 1976 + expect(modlogCall[0]).toContain("limit=50"); 1977 + }); 1978 + 1979 + it("ignores invalid offset and defaults to 0", async () => { 1980 + setupSession(["space.atbb.permission.banUsers"]); 1981 + mockFetch.mockResolvedValueOnce( 1982 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1983 + ); 1984 + const routes = await loadAdminRoutes(); 1985 + const res = await routes.request("/admin/modlog?offset=notanumber", { 1986 + headers: { cookie: "atbb_session=token" }, 1987 + }); 1988 + expect(res.status).toBe(200); 1989 + const modlogCall = mockFetch.mock.calls[2]; 1990 + expect(modlogCall[0]).toContain("offset=0"); 1991 + }); 1992 + 1993 + // ── Error handling ─────────────────────────────────────────────────────── 1994 + 1995 + it("returns 503 on AppView network error", async () => { 1996 + setupSession(["space.atbb.permission.banUsers"]); 1997 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1998 + const routes = await loadAdminRoutes(); 1999 + const res = await routes.request("/admin/modlog", { 2000 + headers: { cookie: "atbb_session=token" }, 2001 + }); 2002 + expect(res.status).toBe(503); 2003 + const html = await res.text(); 2004 + expect(html).toContain("error-display"); 2005 + }); 2006 + 2007 + it("returns 500 on AppView server error", async () => { 2008 + setupSession(["space.atbb.permission.banUsers"]); 2009 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 2010 + const routes = await loadAdminRoutes(); 2011 + const res = await routes.request("/admin/modlog", { 2012 + headers: { cookie: "atbb_session=token" }, 2013 + }); 2014 + expect(res.status).toBe(500); 2015 + const html = await res.text(); 2016 + expect(html).toContain("error-display"); 2017 + }); 2018 + 2019 + it("returns 500 when AppView returns non-JSON response body", async () => { 2020 + setupSession(["space.atbb.permission.banUsers"]); 2021 + mockFetch.mockResolvedValueOnce({ 2022 + ok: true, 2023 + status: 200, 2024 + statusText: "OK", 2025 + json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2026 + }); 2027 + const routes = await loadAdminRoutes(); 2028 + const res = await routes.request("/admin/modlog", { 2029 + headers: { cookie: "atbb_session=token" }, 2030 + }); 2031 + expect(res.status).toBe(500); 2032 + const html = await res.text(); 2033 + expect(html).toContain("error-display"); 2034 + }); 2035 + 2036 + it("redirects to /login when AppView returns 401", async () => { 2037 + setupSession(["space.atbb.permission.banUsers"]); 2038 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 2039 + const routes = await loadAdminRoutes(); 2040 + const res = await routes.request("/admin/modlog", { 2041 + headers: { cookie: "atbb_session=token" }, 2042 + }); 2043 + expect(res.status).toBe(302); 2044 + expect(res.headers.get("location")).toBe("/login"); 2045 + }); 2046 + });
+201
apps/web/src/routes/admin.tsx
··· 47 uri: string; 48 } 49 50 // ─── Helpers ─────────────────────────────────────────────────────────────── 51 52 function formatJoinedDate(isoString: string | null): string { ··· 60 }); 61 } 62 63 // ─── Components ──────────────────────────────────────────────────────────── 64 65 function MemberRow({ ··· 117 </td> 118 ) 119 )} 120 </tr> 121 ); 122 } ··· 1244 } 1245 1246 return c.redirect("/admin/structure", 302); 1247 }); 1248 1249 return app;
··· 47 uri: string; 48 } 49 50 + interface 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 + 62 + const 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 73 function formatJoinedDate(isoString: string | null): string { ··· 81 }); 82 } 83 84 + function 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 99 function MemberRow({ ··· 151 </td> 152 ) 153 )} 154 + </tr> 155 + ); 156 + } 157 + 158 + function 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 } ··· 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&apos;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;
+741
docs/plans/2026-03-01-atb-48-modlog-ui.md
···
··· 1 + # ATB-48: Admin Mod Action Log UI (/admin/modlog) Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `GET /admin/modlog` — a paginated, read-only audit log of moderation actions — to the web app's admin panel. 6 + 7 + **Architecture:** The web server proxies `GET /api/admin/modlog?limit=50&offset=N` from the AppView (already implemented in ATB-46), renders the result as an HTML table, and serves offset-based pagination via Next/Previous links. All permission gating uses the existing `canViewModLog()` session helper. 8 + 9 + **Tech Stack:** Hono (web server), Hono JSX (templates), Vitest (tests). No new files — only `apps/web/src/routes/admin.tsx` and `apps/web/src/routes/__tests__/admin.test.tsx` are touched. 10 + 11 + --- 12 + 13 + ## Context You Need to Know 14 + 15 + ### File locations 16 + - **Route to modify:** `apps/web/src/routes/admin.tsx` (add new route before `return app;` at line ~1249) 17 + - **Test file to modify:** `apps/web/src/routes/__tests__/admin.test.tsx` (add new `describe` block at the end) 18 + - **Session helpers:** `apps/web/src/lib/session.ts` — `canViewModLog()` already exists 19 + - **AppView endpoint already exists:** `GET /api/admin/modlog` in `apps/appview/src/routes/admin.ts` 20 + 21 + ### AppView response shape 22 + ```json 23 + { 24 + "actions": [ 25 + { 26 + "id": "123", 27 + "action": "space.atbb.modAction.ban", 28 + "moderatorDid": "did:plc:abc", 29 + "moderatorHandle": "alice.bsky.social", 30 + "subjectDid": "did:plc:xyz", 31 + "subjectHandle": "bob.bsky.social", 32 + "subjectPostUri": null, 33 + "reason": "Spam", 34 + "createdAt": "2026-02-26T12:01:00Z" 35 + } 36 + ], 37 + "total": 42, 38 + "offset": 0, 39 + "limit": 50 40 + } 41 + ``` 42 + 43 + ### Actual action token values (from mod.ts — differ from design doc) 44 + | Token | Human label | 45 + |-------|-------------| 46 + | `space.atbb.modAction.ban` | Ban | 47 + | `space.atbb.modAction.unban` | Unban | 48 + | `space.atbb.modAction.lock` | Lock | 49 + | `space.atbb.modAction.unlock` | Unlock | 50 + | `space.atbb.modAction.delete` | Hide | 51 + | `space.atbb.modAction.undelete` | Unhide | 52 + 53 + ### Subject column logic 54 + - If `subjectPostUri` is non-null → show the post URI (post-targeting action) 55 + - Else if `subjectHandle` is non-null → show the handle (user-targeting action) 56 + - Else → show `subjectDid` as fallback (handle not indexed) 57 + 58 + ### Permission gate 59 + Any of: `space.atbb.permission.moderatePosts`, `space.atbb.permission.banUsers`, `space.atbb.permission.lockTopics` (or `*`). Use `canViewModLog(auth)` from session.ts. 60 + 61 + ### Pagination design 62 + - 50 rows per page, read `?offset` from query string (default 0) 63 + - Previous link: `href="/admin/modlog?offset={offset - 50}"` (hidden when offset === 0) 64 + - Next link: `href="/admin/modlog?offset={offset + 50}"` (hidden when offset + limit >= total) 65 + - Page indicator: `Page {currentPage} of {totalPages}` 66 + 67 + ### Test pattern (copy from existing describe blocks) 68 + Each `describe` block in the test file: 69 + 1. `beforeEach` stubs global `fetch`, `APPVIEW_URL`, and calls `vi.resetModules()` 70 + 2. `afterEach` unstubs everything 71 + 3. `setupSession(permissions)` mock sets up the two-call auth sequence (session + members/me) 72 + 4. `loadAdminRoutes()` does dynamic import to get a fresh Hono app per test 73 + 74 + --- 75 + 76 + ## Task 1: Add types and helpers to admin.tsx 77 + 78 + **Files:** 79 + - Modify: `apps/web/src/routes/admin.tsx` (add after the `BoardEntry` interface, around line 48) 80 + 81 + **Step 1: Add `ModLogEntry` interface and `ACTION_LABELS` map** 82 + 83 + Add these after the `BoardEntry` interface (before the `// ─── Helpers ───` section): 84 + 85 + ```typescript 86 + interface ModLogEntry { 87 + id: string; 88 + action: string; 89 + moderatorDid: string; 90 + moderatorHandle: string; 91 + subjectDid: string | null; 92 + subjectHandle: string | null; 93 + subjectPostUri: string | null; 94 + reason: string | null; 95 + createdAt: string; 96 + } 97 + 98 + const ACTION_LABELS: Record<string, string> = { 99 + "space.atbb.modAction.ban": "Ban", 100 + "space.atbb.modAction.unban": "Unban", 101 + "space.atbb.modAction.lock": "Lock", 102 + "space.atbb.modAction.unlock": "Unlock", 103 + "space.atbb.modAction.delete": "Hide", 104 + "space.atbb.modAction.undelete": "Unhide", 105 + }; 106 + ``` 107 + 108 + **Step 2: Add `formatModLogDate` helper** 109 + 110 + Add after `formatJoinedDate` in the `// ─── Helpers ───` section: 111 + 112 + ```typescript 113 + function formatModLogDate(isoString: string): string { 114 + const d = new Date(isoString); 115 + if (isNaN(d.getTime())) return "—"; 116 + return d.toLocaleString("en-US", { 117 + year: "numeric", 118 + month: "2-digit", 119 + day: "2-digit", 120 + hour: "2-digit", 121 + minute: "2-digit", 122 + hour12: false, 123 + }); 124 + } 125 + ``` 126 + 127 + **Step 3: No test to run yet — verify TypeScript compiles** 128 + 129 + Run: `PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web build` (or skip — build happens in Task 3 after route is added) 130 + 131 + **Step 4: No commit yet — combine with Task 2** 132 + 133 + --- 134 + 135 + ## Task 2: Add ModLogRow component to admin.tsx 136 + 137 + **Files:** 138 + - Modify: `apps/web/src/routes/admin.tsx` (add in the `// ─── Components ───` section, after `MemberRow`) 139 + 140 + **Step 1: Add `ModLogRow` component** 141 + 142 + Add after the `MemberRow` component (around line 122): 143 + 144 + ```tsx 145 + function ModLogRow({ entry }: { entry: ModLogEntry }) { 146 + const label = ACTION_LABELS[entry.action] ?? entry.action; 147 + const subject = entry.subjectPostUri 148 + ? entry.subjectPostUri 149 + : (entry.subjectHandle ?? entry.subjectDid ?? "—"); 150 + 151 + return ( 152 + <tr> 153 + <td class="modlog-table__time">{formatModLogDate(entry.createdAt)}</td> 154 + <td class="modlog-table__moderator">{entry.moderatorHandle}</td> 155 + <td class="modlog-table__action"> 156 + <span class={`modlog-action-badge modlog-action-badge--${label.toLowerCase()}`}> 157 + {label} 158 + </span> 159 + </td> 160 + <td class="modlog-table__subject">{subject}</td> 161 + <td class="modlog-table__reason">{entry.reason ?? "—"}</td> 162 + </tr> 163 + ); 164 + } 165 + ``` 166 + 167 + **Step 2: No commit yet — combine with Task 3** 168 + 169 + --- 170 + 171 + ## Task 3: Add GET /admin/modlog route to admin.tsx 172 + 173 + **Files:** 174 + - Modify: `apps/web/src/routes/admin.tsx` (add before `return app;` at the end of `createAdminRoutes`) 175 + 176 + **Step 1: Write the failing test first (see Task 4, Step 1)** 177 + 178 + Skip ahead to Task 4 to write one smoke-test first, then come back here. 179 + 180 + **Step 2: Add the route** 181 + 182 + Add before `return app;` (around line 1249): 183 + 184 + ```tsx 185 + // ── GET /admin/modlog ───────────────────────────────────────────────────── 186 + 187 + app.get("/admin/modlog", async (c) => { 188 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 189 + 190 + if (!auth.authenticated) { 191 + return c.redirect("/login"); 192 + } 193 + 194 + if (!canViewModLog(auth)) { 195 + return c.html( 196 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 197 + <PageHeader title="Mod Action Log" /> 198 + <p>You don&apos;t have permission to view the mod action log.</p> 199 + </BaseLayout>, 200 + 403 201 + ); 202 + } 203 + 204 + const rawOffset = c.req.query("offset"); 205 + const offset = rawOffset !== undefined && /^\d+$/.test(rawOffset) 206 + ? parseInt(rawOffset, 10) 207 + : 0; 208 + const limit = 50; 209 + 210 + const cookie = c.req.header("cookie") ?? ""; 211 + 212 + let modlogRes: Response; 213 + try { 214 + modlogRes = await fetch( 215 + `${appviewUrl}/api/admin/modlog?limit=${limit}&offset=${offset}`, 216 + { headers: { Cookie: cookie } } 217 + ); 218 + } catch (error) { 219 + if (isProgrammingError(error)) throw error; 220 + logger.error("Network error fetching mod action log", { 221 + operation: "GET /admin/modlog", 222 + error: error instanceof Error ? error.message : String(error), 223 + }); 224 + return c.html( 225 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 226 + <PageHeader title="Mod Action Log" /> 227 + <ErrorDisplay 228 + message="Unable to load mod action log" 229 + detail="The forum is temporarily unavailable. Please try again." 230 + /> 231 + </BaseLayout>, 232 + 503 233 + ); 234 + } 235 + 236 + if (!modlogRes.ok) { 237 + if (modlogRes.status === 401) { 238 + return c.redirect("/login"); 239 + } 240 + logger.error("AppView returned error for mod action log", { 241 + operation: "GET /admin/modlog", 242 + status: modlogRes.status, 243 + }); 244 + return c.html( 245 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 246 + <PageHeader title="Mod Action Log" /> 247 + <ErrorDisplay 248 + message="Something went wrong" 249 + detail="Could not load mod action log. Please try again." 250 + /> 251 + </BaseLayout>, 252 + 500 253 + ); 254 + } 255 + 256 + const data = (await modlogRes.json()) as { 257 + actions: ModLogEntry[]; 258 + total: number; 259 + offset: number; 260 + limit: number; 261 + }; 262 + 263 + const { actions, total } = data; 264 + const totalPages = total === 0 ? 1 : Math.ceil(total / limit); 265 + const currentPage = Math.floor(offset / limit) + 1; 266 + const hasPrev = offset > 0; 267 + const hasNext = offset + limit < total; 268 + 269 + return c.html( 270 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 271 + <PageHeader title="Mod Action Log" /> 272 + {actions.length === 0 ? ( 273 + <EmptyState message="No moderation actions yet" /> 274 + ) : ( 275 + <div class="card"> 276 + <table class="modlog-table"> 277 + <thead> 278 + <tr> 279 + <th scope="col">Time</th> 280 + <th scope="col">Moderator</th> 281 + <th scope="col">Action</th> 282 + <th scope="col">Subject</th> 283 + <th scope="col">Reason</th> 284 + </tr> 285 + </thead> 286 + <tbody> 287 + {actions.map((entry) => ( 288 + <ModLogRow entry={entry} /> 289 + ))} 290 + </tbody> 291 + </table> 292 + </div> 293 + )} 294 + <div class="modlog-pagination"> 295 + {hasPrev && ( 296 + <a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary"> 297 + ← Previous 298 + </a> 299 + )} 300 + <span class="modlog-pagination__indicator"> 301 + Page {currentPage} of {totalPages} 302 + </span> 303 + {hasNext && ( 304 + <a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary"> 305 + Next → 306 + </a> 307 + )} 308 + </div> 309 + </BaseLayout> 310 + ); 311 + }); 312 + ``` 313 + 314 + **Step 3: Run tests (after writing them in Task 4)** 315 + 316 + Run: `PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test` 317 + Expected: All tests pass 318 + 319 + **Step 4: Commit** 320 + 321 + ```bash 322 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 323 + git commit -m "feat(web): admin mod action log page — /admin/modlog (ATB-48)" 324 + ``` 325 + 326 + --- 327 + 328 + ## Task 4: Add tests to admin.test.tsx 329 + 330 + **Files:** 331 + - Modify: `apps/web/src/routes/__tests__/admin.test.tsx` (add new `describe` block at the end) 332 + 333 + **Step 1: Write the describe block with all required tests** 334 + 335 + Append to the end of `admin.test.tsx`: 336 + 337 + ```typescript 338 + describe("createAdminRoutes — GET /admin/modlog", () => { 339 + beforeEach(() => { 340 + vi.stubGlobal("fetch", mockFetch); 341 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 342 + vi.resetModules(); 343 + }); 344 + 345 + afterEach(() => { 346 + vi.unstubAllGlobals(); 347 + vi.unstubAllEnvs(); 348 + mockFetch.mockReset(); 349 + }); 350 + 351 + function mockResponse(body: unknown, ok = true, status = 200) { 352 + return { 353 + ok, 354 + status, 355 + statusText: ok ? "OK" : "Error", 356 + json: () => Promise.resolve(body), 357 + }; 358 + } 359 + 360 + function setupSession(permissions: string[]) { 361 + mockFetch.mockResolvedValueOnce( 362 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 363 + ); 364 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 365 + } 366 + 367 + async function loadAdminRoutes() { 368 + const { createAdminRoutes } = await import("../admin.js"); 369 + return createAdminRoutes("http://localhost:3000"); 370 + } 371 + 372 + const SAMPLE_ACTIONS = [ 373 + { 374 + id: "1", 375 + action: "space.atbb.modAction.ban", 376 + moderatorDid: "did:plc:alice", 377 + moderatorHandle: "alice.bsky.social", 378 + subjectDid: "did:plc:bob", 379 + subjectHandle: "bob.bsky.social", 380 + subjectPostUri: null, 381 + reason: "Spam", 382 + createdAt: "2026-02-26T12:01:00.000Z", 383 + }, 384 + { 385 + id: "2", 386 + action: "space.atbb.modAction.delete", 387 + moderatorDid: "did:plc:alice", 388 + moderatorHandle: "alice.bsky.social", 389 + subjectDid: null, 390 + subjectHandle: null, 391 + subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123", 392 + reason: "Inappropriate", 393 + createdAt: "2026-02-26T11:30:00.000Z", 394 + }, 395 + ]; 396 + 397 + // ── Auth & permission gates ────────────────────────────────────────────── 398 + 399 + it("redirects unauthenticated users to /login", async () => { 400 + const routes = await loadAdminRoutes(); 401 + const res = await routes.request("/admin/modlog"); 402 + expect(res.status).toBe(302); 403 + expect(res.headers.get("location")).toBe("/login"); 404 + }); 405 + 406 + it("returns 403 for user without any mod permission", async () => { 407 + setupSession(["space.atbb.permission.manageCategories"]); 408 + const routes = await loadAdminRoutes(); 409 + const res = await routes.request("/admin/modlog", { 410 + headers: { cookie: "atbb_session=token" }, 411 + }); 412 + expect(res.status).toBe(403); 413 + const html = await res.text(); 414 + expect(html).toContain("permission"); 415 + }); 416 + 417 + it("allows access for moderatePosts permission", async () => { 418 + setupSession(["space.atbb.permission.moderatePosts"]); 419 + mockFetch.mockResolvedValueOnce( 420 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 421 + ); 422 + const routes = await loadAdminRoutes(); 423 + const res = await routes.request("/admin/modlog", { 424 + headers: { cookie: "atbb_session=token" }, 425 + }); 426 + expect(res.status).toBe(200); 427 + }); 428 + 429 + it("allows access for banUsers permission", async () => { 430 + setupSession(["space.atbb.permission.banUsers"]); 431 + mockFetch.mockResolvedValueOnce( 432 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 433 + ); 434 + const routes = await loadAdminRoutes(); 435 + const res = await routes.request("/admin/modlog", { 436 + headers: { cookie: "atbb_session=token" }, 437 + }); 438 + expect(res.status).toBe(200); 439 + }); 440 + 441 + it("allows access for lockTopics permission", async () => { 442 + setupSession(["space.atbb.permission.lockTopics"]); 443 + mockFetch.mockResolvedValueOnce( 444 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 445 + ); 446 + const routes = await loadAdminRoutes(); 447 + const res = await routes.request("/admin/modlog", { 448 + headers: { cookie: "atbb_session=token" }, 449 + }); 450 + expect(res.status).toBe(200); 451 + }); 452 + 453 + // ── Table rendering ────────────────────────────────────────────────────── 454 + 455 + it("renders table with moderator handle and action label", async () => { 456 + setupSession(["space.atbb.permission.banUsers"]); 457 + mockFetch.mockResolvedValueOnce( 458 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 459 + ); 460 + const routes = await loadAdminRoutes(); 461 + const res = await routes.request("/admin/modlog", { 462 + headers: { cookie: "atbb_session=token" }, 463 + }); 464 + const html = await res.text(); 465 + expect(html).toContain("alice.bsky.social"); 466 + expect(html).toContain("Ban"); 467 + expect(html).toContain("bob.bsky.social"); 468 + expect(html).toContain("Spam"); 469 + }); 470 + 471 + it("maps space.atbb.modAction.delete to 'Hide' label", async () => { 472 + setupSession(["space.atbb.permission.moderatePosts"]); 473 + mockFetch.mockResolvedValueOnce( 474 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 475 + ); 476 + const routes = await loadAdminRoutes(); 477 + const res = await routes.request("/admin/modlog", { 478 + headers: { cookie: "atbb_session=token" }, 479 + }); 480 + const html = await res.text(); 481 + expect(html).toContain("Hide"); 482 + }); 483 + 484 + it("shows post URI in subject column for post-targeting actions", async () => { 485 + setupSession(["space.atbb.permission.moderatePosts"]); 486 + mockFetch.mockResolvedValueOnce( 487 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 488 + ); 489 + const routes = await loadAdminRoutes(); 490 + const res = await routes.request("/admin/modlog", { 491 + headers: { cookie: "atbb_session=token" }, 492 + }); 493 + const html = await res.text(); 494 + expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123"); 495 + }); 496 + 497 + it("shows handle in subject column for user-targeting actions", async () => { 498 + setupSession(["space.atbb.permission.banUsers"]); 499 + mockFetch.mockResolvedValueOnce( 500 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 501 + ); 502 + const routes = await loadAdminRoutes(); 503 + const res = await routes.request("/admin/modlog", { 504 + headers: { cookie: "atbb_session=token" }, 505 + }); 506 + const html = await res.text(); 507 + expect(html).toContain("bob.bsky.social"); 508 + }); 509 + 510 + it("shows empty state when no actions", async () => { 511 + setupSession(["space.atbb.permission.banUsers"]); 512 + mockFetch.mockResolvedValueOnce( 513 + mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 514 + ); 515 + const routes = await loadAdminRoutes(); 516 + const res = await routes.request("/admin/modlog", { 517 + headers: { cookie: "atbb_session=token" }, 518 + }); 519 + const html = await res.text(); 520 + expect(html).toContain("No moderation actions"); 521 + }); 522 + 523 + // ── Pagination ─────────────────────────────────────────────────────────── 524 + 525 + it("renders 'Page 1 of 2' indicator for 51 total actions", async () => { 526 + setupSession(["space.atbb.permission.banUsers"]); 527 + mockFetch.mockResolvedValueOnce( 528 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 529 + ); 530 + const routes = await loadAdminRoutes(); 531 + const res = await routes.request("/admin/modlog", { 532 + headers: { cookie: "atbb_session=token" }, 533 + }); 534 + const html = await res.text(); 535 + expect(html).toContain("Page 1 of 2"); 536 + }); 537 + 538 + it("shows Next link when more pages exist", async () => { 539 + setupSession(["space.atbb.permission.banUsers"]); 540 + mockFetch.mockResolvedValueOnce( 541 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 542 + ); 543 + const routes = await loadAdminRoutes(); 544 + const res = await routes.request("/admin/modlog", { 545 + headers: { cookie: "atbb_session=token" }, 546 + }); 547 + const html = await res.text(); 548 + expect(html).toContain('href="/admin/modlog?offset=50"'); 549 + expect(html).toContain("Next"); 550 + }); 551 + 552 + it("hides Next link on last page", async () => { 553 + setupSession(["space.atbb.permission.banUsers"]); 554 + mockFetch.mockResolvedValueOnce( 555 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 556 + ); 557 + const routes = await loadAdminRoutes(); 558 + const res = await routes.request("/admin/modlog?offset=50", { 559 + headers: { cookie: "atbb_session=token" }, 560 + }); 561 + const html = await res.text(); 562 + expect(html).not.toContain('href="/admin/modlog?offset=100"'); 563 + }); 564 + 565 + it("shows Previous link when not on first page", async () => { 566 + setupSession(["space.atbb.permission.banUsers"]); 567 + mockFetch.mockResolvedValueOnce( 568 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 569 + ); 570 + const routes = await loadAdminRoutes(); 571 + const res = await routes.request("/admin/modlog?offset=50", { 572 + headers: { cookie: "atbb_session=token" }, 573 + }); 574 + const html = await res.text(); 575 + expect(html).toContain('href="/admin/modlog?offset=0"'); 576 + expect(html).toContain("Previous"); 577 + }); 578 + 579 + it("hides Previous link on first page", async () => { 580 + setupSession(["space.atbb.permission.banUsers"]); 581 + mockFetch.mockResolvedValueOnce( 582 + mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 583 + ); 584 + const routes = await loadAdminRoutes(); 585 + const res = await routes.request("/admin/modlog", { 586 + headers: { cookie: "atbb_session=token" }, 587 + }); 588 + const html = await res.text(); 589 + expect(html).not.toContain('href="/admin/modlog?offset=-50"'); 590 + expect(html).not.toContain("Previous"); 591 + }); 592 + 593 + it("passes offset query param to AppView", async () => { 594 + setupSession(["space.atbb.permission.banUsers"]); 595 + mockFetch.mockResolvedValueOnce( 596 + mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 }) 597 + ); 598 + const routes = await loadAdminRoutes(); 599 + await routes.request("/admin/modlog?offset=50", { 600 + headers: { cookie: "atbb_session=token" }, 601 + }); 602 + // Third fetch call (index 2) is the modlog API call 603 + const modlogCall = mockFetch.mock.calls[2]; 604 + expect(modlogCall[0]).toContain("offset=50"); 605 + expect(modlogCall[0]).toContain("limit=50"); 606 + }); 607 + 608 + it("ignores invalid offset and defaults to 0", async () => { 609 + setupSession(["space.atbb.permission.banUsers"]); 610 + mockFetch.mockResolvedValueOnce( 611 + mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 612 + ); 613 + const routes = await loadAdminRoutes(); 614 + const res = await routes.request("/admin/modlog?offset=notanumber", { 615 + headers: { cookie: "atbb_session=token" }, 616 + }); 617 + expect(res.status).toBe(200); 618 + const modlogCall = mockFetch.mock.calls[2]; 619 + expect(modlogCall[0]).toContain("offset=0"); 620 + }); 621 + 622 + // ── Error handling ─────────────────────────────────────────────────────── 623 + 624 + it("returns 503 on AppView network error", async () => { 625 + setupSession(["space.atbb.permission.banUsers"]); 626 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 627 + const routes = await loadAdminRoutes(); 628 + const res = await routes.request("/admin/modlog", { 629 + headers: { cookie: "atbb_session=token" }, 630 + }); 631 + expect(res.status).toBe(503); 632 + const html = await res.text(); 633 + expect(html).toContain("error-display"); 634 + }); 635 + 636 + it("returns 500 on AppView server error", async () => { 637 + setupSession(["space.atbb.permission.banUsers"]); 638 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 639 + const routes = await loadAdminRoutes(); 640 + const res = await routes.request("/admin/modlog", { 641 + headers: { cookie: "atbb_session=token" }, 642 + }); 643 + expect(res.status).toBe(500); 644 + const html = await res.text(); 645 + expect(html).toContain("error-display"); 646 + }); 647 + 648 + it("redirects to /login when AppView returns 401", async () => { 649 + setupSession(["space.atbb.permission.banUsers"]); 650 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 651 + const routes = await loadAdminRoutes(); 652 + const res = await routes.request("/admin/modlog", { 653 + headers: { cookie: "atbb_session=token" }, 654 + }); 655 + expect(res.status).toBe(302); 656 + expect(res.headers.get("location")).toBe("/login"); 657 + }); 658 + }); 659 + ``` 660 + 661 + **Step 2: Run the tests (all should fail — route not added yet)** 662 + 663 + Run: `PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test` 664 + Expected: New modlog tests FAIL ("Cannot read properties of undefined" or similar) 665 + 666 + **Step 3: Now go implement Tasks 1–3 (types, component, route)** 667 + 668 + **Step 4: Run tests again** 669 + 670 + Run: `PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test` 671 + Expected: All tests PASS 672 + 673 + **Step 5: Commit** 674 + 675 + ```bash 676 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 677 + git commit -m "feat(web): admin mod action log page — /admin/modlog (ATB-48)" 678 + ``` 679 + 680 + --- 681 + 682 + ## Task 5: Run full test suite 683 + 684 + **Step 1: Run all tests** 685 + 686 + Run: `PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm test` 687 + Expected: All tests pass 688 + 689 + **Step 2: If tests fail** — check whether it's a Turbo env var issue. See CLAUDE.md section "Environment Variables in Tests". 690 + 691 + --- 692 + 693 + ## Task 6: Update documentation 694 + 695 + **Step 1: Move plan doc to complete/** 696 + 697 + No — plan docs only move to `docs/plans/complete/` after work is *shipped* (merged). Leave this plan in `docs/plans/` until the PR is merged. 698 + 699 + **Step 2: Update docs/atproto-forum-plan.md** 700 + 701 + In `docs/atproto-forum-plan.md`, find the ATB-48 entry and mark it `[x]`. Add a note: `ATB-48: /admin/modlog UI — see PR`. 702 + 703 + **Step 3: Update Linear** 704 + 705 + - Change ATB-48 status from Backlog → In Progress → Done 706 + - Add a comment: "Implemented `GET /admin/modlog` in `apps/web/src/routes/admin.tsx`. Permission gate uses `canViewModLog()`. Offset pagination with 50 rows per page. Action labels mapped from actual `space.atbb.modAction.*` tokens." 707 + 708 + **Step 4: Commit doc update (after PR is approved)** 709 + 710 + ```bash 711 + git add docs/atproto-forum-plan.md 712 + git commit -m "docs: mark ATB-48 complete" 713 + ``` 714 + 715 + --- 716 + 717 + ## Task 7: Open PR 718 + 719 + ```bash 720 + git push -u origin HEAD 721 + gh pr create \ 722 + --title "feat(web): admin mod action log UI — /admin/modlog (ATB-48)" \ 723 + --body "$(cat <<'EOF' 724 + ## Summary 725 + - Adds `GET /admin/modlog` to the web admin panel 726 + - Permission gate: any of `moderatePosts`, `banUsers`, `lockTopics` 727 + - Renders a table with timestamp, moderator, action (human-readable label), subject, and reason 728 + - Offset-based pagination: 50 rows per page, Previous/Next links 729 + - Action tokens mapped to labels: `space.atbb.modAction.delete` → Hide, `space.atbb.modAction.lock` → Lock, etc. 730 + - Subject column shows handle for user-targeting actions, AT URI for post-targeting actions 731 + - Error handling: 503 for network errors, 500 for server errors, redirect for 401 732 + 733 + ## Test plan 734 + - [ ] All new tests in `admin.test.tsx` pass 735 + - [ ] Full `pnpm test` passes 736 + - [ ] Manual smoke: log in with a mod permission, navigate to `/admin/modlog` 737 + - [ ] Manual: verify pagination Next/Previous links work with offset param 738 + - [ ] Manual: verify the admin landing page mod log card links to `/admin/modlog` 739 + EOF 740 + )" 741 + ```
+18
docs/plans/complete/atproto-forum-plan.md
··· 280 - `canManageRoles` session helper added to `apps/web/src/lib/session.ts` 281 - 31 integration tests: auth guards, table render, role form visibility, HTMX success/error paths, 503/500 error display 282 - Files: `apps/web/src/routes/admin.tsx`, `apps/web/src/lib/session.ts`, `apps/web/public/static/css/theme.css`, `apps/appview/src/routes/admin.ts` 283 - [x] Basic responsive design 284 - ATB-32 | Mobile-first responsive breakpoints (375px/768px/1024px), CSS-only hamburger nav via `<details>`/`<summary>`, token overrides for mobile, accessibility improvements (skip link, focus-visible, ARIA attributes, semantic HTML), 404 page, visual polish (transitions, hover states), SVG favicon 285 - [x] Show author handles in posts
··· 280 - `canManageRoles` session helper added to `apps/web/src/lib/session.ts` 281 - 31 integration tests: auth guards, table render, role form visibility, HTMX success/error paths, 503/500 error display 282 - Files: `apps/web/src/routes/admin.tsx`, `apps/web/src/lib/session.ts`, `apps/web/public/static/css/theme.css`, `apps/appview/src/routes/admin.ts` 283 + - [x] **ATB-46: Admin panel: mod action log AppView endpoint** — **Complete:** 2026-03-02 284 + - `GET /api/admin/modlog` — paginated, reverse-chronological list; requires any of `moderatePosts`, `banUsers`, or `lockTopics` 285 + - Joins `users` table twice via Drizzle `alias()` pattern: one alias for moderator handle, one for subject handle 286 + - `subjectDid`/`subjectHandle` null for post-targeting actions (hide/lock); `subjectPostUri` null for user-targeting actions (ban) 287 + - `moderatorHandle` falls back to `moderatorDid` when user record lacks an indexed handle 288 + - Files: `apps/appview/src/routes/admin.ts`, `bruno/AppView API/Admin/Get Mod Action Log.bru` 289 + - [x] **ATB-47: Admin panel: forum structure management UI (`/admin/structure`)** — **Complete:** 2026-03-01 290 + - `GET /admin/structure` — full CRUD for categories and boards; permission gate: `manageCategories` 291 + - Web-layer POST proxy routes translate to the correct HTTP method (PUT/DELETE) for AppView 292 + - Inline edit forms and `<dialog>` confirmation for delete; AppView 409 (referential integrity) rendered as human-readable inline error 293 + - Files: `apps/web/src/routes/admin.tsx`, `apps/web/public/static/css/theme.css` 294 + - [x] **ATB-48: Admin panel: mod action log UI (`/admin/modlog`)** — **Complete:** 2026-03-01 295 + - `GET /admin/modlog` — read-only paginated table; permission gate: `canViewModLog()` (any of `moderatePosts`, `banUsers`, `lockTopics`) 296 + - `ACTION_LABELS` constant maps full lexicon tokens (e.g. `space.atbb.modAction.ban`) to labels ("Ban", "Hide", "Lock", etc.); falls back to raw token for unknown actions 297 + - Subject column: `subjectPostUri` shown for post-targeting actions; `subjectHandle ?? subjectDid` for user-targeting 298 + - Offset pagination: Previous/Next links, 50 rows per page; invalid offset defaults to 0 299 + - 18 integration tests covering auth gates, table render, action label mapping, subject column logic, pagination, error display 300 + - Files: `apps/web/src/routes/admin.tsx`, `apps/web/src/routes/__tests__/admin.test.tsx`, `apps/web/public/static/css/theme.css` 301 - [x] Basic responsive design 302 - ATB-32 | Mobile-first responsive breakpoints (375px/768px/1024px), CSS-only hamburger nav via `<details>`/`<summary>`, token overrides for mobile, accessibility improvements (skip link, focus-visible, ARIA attributes, semantic HTML), 404 page, visual polish (transitions, hover states), SVG favicon 303 - [x] Show author handles in posts