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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

Malpercio 55437978 80496123

+505
+322
apps/web/src/routes/__tests__/admin.test.tsx
··· 1705 1705 expect(location).toContain("error="); 1706 1706 }); 1707 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("redirects to /login when AppView returns 401", async () => { 2020 + setupSession(["space.atbb.permission.banUsers"]); 2021 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 2022 + const routes = await loadAdminRoutes(); 2023 + const res = await routes.request("/admin/modlog", { 2024 + headers: { cookie: "atbb_session=token" }, 2025 + }); 2026 + expect(res.status).toBe(302); 2027 + expect(res.headers.get("location")).toBe("/login"); 2028 + }); 2029 + });
+183
apps/web/src/routes/admin.tsx
··· 47 47 uri: string; 48 48 } 49 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 + 50 71 // ─── Helpers ─────────────────────────────────────────────────────────────── 51 72 52 73 function formatJoinedDate(isoString: string | null): string { ··· 60 81 }); 61 82 } 62 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 + 63 97 // ─── Components ──────────────────────────────────────────────────────────── 64 98 65 99 function MemberRow({ ··· 117 151 </td> 118 152 ) 119 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> 120 175 </tr> 121 176 ); 122 177 } ··· 1244 1299 } 1245 1300 1246 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 + const data = (await modlogRes.json()) as { 1376 + actions: ModLogEntry[]; 1377 + total: number; 1378 + offset: number; 1379 + limit: number; 1380 + }; 1381 + 1382 + const { actions, total } = data; 1383 + const totalPages = total === 0 ? 1 : Math.ceil(total / limit); 1384 + const currentPage = Math.floor(offset / limit) + 1; 1385 + const hasPrev = offset > 0; 1386 + const hasNext = offset + limit < total; 1387 + 1388 + return c.html( 1389 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1390 + <PageHeader title="Mod Action Log" /> 1391 + {actions.length === 0 ? ( 1392 + <EmptyState message="No moderation actions yet" /> 1393 + ) : ( 1394 + <div class="card"> 1395 + <table class="modlog-table"> 1396 + <thead> 1397 + <tr> 1398 + <th scope="col">Time</th> 1399 + <th scope="col">Moderator</th> 1400 + <th scope="col">Action</th> 1401 + <th scope="col">Subject</th> 1402 + <th scope="col">Reason</th> 1403 + </tr> 1404 + </thead> 1405 + <tbody> 1406 + {actions.map((entry) => ( 1407 + <ModLogRow entry={entry} /> 1408 + ))} 1409 + </tbody> 1410 + </table> 1411 + </div> 1412 + )} 1413 + <div class="modlog-pagination"> 1414 + {hasPrev && ( 1415 + <a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary"> 1416 + ← Previous 1417 + </a> 1418 + )} 1419 + <span class="modlog-pagination__indicator"> 1420 + Page {currentPage} of {totalPages} 1421 + </span> 1422 + {hasNext && ( 1423 + <a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary"> 1424 + Next → 1425 + </a> 1426 + )} 1427 + </div> 1428 + </BaseLayout> 1429 + ); 1247 1430 }); 1248 1431 1249 1432 return app;