WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat(web): add GET /admin/members page and POST proxy route (ATB-43)

+695 -4
+365
apps/web/src/routes/__tests__/admin.test.tsx
··· 195 195 expect(html).toContain("admin-nav-grid"); 196 196 }); 197 197 }); 198 + 199 + describe("createAdminRoutes — GET /admin/members", () => { 200 + beforeEach(() => { 201 + vi.stubGlobal("fetch", mockFetch); 202 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 203 + vi.resetModules(); 204 + }); 205 + 206 + afterEach(() => { 207 + vi.unstubAllGlobals(); 208 + vi.unstubAllEnvs(); 209 + mockFetch.mockReset(); 210 + }); 211 + 212 + function mockResponse(body: unknown, ok = true, status = 200) { 213 + return { 214 + ok, 215 + status, 216 + statusText: ok ? "OK" : "Error", 217 + json: () => Promise.resolve(body), 218 + }; 219 + } 220 + 221 + function setupSession(permissions: string[]) { 222 + mockFetch.mockResolvedValueOnce( 223 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 224 + ); 225 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 226 + } 227 + 228 + const SAMPLE_MEMBERS = [ 229 + { 230 + did: "did:plc:alice", 231 + handle: "alice.bsky.social", 232 + role: "Owner", 233 + roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 234 + joinedAt: "2026-01-01T00:00:00.000Z", 235 + }, 236 + { 237 + did: "did:plc:bob", 238 + handle: "bob.bsky.social", 239 + role: "Member", 240 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 241 + joinedAt: "2026-01-05T00:00:00.000Z", 242 + }, 243 + ]; 244 + 245 + const SAMPLE_ROLES = [ 246 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 247 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 248 + ]; 249 + 250 + async function loadAdminRoutes() { 251 + const { createAdminRoutes } = await import("../admin.js"); 252 + return createAdminRoutes("http://localhost:3000"); 253 + } 254 + 255 + it("redirects unauthenticated users to /login", async () => { 256 + const routes = await loadAdminRoutes(); 257 + const res = await routes.request("/admin/members"); 258 + expect(res.status).toBe(302); 259 + expect(res.headers.get("location")).toBe("/login"); 260 + }); 261 + 262 + it("returns 403 for authenticated user without manageMembers", async () => { 263 + setupSession(["space.atbb.permission.manageCategories"]); 264 + const routes = await loadAdminRoutes(); 265 + const res = await routes.request("/admin/members", { 266 + headers: { cookie: "atbb_session=token" }, 267 + }); 268 + expect(res.status).toBe(403); 269 + }); 270 + 271 + it("renders member table with handles and role badges", async () => { 272 + setupSession(["space.atbb.permission.manageMembers"]); 273 + mockFetch.mockResolvedValueOnce( 274 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 275 + ); 276 + 277 + const routes = await loadAdminRoutes(); 278 + const res = await routes.request("/admin/members", { 279 + headers: { cookie: "atbb_session=token" }, 280 + }); 281 + 282 + expect(res.status).toBe(200); 283 + const html = await res.text(); 284 + expect(html).toContain("alice.bsky.social"); 285 + expect(html).toContain("bob.bsky.social"); 286 + expect(html).toContain("role-badge"); 287 + expect(html).toContain("Owner"); 288 + }); 289 + 290 + it("renders joined date for members", async () => { 291 + setupSession(["space.atbb.permission.manageMembers"]); 292 + mockFetch.mockResolvedValueOnce( 293 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 294 + ); 295 + 296 + const routes = await loadAdminRoutes(); 297 + const res = await routes.request("/admin/members", { 298 + headers: { cookie: "atbb_session=token" }, 299 + }); 300 + 301 + const html = await res.text(); 302 + expect(html).toContain("Jan"); 303 + expect(html).toContain("2026"); 304 + }); 305 + 306 + it("hides role assignment form when user lacks manageRoles", async () => { 307 + setupSession(["space.atbb.permission.manageMembers"]); 308 + mockFetch.mockResolvedValueOnce( 309 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 310 + ); 311 + 312 + const routes = await loadAdminRoutes(); 313 + const res = await routes.request("/admin/members", { 314 + headers: { cookie: "atbb_session=token" }, 315 + }); 316 + 317 + const html = await res.text(); 318 + expect(html).not.toContain("hx-post"); 319 + expect(html).not.toContain("Assign"); 320 + }); 321 + 322 + it("shows role assignment form when user has manageRoles", async () => { 323 + setupSession([ 324 + "space.atbb.permission.manageMembers", 325 + "space.atbb.permission.manageRoles", 326 + ]); 327 + mockFetch.mockResolvedValueOnce( 328 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 329 + ); 330 + mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); 331 + 332 + const routes = await loadAdminRoutes(); 333 + const res = await routes.request("/admin/members", { 334 + headers: { cookie: "atbb_session=token" }, 335 + }); 336 + 337 + const html = await res.text(); 338 + expect(html).toContain("hx-post"); 339 + expect(html).toContain("/admin/members/did:plc:bob/role"); 340 + expect(html).toContain("Assign"); 341 + }); 342 + 343 + it("shows empty state when no members", async () => { 344 + setupSession(["space.atbb.permission.manageMembers"]); 345 + mockFetch.mockResolvedValueOnce( 346 + mockResponse({ members: [], isTruncated: false }) 347 + ); 348 + 349 + const routes = await loadAdminRoutes(); 350 + const res = await routes.request("/admin/members", { 351 + headers: { cookie: "atbb_session=token" }, 352 + }); 353 + 354 + const html = await res.text(); 355 + expect(html).toContain("No members"); 356 + }); 357 + 358 + it("shows truncated indicator when isTruncated is true", async () => { 359 + setupSession(["space.atbb.permission.manageMembers"]); 360 + mockFetch.mockResolvedValueOnce( 361 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) 362 + ); 363 + 364 + const routes = await loadAdminRoutes(); 365 + const res = await routes.request("/admin/members", { 366 + headers: { cookie: "atbb_session=token" }, 367 + }); 368 + 369 + const html = await res.text(); 370 + expect(html).toContain("+"); 371 + }); 372 + 373 + it("returns 503 on AppView network error fetching members", async () => { 374 + setupSession(["space.atbb.permission.manageMembers"]); 375 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 376 + 377 + const routes = await loadAdminRoutes(); 378 + const res = await routes.request("/admin/members", { 379 + headers: { cookie: "atbb_session=token" }, 380 + }); 381 + 382 + expect(res.status).toBe(503); 383 + const html = await res.text(); 384 + expect(html).toContain("error-display"); 385 + }); 386 + 387 + it("returns 500 on AppView server error fetching members", async () => { 388 + setupSession(["space.atbb.permission.manageMembers"]); 389 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 390 + 391 + const routes = await loadAdminRoutes(); 392 + const res = await routes.request("/admin/members", { 393 + headers: { cookie: "atbb_session=token" }, 394 + }); 395 + 396 + expect(res.status).toBe(500); 397 + const html = await res.text(); 398 + expect(html).toContain("error-display"); 399 + }); 400 + }); 401 + 402 + describe("createAdminRoutes — POST /admin/members/:did/role", () => { 403 + beforeEach(() => { 404 + vi.stubGlobal("fetch", mockFetch); 405 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 406 + vi.resetModules(); 407 + }); 408 + 409 + afterEach(() => { 410 + vi.unstubAllGlobals(); 411 + vi.unstubAllEnvs(); 412 + mockFetch.mockReset(); 413 + }); 414 + 415 + function mockResponse(body: unknown, ok = true, status = 200) { 416 + return { 417 + ok, 418 + status, 419 + statusText: ok ? "OK" : "Error", 420 + json: () => Promise.resolve(body), 421 + }; 422 + } 423 + 424 + const SAMPLE_ROLES = [ 425 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 426 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 427 + ]; 428 + 429 + function makeFormBody(overrides: Partial<Record<string, string>> = {}): string { 430 + return new URLSearchParams({ 431 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 432 + handle: "bob.bsky.social", 433 + joinedAt: "2026-01-05T00:00:00.000Z", 434 + currentRole: "Owner", 435 + currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 436 + canManageRoles: "1", 437 + rolesJson: JSON.stringify(SAMPLE_ROLES), 438 + ...overrides, 439 + }).toString(); 440 + } 441 + 442 + async function loadAdminRoutes() { 443 + const { createAdminRoutes } = await import("../admin.js"); 444 + return createAdminRoutes("http://localhost:3000"); 445 + } 446 + 447 + it("returns updated <tr> with new role name on success", async () => { 448 + mockFetch.mockResolvedValueOnce( 449 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 450 + ); 451 + 452 + const routes = await loadAdminRoutes(); 453 + const res = await routes.request("/admin/members/did:plc:bob/role", { 454 + method: "POST", 455 + headers: { 456 + "Content-Type": "application/x-www-form-urlencoded", 457 + cookie: "atbb_session=token", 458 + }, 459 + body: makeFormBody(), 460 + }); 461 + 462 + expect(res.status).toBe(200); 463 + const html = await res.text(); 464 + expect(html).toContain("<tr"); 465 + expect(html).toContain("Member"); 466 + expect(html).toContain("bob.bsky.social"); 467 + }); 468 + 469 + it("returns row with friendly error on AppView 403", async () => { 470 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 471 + 472 + const routes = await loadAdminRoutes(); 473 + const res = await routes.request("/admin/members/did:plc:bob/role", { 474 + method: "POST", 475 + headers: { 476 + "Content-Type": "application/x-www-form-urlencoded", 477 + cookie: "atbb_session=token", 478 + }, 479 + body: makeFormBody(), 480 + }); 481 + 482 + expect(res.status).toBe(200); 483 + const html = await res.text(); 484 + expect(html).toContain("member-row__error"); 485 + expect(html).toContain("equal or higher authority"); 486 + expect(html).toContain("Owner"); // preserves current role 487 + }); 488 + 489 + it("returns row with friendly error on AppView 404", async () => { 490 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 491 + 492 + const routes = await loadAdminRoutes(); 493 + const res = await routes.request("/admin/members/did:plc:bob/role", { 494 + method: "POST", 495 + headers: { 496 + "Content-Type": "application/x-www-form-urlencoded", 497 + cookie: "atbb_session=token", 498 + }, 499 + body: makeFormBody(), 500 + }); 501 + 502 + expect(res.status).toBe(200); 503 + const html = await res.text(); 504 + expect(html).toContain("member-row__error"); 505 + expect(html).toContain("not found"); 506 + }); 507 + 508 + it("returns row with friendly error on AppView 500", async () => { 509 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 510 + 511 + const routes = await loadAdminRoutes(); 512 + const res = await routes.request("/admin/members/did:plc:bob/role", { 513 + method: "POST", 514 + headers: { 515 + "Content-Type": "application/x-www-form-urlencoded", 516 + cookie: "atbb_session=token", 517 + }, 518 + body: makeFormBody(), 519 + }); 520 + 521 + expect(res.status).toBe(200); 522 + const html = await res.text(); 523 + expect(html).toContain("member-row__error"); 524 + expect(html).toContain("Something went wrong"); 525 + }); 526 + 527 + it("returns row with unavailable message on network error", async () => { 528 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 529 + 530 + const routes = await loadAdminRoutes(); 531 + const res = await routes.request("/admin/members/did:plc:bob/role", { 532 + method: "POST", 533 + headers: { 534 + "Content-Type": "application/x-www-form-urlencoded", 535 + cookie: "atbb_session=token", 536 + }, 537 + body: makeFormBody(), 538 + }); 539 + 540 + expect(res.status).toBe(200); 541 + const html = await res.text(); 542 + expect(html).toContain("member-row__error"); 543 + expect(html).toContain("temporarily unavailable"); 544 + }); 545 + 546 + it("returns row with error and makes no AppView call when roleUri is missing", async () => { 547 + const routes = await loadAdminRoutes(); 548 + const res = await routes.request("/admin/members/did:plc:bob/role", { 549 + method: "POST", 550 + headers: { 551 + "Content-Type": "application/x-www-form-urlencoded", 552 + cookie: "atbb_session=token", 553 + }, 554 + body: makeFormBody({ roleUri: "" }), 555 + }); 556 + 557 + expect(res.status).toBe(200); 558 + const html = await res.text(); 559 + expect(html).toContain("member-row__error"); 560 + expect(mockFetch).not.toHaveBeenCalled(); 561 + }); 562 + });
+330 -4
apps/web/src/routes/admin.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 - import { PageHeader, Card } from "../components/index.js"; 4 - import { getSessionWithPermissions, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog } from "../lib/session.js"; 3 + import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js"; 4 + import { 5 + getSessionWithPermissions, 6 + hasAnyAdminPermission, 7 + canManageMembers, 8 + canManageCategories, 9 + canViewModLog, 10 + canManageRoles, 11 + } from "../lib/session.js"; 12 + import { isProgrammingError } from "../lib/errors.js"; 13 + import { logger } from "../lib/logger.js"; 14 + 15 + // ─── Types ───────────────────────────────────────────────────────────────── 16 + 17 + interface MemberEntry { 18 + did: string; 19 + handle: string; 20 + role: string; 21 + roleUri: string | null; 22 + joinedAt: string | null; 23 + } 24 + 25 + interface RoleEntry { 26 + id: string; 27 + name: string; 28 + uri: string; 29 + priority: number; 30 + } 31 + 32 + // ─── Helpers ─────────────────────────────────────────────────────────────── 33 + 34 + function formatJoinedDate(isoString: string | null): string { 35 + if (!isoString) return "—"; 36 + const d = new Date(isoString); 37 + if (isNaN(d.getTime())) return "—"; 38 + return d.toLocaleDateString("en-US", { 39 + month: "short", 40 + day: "numeric", 41 + year: "numeric", 42 + }); 43 + } 44 + 45 + // ─── Components ──────────────────────────────────────────────────────────── 46 + 47 + function MemberRow({ 48 + member, 49 + roles, 50 + showRoleControls, 51 + errorMsg = null, 52 + }: { 53 + member: MemberEntry; 54 + roles: RoleEntry[]; 55 + showRoleControls: boolean; 56 + errorMsg?: string | null; 57 + }) { 58 + return ( 59 + <tr> 60 + <td>{member.handle}</td> 61 + <td> 62 + <span class="role-badge">{member.role}</span> 63 + </td> 64 + <td>{formatJoinedDate(member.joinedAt)}</td> 65 + {showRoleControls && ( 66 + <td> 67 + <form 68 + hx-post={`/admin/members/${member.did}/role`} 69 + hx-target="closest tr" 70 + hx-swap="outerHTML" 71 + > 72 + <input type="hidden" name="handle" value={member.handle} /> 73 + <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} /> 74 + <input type="hidden" name="currentRole" value={member.role} /> 75 + <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} /> 76 + <input type="hidden" name="canManageRoles" value="1" /> 77 + <input type="hidden" name="rolesJson" value={JSON.stringify(roles)} /> 78 + <div class="member-row__assign-form"> 79 + <label class="sr-only" for={`role-${member.did}`}> 80 + Assign role to {member.handle} 81 + </label> 82 + <select id={`role-${member.did}`} name="roleUri"> 83 + {roles.map((role) => ( 84 + <option value={role.uri} selected={member.roleUri === role.uri}> 85 + {role.name} 86 + </option> 87 + ))} 88 + </select> 89 + <button type="submit" class="btn btn-primary"> 90 + Assign 91 + </button> 92 + </div> 93 + {errorMsg && <span class="member-row__error">{errorMsg}</span>} 94 + </form> 95 + </td> 96 + )} 97 + </tr> 98 + ); 99 + } 5 100 6 - // ─── Route ──────────────────────────────────────────────────────────────── 101 + // ─── Routes ──────────────────────────────────────────────────────────────── 7 102 8 103 export function createAdminRoutes(appviewUrl: string) { 9 - return new Hono().get("/admin", async (c) => { 104 + const app = new Hono(); 105 + 106 + // ── GET /admin ──────────────────────────────────────────────────────────── 107 + 108 + app.get("/admin", async (c) => { 10 109 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 11 110 12 111 if (!auth.authenticated) { ··· 62 161 </BaseLayout> 63 162 ); 64 163 }); 164 + 165 + // ── GET /admin/members ──────────────────────────────────────────────────── 166 + 167 + app.get("/admin/members", async (c) => { 168 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 169 + 170 + if (!auth.authenticated) { 171 + return c.redirect("/login"); 172 + } 173 + 174 + if (!canManageMembers(auth)) { 175 + return c.html( 176 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 177 + <PageHeader title="Members" /> 178 + <p>You don&apos;t have permission to manage members.</p> 179 + </BaseLayout>, 180 + 403 181 + ); 182 + } 183 + 184 + const cookie = c.req.header("cookie") ?? ""; 185 + const showRoleControls = canManageRoles(auth); 186 + 187 + let membersRes: Response; 188 + let rolesRes: Response | null = null; 189 + 190 + try { 191 + [membersRes, rolesRes] = await Promise.all([ 192 + fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }), 193 + showRoleControls 194 + ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } }) 195 + : Promise.resolve(null), 196 + ]); 197 + } catch (error) { 198 + if (isProgrammingError(error)) throw error; 199 + logger.error("Network error fetching members", { 200 + operation: "GET /admin/members", 201 + error: error instanceof Error ? error.message : String(error), 202 + }); 203 + return c.html( 204 + <BaseLayout title="Members — atBB Forum" auth={auth}> 205 + <PageHeader title="Members" /> 206 + <ErrorDisplay 207 + message="Unable to load members" 208 + detail="The forum is temporarily unavailable. Please try again." 209 + /> 210 + </BaseLayout>, 211 + 503 212 + ); 213 + } 214 + 215 + if (!membersRes.ok) { 216 + logger.error("AppView returned error for members list", { 217 + operation: "GET /admin/members", 218 + status: membersRes.status, 219 + }); 220 + return c.html( 221 + <BaseLayout title="Members — atBB Forum" auth={auth}> 222 + <PageHeader title="Members" /> 223 + <ErrorDisplay 224 + message="Something went wrong" 225 + detail="Could not load member list. Please try again." 226 + /> 227 + </BaseLayout>, 228 + 500 229 + ); 230 + } 231 + 232 + const membersData = (await membersRes.json()) as { 233 + members: MemberEntry[]; 234 + isTruncated: boolean; 235 + }; 236 + const rolesData = rolesRes?.ok 237 + ? ((await rolesRes.json()) as { roles: RoleEntry[] }) 238 + : null; 239 + 240 + const members = membersData.members; 241 + const roles = rolesData?.roles ?? []; 242 + const isTruncated = membersData.isTruncated; 243 + const title = `Members (${members.length}${isTruncated ? "+" : ""})`; 244 + 245 + return c.html( 246 + <BaseLayout title="Members — atBB Forum" auth={auth}> 247 + <PageHeader title={title} /> 248 + {members.length === 0 ? ( 249 + <EmptyState message="No members yet" /> 250 + ) : ( 251 + <div class="card"> 252 + <table class="admin-member-table"> 253 + <thead> 254 + <tr> 255 + <th scope="col">Handle</th> 256 + <th scope="col">Role</th> 257 + <th scope="col">Joined</th> 258 + {showRoleControls && <th scope="col">Assign Role</th>} 259 + </tr> 260 + </thead> 261 + <tbody> 262 + {members.map((member) => ( 263 + <MemberRow 264 + member={member} 265 + roles={roles} 266 + showRoleControls={showRoleControls} 267 + /> 268 + ))} 269 + </tbody> 270 + </table> 271 + </div> 272 + )} 273 + </BaseLayout> 274 + ); 275 + }); 276 + 277 + // ── POST /admin/members/:did/role (HTMX proxy) ──────────────────────────── 278 + 279 + app.post("/admin/members/:did/role", async (c) => { 280 + const targetDid = c.req.param("did"); 281 + const cookie = c.req.header("cookie") ?? ""; 282 + 283 + let body: Record<string, string | File>; 284 + try { 285 + body = await c.req.parseBody(); 286 + } catch { 287 + return c.html( 288 + <tr> 289 + <td colspan={4}> 290 + <span class="member-row__error">Invalid form submission.</span> 291 + </td> 292 + </tr> 293 + ); 294 + } 295 + 296 + const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : ""; 297 + const handle = typeof body.handle === "string" ? body.handle : targetDid; 298 + const joinedAt = typeof body.joinedAt === "string" && body.joinedAt ? body.joinedAt : null; 299 + const currentRole = typeof body.currentRole === "string" ? body.currentRole : ""; 300 + const currentRoleUri = 301 + typeof body.currentRoleUri === "string" && body.currentRoleUri 302 + ? body.currentRoleUri 303 + : null; 304 + const showRoleControls = body.canManageRoles === "1"; 305 + 306 + let roles: RoleEntry[] = []; 307 + try { 308 + const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]"; 309 + roles = JSON.parse(rolesJson) as RoleEntry[]; 310 + } catch { 311 + // roles stays empty — row renders without dropdown 312 + } 313 + 314 + if (!roleUri) { 315 + return c.html( 316 + <MemberRow 317 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 318 + roles={roles} 319 + showRoleControls={showRoleControls} 320 + errorMsg="Please select a role." 321 + /> 322 + ); 323 + } 324 + 325 + let appviewRes: Response; 326 + try { 327 + appviewRes = await fetch(`${appviewUrl}/api/admin/members/${targetDid}/role`, { 328 + method: "POST", 329 + headers: { 330 + "Content-Type": "application/json", 331 + Cookie: cookie, 332 + }, 333 + body: JSON.stringify({ roleUri }), 334 + }); 335 + } catch (error) { 336 + if (isProgrammingError(error)) throw error; 337 + logger.error("Network error proxying role assignment", { 338 + operation: "POST /admin/members/:did/role", 339 + targetDid, 340 + error: error instanceof Error ? error.message : String(error), 341 + }); 342 + return c.html( 343 + <MemberRow 344 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 345 + roles={roles} 346 + showRoleControls={showRoleControls} 347 + errorMsg="Forum temporarily unavailable. Please try again." 348 + /> 349 + ); 350 + } 351 + 352 + if (appviewRes.ok) { 353 + const data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string }; 354 + const newRoleName = data.roleAssigned || currentRole; 355 + return c.html( 356 + <MemberRow 357 + member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }} 358 + roles={roles} 359 + showRoleControls={showRoleControls} 360 + /> 361 + ); 362 + } 363 + 364 + let errorMsg: string; 365 + if (appviewRes.status === 403) { 366 + errorMsg = "Cannot assign a role with equal or higher authority than your own."; 367 + } else if (appviewRes.status === 404) { 368 + errorMsg = "Member or role not found."; 369 + } else if (appviewRes.status === 401) { 370 + errorMsg = "Your session has expired. Please log in again."; 371 + } else { 372 + logger.error("AppView returned error for role assignment", { 373 + operation: "POST /admin/members/:did/role", 374 + targetDid, 375 + status: appviewRes.status, 376 + }); 377 + errorMsg = "Something went wrong. Please try again."; 378 + } 379 + 380 + return c.html( 381 + <MemberRow 382 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 383 + roles={roles} 384 + showRoleControls={showRoleControls} 385 + errorMsg={errorMsg} 386 + /> 387 + ); 388 + }); 389 + 390 + return app; 65 391 }