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+appview): admin theme list page — GET /admin/themes + CRUD routes (ATB-58) (#88)

* feat(appview): add GET /api/admin/themes — unfiltered theme list for admin UI (ATB-58)

* fix(appview): add cleanDatabase, isTruncated, and Bruno collection for GET /api/admin/themes (ATB-58)

* feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58)

* fix(appview): use != null guards for optional fields and add cssOverrides/fontUrls test in duplicate (ATB-58)

* feat(web): add canManageThemes permission check and Themes card on admin landing (ATB-58)

* test(web): add negative assertions to admin landing page permission tests

Add missing negative assertions to ensure single-permission tests verify
that unrelated cards are not shown. The Themes card test now asserts that
members/structure/modlog links are absent; the manageCategories, moderatePosts,
banUsers, and lockTopics tests now assert that the themes link is absent.

* test(web): complete themes card assertions across all admin landing tests

Add missing `href="/admin/themes"` assertions to three tests:
- wildcard (*) permission test: assert themes card IS shown
- manageMembers-only test: assert themes card is NOT shown
- manageMembers + moderatePosts combo test: assert themes card is NOT shown

* feat(web): implement GET /admin/themes page — theme cards, policy form, create form (ATB-58)

* fix(web): rename _THEME_PRESETS and log non-404 policy fetch errors (ATB-58)

* feat(web): POST /admin/themes — create theme from preset and redirect (ATB-58)

* feat(web): POST /admin/themes/:rkey/duplicate — proxy duplicate to AppView (ATB-58)

* feat(web): POST /admin/themes/:rkey/delete — proxy delete to AppView with 409 handling (ATB-58)

* feat(web): POST /admin/theme-policy — update theme policy with availability and defaults (ATB-58)

* fix(appview): PUT /theme-policy accepts availableThemes without cid — looks up from DB (ATB-58)

* fix(web): add auth/permission/network tests and 409-specific delete handling (ATB-58)

Add missing unauthenticated, 403, and network-error tests to all four POST
theme routes. Separate the 409 branch in POST /admin/themes/:rkey/delete to
return a web-layer-owned human-friendly message. Strengthen the availableThemes
assertion in the theme-policy success test to verify exact payload shape.

* fix(atb-58): address PR review — CID validation, SyntaxError handling, Bruno seq

- Return 400 (not 200 with cid:"") when availableThemes contains uri-only entries
not found in the themes DB — empty string is not a valid AT Proto strongRef CID
- Wrap Response.json() calls in GET /admin/themes in inner try-catch so upstream
non-JSON responses are caught as parse errors rather than re-thrown as programming
errors via isProgrammingError(SyntaxError)
- Fix Bruno seq conflict: Duplicate Theme seq 4→5, List Themes seq 5→6

* fix(atb-58): block cid:\"\" as invalid strongRef; add DB failure test for needsLookup

- Introduce isMissingCid predicate (typeof !== string || === "") applied to all
three sites: needsLookup check, unresolvedUris filter, resolvedThemes map.
Explicit cid:"" bypassed the previous typeof-only guard and would have been
written verbatim to the PDS as an invalid strongRef CID.
- Add test: cid:"" entry returns 400 (same as absent cid not found in DB)
- Add test: DB select failure during needsLookup returns 500

authored by

Malpercio and committed by
GitHub
f94f86fb 6152fc71

+1852 -18
+247 -6
apps/appview/src/routes/__tests__/admin.test.ts
··· 2445 2445 }); 2446 2446 }); 2447 2447 2448 + describe("GET /api/admin/themes", () => { 2449 + beforeEach(async () => { 2450 + await ctx.cleanDatabase(); 2451 + }); 2452 + 2453 + it("returns empty array when no themes exist", async () => { 2454 + const res = await app.request("/api/admin/themes"); 2455 + expect(res.status).toBe(200); 2456 + const body = await res.json(); 2457 + expect(body).toHaveProperty("themes"); 2458 + expect(body.themes).toEqual([]); 2459 + }); 2460 + 2461 + it("returns all themes regardless of policy availability", async () => { 2462 + // Insert two themes but only add one to policy 2463 + await ctx.db.insert(themes).values([ 2464 + { 2465 + did: ctx.config.forumDid, 2466 + rkey: "3lbltheme1aa", 2467 + cid: "bafytheme1", 2468 + name: "Neobrutal Light", 2469 + colorScheme: "light", 2470 + tokens: { "color-bg": "#f5f0e8" }, 2471 + createdAt: new Date(), 2472 + indexedAt: new Date(), 2473 + }, 2474 + { 2475 + did: ctx.config.forumDid, 2476 + rkey: "3lbltheme2bb", 2477 + cid: "bafytheme2", 2478 + name: "Neobrutal Dark", 2479 + colorScheme: "dark", 2480 + tokens: { "color-bg": "#1a1a1a" }, 2481 + createdAt: new Date(), 2482 + indexedAt: new Date(), 2483 + }, 2484 + ]); 2485 + 2486 + const res = await app.request("/api/admin/themes"); 2487 + expect(res.status).toBe(200); 2488 + const body = await res.json(); 2489 + 2490 + // Returns BOTH themes — not filtered by policy 2491 + expect(body.themes).toHaveLength(2); 2492 + expect(body.themes[0]).toMatchObject({ 2493 + name: "Neobrutal Light", 2494 + colorScheme: "light", 2495 + }); 2496 + expect(body.themes[0]).toHaveProperty("tokens"); 2497 + expect(body.themes[0]).toHaveProperty("uri"); 2498 + expect(body.themes[0].uri).toContain("space.atbb.forum.theme"); 2499 + }); 2500 + 2501 + it("returns 401 when not authenticated", async () => { 2502 + mockUser = null; 2503 + const res = await app.request("/api/admin/themes"); 2504 + expect(res.status).toBe(401); 2505 + }); 2506 + }); 2507 + 2448 2508 describe("POST /api/admin/themes", () => { 2449 2509 it("creates theme and returns 201 with uri and cid", async () => { 2450 2510 const res = await app.request("/api/admin/themes", { ··· 2991 3051 }); 2992 3052 }); 2993 3053 3054 + describe("POST /api/admin/themes/:rkey/duplicate", () => { 3055 + beforeEach(async () => { 3056 + await ctx.cleanDatabase(); 3057 + await ctx.db.insert(themes).values({ 3058 + did: ctx.config.forumDid, 3059 + rkey: "3lblsource1aa", 3060 + cid: "bafysource1", 3061 + name: "Neobrutal Light", 3062 + colorScheme: "light", 3063 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 3064 + createdAt: new Date(), 3065 + indexedAt: new Date(), 3066 + }); 3067 + }); 3068 + 3069 + it("calls putRecord with a new rkey and '(Copy)' name", async () => { 3070 + mockPutRecord.mockResolvedValueOnce({ 3071 + data: { uri: "at://did:plc:test-forum/space.atbb.forum.theme/3lblcopy001a", cid: "bafycopy1" }, 3072 + }); 3073 + 3074 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3075 + method: "POST", 3076 + }); 3077 + 3078 + expect(res.status).toBe(201); 3079 + const body = await res.json(); 3080 + expect(body.name).toBe("Neobrutal Light (Copy)"); 3081 + expect(body.rkey).toBeDefined(); 3082 + expect(body.rkey).not.toBe("3lblsource1aa"); 3083 + expect(body.uri).toContain("space.atbb.forum.theme"); 3084 + 3085 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3086 + const putCall = mockPutRecord.mock.calls[0][0]; 3087 + expect(putCall.record.name).toBe("Neobrutal Light (Copy)"); 3088 + expect(putCall.record.colorScheme).toBe("light"); 3089 + expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }); 3090 + expect(putCall.collection).toBe("space.atbb.forum.theme"); 3091 + }); 3092 + 3093 + it("returns 404 when source rkey does not exist", async () => { 3094 + const res = await app.request("/api/admin/themes/nonexistent/duplicate", { 3095 + method: "POST", 3096 + }); 3097 + expect(res.status).toBe(404); 3098 + }); 3099 + 3100 + it("returns 401 when not authenticated", async () => { 3101 + mockUser = null; 3102 + const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3103 + method: "POST", 3104 + }); 3105 + expect(res.status).toBe(401); 3106 + }); 3107 + 3108 + it("copies cssOverrides and fontUrls when they are set on the source", async () => { 3109 + // Insert a theme with optional fields populated 3110 + await ctx.db.insert(themes).values({ 3111 + did: ctx.config.forumDid, 3112 + rkey: "3lblsource2bb", 3113 + cid: "bafysource2", 3114 + name: "Custom Theme", 3115 + colorScheme: "dark", 3116 + tokens: { "color-bg": "#1a1a1a" }, 3117 + cssOverrides: "body { font-size: 18px; }", 3118 + fontUrls: ["https://fonts.googleapis.com/css2?family=Roboto"], 3119 + createdAt: new Date(), 3120 + indexedAt: new Date(), 3121 + }); 3122 + 3123 + const res = await app.request("/api/admin/themes/3lblsource2bb/duplicate", { 3124 + method: "POST", 3125 + }); 3126 + 3127 + expect(res.status).toBe(201); 3128 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3129 + const putCall = mockPutRecord.mock.calls[0][0]; 3130 + expect(putCall.record.cssOverrides).toBe("body { font-size: 18px; }"); 3131 + expect(putCall.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Roboto"]); 3132 + expect(putCall.record.name).toBe("Custom Theme (Copy)"); 3133 + }); 3134 + }); 3135 + 2994 3136 describe("PUT /api/admin/theme-policy", () => { 2995 3137 const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 2996 3138 const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; ··· 3094 3236 expect(body.error).toMatch(/availableThemes/i); 3095 3237 }); 3096 3238 3097 - it("returns 400 when availableThemes item is missing cid", async () => { 3239 + it("accepts availableThemes with just uri (no cid) by looking up cid from DB", async () => { 3240 + // Insert a theme so the DB lookup will find it 3241 + const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`; 3242 + await ctx.db.insert(themes).values({ 3243 + did: ctx.config.forumDid, 3244 + rkey: "3lbltheme1aa", 3245 + cid: "bafytheme1", 3246 + name: "Neobrutal Light", 3247 + colorScheme: "light", 3248 + tokens: {}, 3249 + createdAt: new Date(), 3250 + indexedAt: new Date(), 3251 + }); 3252 + 3253 + const res = await app.request("/api/admin/theme-policy", { 3254 + method: "PUT", 3255 + headers: { "Content-Type": "application/json" }, 3256 + body: JSON.stringify({ 3257 + defaultLightThemeUri: themeUri, 3258 + defaultDarkThemeUri: themeUri, 3259 + allowUserChoice: true, 3260 + availableThemes: [{ uri: themeUri }], // no cid 3261 + }), 3262 + }); 3263 + 3264 + expect(res.status).toBe(200); 3265 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3266 + const putCall = mockPutRecord.mock.calls[0][0]; 3267 + // The resolved entry should have the CID looked up from the DB 3268 + expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3269 + expect(putCall.record.defaultLightTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3270 + expect(putCall.record.defaultDarkTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3271 + }); 3272 + 3273 + it("returns 400 when availableThemes contains a uri-only entry not found in DB", async () => { 3274 + // No theme inserted in DB — URI won't be found 3275 + const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown1`; 3276 + 3098 3277 const res = await app.request("/api/admin/theme-policy", { 3099 3278 method: "PUT", 3100 3279 headers: { "Content-Type": "application/json" }, 3101 3280 body: JSON.stringify({ 3102 - ...validBody, 3103 - availableThemes: [{ uri: lightUri }], // missing cid 3104 - defaultLightThemeUri: lightUri, 3105 - defaultDarkThemeUri: lightUri, 3281 + defaultLightThemeUri: unknownUri, 3282 + defaultDarkThemeUri: unknownUri, 3283 + allowUserChoice: true, 3284 + availableThemes: [{ uri: unknownUri }], // no cid, not in DB 3106 3285 }), 3107 3286 }); 3287 + 3108 3288 expect(res.status).toBe(400); 3109 3289 const body = await res.json(); 3110 - expect(body.error).toMatch(/uri.*cid|cid.*uri|uri and cid/i); 3290 + expect(body.error).toMatch(/unknown theme uri/i); 3291 + expect(mockPutRecord).not.toHaveBeenCalled(); 3292 + }); 3293 + 3294 + it("returns 400 when availableThemes entry has cid: \"\" (empty string is not a valid CID)", async () => { 3295 + // Explicit cid: "" must be treated the same as absent cid — not a valid strongRef CID 3296 + const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown2`; 3297 + 3298 + const res = await app.request("/api/admin/theme-policy", { 3299 + method: "PUT", 3300 + headers: { "Content-Type": "application/json" }, 3301 + body: JSON.stringify({ 3302 + defaultLightThemeUri: unknownUri, 3303 + defaultDarkThemeUri: unknownUri, 3304 + allowUserChoice: true, 3305 + availableThemes: [{ uri: unknownUri, cid: "" }], // empty string cid, not in DB 3306 + }), 3307 + }); 3308 + 3309 + expect(res.status).toBe(400); 3310 + const body = await res.json(); 3311 + expect(body.error).toMatch(/unknown theme uri/i); 3312 + expect(mockPutRecord).not.toHaveBeenCalled(); 3313 + }); 3314 + 3315 + it("returns 500 when DB query fails during uri-only CID lookup", async () => { 3316 + // Force needsLookup = true by omitting cid, then fail the DB select 3317 + const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown3`; 3318 + const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 3319 + throw new Error("Database connection lost"); 3320 + }); 3321 + 3322 + const res = await app.request("/api/admin/theme-policy", { 3323 + method: "PUT", 3324 + headers: { "Content-Type": "application/json" }, 3325 + body: JSON.stringify({ 3326 + defaultLightThemeUri: unknownUri, 3327 + defaultDarkThemeUri: unknownUri, 3328 + allowUserChoice: true, 3329 + availableThemes: [{ uri: unknownUri }], // no cid → triggers needsLookup 3330 + }), 3331 + }); 3332 + 3333 + expect(res.status).toBe(500); 3334 + const body = await res.json(); 3335 + expect(body.error).toMatch(/failed to look up theme data/i); 3336 + expect(mockPutRecord).not.toHaveBeenCalled(); 3337 + 3338 + dbSelectSpy.mockRestore(); 3339 + }); 3340 + 3341 + it("uses provided cid when entry already includes one (no DB lookup needed)", async () => { 3342 + // The existing validBody has cid on each entry — should use those directly 3343 + await app.request("/api/admin/theme-policy", { 3344 + method: "PUT", 3345 + headers: { "Content-Type": "application/json" }, 3346 + body: JSON.stringify(validBody), 3347 + }); 3348 + const putCall = mockPutRecord.mock.calls[0][0]; 3349 + // Should use the cids from the request, not from DB 3350 + expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3351 + expect(putCall.record.availableThemes[1]).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3111 3352 }); 3112 3353 3113 3354 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
+168 -9
apps/appview/src/routes/admin.ts
··· 15 15 getForumAgentOrError, 16 16 } from "../lib/route-errors.js"; 17 17 import { TID } from "@atproto/common-web"; 18 - import { parseBigIntParam } from "./helpers.js"; 18 + import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js"; 19 19 20 20 export function createAdminRoutes(ctx: AppContext) { 21 21 const app = new Hono<{ Variables: Variables }>(); ··· 982 982 ); 983 983 984 984 /** 985 + * GET /api/admin/themes 986 + * 987 + * Returns all themes for this forum — no policy filtering. 988 + * Admins need to see all themes, including drafts not yet in the policy. 989 + */ 990 + app.get( 991 + "/themes", 992 + requireAuth(ctx), 993 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 994 + async (c) => { 995 + try { 996 + const themeList = await ctx.db 997 + .select() 998 + .from(themes) 999 + .where(eq(themes.did, ctx.config.forumDid)) 1000 + .limit(100); 1001 + 1002 + return c.json({ 1003 + themes: themeList.map((theme) => ({ 1004 + id: serializeBigInt(theme.id), 1005 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 1006 + name: theme.name, 1007 + colorScheme: theme.colorScheme, 1008 + tokens: theme.tokens, 1009 + cssOverrides: theme.cssOverrides ?? null, 1010 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 1011 + createdAt: serializeDate(theme.createdAt), 1012 + indexedAt: serializeDate(theme.indexedAt), 1013 + })), 1014 + isTruncated: themeList.length === 100, 1015 + }); 1016 + } catch (error) { 1017 + return handleRouteError(c, error, "Failed to retrieve themes", { 1018 + operation: "GET /api/admin/themes", 1019 + logger: ctx.logger, 1020 + }); 1021 + } 1022 + } 1023 + ); 1024 + 1025 + /** 985 1026 * POST /api/admin/themes 986 1027 * 987 1028 * Create a new theme record on Forum DID's PDS. ··· 1248 1289 ); 1249 1290 1250 1291 /** 1292 + * POST /api/admin/themes/:rkey/duplicate 1293 + * 1294 + * Clones an existing theme record with " (Copy)" appended to the name. 1295 + * Uses a fresh TID as the new record key. 1296 + * The firehose indexer will create the DB row asynchronously. 1297 + */ 1298 + app.post( 1299 + "/themes/:rkey/duplicate", 1300 + requireAuth(ctx), 1301 + requirePermission(ctx, "space.atbb.permission.manageThemes"), 1302 + async (c) => { 1303 + const sourceRkey = c.req.param("rkey").trim(); 1304 + 1305 + let source: typeof themes.$inferSelect; 1306 + try { 1307 + const [row] = await ctx.db 1308 + .select() 1309 + .from(themes) 1310 + .where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey))) 1311 + .limit(1); 1312 + 1313 + if (!row) { 1314 + return c.json({ error: "Theme not found" }, 404); 1315 + } 1316 + source = row; 1317 + } catch (error) { 1318 + return handleRouteError(c, error, "Failed to look up source theme", { 1319 + operation: "POST /api/admin/themes/:rkey/duplicate", 1320 + logger: ctx.logger, 1321 + sourceRkey, 1322 + }); 1323 + } 1324 + 1325 + const { agent, error: agentError } = getForumAgentOrError( 1326 + ctx, 1327 + c, 1328 + "POST /api/admin/themes/:rkey/duplicate" 1329 + ); 1330 + if (agentError) return agentError; 1331 + 1332 + const newRkey = TID.nextStr(); 1333 + const newName = `${source.name} (Copy)`; 1334 + const now = new Date().toISOString(); 1335 + 1336 + try { 1337 + const result = await agent.com.atproto.repo.putRecord({ 1338 + repo: ctx.config.forumDid, 1339 + collection: "space.atbb.forum.theme", 1340 + rkey: newRkey, 1341 + record: { 1342 + $type: "space.atbb.forum.theme", 1343 + name: newName, 1344 + colorScheme: source.colorScheme, 1345 + tokens: source.tokens, 1346 + ...(source.cssOverrides != null && { cssOverrides: source.cssOverrides }), 1347 + ...(source.fontUrls != null && { fontUrls: source.fontUrls }), 1348 + createdAt: now, 1349 + }, 1350 + }); 1351 + 1352 + return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201); 1353 + } catch (error) { 1354 + return handleRouteError(c, error, "Failed to duplicate theme", { 1355 + operation: "POST /api/admin/themes/:rkey/duplicate", 1356 + logger: ctx.logger, 1357 + sourceRkey, 1358 + newRkey, 1359 + }); 1360 + } 1361 + } 1362 + ); 1363 + 1364 + /** 1251 1365 * PUT /api/admin/theme-policy 1252 1366 * 1253 1367 * Create or update the themePolicy singleton (rkey: "self") on Forum DID's PDS. ··· 1271 1385 if ( 1272 1386 typeof t !== "object" || 1273 1387 t === null || 1274 - typeof (t as Record<string, unknown>).uri !== "string" || 1275 - typeof (t as Record<string, unknown>).cid !== "string" 1388 + typeof (t as Record<string, unknown>).uri !== "string" 1276 1389 ) { 1277 - return c.json({ error: "Each availableThemes entry must have uri and cid string fields" }, 400); 1390 + return c.json({ error: "Each availableThemes entry must have a uri string field" }, 400); 1278 1391 } 1279 1392 } 1280 1393 ··· 1285 1398 return c.json({ error: "defaultDarkThemeUri is required and must be an AT-URI" }, 400); 1286 1399 } 1287 1400 1288 - const availableUris = (availableThemes as Array<{ uri: string; cid: string }>).map((t) => t.uri); 1401 + const typedAvailableThemes = availableThemes as Array<{ uri: string; cid?: string }>; 1402 + const availableUris = typedAvailableThemes.map((t) => t.uri); 1289 1403 if (!availableUris.includes(defaultLightThemeUri)) { 1290 1404 return c.json({ error: "defaultLightThemeUri must be present in availableThemes" }, 400); 1291 1405 } ··· 1295 1409 1296 1410 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1297 1411 1298 - const typedAvailableThemes = availableThemes as Array<{ uri: string; cid: string }>; 1299 - const lightTheme = typedAvailableThemes.find((t) => t.uri === defaultLightThemeUri)!; 1300 - const darkTheme = typedAvailableThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1412 + // Build URI→CID map from DB for entries that don't supply a valid cid. 1413 + // Treat cid: "" the same as absent — empty string is not a valid strongRef CID. 1414 + const isMissingCid = (t: { cid?: string }) => 1415 + typeof t.cid !== "string" || t.cid === ""; 1416 + let uriToCid = new Map<string, string>(); 1417 + const needsLookup = typedAvailableThemes.some(isMissingCid); 1418 + if (needsLookup) { 1419 + try { 1420 + const allThemes = await ctx.db 1421 + .select({ did: themes.did, rkey: themes.rkey, cid: themes.cid }) 1422 + .from(themes) 1423 + .where(eq(themes.did, ctx.config.forumDid)); 1424 + uriToCid = new Map( 1425 + allThemes.map((t) => [ 1426 + `at://${t.did}/space.atbb.forum.theme/${t.rkey}`, 1427 + t.cid, 1428 + ]) 1429 + ); 1430 + } catch (error) { 1431 + if (isProgrammingError(error)) throw error; 1432 + ctx.logger.error("Failed to look up theme CIDs from DB", { 1433 + operation: "PUT /api/admin/theme-policy", 1434 + error: error instanceof Error ? error.message : String(error), 1435 + }); 1436 + return c.json({ error: "Failed to look up theme data. Please try again later." }, 500); 1437 + } 1438 + } 1439 + 1440 + // Resolve CIDs: use provided cid if non-empty, otherwise look up from DB. 1441 + // Reject URIs that can't be resolved — "" is not a valid strongRef CID. 1442 + const unresolvedUris = typedAvailableThemes 1443 + .filter((t) => isMissingCid(t) && !uriToCid.has(t.uri)) 1444 + .map((t) => t.uri); 1445 + 1446 + if (unresolvedUris.length > 0) { 1447 + return c.json( 1448 + { error: `Unknown theme URIs in availableThemes: ${unresolvedUris.join(", ")}` }, 1449 + 400 1450 + ); 1451 + } 1452 + 1453 + const resolvedThemes = typedAvailableThemes.map((t) => ({ 1454 + uri: t.uri, 1455 + cid: !isMissingCid(t) ? t.cid! : uriToCid.get(t.uri)!, 1456 + })); 1457 + 1458 + const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!; 1459 + const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1301 1460 1302 1461 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1303 1462 if (agentError) return agentError; ··· 1309 1468 rkey: "self", 1310 1469 record: { 1311 1470 $type: "space.atbb.forum.themePolicy", 1312 - availableThemes: typedAvailableThemes.map((t) => ({ 1471 + availableThemes: resolvedThemes.map((t) => ({ 1313 1472 theme: { uri: t.uri, cid: t.cid }, 1314 1473 })), 1315 1474 defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } },
+10
apps/web/src/lib/session.ts
··· 163 163 "space.atbb.permission.moderatePosts", 164 164 "space.atbb.permission.banUsers", 165 165 "space.atbb.permission.lockTopics", 166 + "space.atbb.permission.manageThemes", 166 167 ] as const; 167 168 168 169 /** ··· 214 215 auth.permissions.has("*")) 215 216 ); 216 217 } 218 + 219 + /** Returns true if the session grants permission to manage forum themes. */ 220 + export function canManageThemes(auth: WebSessionWithPermissions): boolean { 221 + return ( 222 + auth.authenticated && 223 + (auth.permissions.has("space.atbb.permission.manageThemes") || 224 + auth.permissions.has("*")) 225 + ); 226 + }
+788
apps/web/src/routes/__tests__/admin.test.tsx
··· 86 86 expect(html).toContain('href="/admin/members"'); 87 87 expect(html).toContain('href="/admin/structure"'); 88 88 expect(html).toContain('href="/admin/modlog"'); 89 + expect(html).toContain('href="/admin/themes"'); 89 90 }); 90 91 91 92 // ── Single permission → only that card ────────────────────────────────── ··· 101 102 expect(html).toContain('href="/admin/members"'); 102 103 expect(html).not.toContain('href="/admin/structure"'); 103 104 expect(html).not.toContain('href="/admin/modlog"'); 105 + expect(html).not.toContain('href="/admin/themes"'); 104 106 }); 105 107 106 108 it("shows only Structure card for user with only manageCategories", async () => { ··· 114 116 expect(html).not.toContain('href="/admin/members"'); 115 117 expect(html).toContain('href="/admin/structure"'); 116 118 expect(html).not.toContain('href="/admin/modlog"'); 119 + expect(html).not.toContain('href="/admin/themes"'); 117 120 }); 118 121 119 122 it("shows only Mod Log card for user with only moderatePosts", async () => { ··· 127 130 expect(html).not.toContain('href="/admin/members"'); 128 131 expect(html).not.toContain('href="/admin/structure"'); 129 132 expect(html).toContain('href="/admin/modlog"'); 133 + expect(html).not.toContain('href="/admin/themes"'); 130 134 }); 131 135 132 136 it("shows only Mod Log card for user with only banUsers", async () => { ··· 140 144 expect(html).not.toContain('href="/admin/members"'); 141 145 expect(html).not.toContain('href="/admin/structure"'); 142 146 expect(html).toContain('href="/admin/modlog"'); 147 + expect(html).not.toContain('href="/admin/themes"'); 143 148 }); 144 149 145 150 it("shows only Mod Log card for user with only lockTopics", async () => { ··· 153 158 expect(html).not.toContain('href="/admin/members"'); 154 159 expect(html).not.toContain('href="/admin/structure"'); 155 160 expect(html).toContain('href="/admin/modlog"'); 161 + expect(html).not.toContain('href="/admin/themes"'); 162 + }); 163 + 164 + it("shows Themes card for user with manageThemes permission", async () => { 165 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 166 + const routes = await loadAdminRoutes(); 167 + const res = await routes.request("/admin", { 168 + headers: { cookie: "atbb_session=token" }, 169 + }); 170 + expect(res.status).toBe(200); 171 + const html = await res.text(); 172 + expect(html).toContain('href="/admin/themes"'); 173 + expect(html).toContain("🎨"); 174 + expect(html).not.toContain('href="/admin/members"'); 175 + expect(html).not.toContain('href="/admin/structure"'); 176 + expect(html).not.toContain('href="/admin/modlog"'); 177 + }); 178 + 179 + it("does not show Themes card for user with only manageMembers permission", async () => { 180 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 181 + const routes = await loadAdminRoutes(); 182 + const res = await routes.request("/admin", { 183 + headers: { cookie: "atbb_session=token" }, 184 + }); 185 + expect(res.status).toBe(200); 186 + const html = await res.text(); 187 + expect(html).not.toContain('href="/admin/themes"'); 188 + }); 189 + 190 + it("shows Themes card for wildcard (*) permission user", async () => { 191 + setupAuthenticatedSession(["*"]); 192 + const routes = await loadAdminRoutes(); 193 + const res = await routes.request("/admin", { 194 + headers: { cookie: "atbb_session=token" }, 195 + }); 196 + expect(res.status).toBe(200); 197 + const html = await res.text(); 198 + expect(html).toContain('href="/admin/themes"'); 199 + }); 200 + 201 + it("grants access to /admin landing page for user with only manageThemes", async () => { 202 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 203 + const routes = await loadAdminRoutes(); 204 + const res = await routes.request("/admin", { 205 + headers: { cookie: "atbb_session=token" }, 206 + }); 207 + // manageThemes should be in ADMIN_PERMISSIONS so the landing page is accessible 208 + expect(res.status).toBe(200); 156 209 }); 157 210 158 211 // ── Multi-permission combos ────────────────────────────────────────────── ··· 171 224 expect(html).toContain('href="/admin/members"'); 172 225 expect(html).not.toContain('href="/admin/structure"'); 173 226 expect(html).toContain('href="/admin/modlog"'); 227 + expect(html).not.toContain('href="/admin/themes"'); 174 228 }); 175 229 176 230 // ── Page structure ─────────────────────────────────────────────────────── ··· 2044 2098 expect(res.headers.get("location")).toBe("/login"); 2045 2099 }); 2046 2100 }); 2101 + 2102 + describe("createAdminRoutes — GET /admin/themes", () => { 2103 + beforeEach(() => { 2104 + vi.stubGlobal("fetch", mockFetch); 2105 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2106 + vi.resetModules(); 2107 + }); 2108 + 2109 + afterEach(() => { 2110 + vi.unstubAllGlobals(); 2111 + vi.unstubAllEnvs(); 2112 + mockFetch.mockReset(); 2113 + }); 2114 + 2115 + function mockResponse(body: unknown, ok = true, status = 200) { 2116 + return { 2117 + ok, 2118 + status, 2119 + statusText: ok ? "OK" : "Error", 2120 + json: () => Promise.resolve(body), 2121 + }; 2122 + } 2123 + 2124 + function setupAuthenticatedSession(permissions: string[]) { 2125 + mockFetch.mockResolvedValueOnce( 2126 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2127 + ); 2128 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2129 + } 2130 + 2131 + async function loadAdminRoutes() { 2132 + const { createAdminRoutes } = await import("../admin.js"); 2133 + return createAdminRoutes("http://localhost:3000"); 2134 + } 2135 + 2136 + it("redirects unauthenticated users to /login", async () => { 2137 + const routes = await loadAdminRoutes(); 2138 + const res = await routes.request("/admin/themes"); 2139 + expect(res.status).toBe(302); 2140 + expect(res.headers.get("location")).toBe("/login"); 2141 + }); 2142 + 2143 + it("returns 403 for users without manageThemes permission", async () => { 2144 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2145 + const routes = await loadAdminRoutes(); 2146 + const res = await routes.request("/admin/themes", { 2147 + headers: { cookie: "atbb_session=token" }, 2148 + }); 2149 + expect(res.status).toBe(403); 2150 + }); 2151 + 2152 + it("renders theme cards with name, colorScheme badge, and swatches", async () => { 2153 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2154 + // GET /api/admin/themes 2155 + mockFetch.mockResolvedValueOnce( 2156 + mockResponse({ 2157 + themes: [ 2158 + { 2159 + id: "1", 2160 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2161 + name: "Neobrutal Light", 2162 + colorScheme: "light", 2163 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00", "color-surface": "#ffffff", "color-secondary": "#3a86ff", "color-border": "#1a1a1a" }, 2164 + cssOverrides: null, 2165 + fontUrls: null, 2166 + createdAt: "2026-01-01T00:00:00.000Z", 2167 + indexedAt: "2026-01-01T00:00:00.000Z", 2168 + }, 2169 + ], 2170 + }) 2171 + ); 2172 + // GET /api/theme-policy 2173 + mockFetch.mockResolvedValueOnce( 2174 + mockResponse({ 2175 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2176 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2177 + allowUserChoice: true, 2178 + availableThemes: [ 2179 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", cid: "bafytheme1" }, 2180 + ], 2181 + }) 2182 + ); 2183 + 2184 + const routes = await loadAdminRoutes(); 2185 + const res = await routes.request("/admin/themes", { 2186 + headers: { cookie: "atbb_session=token" }, 2187 + }); 2188 + expect(res.status).toBe(200); 2189 + const html = await res.text(); 2190 + expect(html).toContain("Neobrutal Light"); 2191 + expect(html).toContain("light"); // colorScheme badge 2192 + expect(html).toContain("#f5f0e8"); // color-bg swatch 2193 + expect(html).toContain("#ff5c00"); // color-primary swatch 2194 + expect(html).toContain("policy-form"); // policy form id 2195 + expect(html).toContain("availableThemes"); // checkbox name 2196 + }); 2197 + 2198 + it("shows error banner when ?error= query param is present", async () => { 2199 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2200 + mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 2201 + mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); // no policy yet 2202 + 2203 + const routes = await loadAdminRoutes(); 2204 + const res = await routes.request( 2205 + "/admin/themes?error=" + encodeURIComponent("Cannot delete a default theme"), 2206 + { headers: { cookie: "atbb_session=token" } } 2207 + ); 2208 + expect(res.status).toBe(200); 2209 + const html = await res.text(); 2210 + expect(html).toContain("Cannot delete a default theme"); 2211 + expect(html).toContain("structure-error-banner"); 2212 + }); 2213 + 2214 + it("renders create form with preset options", async () => { 2215 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2216 + mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 2217 + mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); 2218 + 2219 + const routes = await loadAdminRoutes(); 2220 + const res = await routes.request("/admin/themes", { 2221 + headers: { cookie: "atbb_session=token" }, 2222 + }); 2223 + expect(res.status).toBe(200); 2224 + const html = await res.text(); 2225 + expect(html).toContain("neobrutal-light"); 2226 + expect(html).toContain("neobrutal-dark"); 2227 + expect(html).toContain("blank"); 2228 + }); 2229 + 2230 + it("renders page gracefully when AppView returns non-JSON response", async () => { 2231 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2232 + // AppView returns an HTML error page — .json() throws SyntaxError 2233 + mockFetch.mockResolvedValueOnce({ 2234 + ok: true, 2235 + status: 200, 2236 + statusText: "OK", 2237 + json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2238 + }); 2239 + mockFetch.mockResolvedValueOnce({ 2240 + ok: true, 2241 + status: 200, 2242 + statusText: "OK", 2243 + json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2244 + }); 2245 + 2246 + const routes = await loadAdminRoutes(); 2247 + const res = await routes.request("/admin/themes", { 2248 + headers: { cookie: "atbb_session=token" }, 2249 + }); 2250 + // Should render the page with empty data rather than crashing with 500 2251 + expect(res.status).toBe(200); 2252 + const html = await res.text(); 2253 + expect(html).toContain("No themes yet"); 2254 + }); 2255 + }); 2256 + 2257 + describe("createAdminRoutes — POST /admin/themes", () => { 2258 + beforeEach(() => { 2259 + vi.stubGlobal("fetch", mockFetch); 2260 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2261 + vi.resetModules(); 2262 + }); 2263 + 2264 + afterEach(() => { 2265 + vi.unstubAllGlobals(); 2266 + vi.unstubAllEnvs(); 2267 + mockFetch.mockReset(); 2268 + }); 2269 + 2270 + function mockResponse(body: unknown, ok = true, status = 200) { 2271 + return { ok, status, json: () => Promise.resolve(body) }; 2272 + } 2273 + 2274 + function setupAuthenticatedSession(permissions: string[]) { 2275 + mockFetch.mockResolvedValueOnce( 2276 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2277 + ); 2278 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2279 + } 2280 + 2281 + async function loadAdminRoutes() { 2282 + const { createAdminRoutes } = await import("../admin.js"); 2283 + return createAdminRoutes("http://localhost:3000"); 2284 + } 2285 + 2286 + it("redirects to /login when unauthenticated", async () => { 2287 + mockFetch.mockResolvedValueOnce( 2288 + mockResponse({ authenticated: false, did: null, handle: null }) 2289 + ); 2290 + const routes = await loadAdminRoutes(); 2291 + const res = await routes.request("/admin/themes", { 2292 + method: "POST", 2293 + headers: { "content-type": "application/x-www-form-urlencoded" }, 2294 + body: "name=Test&colorScheme=light&preset=blank", 2295 + }); 2296 + expect(res.status).toBe(302); 2297 + expect(res.headers.get("location")).toBe("/login"); 2298 + }); 2299 + 2300 + it("returns 403 without manageThemes permission", async () => { 2301 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2302 + const routes = await loadAdminRoutes(); 2303 + const res = await routes.request("/admin/themes", { 2304 + method: "POST", 2305 + headers: { 2306 + cookie: "atbb_session=token", 2307 + "content-type": "application/x-www-form-urlencoded", 2308 + }, 2309 + body: "name=Test&colorScheme=light&preset=blank", 2310 + }); 2311 + expect(res.status).toBe(403); 2312 + }); 2313 + 2314 + it("redirects with error on network failure", async () => { 2315 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2316 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2317 + 2318 + const routes = await loadAdminRoutes(); 2319 + const res = await routes.request("/admin/themes", { 2320 + method: "POST", 2321 + headers: { 2322 + cookie: "atbb_session=token", 2323 + "content-type": "application/x-www-form-urlencoded", 2324 + }, 2325 + body: "name=My+Theme&colorScheme=light&preset=blank", 2326 + }); 2327 + 2328 + expect(res.status).toBe(302); 2329 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2330 + }); 2331 + 2332 + it("creates theme and redirects to /admin/themes on success", async () => { 2333 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2334 + mockFetch.mockResolvedValueOnce( 2335 + mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.theme/newrkey", cid: "bafy" }, true, 201) 2336 + ); 2337 + 2338 + const routes = await loadAdminRoutes(); 2339 + const res = await routes.request("/admin/themes", { 2340 + method: "POST", 2341 + headers: { 2342 + cookie: "atbb_session=token", 2343 + "content-type": "application/x-www-form-urlencoded", 2344 + }, 2345 + body: "name=My+Theme&colorScheme=light&preset=neobrutal-light", 2346 + }); 2347 + 2348 + expect(res.status).toBe(302); 2349 + expect(res.headers.get("location")).toBe("/admin/themes"); 2350 + }); 2351 + 2352 + it("sends preset tokens to API when preset is neobrutal-light", async () => { 2353 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2354 + mockFetch.mockResolvedValueOnce( 2355 + mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 2356 + ); 2357 + 2358 + const routes = await loadAdminRoutes(); 2359 + await routes.request("/admin/themes", { 2360 + method: "POST", 2361 + headers: { 2362 + cookie: "atbb_session=token", 2363 + "content-type": "application/x-www-form-urlencoded", 2364 + }, 2365 + body: "name=Neo&colorScheme=light&preset=neobrutal-light", 2366 + }); 2367 + 2368 + // The API call should contain the preset tokens 2369 + const apiCall = mockFetch.mock.calls[2]; // calls 0+1 = auth, call 2 = POST /api/admin/themes 2370 + const body = JSON.parse(apiCall[1].body); 2371 + expect(body.tokens).toHaveProperty("color-bg"); 2372 + expect(body.tokens["color-bg"]).toBe("#f5f0e8"); 2373 + expect(body.name).toBe("Neo"); 2374 + expect(body.colorScheme).toBe("light"); 2375 + }); 2376 + 2377 + it("sends empty tokens for blank preset", async () => { 2378 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2379 + mockFetch.mockResolvedValueOnce( 2380 + mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 2381 + ); 2382 + 2383 + const routes = await loadAdminRoutes(); 2384 + await routes.request("/admin/themes", { 2385 + method: "POST", 2386 + headers: { 2387 + cookie: "atbb_session=token", 2388 + "content-type": "application/x-www-form-urlencoded", 2389 + }, 2390 + body: "name=Blank+Theme&colorScheme=light&preset=blank", 2391 + }); 2392 + 2393 + const apiCall = mockFetch.mock.calls[2]; 2394 + const body = JSON.parse(apiCall[1].body); 2395 + expect(body.tokens).toEqual({}); 2396 + }); 2397 + 2398 + it("redirects with error when name is missing", async () => { 2399 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2400 + 2401 + const routes = await loadAdminRoutes(); 2402 + const res = await routes.request("/admin/themes", { 2403 + method: "POST", 2404 + headers: { 2405 + cookie: "atbb_session=token", 2406 + "content-type": "application/x-www-form-urlencoded", 2407 + }, 2408 + body: "colorScheme=light&preset=blank", 2409 + }); 2410 + 2411 + expect(res.status).toBe(302); 2412 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2413 + expect(res.headers.get("location")).toContain("required"); 2414 + }); 2415 + 2416 + it("redirects with error on AppView API failure", async () => { 2417 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2418 + mockFetch.mockResolvedValueOnce( 2419 + mockResponse({ error: "Theme creation failed" }, false, 500) 2420 + ); 2421 + 2422 + const routes = await loadAdminRoutes(); 2423 + const res = await routes.request("/admin/themes", { 2424 + method: "POST", 2425 + headers: { 2426 + cookie: "atbb_session=token", 2427 + "content-type": "application/x-www-form-urlencoded", 2428 + }, 2429 + body: "name=My+Theme&colorScheme=light&preset=blank", 2430 + }); 2431 + 2432 + expect(res.status).toBe(302); 2433 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2434 + }); 2435 + }); 2436 + 2437 + describe("createAdminRoutes — POST /admin/themes/:rkey/duplicate", () => { 2438 + beforeEach(() => { 2439 + vi.stubGlobal("fetch", mockFetch); 2440 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2441 + vi.resetModules(); 2442 + }); 2443 + 2444 + afterEach(() => { 2445 + vi.unstubAllGlobals(); 2446 + vi.unstubAllEnvs(); 2447 + mockFetch.mockReset(); 2448 + }); 2449 + 2450 + function mockResponse(body: unknown, ok = true, status = 200) { 2451 + return { ok, status, json: () => Promise.resolve(body) }; 2452 + } 2453 + 2454 + function setupAuthenticatedSession(permissions: string[]) { 2455 + mockFetch.mockResolvedValueOnce( 2456 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2457 + ); 2458 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2459 + } 2460 + 2461 + async function loadAdminRoutes() { 2462 + const { createAdminRoutes } = await import("../admin.js"); 2463 + return createAdminRoutes("http://localhost:3000"); 2464 + } 2465 + 2466 + it("redirects to /login when unauthenticated", async () => { 2467 + mockFetch.mockResolvedValueOnce( 2468 + mockResponse({ authenticated: false, did: null, handle: null }) 2469 + ); 2470 + const routes = await loadAdminRoutes(); 2471 + const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2472 + method: "POST", 2473 + headers: { "content-type": "application/x-www-form-urlencoded" }, 2474 + }); 2475 + expect(res.status).toBe(302); 2476 + expect(res.headers.get("location")).toBe("/login"); 2477 + }); 2478 + 2479 + it("returns 403 without manageThemes permission", async () => { 2480 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2481 + const routes = await loadAdminRoutes(); 2482 + const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2483 + method: "POST", 2484 + headers: { 2485 + cookie: "atbb_session=token", 2486 + "content-type": "application/x-www-form-urlencoded", 2487 + }, 2488 + }); 2489 + expect(res.status).toBe(403); 2490 + }); 2491 + 2492 + it("redirects with error on network failure", async () => { 2493 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2494 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2495 + 2496 + const routes = await loadAdminRoutes(); 2497 + const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2498 + method: "POST", 2499 + headers: { cookie: "atbb_session=token" }, 2500 + }); 2501 + 2502 + expect(res.status).toBe(302); 2503 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2504 + }); 2505 + 2506 + it("duplicates theme and redirects to /admin/themes on success", async () => { 2507 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2508 + mockFetch.mockResolvedValueOnce( 2509 + mockResponse( 2510 + { uri: "at://...", rkey: "newrkey", name: "Neobrutal Light (Copy)" }, 2511 + true, 2512 + 201 2513 + ) 2514 + ); 2515 + 2516 + const routes = await loadAdminRoutes(); 2517 + const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2518 + method: "POST", 2519 + headers: { cookie: "atbb_session=token" }, 2520 + }); 2521 + 2522 + expect(res.status).toBe(302); 2523 + expect(res.headers.get("location")).toBe("/admin/themes"); 2524 + }); 2525 + 2526 + it("redirects with error on AppView failure", async () => { 2527 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2528 + mockFetch.mockResolvedValueOnce( 2529 + mockResponse({ error: "Theme not found" }, false, 404) 2530 + ); 2531 + 2532 + const routes = await loadAdminRoutes(); 2533 + const res = await routes.request("/admin/themes/nonexistent/duplicate", { 2534 + method: "POST", 2535 + headers: { cookie: "atbb_session=token" }, 2536 + }); 2537 + 2538 + expect(res.status).toBe(302); 2539 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2540 + }); 2541 + }); 2542 + 2543 + describe("createAdminRoutes — POST /admin/themes/:rkey/delete", () => { 2544 + beforeEach(() => { 2545 + vi.stubGlobal("fetch", mockFetch); 2546 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2547 + vi.resetModules(); 2548 + }); 2549 + 2550 + afterEach(() => { 2551 + vi.unstubAllGlobals(); 2552 + vi.unstubAllEnvs(); 2553 + mockFetch.mockReset(); 2554 + }); 2555 + 2556 + function mockResponse(body: unknown, ok = true, status = 200) { 2557 + return { ok, status, json: () => Promise.resolve(body) }; 2558 + } 2559 + 2560 + function setupAuthenticatedSession(permissions: string[]) { 2561 + mockFetch.mockResolvedValueOnce( 2562 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2563 + ); 2564 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2565 + } 2566 + 2567 + async function loadAdminRoutes() { 2568 + const { createAdminRoutes } = await import("../admin.js"); 2569 + return createAdminRoutes("http://localhost:3000"); 2570 + } 2571 + 2572 + it("redirects to /login when unauthenticated", async () => { 2573 + mockFetch.mockResolvedValueOnce( 2574 + mockResponse({ authenticated: false, did: null, handle: null }) 2575 + ); 2576 + const routes = await loadAdminRoutes(); 2577 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2578 + method: "POST", 2579 + headers: { "content-type": "application/x-www-form-urlencoded" }, 2580 + }); 2581 + expect(res.status).toBe(302); 2582 + expect(res.headers.get("location")).toBe("/login"); 2583 + }); 2584 + 2585 + it("returns 403 without manageThemes permission", async () => { 2586 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2587 + const routes = await loadAdminRoutes(); 2588 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2589 + method: "POST", 2590 + headers: { 2591 + cookie: "atbb_session=token", 2592 + "content-type": "application/x-www-form-urlencoded", 2593 + }, 2594 + }); 2595 + expect(res.status).toBe(403); 2596 + }); 2597 + 2598 + it("redirects with error on network failure", async () => { 2599 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2600 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2601 + 2602 + const routes = await loadAdminRoutes(); 2603 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2604 + method: "POST", 2605 + headers: { cookie: "atbb_session=token" }, 2606 + }); 2607 + 2608 + expect(res.status).toBe(302); 2609 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2610 + }); 2611 + 2612 + it("deletes theme and redirects to /admin/themes on success", async () => { 2613 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2614 + mockFetch.mockResolvedValueOnce(mockResponse({ deleted: true }, true, 200)); 2615 + 2616 + const routes = await loadAdminRoutes(); 2617 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2618 + method: "POST", 2619 + headers: { cookie: "atbb_session=token" }, 2620 + }); 2621 + 2622 + expect(res.status).toBe(302); 2623 + expect(res.headers.get("location")).toBe("/admin/themes"); 2624 + }); 2625 + 2626 + it("redirects with human-friendly error message on 409 conflict (theme is a default)", async () => { 2627 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2628 + mockFetch.mockResolvedValueOnce( 2629 + mockResponse( 2630 + { error: "Cannot delete a theme that is currently set as a default" }, 2631 + false, 2632 + 409 2633 + ) 2634 + ); 2635 + 2636 + const routes = await loadAdminRoutes(); 2637 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2638 + method: "POST", 2639 + headers: { cookie: "atbb_session=token" }, 2640 + }); 2641 + 2642 + expect(res.status).toBe(302); 2643 + const location = res.headers.get("location") ?? ""; 2644 + expect(location).toContain("/admin/themes?error="); 2645 + expect(decodeURIComponent(location)).toContain("Cannot delete"); 2646 + }); 2647 + 2648 + it("redirects with error on generic AppView failure", async () => { 2649 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2650 + mockFetch.mockResolvedValueOnce( 2651 + mockResponse({ error: "Internal server error" }, false, 500) 2652 + ); 2653 + 2654 + const routes = await loadAdminRoutes(); 2655 + const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2656 + method: "POST", 2657 + headers: { cookie: "atbb_session=token" }, 2658 + }); 2659 + 2660 + expect(res.status).toBe(302); 2661 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2662 + }); 2663 + }); 2664 + 2665 + describe("createAdminRoutes — POST /admin/theme-policy", () => { 2666 + beforeEach(() => { 2667 + vi.stubGlobal("fetch", mockFetch); 2668 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2669 + vi.resetModules(); 2670 + }); 2671 + 2672 + afterEach(() => { 2673 + vi.unstubAllGlobals(); 2674 + vi.unstubAllEnvs(); 2675 + mockFetch.mockReset(); 2676 + }); 2677 + 2678 + function mockResponse(body: unknown, ok = true, status = 200) { 2679 + return { ok, status, json: () => Promise.resolve(body) }; 2680 + } 2681 + 2682 + function setupAuthenticatedSession(permissions: string[]) { 2683 + mockFetch.mockResolvedValueOnce( 2684 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2685 + ); 2686 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2687 + } 2688 + 2689 + async function loadAdminRoutes() { 2690 + const { createAdminRoutes } = await import("../admin.js"); 2691 + return createAdminRoutes("http://localhost:3000"); 2692 + } 2693 + 2694 + it("redirects to /login when unauthenticated", async () => { 2695 + mockFetch.mockResolvedValueOnce( 2696 + mockResponse({ authenticated: false, did: null, handle: null }) 2697 + ); 2698 + const routes = await loadAdminRoutes(); 2699 + const res = await routes.request("/admin/theme-policy", { 2700 + method: "POST", 2701 + headers: { "content-type": "application/x-www-form-urlencoded" }, 2702 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2703 + }); 2704 + expect(res.status).toBe(302); 2705 + expect(res.headers.get("location")).toBe("/login"); 2706 + }); 2707 + 2708 + it("returns 403 without manageThemes permission", async () => { 2709 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2710 + const routes = await loadAdminRoutes(); 2711 + const res = await routes.request("/admin/theme-policy", { 2712 + method: "POST", 2713 + headers: { 2714 + cookie: "atbb_session=token", 2715 + "content-type": "application/x-www-form-urlencoded", 2716 + }, 2717 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2718 + }); 2719 + expect(res.status).toBe(403); 2720 + }); 2721 + 2722 + it("redirects with error on network failure", async () => { 2723 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2724 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2725 + 2726 + const routes = await loadAdminRoutes(); 2727 + const res = await routes.request("/admin/theme-policy", { 2728 + method: "POST", 2729 + headers: { 2730 + cookie: "atbb_session=token", 2731 + "content-type": "application/x-www-form-urlencoded", 2732 + }, 2733 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2734 + }); 2735 + 2736 + expect(res.status).toBe(302); 2737 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2738 + }); 2739 + 2740 + it("saves policy and redirects to /admin/themes on success", async () => { 2741 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2742 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2743 + 2744 + const routes = await loadAdminRoutes(); 2745 + const body = new URLSearchParams({ 2746 + defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1", 2747 + defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2", 2748 + allowUserChoice: "on", 2749 + }); 2750 + body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1"); 2751 + body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2"); 2752 + 2753 + const res = await routes.request("/admin/theme-policy", { 2754 + method: "POST", 2755 + headers: { 2756 + cookie: "atbb_session=token", 2757 + "content-type": "application/x-www-form-urlencoded", 2758 + }, 2759 + body: body.toString(), 2760 + }); 2761 + 2762 + expect(res.status).toBe(302); 2763 + expect(res.headers.get("location")).toBe("/admin/themes"); 2764 + 2765 + const apiCall = mockFetch.mock.calls[2]; 2766 + const sentBody = JSON.parse(apiCall[1].body); 2767 + expect(sentBody.allowUserChoice).toBe(true); 2768 + expect(sentBody.availableThemes).toEqual([ 2769 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1" }, 2770 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2" }, 2771 + ]); 2772 + }); 2773 + 2774 + it("treats absent allowUserChoice checkbox as false", async () => { 2775 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2776 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2777 + 2778 + const routes = await loadAdminRoutes(); 2779 + // No allowUserChoice field — checkbox was unchecked 2780 + const res = await routes.request("/admin/theme-policy", { 2781 + method: "POST", 2782 + headers: { 2783 + cookie: "atbb_session=token", 2784 + "content-type": "application/x-www-form-urlencoded", 2785 + }, 2786 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2787 + }); 2788 + 2789 + expect(res.status).toBe(302); 2790 + const apiCall = mockFetch.mock.calls[2]; 2791 + const sentBody = JSON.parse(apiCall[1].body); 2792 + expect(sentBody.allowUserChoice).toBe(false); 2793 + }); 2794 + 2795 + it("sends empty availableThemes when no checkboxes are checked", async () => { 2796 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2797 + mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2798 + 2799 + const routes = await loadAdminRoutes(); 2800 + const res = await routes.request("/admin/theme-policy", { 2801 + method: "POST", 2802 + headers: { 2803 + cookie: "atbb_session=token", 2804 + "content-type": "application/x-www-form-urlencoded", 2805 + }, 2806 + body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test&allowUserChoice=on", 2807 + }); 2808 + 2809 + expect(res.status).toBe(302); 2810 + const apiCall = mockFetch.mock.calls[2]; 2811 + const sentBody = JSON.parse(apiCall[1].body); 2812 + expect(sentBody.availableThemes).toEqual([]); 2813 + }); 2814 + 2815 + it("redirects with error on AppView failure", async () => { 2816 + setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2817 + mockFetch.mockResolvedValueOnce( 2818 + mockResponse({ error: "Invalid theme URIs" }, false, 400) 2819 + ); 2820 + 2821 + const routes = await loadAdminRoutes(); 2822 + const res = await routes.request("/admin/theme-policy", { 2823 + method: "POST", 2824 + headers: { 2825 + cookie: "atbb_session=token", 2826 + "content-type": "application/x-www-form-urlencoded", 2827 + }, 2828 + body: "defaultLightThemeUri=bad&defaultDarkThemeUri=bad", 2829 + }); 2830 + 2831 + expect(res.status).toBe(302); 2832 + expect(res.headers.get("location")).toContain("/admin/themes?error="); 2833 + }); 2834 + });
+544
apps/web/src/routes/admin.tsx
··· 8 8 canManageCategories, 9 9 canViewModLog, 10 10 canManageRoles, 11 + canManageThemes, 11 12 } from "../lib/session.js"; 12 13 import { isProgrammingError } from "../lib/errors.js"; 13 14 import { logger } from "../lib/logger.js"; 15 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 16 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 14 17 15 18 // ─── Types ───────────────────────────────────────────────────────────────── 16 19 ··· 58 61 reason: string | null; 59 62 createdAt: string; 60 63 } 64 + 65 + interface AdminThemeEntry { 66 + id: string; 67 + uri: string; 68 + name: string; 69 + colorScheme: string; 70 + tokens: Record<string, string>; 71 + cssOverrides: string | null; 72 + fontUrls: string[] | null; 73 + createdAt: string; 74 + indexedAt: string; 75 + } 76 + 77 + interface ThemePolicy { 78 + defaultLightThemeUri: string | null; 79 + defaultDarkThemeUri: string | null; 80 + allowUserChoice: boolean; 81 + availableThemes: Array<{ uri: string; cid: string }>; 82 + } 83 + 84 + // Preset token maps — used by POST /admin/themes to seed tokens on creation 85 + const THEME_PRESETS: Record<string, Record<string, string>> = { 86 + "neobrutal-light": neobrutalLight as Record<string, string>, 87 + "neobrutal-dark": neobrutalDark as Record<string, string>, 88 + "blank": {}, 89 + }; 61 90 62 91 const ACTION_LABELS: Record<string, string> = { 63 92 "space.atbb.modAction.ban": "Ban", ··· 381 410 const showMembers = canManageMembers(auth); 382 411 const showStructure = canManageCategories(auth); 383 412 const showModLog = canViewModLog(auth); 413 + const showThemes = canManageThemes(auth); 384 414 385 415 return c.html( 386 416 <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> ··· 410 440 <p class="admin-nav-card__icon" aria-hidden="true">📋</p> 411 441 <p class="admin-nav-card__title">Mod Log</p> 412 442 <p class="admin-nav-card__description">Audit trail of moderation actions</p> 443 + </Card> 444 + </a> 445 + )} 446 + {showThemes && ( 447 + <a href="/admin/themes" class="admin-nav-card"> 448 + <Card> 449 + <p class="admin-nav-card__icon" aria-hidden="true">🎨</p> 450 + <p class="admin-nav-card__title">Themes</p> 451 + <p class="admin-nav-card__description"> 452 + Customize forum appearance and color schemes 453 + </p> 413 454 </Card> 414 455 </a> 415 456 )} ··· 1445 1486 </div> 1446 1487 </BaseLayout> 1447 1488 ); 1489 + }); 1490 + 1491 + // ─── Themes ──────────────────────────────────────────────────────────────── 1492 + 1493 + app.get("/admin/themes", async (c) => { 1494 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1495 + 1496 + if (!auth.authenticated) { 1497 + return c.redirect("/login"); 1498 + } 1499 + 1500 + if (!canManageThemes(auth)) { 1501 + return c.html( 1502 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1503 + <PageHeader title="Themes" /> 1504 + <p>You don&apos;t have permission to manage themes.</p> 1505 + </BaseLayout>, 1506 + 403 1507 + ); 1508 + } 1509 + 1510 + const cookie = c.req.header("cookie") ?? ""; 1511 + const errorMsg = c.req.query("error") ?? null; 1512 + 1513 + let adminThemes: AdminThemeEntry[] = []; 1514 + let policy: ThemePolicy | null = null; 1515 + 1516 + try { 1517 + const [themesRes, policyRes] = await Promise.all([ 1518 + fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 1519 + fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 1520 + ]); 1521 + 1522 + if (themesRes.ok) { 1523 + try { 1524 + const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 1525 + adminThemes = data.themes; 1526 + } catch { 1527 + logger.error("Failed to parse admin themes response", { 1528 + operation: "GET /admin/themes", 1529 + status: themesRes.status, 1530 + }); 1531 + } 1532 + } else { 1533 + logger.error("Failed to fetch admin themes list", { 1534 + operation: "GET /admin/themes", 1535 + status: themesRes.status, 1536 + }); 1537 + } 1538 + 1539 + if (policyRes.ok) { 1540 + try { 1541 + policy = (await policyRes.json()) as ThemePolicy; 1542 + } catch { 1543 + logger.error("Failed to parse theme policy response", { 1544 + operation: "GET /admin/themes", 1545 + status: policyRes.status, 1546 + }); 1547 + } 1548 + } else if (policyRes.status !== 404) { 1549 + logger.error("Failed to fetch theme policy", { 1550 + operation: "GET /admin/themes", 1551 + status: policyRes.status, 1552 + }); 1553 + } 1554 + // 404 = no policy yet — render page with empty policy (not an error) 1555 + } catch (error) { 1556 + if (isProgrammingError(error)) throw error; 1557 + logger.error("Network error fetching themes data", { 1558 + operation: "GET /admin/themes", 1559 + error: error instanceof Error ? error.message : String(error), 1560 + }); 1561 + } 1562 + 1563 + const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 1564 + const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 1565 + const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 1566 + 1567 + return c.html( 1568 + <BaseLayout title="Themes — atBB Admin" auth={auth}> 1569 + <PageHeader title="Themes" /> 1570 + 1571 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 1572 + 1573 + {adminThemes.length === 0 ? ( 1574 + <EmptyState message="No themes yet. Create one below." /> 1575 + ) : ( 1576 + <div class="structure-list"> 1577 + {adminThemes.map((theme) => { 1578 + const themeRkey = theme.uri.split("/").pop() ?? theme.id; 1579 + const dialogId = `confirm-delete-theme-${themeRkey}`; 1580 + const swatchTokens = [ 1581 + "color-bg", 1582 + "color-surface", 1583 + "color-primary", 1584 + "color-secondary", 1585 + "color-border", 1586 + ] as const; 1587 + 1588 + return ( 1589 + <div class="structure-item"> 1590 + <div class="structure-item__header"> 1591 + <div class="structure-item__title"> 1592 + <label> 1593 + <input 1594 + type="checkbox" 1595 + form="policy-form" 1596 + name="availableThemes" 1597 + value={theme.uri} 1598 + checked={availableUris.has(theme.uri)} 1599 + /> 1600 + {" "} 1601 + {theme.name} 1602 + </label> 1603 + <span class={`badge badge--${theme.colorScheme}`}> 1604 + {theme.colorScheme} 1605 + </span> 1606 + </div> 1607 + 1608 + <div class="theme-swatches" aria-hidden="true"> 1609 + {swatchTokens.map((token) => { 1610 + const value = theme.tokens[token] ?? "#cccccc"; 1611 + const safe = 1612 + !value.startsWith("var(") && 1613 + !value.includes(";") && 1614 + !value.includes("<"); 1615 + return ( 1616 + <span 1617 + class="theme-swatch" 1618 + style={safe ? `background:${value}` : "background:#cccccc"} 1619 + title={token} 1620 + /> 1621 + ); 1622 + })} 1623 + </div> 1624 + 1625 + <div class="structure-item__actions"> 1626 + <span class="btn btn-secondary btn-sm" aria-disabled="true"> 1627 + Edit 1628 + </span> 1629 + 1630 + <form 1631 + method="post" 1632 + action={`/admin/themes/${themeRkey}/duplicate`} 1633 + style="display:inline" 1634 + > 1635 + <button type="submit" class="btn btn-secondary btn-sm"> 1636 + Duplicate 1637 + </button> 1638 + </form> 1639 + 1640 + <button 1641 + type="button" 1642 + class="btn btn-danger btn-sm" 1643 + onclick={`document.getElementById('${dialogId}').showModal()`} 1644 + > 1645 + Delete 1646 + </button> 1647 + </div> 1648 + </div> 1649 + 1650 + <dialog id={dialogId} class="structure-confirm-dialog"> 1651 + <p> 1652 + Delete theme &quot;{theme.name}&quot;? This cannot be undone. 1653 + </p> 1654 + <form 1655 + method="post" 1656 + action={`/admin/themes/${themeRkey}/delete`} 1657 + class="dialog-actions" 1658 + > 1659 + <button type="submit" class="btn btn-danger"> 1660 + Delete 1661 + </button> 1662 + <button 1663 + type="button" 1664 + class="btn btn-secondary" 1665 + onclick={`document.getElementById('${dialogId}').close()`} 1666 + > 1667 + Cancel 1668 + </button> 1669 + </form> 1670 + </dialog> 1671 + </div> 1672 + ); 1673 + })} 1674 + </div> 1675 + )} 1676 + 1677 + {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 1678 + <section class="admin-section"> 1679 + <h2>Theme Policy</h2> 1680 + <form id="policy-form" method="post" action="/admin/theme-policy"> 1681 + <div class="form-group"> 1682 + <label for="defaultLightThemeUri">Default Light Theme</label> 1683 + <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 1684 + <option value="">— none —</option> 1685 + {lightThemes.map((t) => ( 1686 + <option 1687 + value={t.uri} 1688 + selected={policy?.defaultLightThemeUri === t.uri} 1689 + > 1690 + {t.name} 1691 + </option> 1692 + ))} 1693 + </select> 1694 + </div> 1695 + 1696 + <div class="form-group"> 1697 + <label for="defaultDarkThemeUri">Default Dark Theme</label> 1698 + <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 1699 + <option value="">— none —</option> 1700 + {darkThemes.map((t) => ( 1701 + <option 1702 + value={t.uri} 1703 + selected={policy?.defaultDarkThemeUri === t.uri} 1704 + > 1705 + {t.name} 1706 + </option> 1707 + ))} 1708 + </select> 1709 + </div> 1710 + 1711 + <div class="form-group"> 1712 + <label> 1713 + <input 1714 + type="checkbox" 1715 + name="allowUserChoice" 1716 + checked={policy?.allowUserChoice ?? true} 1717 + /> 1718 + {" "}Allow users to choose their own theme 1719 + </label> 1720 + </div> 1721 + 1722 + <p class="form-hint"> 1723 + Check themes above to make them available to users. 1724 + </p> 1725 + <button type="submit" class="btn btn-primary"> 1726 + Save Policy 1727 + </button> 1728 + </form> 1729 + </section> 1730 + 1731 + {/* Create new theme */} 1732 + <details class="structure-add-form"> 1733 + <summary class="structure-add-form__trigger">+ Create New Theme</summary> 1734 + <form 1735 + method="post" 1736 + action="/admin/themes" 1737 + class="structure-edit-form__body" 1738 + > 1739 + <div class="form-group"> 1740 + <label for="new-theme-name">Name</label> 1741 + <input 1742 + id="new-theme-name" 1743 + type="text" 1744 + name="name" 1745 + required 1746 + placeholder="My Custom Theme" 1747 + /> 1748 + </div> 1749 + <div class="form-group"> 1750 + <label for="new-theme-scheme">Color Scheme</label> 1751 + <select id="new-theme-scheme" name="colorScheme"> 1752 + <option value="light">Light</option> 1753 + <option value="dark">Dark</option> 1754 + </select> 1755 + </div> 1756 + <div class="form-group"> 1757 + <label for="new-theme-preset">Start from Preset</label> 1758 + <select id="new-theme-preset" name="preset"> 1759 + <option value="neobrutal-light">Neobrutal Light</option> 1760 + <option value="neobrutal-dark">Neobrutal Dark</option> 1761 + <option value="blank">Blank</option> 1762 + </select> 1763 + </div> 1764 + <button type="submit" class="btn btn-primary"> 1765 + Create Theme 1766 + </button> 1767 + </form> 1768 + </details> 1769 + </BaseLayout> 1770 + ); 1771 + }); 1772 + 1773 + // ── POST /admin/themes ──────────────────────────────────────────────────── 1774 + 1775 + app.post("/admin/themes", async (c) => { 1776 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1777 + if (!auth.authenticated) return c.redirect("/login"); 1778 + if (!canManageThemes(auth)) { 1779 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1780 + } 1781 + 1782 + const cookie = c.req.header("cookie") ?? ""; 1783 + 1784 + let body: Record<string, string | File>; 1785 + try { 1786 + body = await c.req.parseBody(); 1787 + } catch (error) { 1788 + if (isProgrammingError(error)) throw error; 1789 + return c.redirect( 1790 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1791 + 302 1792 + ); 1793 + } 1794 + 1795 + const name = typeof body.name === "string" ? body.name.trim() : ""; 1796 + const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 1797 + const preset = typeof body.preset === "string" ? body.preset : "blank"; 1798 + 1799 + if (!name) { 1800 + return c.redirect( 1801 + `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 1802 + 302 1803 + ); 1804 + } 1805 + 1806 + const tokens = THEME_PRESETS[preset] ?? {}; 1807 + 1808 + let apiRes: Response; 1809 + try { 1810 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1811 + method: "POST", 1812 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1813 + body: JSON.stringify({ name, colorScheme, tokens }), 1814 + }); 1815 + } catch (error) { 1816 + if (isProgrammingError(error)) throw error; 1817 + logger.error("Network error creating theme", { 1818 + operation: "POST /admin/themes", 1819 + error: error instanceof Error ? error.message : String(error), 1820 + }); 1821 + return c.redirect( 1822 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1823 + 302 1824 + ); 1825 + } 1826 + 1827 + if (!apiRes.ok) { 1828 + const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 1829 + return c.redirect( 1830 + `/admin/themes?error=${encodeURIComponent(msg)}`, 1831 + 302 1832 + ); 1833 + } 1834 + 1835 + return c.redirect("/admin/themes", 302); 1836 + }); 1837 + 1838 + // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 1839 + 1840 + app.post("/admin/themes/:rkey/duplicate", async (c) => { 1841 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1842 + if (!auth.authenticated) return c.redirect("/login"); 1843 + if (!canManageThemes(auth)) { 1844 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1845 + } 1846 + 1847 + const cookie = c.req.header("cookie") ?? ""; 1848 + const themeRkey = c.req.param("rkey"); 1849 + 1850 + let apiRes: Response; 1851 + try { 1852 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 1853 + method: "POST", 1854 + headers: { Cookie: cookie }, 1855 + }); 1856 + } catch (error) { 1857 + if (isProgrammingError(error)) throw error; 1858 + logger.error("Network error duplicating theme", { 1859 + operation: "POST /admin/themes/:rkey/duplicate", 1860 + themeRkey, 1861 + error: error instanceof Error ? error.message : String(error), 1862 + }); 1863 + return c.redirect( 1864 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1865 + 302 1866 + ); 1867 + } 1868 + 1869 + if (!apiRes.ok) { 1870 + const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 1871 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1872 + } 1873 + 1874 + return c.redirect("/admin/themes", 302); 1875 + }); 1876 + 1877 + // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 1878 + 1879 + app.post("/admin/themes/:rkey/delete", async (c) => { 1880 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1881 + if (!auth.authenticated) return c.redirect("/login"); 1882 + if (!canManageThemes(auth)) { 1883 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1884 + } 1885 + 1886 + const cookie = c.req.header("cookie") ?? ""; 1887 + const themeRkey = c.req.param("rkey"); 1888 + 1889 + let apiRes: Response; 1890 + try { 1891 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1892 + method: "DELETE", 1893 + headers: { Cookie: cookie }, 1894 + }); 1895 + } catch (error) { 1896 + if (isProgrammingError(error)) throw error; 1897 + logger.error("Network error deleting theme", { 1898 + operation: "POST /admin/themes/:rkey/delete", 1899 + themeRkey, 1900 + error: error instanceof Error ? error.message : String(error), 1901 + }); 1902 + return c.redirect( 1903 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1904 + 302 1905 + ); 1906 + } 1907 + 1908 + if (!apiRes.ok) { 1909 + if (apiRes.status === 409) { 1910 + return c.redirect( 1911 + `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 1912 + 302 1913 + ); 1914 + } 1915 + const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 1916 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1917 + } 1918 + 1919 + return c.redirect("/admin/themes", 302); 1920 + }); 1921 + 1922 + // ── POST /admin/theme-policy ────────────────────────────────────────────── 1923 + 1924 + app.post("/admin/theme-policy", async (c) => { 1925 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1926 + if (!auth.authenticated) return c.redirect("/login"); 1927 + if (!canManageThemes(auth)) { 1928 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1929 + } 1930 + 1931 + const cookie = c.req.header("cookie") ?? ""; 1932 + 1933 + let rawBody: Record<string, string | string[] | File | File[]>; 1934 + try { 1935 + rawBody = await c.req.parseBody({ all: true }); 1936 + } catch (error) { 1937 + if (isProgrammingError(error)) throw error; 1938 + return c.redirect( 1939 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1940 + 302 1941 + ); 1942 + } 1943 + 1944 + const defaultLightThemeUri = 1945 + typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 1946 + const defaultDarkThemeUri = 1947 + typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 1948 + // Checkbox: present with value "on" when checked, absent when unchecked 1949 + const allowUserChoice = rawBody.allowUserChoice === "on"; 1950 + 1951 + // availableThemes may be a single string, an array, or absent 1952 + const rawAvailable = rawBody.availableThemes; 1953 + const availableThemes = 1954 + rawAvailable === undefined 1955 + ? [] 1956 + : Array.isArray(rawAvailable) 1957 + ? rawAvailable.filter((v): v is string => typeof v === "string") 1958 + : typeof rawAvailable === "string" 1959 + ? [rawAvailable] 1960 + : []; 1961 + 1962 + let apiRes: Response; 1963 + try { 1964 + apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 1965 + method: "PUT", 1966 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1967 + body: JSON.stringify({ 1968 + defaultLightThemeUri, 1969 + defaultDarkThemeUri, 1970 + allowUserChoice, 1971 + availableThemes: availableThemes.map((uri) => ({ uri })), 1972 + }), 1973 + }); 1974 + } catch (error) { 1975 + if (isProgrammingError(error)) throw error; 1976 + logger.error("Network error updating theme policy", { 1977 + operation: "POST /admin/theme-policy", 1978 + error: error instanceof Error ? error.message : String(error), 1979 + }); 1980 + return c.redirect( 1981 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1982 + 302 1983 + ); 1984 + } 1985 + 1986 + if (!apiRes.ok) { 1987 + const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 1988 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1989 + } 1990 + 1991 + return c.redirect("/admin/themes", 302); 1448 1992 }); 1449 1993 1450 1994 return app;
+41
bruno/AppView API/Admin Themes/Duplicate Theme.bru
··· 1 + meta { 2 + name: Duplicate Theme 3 + type: http 4 + seq: 5 5 + } 6 + 7 + post { 8 + url: {{appview_url}}/api/admin/themes/{{theme_rkey}}/duplicate 9 + } 10 + 11 + assert { 12 + res.status: eq 201 13 + res.body.uri: isDefined 14 + res.body.rkey: isDefined 15 + res.body.name: isDefined 16 + } 17 + 18 + docs { 19 + Clone an existing theme record with " (Copy)" appended to the name. 20 + A fresh TID is generated as the new record key. 21 + The firehose indexer creates the DB row asynchronously. 22 + 23 + **Requires:** space.atbb.permission.manageThemes 24 + 25 + Path params: 26 + - rkey: Source theme record key (TID) 27 + 28 + Returns (201): 29 + { 30 + "uri": "at://did:plc:.../space.atbb.forum.theme/newrkey123", 31 + "rkey": "newrkey123", 32 + "name": "Original Name (Copy)" 33 + } 34 + 35 + Error codes: 36 + - 401: Not authenticated 37 + - 403: Missing manageThemes permission 38 + - 404: Source theme not found 39 + - 500: ForumAgent not configured (server configuration issue) 40 + - 503: ForumAgent not authenticated or PDS network error 41 + }
+50
bruno/AppView API/Admin Themes/List Themes.bru
··· 1 + meta { 2 + name: List Themes 3 + type: http 4 + seq: 6 5 + } 6 + 7 + get { 8 + url: {{appview_url}}/api/admin/themes 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + res.body.themes: isArray 14 + res.body.isTruncated: isDefined 15 + } 16 + 17 + docs { 18 + List all themes for the forum, regardless of whether they appear in the active theme policy. 19 + Admins need to see all themes, including drafts not yet published to the policy. 20 + 21 + **Requires:** `space.atbb.permission.manageThemes` permission 22 + 23 + Returns (200): 24 + { 25 + "themes": [ 26 + { 27 + "id": "1", 28 + "uri": "at://did:plc:.../space.atbb.forum.theme/abc123", 29 + "name": "Neobrutal Light", 30 + "colorScheme": "light", 31 + "tokens": { "color-bg": "#f5f0e8" }, 32 + "cssOverrides": null, 33 + "fontUrls": null, 34 + "createdAt": "2026-01-01T00:00:00.000Z", 35 + "indexedAt": "2026-01-01T00:00:00.000Z" 36 + }, 37 + ... 38 + ], 39 + "isTruncated": false 40 + } 41 + 42 + Error codes: 43 + - 401: Not authenticated 44 + - 403: Missing manageThemes permission 45 + - 500: Database error 46 + 47 + Notes: 48 + - Returns at most 100 themes. If isTruncated is true, more themes exist beyond this page. 49 + - Results are not policy-filtered — all forum themes are returned. 50 + }
+4 -3
bruno/AppView API/Admin Themes/Update Theme Policy.bru
··· 11 11 body:json { 12 12 { 13 13 "availableThemes": [ 14 - { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", "cid": "bafylight" }, 15 - { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", "cid": "bafydark" } 14 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1" }, 15 + { "uri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11" } 16 16 ], 17 17 "defaultLightThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbllight1", 18 18 "defaultDarkThemeUri": "at://did:plc:example/space.atbb.forum.theme/3lbldark11", ··· 33 33 **Requires:** space.atbb.permission.manageThemes 34 34 35 35 Body: 36 - - availableThemes (required): Non-empty array of { uri, cid } theme references. 36 + - availableThemes (required): Non-empty array of { uri, cid? } theme references. 37 + cid is optional — if omitted, the AppView looks it up from the themes table by URI. 37 38 Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 38 39 - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 39 40 Must be in availableThemes.