Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(admin): add admin dashboard with moderation, categories, settings, users (#12)

Admin panel with sidebar navigation and 6 pages:
- Dashboard with stats cards (topics, replies, users, reports)
- Category management with tree editor and maturity ratings
- Moderation queue with DSA-compliant report handling, first-post
queue, action log, most-reported users, and threshold settings
- Community settings (name, description, branding, reactions, maturity)
- Content ratings overview (maturity level explanations)
- User management with ban/unban controls and cross-community warnings

Includes API types, client functions, mock data, and MSW handlers
for all admin endpoints. 52 new tests, all 321 passing.

authored by

Guido X Jansen and committed by
GitHub
2c159d32 1509b13c

+3095
+93
src/app/admin/categories/page.test.tsx
··· 1 + /** 2 + * Tests for admin categories page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import AdminCategoriesPage from './page' 10 + 11 + vi.mock('next/navigation', () => ({ 12 + useRouter: () => ({ push: vi.fn() }), 13 + usePathname: () => '/admin/categories', 14 + })) 15 + 16 + vi.mock('next/link', () => ({ 17 + default: ({ 18 + children, 19 + href, 20 + ...props 21 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 22 + <a href={href} {...props}> 23 + {children} 24 + </a> 25 + ), 26 + })) 27 + 28 + vi.mock('next/image', () => ({ 29 + default: (props: Record<string, unknown>) => { 30 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 31 + return <img {...props} /> 32 + }, 33 + })) 34 + 35 + describe('AdminCategoriesPage', () => { 36 + it('renders categories heading', () => { 37 + render(<AdminCategoriesPage />) 38 + expect(screen.getByRole('heading', { name: /categories/i })).toBeInTheDocument() 39 + }) 40 + 41 + it('renders categories from API', async () => { 42 + render(<AdminCategoriesPage />) 43 + await waitFor(() => { 44 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 45 + }) 46 + expect(screen.getByText('Development')).toBeInTheDocument() 47 + expect(screen.getByText('Frontend')).toBeInTheDocument() 48 + expect(screen.getByText('Backend')).toBeInTheDocument() 49 + }) 50 + 51 + it('renders maturity rating for each category', async () => { 52 + render(<AdminCategoriesPage />) 53 + await waitFor(() => { 54 + expect(screen.getAllByText(/safe/i).length).toBeGreaterThan(0) 55 + }) 56 + }) 57 + 58 + it('renders add category button', () => { 59 + render(<AdminCategoriesPage />) 60 + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument() 61 + }) 62 + 63 + it('shows edit form when edit button is clicked', async () => { 64 + const user = userEvent.setup() 65 + render(<AdminCategoriesPage />) 66 + await waitFor(() => { 67 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 68 + }) 69 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 70 + await user.click(editButtons[0]!) 71 + expect(screen.getByLabelText(/category name/i)).toBeInTheDocument() 72 + }) 73 + 74 + it('shows child categories indented', async () => { 75 + render(<AdminCategoriesPage />) 76 + await waitFor(() => { 77 + expect(screen.getByText('Frontend')).toBeInTheDocument() 78 + }) 79 + // Frontend and Backend are children of Development 80 + const frontend = screen.getByText('Frontend') 81 + const container = frontend.closest('[data-depth]') 82 + expect(container).toHaveAttribute('data-depth', '1') 83 + }) 84 + 85 + it('passes axe accessibility check', async () => { 86 + const { container } = render(<AdminCategoriesPage />) 87 + await waitFor(() => { 88 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 89 + }) 90 + const results = await axe(container) 91 + expect(results).toHaveNoViolations() 92 + }) 93 + })
+316
src/app/admin/categories/page.tsx
··· 1 + /** 2 + * Admin categories page. 3 + * URL: /admin/categories 4 + * Category tree editor with maturity rating per category. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { PencilSimple, Plus, TrashSimple } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' 14 + import { cn } from '@/lib/utils' 15 + import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 16 + 17 + // TODO: Replace with actual auth token from session 18 + const MOCK_TOKEN = 'mock-access-token' 19 + 20 + const MATURITY_LABELS: Record<MaturityRating, string> = { 21 + safe: 'Safe', 22 + mature: 'Mature', 23 + adult: 'Adult', 24 + } 25 + 26 + const MATURITY_COLORS: Record<MaturityRating, string> = { 27 + safe: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 28 + mature: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', 29 + adult: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 30 + } 31 + 32 + interface EditingCategory { 33 + id: string | null 34 + name: string 35 + slug: string 36 + description: string 37 + parentId: string | null 38 + maturityRating: MaturityRating 39 + } 40 + 41 + function CategoryRow({ 42 + category, 43 + depth, 44 + onEdit, 45 + onDelete, 46 + }: { 47 + category: CategoryTreeNode 48 + depth: number 49 + onEdit: (cat: CategoryTreeNode) => void 50 + onDelete: (id: string) => void 51 + }) { 52 + return ( 53 + <> 54 + <div 55 + data-depth={depth} 56 + className={cn( 57 + 'flex items-center justify-between rounded-md border border-border bg-card p-3', 58 + depth > 0 && 'ml-6' 59 + )} 60 + > 61 + <div className="flex items-center gap-3"> 62 + <div> 63 + <p className="text-sm font-medium text-foreground">{category.name}</p> 64 + {category.description && ( 65 + <p className="text-xs text-muted-foreground">{category.description}</p> 66 + )} 67 + </div> 68 + </div> 69 + <div className="flex items-center gap-2"> 70 + <span 71 + className={cn( 72 + 'rounded-full px-2 py-0.5 text-xs font-medium', 73 + MATURITY_COLORS[category.maturityRating] 74 + )} 75 + > 76 + {MATURITY_LABELS[category.maturityRating]} 77 + </span> 78 + <button 79 + type="button" 80 + onClick={() => onEdit(category)} 81 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 82 + aria-label={`Edit ${category.name}`} 83 + > 84 + <PencilSimple size={16} aria-hidden="true" /> 85 + </button> 86 + <button 87 + type="button" 88 + onClick={() => onDelete(category.id)} 89 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 90 + aria-label={`Delete ${category.name}`} 91 + > 92 + <TrashSimple size={16} aria-hidden="true" /> 93 + </button> 94 + </div> 95 + </div> 96 + {category.children.map((child) => ( 97 + <CategoryRow 98 + key={child.id} 99 + category={child} 100 + depth={depth + 1} 101 + onEdit={onEdit} 102 + onDelete={onDelete} 103 + /> 104 + ))} 105 + </> 106 + ) 107 + } 108 + 109 + export default function AdminCategoriesPage() { 110 + const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 111 + const [loading, setLoading] = useState(true) 112 + const [editing, setEditing] = useState<EditingCategory | null>(null) 113 + 114 + const fetchCategories = useCallback(async () => { 115 + try { 116 + const response = await getCategories() 117 + setCategories(response.categories) 118 + } catch { 119 + // Silently handle 120 + } finally { 121 + setLoading(false) 122 + } 123 + }, []) 124 + 125 + useEffect(() => { 126 + void fetchCategories() 127 + }, [fetchCategories]) 128 + 129 + const handleEdit = (cat: CategoryTreeNode) => { 130 + setEditing({ 131 + id: cat.id, 132 + name: cat.name, 133 + slug: cat.slug, 134 + description: cat.description ?? '', 135 + parentId: cat.parentId, 136 + maturityRating: cat.maturityRating, 137 + }) 138 + } 139 + 140 + const handleAdd = () => { 141 + setEditing({ 142 + id: null, 143 + name: '', 144 + slug: '', 145 + description: '', 146 + parentId: null, 147 + maturityRating: 'safe', 148 + }) 149 + } 150 + 151 + const handleDelete = async (id: string) => { 152 + try { 153 + await deleteCategory(id, MOCK_TOKEN) 154 + void fetchCategories() 155 + } catch { 156 + // Silently handle 157 + } 158 + } 159 + 160 + const handleSave = async () => { 161 + if (!editing) return 162 + 163 + try { 164 + if (editing.id) { 165 + await updateCategory( 166 + editing.id, 167 + { 168 + name: editing.name, 169 + slug: editing.slug, 170 + description: editing.description || null, 171 + parentId: editing.parentId, 172 + maturityRating: editing.maturityRating, 173 + }, 174 + MOCK_TOKEN 175 + ) 176 + } else { 177 + await createCategory( 178 + { 179 + name: editing.name, 180 + slug: editing.slug, 181 + description: editing.description || null, 182 + parentId: editing.parentId, 183 + sortOrder: categories.length, 184 + maturityRating: editing.maturityRating, 185 + }, 186 + MOCK_TOKEN 187 + ) 188 + } 189 + setEditing(null) 190 + void fetchCategories() 191 + } catch { 192 + // Silently handle 193 + } 194 + } 195 + 196 + return ( 197 + <AdminLayout> 198 + <div className="space-y-6"> 199 + <div className="flex items-center justify-between"> 200 + <h1 className="text-2xl font-bold text-foreground">Categories</h1> 201 + <button 202 + type="button" 203 + onClick={handleAdd} 204 + className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 205 + > 206 + <Plus size={16} aria-hidden="true" /> 207 + Add Category 208 + </button> 209 + </div> 210 + 211 + {/* Edit form */} 212 + {editing && ( 213 + <div className="rounded-lg border border-border bg-card p-4"> 214 + <h2 className="mb-4 text-lg font-semibold text-foreground"> 215 + {editing.id ? 'Edit Category' : 'New Category'} 216 + </h2> 217 + <div className="space-y-4"> 218 + <div> 219 + <label htmlFor="cat-name" className="block text-sm font-medium text-foreground"> 220 + Category Name 221 + </label> 222 + <input 223 + id="cat-name" 224 + type="text" 225 + value={editing.name} 226 + onChange={(e) => setEditing({ ...editing, name: e.target.value })} 227 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 228 + /> 229 + </div> 230 + <div> 231 + <label htmlFor="cat-slug" className="block text-sm font-medium text-foreground"> 232 + Slug 233 + </label> 234 + <input 235 + id="cat-slug" 236 + type="text" 237 + value={editing.slug} 238 + onChange={(e) => setEditing({ ...editing, slug: e.target.value })} 239 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 240 + /> 241 + </div> 242 + <div> 243 + <label htmlFor="cat-desc" className="block text-sm font-medium text-foreground"> 244 + Description 245 + </label> 246 + <textarea 247 + id="cat-desc" 248 + value={editing.description} 249 + onChange={(e) => setEditing({ ...editing, description: e.target.value })} 250 + rows={2} 251 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 252 + /> 253 + </div> 254 + <div> 255 + <label htmlFor="cat-maturity" className="block text-sm font-medium text-foreground"> 256 + Maturity Rating 257 + </label> 258 + <select 259 + id="cat-maturity" 260 + value={editing.maturityRating} 261 + onChange={(e) => 262 + setEditing({ ...editing, maturityRating: e.target.value as MaturityRating }) 263 + } 264 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 265 + > 266 + <option value="safe">Safe</option> 267 + <option value="mature">Mature</option> 268 + <option value="adult">Adult</option> 269 + </select> 270 + </div> 271 + <div className="flex gap-2"> 272 + <button 273 + type="button" 274 + onClick={() => void handleSave()} 275 + className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 276 + > 277 + Save 278 + </button> 279 + <button 280 + type="button" 281 + onClick={() => setEditing(null)} 282 + className="rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted" 283 + > 284 + Cancel 285 + </button> 286 + </div> 287 + </div> 288 + </div> 289 + )} 290 + 291 + {/* Category list */} 292 + {loading && <p className="text-sm text-muted-foreground">Loading categories...</p>} 293 + 294 + {!loading && categories.length === 0 && ( 295 + <p className="py-8 text-center text-muted-foreground"> 296 + No categories yet. Create your first category to organize topics. 297 + </p> 298 + )} 299 + 300 + {!loading && categories.length > 0 && ( 301 + <div className="space-y-2"> 302 + {categories.map((category) => ( 303 + <CategoryRow 304 + key={category.id} 305 + category={category} 306 + depth={0} 307 + onEdit={handleEdit} 308 + onDelete={(id) => void handleDelete(id)} 309 + /> 310 + ))} 311 + </div> 312 + )} 313 + </div> 314 + </AdminLayout> 315 + ) 316 + }
+68
src/app/admin/content-ratings/page.test.tsx
··· 1 + /** 2 + * Tests for admin content ratings page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import AdminContentRatingsPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin/content-ratings', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + describe('AdminContentRatingsPage', () => { 35 + it('renders content ratings heading', () => { 36 + render(<AdminContentRatingsPage />) 37 + expect(screen.getByRole('heading', { name: /content ratings/i })).toBeInTheDocument() 38 + }) 39 + 40 + it('renders community maturity rating', async () => { 41 + render(<AdminContentRatingsPage />) 42 + await waitFor(() => { 43 + expect(screen.getByText(/community rating/i)).toBeInTheDocument() 44 + }) 45 + }) 46 + 47 + it('renders category maturity ratings', async () => { 48 + render(<AdminContentRatingsPage />) 49 + await waitFor(() => { 50 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 51 + }) 52 + }) 53 + 54 + it('renders maturity level explanation', () => { 55 + render(<AdminContentRatingsPage />) 56 + expect(screen.getByText(/safe/i)).toBeInTheDocument() 57 + expect(screen.getByText(/suitable for all audiences/i)).toBeInTheDocument() 58 + }) 59 + 60 + it('passes axe accessibility check', async () => { 61 + const { container } = render(<AdminContentRatingsPage />) 62 + await waitFor(() => { 63 + expect(screen.getByText('General Discussion')).toBeInTheDocument() 64 + }) 65 + const results = await axe(container) 66 + expect(results).toHaveNoViolations() 67 + }) 68 + })
+145
src/app/admin/content-ratings/page.tsx
··· 1 + /** 2 + * Admin content ratings page. 3 + * URL: /admin/content-ratings 4 + * Overview of community and category maturity ratings. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { getCategories, getCommunitySettings } from '@/lib/api/client' 13 + import { cn } from '@/lib/utils' 14 + import type { CategoryTreeNode, CommunitySettings, MaturityRating } from '@/lib/api/types' 15 + 16 + const MATURITY_DESCRIPTIONS: Record<MaturityRating, string> = { 17 + safe: 'Suitable for all audiences. No explicit or mature content.', 18 + mature: 'May contain mature themes. Not suitable for minors.', 19 + adult: 'Contains explicit content. Restricted to adults only.', 20 + } 21 + 22 + const MATURITY_COLORS: Record<MaturityRating, string> = { 23 + safe: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 24 + mature: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', 25 + adult: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', 26 + } 27 + 28 + function CategoryRatingRow({ category, depth }: { category: CategoryTreeNode; depth: number }) { 29 + return ( 30 + <> 31 + <tr> 32 + <td className={cn('py-2 pr-4 text-sm text-foreground', depth > 0 && 'pl-6')}> 33 + {category.name} 34 + </td> 35 + <td className="py-2"> 36 + <span 37 + className={cn( 38 + 'inline-block rounded-full px-2 py-0.5 text-xs font-medium', 39 + MATURITY_COLORS[category.maturityRating] 40 + )} 41 + > 42 + {category.maturityRating} 43 + </span> 44 + </td> 45 + </tr> 46 + {category.children.map((child) => ( 47 + <CategoryRatingRow key={child.id} category={child} depth={depth + 1} /> 48 + ))} 49 + </> 50 + ) 51 + } 52 + 53 + export default function AdminContentRatingsPage() { 54 + const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 55 + const [communitySettings, setCommunitySettings] = useState<CommunitySettings | null>(null) 56 + const [loading, setLoading] = useState(true) 57 + 58 + const fetchData = useCallback(async () => { 59 + try { 60 + const [catRes, settingsRes] = await Promise.all([getCategories(), getCommunitySettings()]) 61 + setCategories(catRes.categories) 62 + setCommunitySettings(settingsRes) 63 + } catch { 64 + // Silently handle 65 + } finally { 66 + setLoading(false) 67 + } 68 + }, []) 69 + 70 + useEffect(() => { 71 + void fetchData() 72 + }, [fetchData]) 73 + 74 + return ( 75 + <AdminLayout> 76 + <div className="space-y-6"> 77 + <h1 className="text-2xl font-bold text-foreground">Content Ratings</h1> 78 + 79 + {/* Rating level explanation */} 80 + <div className="rounded-lg border border-border bg-card p-4"> 81 + <h2 className="mb-3 text-lg font-semibold text-foreground">Maturity Levels</h2> 82 + <dl className="space-y-2"> 83 + {(Object.entries(MATURITY_DESCRIPTIONS) as [MaturityRating, string][]).map( 84 + ([rating, description]) => ( 85 + <div key={rating} className="flex items-start gap-3"> 86 + <dt> 87 + <span 88 + className={cn( 89 + 'inline-block w-16 rounded-full px-2 py-0.5 text-center text-xs font-medium capitalize', 90 + MATURITY_COLORS[rating] 91 + )} 92 + > 93 + {rating} 94 + </span> 95 + </dt> 96 + <dd className="text-sm text-muted-foreground">{description}</dd> 97 + </div> 98 + ) 99 + )} 100 + </dl> 101 + </div> 102 + 103 + {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 104 + 105 + {/* Community rating */} 106 + {communitySettings && ( 107 + <div className="rounded-lg border border-border bg-card p-4"> 108 + <h2 className="mb-2 text-lg font-semibold text-foreground">Community Rating</h2> 109 + <p className="text-sm text-muted-foreground"> 110 + Current community maturity rating:{' '} 111 + <span 112 + className={cn( 113 + 'inline-block rounded-full px-2 py-0.5 text-xs font-medium capitalize', 114 + MATURITY_COLORS[communitySettings.maturityRating] 115 + )} 116 + > 117 + {communitySettings.maturityRating} 118 + </span> 119 + </p> 120 + </div> 121 + )} 122 + 123 + {/* Category ratings table */} 124 + {!loading && categories.length > 0 && ( 125 + <div className="rounded-lg border border-border bg-card p-4"> 126 + <h2 className="mb-3 text-lg font-semibold text-foreground">Category Ratings</h2> 127 + <table className="w-full"> 128 + <thead> 129 + <tr className="border-b border-border text-left"> 130 + <th className="pb-2 text-sm font-medium text-muted-foreground">Category</th> 131 + <th className="pb-2 text-sm font-medium text-muted-foreground">Rating</th> 132 + </tr> 133 + </thead> 134 + <tbody> 135 + {categories.map((category) => ( 136 + <CategoryRatingRow key={category.id} category={category} depth={0} /> 137 + ))} 138 + </tbody> 139 + </table> 140 + </div> 141 + )} 142 + </div> 143 + </AdminLayout> 144 + ) 145 + }
+144
src/app/admin/moderation/page.test.tsx
··· 1 + /** 2 + * Tests for admin moderation page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import AdminModerationPage from './page' 10 + 11 + vi.mock('next/navigation', () => ({ 12 + useRouter: () => ({ push: vi.fn() }), 13 + usePathname: () => '/admin/moderation', 14 + })) 15 + 16 + vi.mock('next/link', () => ({ 17 + default: ({ 18 + children, 19 + href, 20 + ...props 21 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 22 + <a href={href} {...props}> 23 + {children} 24 + </a> 25 + ), 26 + })) 27 + 28 + vi.mock('next/image', () => ({ 29 + default: (props: Record<string, unknown>) => { 30 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 31 + return <img {...props} /> 32 + }, 33 + })) 34 + 35 + describe('AdminModerationPage', () => { 36 + it('renders moderation heading', () => { 37 + render(<AdminModerationPage />) 38 + expect(screen.getByRole('heading', { name: /moderation/i })).toBeInTheDocument() 39 + }) 40 + 41 + it('renders tab navigation', () => { 42 + render(<AdminModerationPage />) 43 + expect(screen.getByRole('tablist')).toBeInTheDocument() 44 + expect(screen.getByRole('tab', { name: /reports/i })).toBeInTheDocument() 45 + expect(screen.getByRole('tab', { name: /first post/i })).toBeInTheDocument() 46 + expect(screen.getByRole('tab', { name: /action log/i })).toBeInTheDocument() 47 + expect(screen.getByRole('tab', { name: /reported users/i })).toBeInTheDocument() 48 + expect(screen.getByRole('tab', { name: /thresholds/i })).toBeInTheDocument() 49 + }) 50 + 51 + it('shows reports queue by default', async () => { 52 + render(<AdminModerationPage />) 53 + await waitFor(() => { 54 + expect(screen.getAllByText(/misleading/i).length).toBeGreaterThan(0) 55 + }) 56 + }) 57 + 58 + it('highlights potentially illegal reports', async () => { 59 + render(<AdminModerationPage />) 60 + await waitFor(() => { 61 + expect(screen.getAllByText(/potentially illegal/i).length).toBeGreaterThan(0) 62 + }) 63 + }) 64 + 65 + it('shows resolve actions on reports', async () => { 66 + render(<AdminModerationPage />) 67 + await waitFor(() => { 68 + expect(screen.getAllByRole('button', { name: /dismiss/i }).length).toBeGreaterThan(0) 69 + }) 70 + }) 71 + 72 + it('switches to first post queue tab', async () => { 73 + const user = userEvent.setup() 74 + render(<AdminModerationPage />) 75 + const firstPostTab = screen.getByRole('tab', { name: /first post/i }) 76 + await user.click(firstPostTab) 77 + await waitFor(() => { 78 + expect(screen.getByText(/newbie\.bsky\.social/i)).toBeInTheDocument() 79 + }) 80 + }) 81 + 82 + it('shows account age for first post queue items', async () => { 83 + const user = userEvent.setup() 84 + render(<AdminModerationPage />) 85 + await user.click(screen.getByRole('tab', { name: /first post/i })) 86 + await waitFor(() => { 87 + expect(screen.getByText(/2 days/i)).toBeInTheDocument() 88 + }) 89 + }) 90 + 91 + it('shows cross-community count for first post items', async () => { 92 + const user = userEvent.setup() 93 + render(<AdminModerationPage />) 94 + await user.click(screen.getByRole('tab', { name: /first post/i })) 95 + await waitFor(() => { 96 + expect(screen.getByText(/active in 3 other communities/i)).toBeInTheDocument() 97 + }) 98 + }) 99 + 100 + it('switches to action log tab', async () => { 101 + const user = userEvent.setup() 102 + render(<AdminModerationPage />) 103 + await user.click(screen.getByRole('tab', { name: /action log/i })) 104 + await waitFor(() => { 105 + expect(screen.getByText(/pinned/i)).toBeInTheDocument() 106 + }) 107 + }) 108 + 109 + it('switches to reported users tab', async () => { 110 + const user = userEvent.setup() 111 + render(<AdminModerationPage />) 112 + await user.click(screen.getByRole('tab', { name: /reported users/i })) 113 + await waitFor(() => { 114 + expect(screen.getByText(/dave\.bsky\.social/i)).toBeInTheDocument() 115 + }) 116 + }) 117 + 118 + it('shows cross-community ban warning for reported users', async () => { 119 + const user = userEvent.setup() 120 + render(<AdminModerationPage />) 121 + await user.click(screen.getByRole('tab', { name: /reported users/i })) 122 + await waitFor(() => { 123 + expect(screen.getByText(/banned from 2 other communities/i)).toBeInTheDocument() 124 + }) 125 + }) 126 + 127 + it('switches to thresholds tab', async () => { 128 + const user = userEvent.setup() 129 + render(<AdminModerationPage />) 130 + await user.click(screen.getByRole('tab', { name: /thresholds/i })) 131 + await waitFor(() => { 132 + expect(screen.getByLabelText(/auto-block/i)).toBeInTheDocument() 133 + }) 134 + }) 135 + 136 + it('passes axe accessibility check', async () => { 137 + const { container } = render(<AdminModerationPage />) 138 + await waitFor(() => { 139 + expect(screen.getAllByText(/misleading/i).length).toBeGreaterThan(0) 140 + }) 141 + const results = await axe(container) 142 + expect(results).toHaveNoViolations() 143 + }) 144 + })
+578
src/app/admin/moderation/page.tsx
··· 1 + /** 2 + * Admin moderation page. 3 + * URL: /admin/moderation 4 + * Reports queue, first-post queue, action log, reported users, thresholds. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { WarningCircle, ShieldCheck, Clock, Prohibit } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { 14 + getModerationReports, 15 + resolveReport, 16 + getFirstPostQueue, 17 + resolveFirstPost, 18 + getModerationLog, 19 + getReportedUsers, 20 + getModerationThresholds, 21 + updateModerationThresholds, 22 + } from '@/lib/api/client' 23 + import { cn } from '@/lib/utils' 24 + import type { 25 + ModerationReport, 26 + FirstPostQueueItem, 27 + ModerationLogEntry, 28 + ReportedUser, 29 + ModerationThresholds, 30 + ReportResolution, 31 + } from '@/lib/api/types' 32 + 33 + // TODO: Replace with actual auth token from session 34 + const MOCK_TOKEN = 'mock-access-token' 35 + 36 + type TabId = 'reports' | 'first-post' | 'action-log' | 'reported-users' | 'thresholds' 37 + 38 + const TABS: { id: TabId; label: string }[] = [ 39 + { id: 'reports', label: 'Reports' }, 40 + { id: 'first-post', label: 'First Post Queue' }, 41 + { id: 'action-log', label: 'Action Log' }, 42 + { id: 'reported-users', label: 'Reported Users' }, 43 + { id: 'thresholds', label: 'Thresholds' }, 44 + ] 45 + 46 + const RESOLUTION_ACTIONS: { value: ReportResolution; label: string }[] = [ 47 + { value: 'dismissed', label: 'Dismiss' }, 48 + { value: 'warned', label: 'Warn' }, 49 + { value: 'labeled', label: 'Label' }, 50 + { value: 'removed', label: 'Remove' }, 51 + { value: 'banned', label: 'Ban' }, 52 + ] 53 + 54 + const ACTION_TYPE_LABELS: Record<string, string> = { 55 + lock: 'Locked', 56 + unlock: 'Unlocked', 57 + pin: 'Pinned', 58 + unpin: 'Unpinned', 59 + delete: 'Deleted', 60 + ban: 'Banned', 61 + unban: 'Unbanned', 62 + warn: 'Warned', 63 + label: 'Labeled', 64 + approve: 'Approved', 65 + reject: 'Rejected', 66 + } 67 + 68 + function formatDate(dateStr: string) { 69 + return new Date(dateStr).toLocaleDateString('en-US', { 70 + month: 'short', 71 + day: 'numeric', 72 + hour: 'numeric', 73 + minute: '2-digit', 74 + }) 75 + } 76 + 77 + // --- Report Queue Tab --- 78 + function ReportsTab({ 79 + reports, 80 + onResolve, 81 + }: { 82 + reports: ModerationReport[] 83 + onResolve: (id: string, resolution: ReportResolution) => void 84 + }) { 85 + // Sort: potentially illegal first, then by date (newest first) 86 + const sorted = [...reports].sort((a, b) => { 87 + if (a.potentiallyIllegal !== b.potentiallyIllegal) { 88 + return a.potentiallyIllegal ? -1 : 1 89 + } 90 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 91 + }) 92 + 93 + return ( 94 + <div className="space-y-3"> 95 + {sorted.map((report) => ( 96 + <article 97 + key={report.id} 98 + className={cn( 99 + 'rounded-lg border border-border bg-card p-4', 100 + report.potentiallyIllegal && 'border-l-4 border-l-destructive' 101 + )} 102 + > 103 + <div className="flex items-start justify-between gap-3"> 104 + <div className="min-w-0 flex-1"> 105 + {report.potentiallyIllegal && ( 106 + <span className="mb-1 inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 107 + <WarningCircle size={12} aria-hidden="true" /> 108 + Potentially illegal 109 + </span> 110 + )} 111 + <p className="text-sm font-medium text-foreground"> 112 + <span className="capitalize">{report.reasonType}</span> 113 + {' -- reported by '} 114 + <span className="text-muted-foreground">{report.reporterHandle}</span> 115 + </p> 116 + <p className="mt-1 text-sm text-muted-foreground">{report.targetContent}</p> 117 + {report.reason && ( 118 + <p className="mt-1 text-xs text-muted-foreground italic"> 119 + &ldquo;{report.reason}&rdquo; 120 + </p> 121 + )} 122 + <p className="mt-1 text-xs text-muted-foreground"> 123 + Target: {report.targetAuthorHandle} &middot; {formatDate(report.createdAt)} 124 + </p> 125 + </div> 126 + <div className="flex shrink-0 gap-1"> 127 + {RESOLUTION_ACTIONS.map((action) => ( 128 + <button 129 + key={action.value} 130 + type="button" 131 + onClick={() => onResolve(report.id, action.value)} 132 + className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 133 + > 134 + {action.label} 135 + </button> 136 + ))} 137 + </div> 138 + </div> 139 + </article> 140 + ))} 141 + {sorted.length === 0 && ( 142 + <p className="py-8 text-center text-muted-foreground">No pending reports.</p> 143 + )} 144 + </div> 145 + ) 146 + } 147 + 148 + // --- First Post Queue Tab --- 149 + function FirstPostTab({ 150 + items, 151 + onResolve, 152 + }: { 153 + items: FirstPostQueueItem[] 154 + onResolve: (id: string, action: 'approved' | 'rejected') => void 155 + }) { 156 + return ( 157 + <div className="space-y-3"> 158 + {items.map((item) => ( 159 + <article key={item.id} className="rounded-lg border border-border bg-card p-4"> 160 + <div className="flex items-start justify-between gap-3"> 161 + <div className="min-w-0 flex-1"> 162 + <p className="text-sm font-medium text-foreground">{item.authorHandle}</p> 163 + <div className="mt-1 flex gap-2 text-xs text-muted-foreground"> 164 + <span className="inline-flex items-center gap-1"> 165 + <Clock size={12} aria-hidden="true" /> 166 + New account, {item.accountAge} old 167 + </span> 168 + {item.crossCommunityCount > 0 && ( 169 + <span className="inline-flex items-center gap-1"> 170 + <ShieldCheck size={12} aria-hidden="true" /> 171 + Active in {item.crossCommunityCount} other communities 172 + </span> 173 + )} 174 + </div> 175 + {item.title && ( 176 + <p className="mt-2 text-sm font-medium text-foreground">{item.title}</p> 177 + )} 178 + <p className="mt-1 text-sm text-muted-foreground">{item.content}</p> 179 + <p className="mt-1 text-xs text-muted-foreground"> 180 + {item.contentType === 'topic' ? 'Topic' : 'Reply'} &middot;{' '} 181 + {formatDate(item.createdAt)} 182 + </p> 183 + </div> 184 + <div className="flex shrink-0 gap-2"> 185 + <button 186 + type="button" 187 + onClick={() => onResolve(item.id, 'approved')} 188 + className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 189 + > 190 + Approve 191 + </button> 192 + <button 193 + type="button" 194 + onClick={() => onResolve(item.id, 'rejected')} 195 + className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 196 + > 197 + Reject 198 + </button> 199 + </div> 200 + </div> 201 + </article> 202 + ))} 203 + {items.length === 0 && ( 204 + <p className="py-8 text-center text-muted-foreground">No posts awaiting approval.</p> 205 + )} 206 + </div> 207 + ) 208 + } 209 + 210 + // --- Action Log Tab --- 211 + function ActionLogTab({ entries }: { entries: ModerationLogEntry[] }) { 212 + return ( 213 + <div className="space-y-2"> 214 + {entries.map((entry) => ( 215 + <div key={entry.id} className="rounded-md border border-border bg-card p-3"> 216 + <div className="flex items-center justify-between"> 217 + <p className="text-sm text-foreground"> 218 + <span className="font-medium">{entry.moderatorHandle}</span>{' '} 219 + <span className="text-muted-foreground"> 220 + {ACTION_TYPE_LABELS[entry.actionType] ?? entry.actionType} 221 + </span> 222 + {entry.targetHandle && ( 223 + <span className="text-muted-foreground"> {entry.targetHandle}</span> 224 + )} 225 + </p> 226 + <span className="text-xs text-muted-foreground">{formatDate(entry.createdAt)}</span> 227 + </div> 228 + {entry.reason && ( 229 + <p className="mt-1 text-xs text-muted-foreground italic">{entry.reason}</p> 230 + )} 231 + </div> 232 + ))} 233 + {entries.length === 0 && ( 234 + <p className="py-8 text-center text-muted-foreground">No moderation actions recorded.</p> 235 + )} 236 + </div> 237 + ) 238 + } 239 + 240 + // --- Reported Users Tab --- 241 + function ReportedUsersTab({ users }: { users: ReportedUser[] }) { 242 + return ( 243 + <div className="space-y-2"> 244 + {users.map((user) => ( 245 + <div key={user.did} className="rounded-md border border-border bg-card p-3"> 246 + <div className="flex items-center justify-between"> 247 + <div> 248 + <p className="text-sm font-medium text-foreground">{user.handle}</p> 249 + <p className="text-xs text-muted-foreground"> 250 + {user.reportCount} reports &middot; Latest: {formatDate(user.latestReportAt)} 251 + </p> 252 + {user.bannedFromOtherCommunities > 0 && ( 253 + <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 254 + <Prohibit size={12} aria-hidden="true" /> 255 + Banned from {user.bannedFromOtherCommunities} other communities 256 + </p> 257 + )} 258 + </div> 259 + </div> 260 + </div> 261 + ))} 262 + {users.length === 0 && ( 263 + <p className="py-8 text-center text-muted-foreground">No reported users.</p> 264 + )} 265 + </div> 266 + ) 267 + } 268 + 269 + // --- Thresholds Tab --- 270 + function ThresholdsTab({ 271 + thresholds, 272 + onSave, 273 + }: { 274 + thresholds: ModerationThresholds 275 + onSave: (updated: Partial<ModerationThresholds>) => void 276 + }) { 277 + const [values, setValues] = useState(thresholds) 278 + 279 + return ( 280 + <div className="max-w-lg space-y-4"> 281 + <div> 282 + <label htmlFor="threshold-autoblock" className="block text-sm font-medium text-foreground"> 283 + Auto-block report count 284 + </label> 285 + <input 286 + id="threshold-autoblock" 287 + type="number" 288 + min={1} 289 + value={values.autoBlockReportCount} 290 + onChange={(e) => 291 + setValues({ ...values, autoBlockReportCount: parseInt(e.target.value, 10) || 1 }) 292 + } 293 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 294 + /> 295 + </div> 296 + <div> 297 + <label htmlFor="threshold-warn" className="block text-sm font-medium text-foreground"> 298 + Warn threshold 299 + </label> 300 + <input 301 + id="threshold-warn" 302 + type="number" 303 + min={1} 304 + value={values.warnThreshold} 305 + onChange={(e) => 306 + setValues({ ...values, warnThreshold: parseInt(e.target.value, 10) || 1 }) 307 + } 308 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 309 + /> 310 + </div> 311 + <div> 312 + <label htmlFor="threshold-fpq" className="block text-sm font-medium text-foreground"> 313 + First-post queue count (0 to disable) 314 + </label> 315 + <input 316 + id="threshold-fpq" 317 + type="number" 318 + min={0} 319 + value={values.firstPostQueueCount} 320 + onChange={(e) => 321 + setValues({ ...values, firstPostQueueCount: parseInt(e.target.value, 10) || 0 }) 322 + } 323 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 324 + /> 325 + </div> 326 + <div> 327 + <label htmlFor="threshold-ratelimit" className="block text-sm font-medium text-foreground"> 328 + New account rate limit (writes/min) 329 + </label> 330 + <input 331 + id="threshold-ratelimit" 332 + type="number" 333 + min={1} 334 + value={values.newAccountRateLimit} 335 + onChange={(e) => 336 + setValues({ ...values, newAccountRateLimit: parseInt(e.target.value, 10) || 1 }) 337 + } 338 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 339 + /> 340 + </div> 341 + <fieldset className="space-y-3"> 342 + <legend className="text-sm font-medium text-foreground">Anti-spam settings</legend> 343 + <div className="flex items-center gap-2"> 344 + <input 345 + id="threshold-linkhold" 346 + type="checkbox" 347 + checked={values.linkPostingHold} 348 + onChange={(e) => setValues({ ...values, linkPostingHold: e.target.checked })} 349 + className="rounded border-border" 350 + /> 351 + <label htmlFor="threshold-linkhold" className="text-sm text-foreground"> 352 + Hold posts with links from new accounts for review 353 + </label> 354 + </div> 355 + <div className="flex items-center gap-2"> 356 + <input 357 + id="threshold-topicdelay" 358 + type="checkbox" 359 + checked={values.topicCreationDelay} 360 + onChange={(e) => setValues({ ...values, topicCreationDelay: e.target.checked })} 361 + className="rounded border-border" 362 + /> 363 + <label htmlFor="threshold-topicdelay" className="text-sm text-foreground"> 364 + Delay topic creation for new accounts 365 + </label> 366 + </div> 367 + </fieldset> 368 + <div className="flex gap-4"> 369 + <div> 370 + <label 371 + htmlFor="threshold-burstcount" 372 + className="block text-sm font-medium text-foreground" 373 + > 374 + Burst detection: posts 375 + </label> 376 + <input 377 + id="threshold-burstcount" 378 + type="number" 379 + min={1} 380 + value={values.burstDetectionPostCount} 381 + onChange={(e) => 382 + setValues({ 383 + ...values, 384 + burstDetectionPostCount: parseInt(e.target.value, 10) || 1, 385 + }) 386 + } 387 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 388 + /> 389 + </div> 390 + <div> 391 + <label htmlFor="threshold-burstmin" className="block text-sm font-medium text-foreground"> 392 + in minutes 393 + </label> 394 + <input 395 + id="threshold-burstmin" 396 + type="number" 397 + min={1} 398 + value={values.burstDetectionMinutes} 399 + onChange={(e) => 400 + setValues({ 401 + ...values, 402 + burstDetectionMinutes: parseInt(e.target.value, 10) || 1, 403 + }) 404 + } 405 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 406 + /> 407 + </div> 408 + </div> 409 + <button 410 + type="button" 411 + onClick={() => onSave(values)} 412 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90" 413 + > 414 + Save Thresholds 415 + </button> 416 + </div> 417 + ) 418 + } 419 + 420 + export default function AdminModerationPage() { 421 + const [activeTab, setActiveTab] = useState<TabId>('reports') 422 + const [reports, setReports] = useState<ModerationReport[]>([]) 423 + const [firstPostQueue, setFirstPostQueue] = useState<FirstPostQueueItem[]>([]) 424 + const [moderationLog, setModerationLog] = useState<ModerationLogEntry[]>([]) 425 + const [reportedUsers, setReportedUsers] = useState<ReportedUser[]>([]) 426 + const [thresholds, setThresholds] = useState<ModerationThresholds | null>(null) 427 + const [loading, setLoading] = useState(true) 428 + 429 + const fetchData = useCallback(async () => { 430 + try { 431 + const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ 432 + getModerationReports(MOCK_TOKEN), 433 + getFirstPostQueue(MOCK_TOKEN), 434 + getModerationLog(MOCK_TOKEN), 435 + getReportedUsers(MOCK_TOKEN), 436 + getModerationThresholds(MOCK_TOKEN), 437 + ]) 438 + setReports(reportsRes.reports) 439 + setFirstPostQueue(queueRes.items) 440 + setModerationLog(logRes.entries) 441 + setReportedUsers(usersRes.users) 442 + setThresholds(thresholdsRes) 443 + } catch { 444 + // Silently handle 445 + } finally { 446 + setLoading(false) 447 + } 448 + }, []) 449 + 450 + useEffect(() => { 451 + void fetchData() 452 + }, [fetchData]) 453 + 454 + const handleResolveReport = async (id: string, resolution: ReportResolution) => { 455 + try { 456 + await resolveReport(id, resolution, MOCK_TOKEN) 457 + setReports((prev) => prev.filter((r) => r.id !== id)) 458 + } catch { 459 + // Silently handle 460 + } 461 + } 462 + 463 + const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { 464 + try { 465 + await resolveFirstPost(id, action, MOCK_TOKEN) 466 + setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 467 + } catch { 468 + // Silently handle 469 + } 470 + } 471 + 472 + const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 473 + try { 474 + const result = await updateModerationThresholds(updated, MOCK_TOKEN) 475 + setThresholds(result) 476 + } catch { 477 + // Silently handle 478 + } 479 + } 480 + 481 + return ( 482 + <AdminLayout> 483 + <div className="space-y-6"> 484 + <h1 className="text-2xl font-bold text-foreground">Moderation</h1> 485 + 486 + {/* Tab navigation */} 487 + <div 488 + role="tablist" 489 + aria-label="Moderation sections" 490 + className="flex gap-1 border-b border-border" 491 + > 492 + {TABS.map((tab) => ( 493 + <button 494 + key={tab.id} 495 + type="button" 496 + role="tab" 497 + aria-selected={activeTab === tab.id} 498 + aria-controls={`panel-${tab.id}`} 499 + id={`tab-${tab.id}`} 500 + onClick={() => setActiveTab(tab.id)} 501 + className={cn( 502 + 'px-4 py-2 text-sm transition-colors', 503 + activeTab === tab.id 504 + ? 'border-b-2 border-primary font-medium text-primary' 505 + : 'text-muted-foreground hover:text-foreground' 506 + )} 507 + > 508 + {tab.label} 509 + </button> 510 + ))} 511 + </div> 512 + 513 + {loading && <p className="text-sm text-muted-foreground">Loading...</p>} 514 + 515 + {/* Tab panels */} 516 + {!loading && ( 517 + <> 518 + <div 519 + role="tabpanel" 520 + id="panel-reports" 521 + aria-labelledby="tab-reports" 522 + hidden={activeTab !== 'reports'} 523 + > 524 + {activeTab === 'reports' && ( 525 + <ReportsTab 526 + reports={reports} 527 + onResolve={(id, res) => void handleResolveReport(id, res)} 528 + /> 529 + )} 530 + </div> 531 + <div 532 + role="tabpanel" 533 + id="panel-first-post" 534 + aria-labelledby="tab-first-post" 535 + hidden={activeTab !== 'first-post'} 536 + > 537 + {activeTab === 'first-post' && ( 538 + <FirstPostTab 539 + items={firstPostQueue} 540 + onResolve={(id, action) => void handleResolveFirstPost(id, action)} 541 + /> 542 + )} 543 + </div> 544 + <div 545 + role="tabpanel" 546 + id="panel-action-log" 547 + aria-labelledby="tab-action-log" 548 + hidden={activeTab !== 'action-log'} 549 + > 550 + {activeTab === 'action-log' && <ActionLogTab entries={moderationLog} />} 551 + </div> 552 + <div 553 + role="tabpanel" 554 + id="panel-reported-users" 555 + aria-labelledby="tab-reported-users" 556 + hidden={activeTab !== 'reported-users'} 557 + > 558 + {activeTab === 'reported-users' && <ReportedUsersTab users={reportedUsers} />} 559 + </div> 560 + <div 561 + role="tabpanel" 562 + id="panel-thresholds" 563 + aria-labelledby="tab-thresholds" 564 + hidden={activeTab !== 'thresholds'} 565 + > 566 + {activeTab === 'thresholds' && thresholds && ( 567 + <ThresholdsTab 568 + thresholds={thresholds} 569 + onSave={(updated) => void handleSaveThresholds(updated)} 570 + /> 571 + )} 572 + </div> 573 + </> 574 + )} 575 + </div> 576 + </AdminLayout> 577 + ) 578 + }
+80
src/app/admin/page.test.tsx
··· 1 + /** 2 + * Tests for admin dashboard page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import AdminDashboardPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + describe('AdminDashboardPage', () => { 35 + it('renders dashboard heading', async () => { 36 + render(<AdminDashboardPage />) 37 + expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument() 38 + }) 39 + 40 + it('renders stats cards from API', async () => { 41 + render(<AdminDashboardPage />) 42 + await waitFor(() => { 43 + expect(screen.getByText('42')).toBeInTheDocument() 44 + }) 45 + expect(screen.getByText('187')).toBeInTheDocument() 46 + expect(screen.getByText('156')).toBeInTheDocument() 47 + }) 48 + 49 + it('renders stat card labels', async () => { 50 + render(<AdminDashboardPage />) 51 + await waitFor(() => { 52 + expect(screen.getByText('Topics')).toBeInTheDocument() 53 + }) 54 + expect(screen.getByText('Replies')).toBeInTheDocument() 55 + // "Users" appears in both sidebar and stat card 56 + expect(screen.getAllByText('Users').length).toBeGreaterThanOrEqual(2) 57 + expect(screen.getByText('Pending Reports')).toBeInTheDocument() 58 + }) 59 + 60 + it('renders recent activity stats', async () => { 61 + render(<AdminDashboardPage />) 62 + await waitFor(() => { 63 + expect(screen.getByText(/8 new/i)).toBeInTheDocument() 64 + }) 65 + }) 66 + 67 + it('renders loading state', () => { 68 + render(<AdminDashboardPage />) 69 + expect(screen.getByText(/loading/i)).toBeInTheDocument() 70 + }) 71 + 72 + it('passes axe accessibility check', async () => { 73 + const { container } = render(<AdminDashboardPage />) 74 + await waitFor(() => { 75 + expect(screen.getByText('42')).toBeInTheDocument() 76 + }) 77 + const results = await axe(container) 78 + expect(results).toHaveNoViolations() 79 + }) 80 + })
+103
src/app/admin/page.tsx
··· 1 + /** 2 + * Admin dashboard page. 3 + * URL: /admin 4 + * Shows community statistics and recent activity. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { ChatCircle, Users, FolderSimple, WarningCircle, TrendUp } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { getCommunityStats } from '@/lib/api/client' 14 + import type { CommunityStats } from '@/lib/api/types' 15 + 16 + // TODO: Replace with actual auth token from session 17 + const MOCK_TOKEN = 'mock-access-token' 18 + 19 + interface StatCardProps { 20 + label: string 21 + value: number 22 + recentValue: number 23 + icon: typeof ChatCircle 24 + } 25 + 26 + function StatCard({ label, value, recentValue, icon: Icon }: StatCardProps) { 27 + return ( 28 + <div className="rounded-lg border border-border bg-card p-4"> 29 + <div className="flex items-center justify-between"> 30 + <div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted"> 31 + <Icon size={20} className="text-muted-foreground" aria-hidden="true" /> 32 + </div> 33 + {recentValue > 0 && ( 34 + <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"> 35 + <TrendUp size={12} aria-hidden="true" /> 36 + {recentValue} new 37 + </span> 38 + )} 39 + </div> 40 + <p className="mt-3 text-2xl font-bold text-foreground">{value}</p> 41 + <p className="text-sm text-muted-foreground">{label}</p> 42 + </div> 43 + ) 44 + } 45 + 46 + export default function AdminDashboardPage() { 47 + const [stats, setStats] = useState<CommunityStats | null>(null) 48 + const [loading, setLoading] = useState(true) 49 + 50 + const fetchStats = useCallback(async () => { 51 + try { 52 + const data = await getCommunityStats(MOCK_TOKEN) 53 + setStats(data) 54 + } catch { 55 + // Silently handle 56 + } finally { 57 + setLoading(false) 58 + } 59 + }, []) 60 + 61 + useEffect(() => { 62 + void fetchStats() 63 + }, [fetchStats]) 64 + 65 + return ( 66 + <AdminLayout> 67 + <div className="space-y-6"> 68 + <h1 className="text-2xl font-bold text-foreground">Dashboard</h1> 69 + 70 + {loading && <p className="text-sm text-muted-foreground">Loading statistics...</p>} 71 + 72 + {stats && ( 73 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> 74 + <StatCard 75 + label="Topics" 76 + value={stats.topicCount} 77 + recentValue={stats.recentTopics} 78 + icon={FolderSimple} 79 + /> 80 + <StatCard 81 + label="Replies" 82 + value={stats.replyCount} 83 + recentValue={stats.recentReplies} 84 + icon={ChatCircle} 85 + /> 86 + <StatCard 87 + label="Users" 88 + value={stats.userCount} 89 + recentValue={stats.recentUsers} 90 + icon={Users} 91 + /> 92 + <StatCard 93 + label="Pending Reports" 94 + value={stats.reportCount} 95 + recentValue={0} 96 + icon={WarningCircle} 97 + /> 98 + </div> 99 + )} 100 + </div> 101 + </AdminLayout> 102 + ) 103 + }
+84
src/app/admin/settings/page.test.tsx
··· 1 + /** 2 + * Tests for admin community settings page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import AdminSettingsPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin/settings', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + describe('AdminSettingsPage', () => { 35 + it('renders community settings heading', () => { 36 + render(<AdminSettingsPage />) 37 + expect(screen.getByRole('heading', { name: /community settings/i })).toBeInTheDocument() 38 + }) 39 + 40 + it('renders community name input with value', async () => { 41 + render(<AdminSettingsPage />) 42 + await waitFor(() => { 43 + const input = screen.getByLabelText(/community name/i) as HTMLInputElement 44 + expect(input.value).toBe('Barazo Test Community') 45 + }) 46 + }) 47 + 48 + it('renders community description textarea', async () => { 49 + render(<AdminSettingsPage />) 50 + await waitFor(() => { 51 + expect(screen.getByLabelText(/description/i)).toBeInTheDocument() 52 + }) 53 + }) 54 + 55 + it('renders maturity rating select', async () => { 56 + render(<AdminSettingsPage />) 57 + await waitFor(() => { 58 + expect(screen.getByLabelText(/community maturity rating/i)).toBeInTheDocument() 59 + }) 60 + }) 61 + 62 + it('renders reaction set configuration', async () => { 63 + render(<AdminSettingsPage />) 64 + await waitFor(() => { 65 + expect(screen.getByLabelText(/reaction set/i)).toBeInTheDocument() 66 + }) 67 + }) 68 + 69 + it('renders save button', async () => { 70 + render(<AdminSettingsPage />) 71 + await waitFor(() => { 72 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 73 + }) 74 + }) 75 + 76 + it('passes axe accessibility check', async () => { 77 + const { container } = render(<AdminSettingsPage />) 78 + await waitFor(() => { 79 + expect(screen.getByLabelText(/community name/i)).toBeInTheDocument() 80 + }) 81 + const results = await axe(container) 82 + expect(results).toHaveNoViolations() 83 + }) 84 + })
+202
src/app/admin/settings/page.tsx
··· 1 + /** 2 + * Admin community settings page. 3 + * URL: /admin/settings 4 + * Community name, description, branding, reaction config, maturity rating. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { AdminLayout } from '@/components/admin/admin-layout' 12 + import { getCommunitySettings, updateCommunitySettings } from '@/lib/api/client' 13 + import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 14 + 15 + // TODO: Replace with actual auth token from session 16 + const MOCK_TOKEN = 'mock-access-token' 17 + 18 + export default function AdminSettingsPage() { 19 + const [settings, setSettings] = useState<CommunitySettings | null>(null) 20 + const [loading, setLoading] = useState(true) 21 + const [saving, setSaving] = useState(false) 22 + 23 + const fetchSettings = useCallback(async () => { 24 + try { 25 + const data = await getCommunitySettings() 26 + setSettings(data) 27 + } catch { 28 + // Silently handle 29 + } finally { 30 + setLoading(false) 31 + } 32 + }, []) 33 + 34 + useEffect(() => { 35 + void fetchSettings() 36 + }, [fetchSettings]) 37 + 38 + const handleSave = async () => { 39 + if (!settings) return 40 + setSaving(true) 41 + try { 42 + const updated = await updateCommunitySettings( 43 + { 44 + communityName: settings.communityName, 45 + communityDescription: settings.communityDescription, 46 + maturityRating: settings.maturityRating, 47 + reactionSet: settings.reactionSet, 48 + primaryColor: settings.primaryColor, 49 + accentColor: settings.accentColor, 50 + }, 51 + MOCK_TOKEN 52 + ) 53 + setSettings(updated) 54 + } catch { 55 + // Silently handle 56 + } finally { 57 + setSaving(false) 58 + } 59 + } 60 + 61 + return ( 62 + <AdminLayout> 63 + <div className="space-y-6"> 64 + <h1 className="text-2xl font-bold text-foreground">Community Settings</h1> 65 + 66 + {loading && <p className="text-sm text-muted-foreground">Loading settings...</p>} 67 + 68 + {settings && ( 69 + <div className="max-w-lg space-y-6"> 70 + <div> 71 + <label htmlFor="settings-name" className="block text-sm font-medium text-foreground"> 72 + Community Name 73 + </label> 74 + <input 75 + id="settings-name" 76 + type="text" 77 + value={settings.communityName} 78 + onChange={(e) => setSettings({ ...settings, communityName: e.target.value })} 79 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 80 + /> 81 + </div> 82 + 83 + <div> 84 + <label htmlFor="settings-desc" className="block text-sm font-medium text-foreground"> 85 + Description 86 + </label> 87 + <textarea 88 + id="settings-desc" 89 + value={settings.communityDescription ?? ''} 90 + onChange={(e) => 91 + setSettings({ ...settings, communityDescription: e.target.value || null }) 92 + } 93 + rows={3} 94 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 95 + /> 96 + </div> 97 + 98 + <div> 99 + <label 100 + htmlFor="settings-maturity" 101 + className="block text-sm font-medium text-foreground" 102 + > 103 + Community Maturity Rating 104 + </label> 105 + <select 106 + id="settings-maturity" 107 + value={settings.maturityRating} 108 + onChange={(e) => 109 + setSettings({ ...settings, maturityRating: e.target.value as MaturityRating }) 110 + } 111 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 112 + > 113 + <option value="safe">Safe (default)</option> 114 + <option value="mature">Mature</option> 115 + <option value="adult">Adult</option> 116 + </select> 117 + <p className="mt-1 text-xs text-muted-foreground"> 118 + Changing to Mature or Adult affects global aggregator visibility. 119 + </p> 120 + </div> 121 + 122 + <div> 123 + <label 124 + htmlFor="settings-reactions" 125 + className="block text-sm font-medium text-foreground" 126 + > 127 + Reaction Set 128 + </label> 129 + <input 130 + id="settings-reactions" 131 + type="text" 132 + value={settings.reactionSet.join(', ')} 133 + onChange={(e) => 134 + setSettings({ 135 + ...settings, 136 + reactionSet: e.target.value 137 + .split(',') 138 + .map((s) => s.trim()) 139 + .filter(Boolean), 140 + }) 141 + } 142 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 143 + /> 144 + <p className="mt-1 text-xs text-muted-foreground"> 145 + Comma-separated list of reaction types available in your community. 146 + </p> 147 + </div> 148 + 149 + <fieldset className="space-y-4"> 150 + <legend className="text-sm font-medium text-foreground">Branding</legend> 151 + <div> 152 + <label 153 + htmlFor="settings-primary-color" 154 + className="block text-sm text-muted-foreground" 155 + > 156 + Primary Color 157 + </label> 158 + <input 159 + id="settings-primary-color" 160 + type="text" 161 + value={settings.primaryColor ?? ''} 162 + onChange={(e) => 163 + setSettings({ ...settings, primaryColor: e.target.value || null }) 164 + } 165 + placeholder="#31748f" 166 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 167 + /> 168 + </div> 169 + <div> 170 + <label 171 + htmlFor="settings-accent-color" 172 + className="block text-sm text-muted-foreground" 173 + > 174 + Accent Color 175 + </label> 176 + <input 177 + id="settings-accent-color" 178 + type="text" 179 + value={settings.accentColor ?? ''} 180 + onChange={(e) => 181 + setSettings({ ...settings, accentColor: e.target.value || null }) 182 + } 183 + placeholder="#c4a7e7" 184 + className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 185 + /> 186 + </div> 187 + </fieldset> 188 + 189 + <button 190 + type="button" 191 + onClick={() => void handleSave()} 192 + disabled={saving} 193 + className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" 194 + > 195 + {saving ? 'Saving...' : 'Save Settings'} 196 + </button> 197 + </div> 198 + )} 199 + </div> 200 + </AdminLayout> 201 + ) 202 + }
+88
src/app/admin/users/page.test.tsx
··· 1 + /** 2 + * Tests for admin user management page. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import AdminUsersPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin/users', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + describe('AdminUsersPage', () => { 35 + it('renders user management heading', () => { 36 + render(<AdminUsersPage />) 37 + expect(screen.getByRole('heading', { name: /user management/i })).toBeInTheDocument() 38 + }) 39 + 40 + it('renders user list from API', async () => { 41 + render(<AdminUsersPage />) 42 + await waitFor(() => { 43 + expect(screen.getByText('Alice Admin')).toBeInTheDocument() 44 + }) 45 + expect(screen.getByText('Bob Moderator')).toBeInTheDocument() 46 + expect(screen.getByText('Carol Member')).toBeInTheDocument() 47 + }) 48 + 49 + it('shows user roles', async () => { 50 + render(<AdminUsersPage />) 51 + await waitFor(() => { 52 + expect(screen.getByText('admin')).toBeInTheDocument() 53 + }) 54 + expect(screen.getByText('moderator')).toBeInTheDocument() 55 + }) 56 + 57 + it('shows banned status', async () => { 58 + render(<AdminUsersPage />) 59 + await waitFor(() => { 60 + expect(screen.getByText('Eve Banned')).toBeInTheDocument() 61 + }) 62 + // Eve should show as banned 63 + expect(screen.getByText('Banned')).toBeInTheDocument() 64 + }) 65 + 66 + it('shows cross-community ban warning', async () => { 67 + render(<AdminUsersPage />) 68 + await waitFor(() => { 69 + expect(screen.getByText(/banned from 2 other communities/i)).toBeInTheDocument() 70 + }) 71 + }) 72 + 73 + it('shows ban/unban buttons', async () => { 74 + render(<AdminUsersPage />) 75 + await waitFor(() => { 76 + expect(screen.getAllByRole('button', { name: /ban/i }).length).toBeGreaterThan(0) 77 + }) 78 + }) 79 + 80 + it('passes axe accessibility check', async () => { 81 + const { container } = render(<AdminUsersPage />) 82 + await waitFor(() => { 83 + expect(screen.getByText('Alice Admin')).toBeInTheDocument() 84 + }) 85 + const results = await axe(container) 86 + expect(results).toHaveNoViolations() 87 + }) 88 + })
+178
src/app/admin/users/page.tsx
··· 1 + /** 2 + * Admin user management page. 3 + * URL: /admin/users 4 + * User list with ban controls and cross-community ban warnings. 5 + * @see specs/prd-web.md Section M11 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useState, useEffect, useCallback } from 'react' 11 + import { Prohibit, WarningCircle } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { getAdminUsers, banUser, unbanUser } from '@/lib/api/client' 14 + import { cn } from '@/lib/utils' 15 + import type { AdminUser } from '@/lib/api/types' 16 + 17 + // TODO: Replace with actual auth token from session 18 + const MOCK_TOKEN = 'mock-access-token' 19 + 20 + const ROLE_COLORS: Record<string, string> = { 21 + admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', 22 + moderator: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', 23 + member: 'bg-muted text-muted-foreground', 24 + } 25 + 26 + function formatDate(dateStr: string) { 27 + return new Date(dateStr).toLocaleDateString('en-US', { 28 + month: 'short', 29 + day: 'numeric', 30 + year: 'numeric', 31 + }) 32 + } 33 + 34 + export default function AdminUsersPage() { 35 + const [users, setUsers] = useState<AdminUser[]>([]) 36 + const [loading, setLoading] = useState(true) 37 + 38 + const fetchUsers = useCallback(async () => { 39 + try { 40 + const response = await getAdminUsers(MOCK_TOKEN) 41 + setUsers(response.users) 42 + } catch { 43 + // Silently handle 44 + } finally { 45 + setLoading(false) 46 + } 47 + }, []) 48 + 49 + useEffect(() => { 50 + void fetchUsers() 51 + }, [fetchUsers]) 52 + 53 + const handleBan = async (did: string) => { 54 + try { 55 + await banUser(did, 'Banned by admin', MOCK_TOKEN) 56 + setUsers((prev) => 57 + prev.map((u) => 58 + u.did === did 59 + ? { 60 + ...u, 61 + isBanned: true, 62 + bannedAt: new Date().toISOString(), 63 + banReason: 'Banned by admin', 64 + } 65 + : u 66 + ) 67 + ) 68 + } catch { 69 + // Silently handle 70 + } 71 + } 72 + 73 + const handleUnban = async (did: string) => { 74 + try { 75 + await unbanUser(did, MOCK_TOKEN) 76 + setUsers((prev) => 77 + prev.map((u) => 78 + u.did === did ? { ...u, isBanned: false, bannedAt: null, banReason: null } : u 79 + ) 80 + ) 81 + } catch { 82 + // Silently handle 83 + } 84 + } 85 + 86 + return ( 87 + <AdminLayout> 88 + <div className="space-y-6"> 89 + <h1 className="text-2xl font-bold text-foreground">User Management</h1> 90 + 91 + {loading && <p className="text-sm text-muted-foreground">Loading users...</p>} 92 + 93 + {!loading && users.length === 0 && ( 94 + <p className="py-8 text-center text-muted-foreground">No users found.</p> 95 + )} 96 + 97 + {!loading && users.length > 0 && ( 98 + <div className="space-y-2"> 99 + {users.map((user) => ( 100 + <article 101 + key={user.did} 102 + className={cn( 103 + 'rounded-lg border border-border bg-card p-4', 104 + user.isBanned && 'border-l-4 border-l-destructive opacity-75' 105 + )} 106 + > 107 + <div className="flex items-start justify-between gap-4"> 108 + <div className="min-w-0 flex-1"> 109 + <div className="flex items-center gap-2"> 110 + <p className="text-sm font-medium text-foreground"> 111 + {user.displayName ?? user.handle} 112 + </p> 113 + <span 114 + className={cn( 115 + 'rounded-full px-2 py-0.5 text-xs font-medium', 116 + ROLE_COLORS[user.role] ?? ROLE_COLORS.member 117 + )} 118 + > 119 + {user.role} 120 + </span> 121 + {user.isBanned && ( 122 + <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive"> 123 + <Prohibit size={10} aria-hidden="true" /> 124 + Banned 125 + </span> 126 + )} 127 + </div> 128 + <p className="text-xs text-muted-foreground">@{user.handle}</p> 129 + <div className="mt-1 flex gap-4 text-xs text-muted-foreground"> 130 + <span>{user.topicCount} topics</span> 131 + <span>{user.replyCount} replies</span> 132 + <span>{user.reportCount} reports</span> 133 + <span>Joined {formatDate(user.firstSeenAt)}</span> 134 + </div> 135 + {user.bannedFromOtherCommunities > 0 && ( 136 + <p className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-destructive"> 137 + <WarningCircle size={12} aria-hidden="true" /> 138 + Banned from {user.bannedFromOtherCommunities} other communities 139 + </p> 140 + )} 141 + {user.isBanned && user.banReason && ( 142 + <p className="mt-1 text-xs text-muted-foreground italic"> 143 + Reason: {user.banReason} 144 + </p> 145 + )} 146 + </div> 147 + <div className="shrink-0"> 148 + {user.isBanned ? ( 149 + <button 150 + type="button" 151 + onClick={() => void handleUnban(user.did)} 152 + className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted" 153 + aria-label={`Unban ${user.displayName ?? user.handle}`} 154 + > 155 + Unban 156 + </button> 157 + ) : ( 158 + user.role !== 'admin' && ( 159 + <button 160 + type="button" 161 + onClick={() => void handleBan(user.did)} 162 + className="rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 163 + aria-label={`Ban ${user.displayName ?? user.handle}`} 164 + > 165 + Ban 166 + </button> 167 + ) 168 + )} 169 + </div> 170 + </div> 171 + </article> 172 + ))} 173 + </div> 174 + )} 175 + </div> 176 + </AdminLayout> 177 + ) 178 + }
+105
src/components/admin/admin-layout.test.tsx
··· 1 + /** 2 + * Tests for admin layout component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import { axe } from 'vitest-axe' 8 + import { AdminLayout } from './admin-layout' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + useRouter: () => ({ push: vi.fn() }), 12 + usePathname: () => '/admin', 13 + })) 14 + 15 + vi.mock('next/link', () => ({ 16 + default: ({ 17 + children, 18 + href, 19 + ...props 20 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 21 + <a href={href} {...props}> 22 + {children} 23 + </a> 24 + ), 25 + })) 26 + 27 + vi.mock('next/image', () => ({ 28 + default: (props: Record<string, unknown>) => { 29 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 30 + return <img {...props} /> 31 + }, 32 + })) 33 + 34 + describe('AdminLayout', () => { 35 + it('renders sidebar navigation', () => { 36 + render( 37 + <AdminLayout> 38 + <p>Admin content</p> 39 + </AdminLayout> 40 + ) 41 + expect(screen.getByRole('navigation', { name: /admin/i })).toBeInTheDocument() 42 + }) 43 + 44 + it('renders main content area', () => { 45 + render( 46 + <AdminLayout> 47 + <p>Admin content</p> 48 + </AdminLayout> 49 + ) 50 + expect(screen.getByText('Admin content')).toBeInTheDocument() 51 + }) 52 + 53 + it('renders all navigation links', () => { 54 + render( 55 + <AdminLayout> 56 + <p>Content</p> 57 + </AdminLayout> 58 + ) 59 + expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument() 60 + expect(screen.getByRole('link', { name: /categories/i })).toBeInTheDocument() 61 + expect(screen.getByRole('link', { name: /moderation/i })).toBeInTheDocument() 62 + expect(screen.getByRole('link', { name: /settings/i })).toBeInTheDocument() 63 + expect(screen.getByRole('link', { name: /content ratings/i })).toBeInTheDocument() 64 + expect(screen.getByRole('link', { name: /users/i })).toBeInTheDocument() 65 + }) 66 + 67 + it('highlights current page link', () => { 68 + render( 69 + <AdminLayout> 70 + <p>Content</p> 71 + </AdminLayout> 72 + ) 73 + const dashboardLink = screen.getByRole('link', { name: /dashboard/i }) 74 + expect(dashboardLink).toHaveAttribute('aria-current', 'page') 75 + }) 76 + 77 + it('renders back to forum link', () => { 78 + render( 79 + <AdminLayout> 80 + <p>Content</p> 81 + </AdminLayout> 82 + ) 83 + expect(screen.getByRole('link', { name: /back to forum/i })).toHaveAttribute('href', '/') 84 + }) 85 + 86 + it('renders main landmark', () => { 87 + render( 88 + <AdminLayout> 89 + <p>Content</p> 90 + </AdminLayout> 91 + ) 92 + expect(screen.getByRole('main')).toBeInTheDocument() 93 + }) 94 + 95 + it('passes axe accessibility check', async () => { 96 + const { container } = render( 97 + <AdminLayout> 98 + <h1>Admin Page</h1> 99 + <p>Content</p> 100 + </AdminLayout> 101 + ) 102 + const results = await axe(container) 103 + expect(results).toHaveNoViolations() 104 + }) 105 + })
+84
src/components/admin/admin-layout.tsx
··· 1 + /** 2 + * Admin layout with sidebar navigation. 3 + * Used by all /admin/* pages. 4 + * @see specs/prd-web.md Section 4 (AdminLayout) 5 + */ 6 + 7 + 'use client' 8 + 9 + import Link from 'next/link' 10 + import { usePathname } from 'next/navigation' 11 + import { 12 + ChartBar, 13 + FolderSimple, 14 + ShieldCheck, 15 + Gear, 16 + Tag, 17 + Users, 18 + ArrowLeft, 19 + } from '@phosphor-icons/react' 20 + import { cn } from '@/lib/utils' 21 + 22 + interface AdminLayoutProps { 23 + children: React.ReactNode 24 + } 25 + 26 + const NAV_ITEMS = [ 27 + { href: '/admin', label: 'Dashboard', icon: ChartBar }, 28 + { href: '/admin/categories', label: 'Categories', icon: FolderSimple }, 29 + { href: '/admin/moderation', label: 'Moderation', icon: ShieldCheck }, 30 + { href: '/admin/settings', label: 'Settings', icon: Gear }, 31 + { href: '/admin/content-ratings', label: 'Content Ratings', icon: Tag }, 32 + { href: '/admin/users', label: 'Users', icon: Users }, 33 + ] 34 + 35 + export function AdminLayout({ children }: AdminLayoutProps) { 36 + const pathname = usePathname() 37 + 38 + return ( 39 + <div className="flex min-h-screen bg-background"> 40 + {/* Sidebar */} 41 + <aside className="flex w-64 shrink-0 flex-col border-r border-border bg-card"> 42 + <div className="flex h-14 items-center border-b border-border px-4"> 43 + <Link 44 + href="/" 45 + className="flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground" 46 + aria-label="Back to forum" 47 + > 48 + <ArrowLeft size={16} aria-hidden="true" /> 49 + Back to forum 50 + </Link> 51 + </div> 52 + 53 + <nav aria-label="Admin navigation" className="flex-1 px-3 py-4"> 54 + <ul className="space-y-1"> 55 + {NAV_ITEMS.map((item) => { 56 + const isActive = pathname === item.href 57 + const Icon = item.icon 58 + return ( 59 + <li key={item.href}> 60 + <Link 61 + href={item.href} 62 + aria-current={isActive ? 'page' : undefined} 63 + className={cn( 64 + 'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors', 65 + isActive 66 + ? 'bg-primary/10 font-medium text-primary' 67 + : 'text-muted-foreground hover:bg-muted hover:text-foreground' 68 + )} 69 + > 70 + <Icon size={18} aria-hidden="true" /> 71 + {item.label} 72 + </Link> 73 + </li> 74 + ) 75 + })} 76 + </ul> 77 + </nav> 78 + </aside> 79 + 80 + {/* Main content */} 81 + <main className="min-w-0 flex-1 p-6">{children}</main> 82 + </div> 83 + ) 84 + }
+214
src/lib/api/client.ts
··· 6 6 7 7 import type { 8 8 CategoriesResponse, 9 + CategoryTreeNode, 9 10 CategoryWithTopicCount, 10 11 CommunitySettings, 11 12 CommunityStats, ··· 17 18 SearchResponse, 18 19 NotificationsResponse, 19 20 PaginationParams, 21 + ModerationReportsResponse, 22 + FirstPostQueueResponse, 23 + ModerationLogResponse, 24 + ModerationThresholds, 25 + ReportedUsersResponse, 26 + AdminUsersResponse, 27 + MaturityRating, 20 28 } from './types' 21 29 22 30 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 226 234 ...options?.headers, 227 235 Authorization: `Bearer ${accessToken}`, 228 236 }, 237 + }) 238 + } 239 + 240 + // --- Admin category endpoints --- 241 + 242 + export function createCategory( 243 + input: { 244 + name: string 245 + slug: string 246 + description: string | null 247 + parentId: string | null 248 + sortOrder: number 249 + maturityRating: MaturityRating 250 + }, 251 + accessToken: string, 252 + options?: FetchOptions 253 + ): Promise<CategoryTreeNode> { 254 + return apiFetch<CategoryTreeNode>('/api/admin/categories', { 255 + ...options, 256 + method: 'POST', 257 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 258 + body: input, 259 + }) 260 + } 261 + 262 + export function updateCategory( 263 + id: string, 264 + input: Partial<{ 265 + name: string 266 + slug: string 267 + description: string | null 268 + parentId: string | null 269 + sortOrder: number 270 + maturityRating: MaturityRating 271 + }>, 272 + accessToken: string, 273 + options?: FetchOptions 274 + ): Promise<CategoryTreeNode> { 275 + return apiFetch<CategoryTreeNode>(`/api/admin/categories/${encodeURIComponent(id)}`, { 276 + ...options, 277 + method: 'PUT', 278 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 279 + body: input, 280 + }) 281 + } 282 + 283 + export function deleteCategory( 284 + id: string, 285 + accessToken: string, 286 + options?: FetchOptions 287 + ): Promise<void> { 288 + return apiFetch<void>(`/api/admin/categories/${encodeURIComponent(id)}`, { 289 + ...options, 290 + method: 'DELETE', 291 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 292 + }) 293 + } 294 + 295 + // --- Moderation endpoints --- 296 + 297 + export function getModerationReports( 298 + accessToken: string, 299 + params: PaginationParams = {}, 300 + options?: FetchOptions 301 + ): Promise<ModerationReportsResponse> { 302 + const query = buildQuery({ limit: params.limit, cursor: params.cursor }) 303 + return apiFetch<ModerationReportsResponse>(`/api/moderation/reports${query}`, { 304 + ...options, 305 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 306 + }) 307 + } 308 + 309 + export function resolveReport( 310 + id: string, 311 + resolution: string, 312 + accessToken: string, 313 + options?: FetchOptions 314 + ): Promise<void> { 315 + return apiFetch<void>(`/api/moderation/reports/${encodeURIComponent(id)}`, { 316 + ...options, 317 + method: 'PUT', 318 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 319 + body: { resolution }, 320 + }) 321 + } 322 + 323 + export function getFirstPostQueue( 324 + accessToken: string, 325 + params: PaginationParams = {}, 326 + options?: FetchOptions 327 + ): Promise<FirstPostQueueResponse> { 328 + const query = buildQuery({ limit: params.limit, cursor: params.cursor }) 329 + return apiFetch<FirstPostQueueResponse>(`/api/moderation/queue${query}`, { 330 + ...options, 331 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 332 + }) 333 + } 334 + 335 + export function resolveFirstPost( 336 + id: string, 337 + action: 'approved' | 'rejected', 338 + accessToken: string, 339 + options?: FetchOptions 340 + ): Promise<void> { 341 + return apiFetch<void>(`/api/moderation/queue/${encodeURIComponent(id)}`, { 342 + ...options, 343 + method: 'PUT', 344 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 345 + body: { action }, 346 + }) 347 + } 348 + 349 + export function getModerationLog( 350 + accessToken: string, 351 + params: PaginationParams = {}, 352 + options?: FetchOptions 353 + ): Promise<ModerationLogResponse> { 354 + const query = buildQuery({ limit: params.limit, cursor: params.cursor }) 355 + return apiFetch<ModerationLogResponse>(`/api/moderation/log${query}`, { 356 + ...options, 357 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 358 + }) 359 + } 360 + 361 + export function getModerationThresholds( 362 + accessToken: string, 363 + options?: FetchOptions 364 + ): Promise<ModerationThresholds> { 365 + return apiFetch<ModerationThresholds>('/api/admin/moderation/thresholds', { 366 + ...options, 367 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 368 + }) 369 + } 370 + 371 + export function updateModerationThresholds( 372 + thresholds: Partial<ModerationThresholds>, 373 + accessToken: string, 374 + options?: FetchOptions 375 + ): Promise<ModerationThresholds> { 376 + return apiFetch<ModerationThresholds>('/api/admin/moderation/thresholds', { 377 + ...options, 378 + method: 'PUT', 379 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 380 + body: thresholds, 381 + }) 382 + } 383 + 384 + export function getReportedUsers( 385 + accessToken: string, 386 + options?: FetchOptions 387 + ): Promise<ReportedUsersResponse> { 388 + return apiFetch<ReportedUsersResponse>('/api/admin/reports/users', { 389 + ...options, 390 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 391 + }) 392 + } 393 + 394 + // --- Admin settings endpoints --- 395 + 396 + export function updateCommunitySettings( 397 + settings: Partial<CommunitySettings>, 398 + accessToken: string, 399 + options?: FetchOptions 400 + ): Promise<CommunitySettings> { 401 + return apiFetch<CommunitySettings>('/api/admin/settings', { 402 + ...options, 403 + method: 'PUT', 404 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 405 + body: settings, 406 + }) 407 + } 408 + 409 + // --- Admin user endpoints --- 410 + 411 + export function getAdminUsers( 412 + accessToken: string, 413 + params: PaginationParams = {}, 414 + options?: FetchOptions 415 + ): Promise<AdminUsersResponse> { 416 + const query = buildQuery({ limit: params.limit, cursor: params.cursor }) 417 + return apiFetch<AdminUsersResponse>(`/api/admin/users${query}`, { 418 + ...options, 419 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 420 + }) 421 + } 422 + 423 + export function banUser( 424 + did: string, 425 + reason: string, 426 + accessToken: string, 427 + options?: FetchOptions 428 + ): Promise<void> { 429 + return apiFetch<void>('/api/moderation/ban', { 430 + ...options, 431 + method: 'POST', 432 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 433 + body: { did, action: 'ban', reason }, 434 + }) 435 + } 436 + 437 + export function unbanUser(did: string, accessToken: string, options?: FetchOptions): Promise<void> { 438 + return apiFetch<void>('/api/moderation/ban', { 439 + ...options, 440 + method: 'POST', 441 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 442 + body: { did, action: 'unban' }, 229 443 }) 230 444 } 231 445
+139
src/lib/api/types.ts
··· 208 208 unreadCount: number 209 209 } 210 210 211 + // --- Moderation --- 212 + 213 + export type ReportReasonType = 214 + | 'spam' 215 + | 'sexual' 216 + | 'harassment' 217 + | 'violation' 218 + | 'misleading' 219 + | 'other' 220 + 221 + export type ReportResolution = 'dismissed' | 'warned' | 'labeled' | 'removed' | 'banned' 222 + 223 + export interface ModerationReport { 224 + id: string 225 + reporterDid: string 226 + reporterHandle: string 227 + targetUri: string 228 + targetAuthorDid: string 229 + targetAuthorHandle: string 230 + targetContent: string 231 + targetTitle: string | null 232 + reasonType: ReportReasonType 233 + reason: string | null 234 + potentiallyIllegal: boolean 235 + status: 'pending' | 'resolved' 236 + resolution: ReportResolution | null 237 + resolvedAt: string | null 238 + resolvedByDid: string | null 239 + communityDid: string 240 + createdAt: string 241 + } 242 + 243 + export interface ModerationReportsResponse { 244 + reports: ModerationReport[] 245 + cursor: string | null 246 + total: number 247 + } 248 + 249 + export interface FirstPostQueueItem { 250 + id: string 251 + authorDid: string 252 + authorHandle: string 253 + contentUri: string 254 + contentType: 'topic' | 'reply' 255 + title: string | null 256 + content: string 257 + accountAge: string 258 + crossCommunityCount: number 259 + status: 'pending' | 'approved' | 'rejected' 260 + communityDid: string 261 + createdAt: string 262 + } 263 + 264 + export interface FirstPostQueueResponse { 265 + items: FirstPostQueueItem[] 266 + cursor: string | null 267 + total: number 268 + } 269 + 270 + export type ModerationActionType = 271 + | 'lock' 272 + | 'unlock' 273 + | 'pin' 274 + | 'unpin' 275 + | 'delete' 276 + | 'ban' 277 + | 'unban' 278 + | 'warn' 279 + | 'label' 280 + | 'approve' 281 + | 'reject' 282 + 283 + export interface ModerationLogEntry { 284 + id: string 285 + actionType: ModerationActionType 286 + moderatorDid: string 287 + moderatorHandle: string 288 + targetUri: string | null 289 + targetDid: string | null 290 + targetHandle: string | null 291 + reason: string | null 292 + communityDid: string 293 + createdAt: string 294 + } 295 + 296 + export interface ModerationLogResponse { 297 + entries: ModerationLogEntry[] 298 + cursor: string | null 299 + total: number 300 + } 301 + 302 + export interface ModerationThresholds { 303 + autoBlockReportCount: number 304 + warnThreshold: number 305 + firstPostQueueCount: number 306 + newAccountRateLimit: number 307 + linkPostingHold: boolean 308 + topicCreationDelay: boolean 309 + burstDetectionPostCount: number 310 + burstDetectionMinutes: number 311 + } 312 + 313 + export interface ReportedUser { 314 + did: string 315 + handle: string 316 + reportCount: number 317 + latestReportAt: string 318 + bannedFromOtherCommunities: number 319 + } 320 + 321 + export interface ReportedUsersResponse { 322 + users: ReportedUser[] 323 + } 324 + 325 + // --- Admin Users --- 326 + 327 + export interface AdminUser { 328 + did: string 329 + handle: string 330 + displayName: string | null 331 + avatarUrl: string | null 332 + role: 'member' | 'moderator' | 'admin' 333 + isBanned: boolean 334 + bannedAt: string | null 335 + banReason: string | null 336 + bannedFromOtherCommunities: number 337 + topicCount: number 338 + replyCount: number 339 + reportCount: number 340 + firstSeenAt: string 341 + lastActiveAt: string 342 + } 343 + 344 + export interface AdminUsersResponse { 345 + users: AdminUser[] 346 + cursor: string | null 347 + total: number 348 + } 349 + 211 350 // --- Shared --- 212 351 213 352 export type MaturityRating = 'safe' | 'mature' | 'adult'
+292
src/mocks/data.ts
··· 10 10 Reply, 11 11 Notification, 12 12 SearchResult, 13 + ModerationReport, 14 + FirstPostQueueItem, 15 + ModerationLogEntry, 16 + ModerationThresholds, 17 + ReportedUser, 18 + AdminUser, 19 + CommunitySettings, 20 + CommunityStats, 13 21 } from '@/lib/api/types' 14 22 15 23 const COMMUNITY_DID = 'did:plc:test-community-123' ··· 408 416 indexedAt: NOW, 409 417 }, 410 418 ] 419 + 420 + // --- Community Settings --- 421 + 422 + export const mockCommunitySettings: CommunitySettings = { 423 + id: 'community-1', 424 + initialized: true, 425 + communityDid: COMMUNITY_DID, 426 + adminDid: mockUsers[0]!.did, 427 + communityName: 'Barazo Test Community', 428 + maturityRating: 'safe', 429 + reactionSet: ['like', 'love', 'laugh', 'surprise', 'sad'], 430 + communityDescription: 'A test community for development', 431 + communityLogoUrl: null, 432 + primaryColor: '#31748f', 433 + accentColor: '#c4a7e7', 434 + createdAt: TWO_DAYS_AGO, 435 + updatedAt: NOW, 436 + } 437 + 438 + // --- Community Stats --- 439 + 440 + export const mockCommunityStats: CommunityStats = { 441 + topicCount: 42, 442 + replyCount: 187, 443 + userCount: 156, 444 + categoryCount: 6, 445 + reportCount: 3, 446 + recentTopics: 8, 447 + recentReplies: 23, 448 + recentUsers: 5, 449 + } 450 + 451 + // --- Moderation Reports --- 452 + 453 + export const mockReports: ModerationReport[] = [ 454 + { 455 + id: 'report-1', 456 + reporterDid: mockUsers[2]!.did, 457 + reporterHandle: mockUsers[2]!.handle, 458 + targetUri: mockTopics[2]!.uri, 459 + targetAuthorDid: mockUsers[3]!.did, 460 + targetAuthorHandle: mockUsers[3]!.handle, 461 + targetContent: 'This content contains misleading information about the protocol.', 462 + targetTitle: 'Feature Request: Dark Mode Improvements', 463 + reasonType: 'misleading', 464 + reason: 'This post contains inaccurate technical claims', 465 + potentiallyIllegal: false, 466 + status: 'pending', 467 + resolution: null, 468 + resolvedAt: null, 469 + resolvedByDid: null, 470 + communityDid: COMMUNITY_DID, 471 + createdAt: NOW, 472 + }, 473 + { 474 + id: 'report-2', 475 + reporterDid: mockUsers[4]!.did, 476 + reporterHandle: mockUsers[4]!.handle, 477 + targetUri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6spam`, 478 + targetAuthorDid: mockUsers[1]!.did, 479 + targetAuthorHandle: mockUsers[1]!.handle, 480 + targetContent: 'Buy cheap products at example.com! Best deals ever!', 481 + targetTitle: null, 482 + reasonType: 'spam', 483 + reason: 'Obvious spam with commercial links', 484 + potentiallyIllegal: false, 485 + status: 'pending', 486 + resolution: null, 487 + resolvedAt: null, 488 + resolvedByDid: null, 489 + communityDid: COMMUNITY_DID, 490 + createdAt: YESTERDAY, 491 + }, 492 + { 493 + id: 'report-3', 494 + reporterDid: mockUsers[0]!.did, 495 + reporterHandle: mockUsers[0]!.handle, 496 + targetUri: `at://${mockUsers[3]!.did}/forum.barazo.reply.post/3kf6ill`, 497 + targetAuthorDid: mockUsers[3]!.did, 498 + targetAuthorHandle: mockUsers[3]!.handle, 499 + targetContent: 'Content that may violate local laws regarding hate speech.', 500 + targetTitle: null, 501 + reasonType: 'harassment', 502 + reason: 'Potentially illegal hate speech content', 503 + potentiallyIllegal: true, 504 + status: 'pending', 505 + resolution: null, 506 + resolvedAt: null, 507 + resolvedByDid: null, 508 + communityDid: COMMUNITY_DID, 509 + createdAt: NOW, 510 + }, 511 + ] 512 + 513 + // --- First Post Queue --- 514 + 515 + export const mockFirstPostQueue: FirstPostQueueItem[] = [ 516 + { 517 + id: 'fpq-1', 518 + authorDid: 'did:plc:new-user-001', 519 + authorHandle: 'newbie.bsky.social', 520 + contentUri: 'at://did:plc:new-user-001/forum.barazo.reply.post/3kf7aaa', 521 + contentType: 'reply', 522 + title: null, 523 + content: 'Hello everyone! I am new here and excited to join this community.', 524 + accountAge: '2 days', 525 + crossCommunityCount: 0, 526 + status: 'pending', 527 + communityDid: COMMUNITY_DID, 528 + createdAt: NOW, 529 + }, 530 + { 531 + id: 'fpq-2', 532 + authorDid: 'did:plc:new-user-002', 533 + authorHandle: 'newcomer.example.com', 534 + contentUri: 'at://did:plc:new-user-002/forum.barazo.topic.post/3kf7bbb', 535 + contentType: 'topic', 536 + title: 'Introduction: Newcomer here!', 537 + content: 'Hi, I found this forum through Bluesky and wanted to introduce myself.', 538 + accountAge: '5 days', 539 + crossCommunityCount: 3, 540 + status: 'pending', 541 + communityDid: COMMUNITY_DID, 542 + createdAt: YESTERDAY, 543 + }, 544 + ] 545 + 546 + // --- Moderation Log --- 547 + 548 + export const mockModerationLog: ModerationLogEntry[] = [ 549 + { 550 + id: 'log-1', 551 + actionType: 'pin', 552 + moderatorDid: mockUsers[0]!.did, 553 + moderatorHandle: mockUsers[0]!.handle, 554 + targetUri: mockTopics[0]!.uri, 555 + targetDid: null, 556 + targetHandle: null, 557 + reason: 'Welcome topic should be visible', 558 + communityDid: COMMUNITY_DID, 559 + createdAt: NOW, 560 + }, 561 + { 562 + id: 'log-2', 563 + actionType: 'warn', 564 + moderatorDid: mockUsers[0]!.did, 565 + moderatorHandle: mockUsers[0]!.handle, 566 + targetUri: null, 567 + targetDid: mockUsers[3]!.did, 568 + targetHandle: mockUsers[3]!.handle, 569 + reason: 'Repeated off-topic posting', 570 + communityDid: COMMUNITY_DID, 571 + createdAt: YESTERDAY, 572 + }, 573 + { 574 + id: 'log-3', 575 + actionType: 'delete', 576 + moderatorDid: mockUsers[4]!.did, 577 + moderatorHandle: mockUsers[4]!.handle, 578 + targetUri: `at://${mockUsers[1]!.did}/forum.barazo.reply.post/3kf6spam`, 579 + targetDid: mockUsers[1]!.did, 580 + targetHandle: mockUsers[1]!.handle, 581 + reason: 'Spam content', 582 + communityDid: COMMUNITY_DID, 583 + createdAt: TWO_DAYS_AGO, 584 + }, 585 + ] 586 + 587 + // --- Moderation Thresholds --- 588 + 589 + export const mockModerationThresholds: ModerationThresholds = { 590 + autoBlockReportCount: 5, 591 + warnThreshold: 3, 592 + firstPostQueueCount: 3, 593 + newAccountRateLimit: 3, 594 + linkPostingHold: true, 595 + topicCreationDelay: true, 596 + burstDetectionPostCount: 5, 597 + burstDetectionMinutes: 10, 598 + } 599 + 600 + // --- Reported Users --- 601 + 602 + export const mockReportedUsers: ReportedUser[] = [ 603 + { 604 + did: mockUsers[3]!.did, 605 + handle: mockUsers[3]!.handle, 606 + reportCount: 4, 607 + latestReportAt: NOW, 608 + bannedFromOtherCommunities: 2, 609 + }, 610 + { 611 + did: mockUsers[1]!.did, 612 + handle: mockUsers[1]!.handle, 613 + reportCount: 2, 614 + latestReportAt: YESTERDAY, 615 + bannedFromOtherCommunities: 0, 616 + }, 617 + ] 618 + 619 + // --- Admin Users --- 620 + 621 + export const mockAdminUsers: AdminUser[] = [ 622 + { 623 + did: mockUsers[0]!.did, 624 + handle: mockUsers[0]!.handle, 625 + displayName: 'Alice Admin', 626 + avatarUrl: null, 627 + role: 'admin', 628 + isBanned: false, 629 + bannedAt: null, 630 + banReason: null, 631 + bannedFromOtherCommunities: 0, 632 + topicCount: 15, 633 + replyCount: 42, 634 + reportCount: 0, 635 + firstSeenAt: TWO_DAYS_AGO, 636 + lastActiveAt: NOW, 637 + }, 638 + { 639 + did: mockUsers[1]!.did, 640 + handle: mockUsers[1]!.handle, 641 + displayName: 'Bob Moderator', 642 + avatarUrl: null, 643 + role: 'moderator', 644 + isBanned: false, 645 + bannedAt: null, 646 + banReason: null, 647 + bannedFromOtherCommunities: 0, 648 + topicCount: 8, 649 + replyCount: 31, 650 + reportCount: 2, 651 + firstSeenAt: TWO_DAYS_AGO, 652 + lastActiveAt: YESTERDAY, 653 + }, 654 + { 655 + did: mockUsers[2]!.did, 656 + handle: mockUsers[2]!.handle, 657 + displayName: 'Carol Member', 658 + avatarUrl: null, 659 + role: 'member', 660 + isBanned: false, 661 + bannedAt: null, 662 + banReason: null, 663 + bannedFromOtherCommunities: 0, 664 + topicCount: 3, 665 + replyCount: 12, 666 + reportCount: 0, 667 + firstSeenAt: YESTERDAY, 668 + lastActiveAt: NOW, 669 + }, 670 + { 671 + did: mockUsers[3]!.did, 672 + handle: mockUsers[3]!.handle, 673 + displayName: 'Dave Troublemaker', 674 + avatarUrl: null, 675 + role: 'member', 676 + isBanned: false, 677 + bannedAt: null, 678 + banReason: null, 679 + bannedFromOtherCommunities: 2, 680 + topicCount: 5, 681 + replyCount: 18, 682 + reportCount: 4, 683 + firstSeenAt: TWO_DAYS_AGO, 684 + lastActiveAt: NOW, 685 + }, 686 + { 687 + did: mockUsers[4]!.did, 688 + handle: mockUsers[4]!.handle, 689 + displayName: 'Eve Banned', 690 + avatarUrl: null, 691 + role: 'member', 692 + isBanned: true, 693 + bannedAt: YESTERDAY, 694 + banReason: 'Repeated spam violations', 695 + bannedFromOtherCommunities: 3, 696 + topicCount: 1, 697 + replyCount: 2, 698 + reportCount: 5, 699 + firstSeenAt: TWO_DAYS_AGO, 700 + lastActiveAt: YESTERDAY, 701 + }, 702 + ]
+182
src/mocks/handlers.ts
··· 12 12 mockReplies, 13 13 mockSearchResults, 14 14 mockNotifications, 15 + mockCommunitySettings, 16 + mockCommunityStats, 17 + mockReports, 18 + mockFirstPostQueue, 19 + mockModerationLog, 20 + mockModerationThresholds, 21 + mockReportedUsers, 22 + mockAdminUsers, 15 23 } from './data' 16 24 17 25 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' ··· 211 219 return HttpResponse.json({ error: 'Topic not found' }, { status: 404 }) 212 220 } 213 221 return HttpResponse.json(topic) 222 + }), 223 + 224 + // --- Admin endpoints --- 225 + 226 + // GET /api/admin/settings 227 + http.get(`${API_URL}/api/admin/settings`, () => { 228 + return HttpResponse.json(mockCommunitySettings) 229 + }), 230 + 231 + // PUT /api/admin/settings 232 + http.put(`${API_URL}/api/admin/settings`, async ({ request }) => { 233 + const auth = request.headers.get('Authorization') 234 + if (!auth?.startsWith('Bearer ')) { 235 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 236 + } 237 + const body = (await request.json()) as Record<string, unknown> 238 + return HttpResponse.json({ ...mockCommunitySettings, ...body }) 239 + }), 240 + 241 + // GET /api/admin/stats 242 + http.get(`${API_URL}/api/admin/stats`, ({ request }) => { 243 + const auth = request.headers.get('Authorization') 244 + if (!auth?.startsWith('Bearer ')) { 245 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 246 + } 247 + return HttpResponse.json(mockCommunityStats) 248 + }), 249 + 250 + // POST /api/admin/categories 251 + http.post(`${API_URL}/api/admin/categories`, async ({ request }) => { 252 + const auth = request.headers.get('Authorization') 253 + if (!auth?.startsWith('Bearer ')) { 254 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 255 + } 256 + const body = (await request.json()) as Record<string, unknown> 257 + const newCategory = { 258 + id: `cat-${Date.now()}`, 259 + ...body, 260 + communityDid: 'did:plc:test-community-123', 261 + createdAt: new Date().toISOString(), 262 + updatedAt: new Date().toISOString(), 263 + children: [], 264 + } 265 + return HttpResponse.json(newCategory, { status: 201 }) 266 + }), 267 + 268 + // PUT /api/admin/categories/:id 269 + http.put(`${API_URL}/api/admin/categories/:id`, async ({ request }) => { 270 + const auth = request.headers.get('Authorization') 271 + if (!auth?.startsWith('Bearer ')) { 272 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 273 + } 274 + const body = (await request.json()) as Record<string, unknown> 275 + return HttpResponse.json({ ...mockCategories[0], ...body, children: [] }) 276 + }), 277 + 278 + // DELETE /api/admin/categories/:id 279 + http.delete(`${API_URL}/api/admin/categories/:id`, ({ request }) => { 280 + const auth = request.headers.get('Authorization') 281 + if (!auth?.startsWith('Bearer ')) { 282 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 283 + } 284 + return new HttpResponse(null, { status: 204 }) 285 + }), 286 + 287 + // GET /api/moderation/reports 288 + http.get(`${API_URL}/api/moderation/reports`, ({ request }) => { 289 + const auth = request.headers.get('Authorization') 290 + if (!auth?.startsWith('Bearer ')) { 291 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 292 + } 293 + return HttpResponse.json({ 294 + reports: mockReports, 295 + cursor: null, 296 + total: mockReports.length, 297 + }) 298 + }), 299 + 300 + // PUT /api/moderation/reports/:id 301 + http.put(`${API_URL}/api/moderation/reports/:id`, async ({ request }) => { 302 + const auth = request.headers.get('Authorization') 303 + if (!auth?.startsWith('Bearer ')) { 304 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 305 + } 306 + return new HttpResponse(null, { status: 204 }) 307 + }), 308 + 309 + // GET /api/moderation/queue 310 + http.get(`${API_URL}/api/moderation/queue`, ({ request }) => { 311 + const auth = request.headers.get('Authorization') 312 + if (!auth?.startsWith('Bearer ')) { 313 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 314 + } 315 + return HttpResponse.json({ 316 + items: mockFirstPostQueue, 317 + cursor: null, 318 + total: mockFirstPostQueue.length, 319 + }) 320 + }), 321 + 322 + // PUT /api/moderation/queue/:id 323 + http.put(`${API_URL}/api/moderation/queue/:id`, async ({ request }) => { 324 + const auth = request.headers.get('Authorization') 325 + if (!auth?.startsWith('Bearer ')) { 326 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 327 + } 328 + return new HttpResponse(null, { status: 204 }) 329 + }), 330 + 331 + // GET /api/moderation/log 332 + http.get(`${API_URL}/api/moderation/log`, ({ request }) => { 333 + const auth = request.headers.get('Authorization') 334 + if (!auth?.startsWith('Bearer ')) { 335 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 336 + } 337 + return HttpResponse.json({ 338 + entries: mockModerationLog, 339 + cursor: null, 340 + total: mockModerationLog.length, 341 + }) 342 + }), 343 + 344 + // GET /api/admin/moderation/thresholds 345 + http.get(`${API_URL}/api/admin/moderation/thresholds`, ({ request }) => { 346 + const auth = request.headers.get('Authorization') 347 + if (!auth?.startsWith('Bearer ')) { 348 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 349 + } 350 + return HttpResponse.json(mockModerationThresholds) 351 + }), 352 + 353 + // PUT /api/admin/moderation/thresholds 354 + http.put(`${API_URL}/api/admin/moderation/thresholds`, async ({ request }) => { 355 + const auth = request.headers.get('Authorization') 356 + if (!auth?.startsWith('Bearer ')) { 357 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 358 + } 359 + const body = (await request.json()) as Record<string, unknown> 360 + return HttpResponse.json({ ...mockModerationThresholds, ...body }) 361 + }), 362 + 363 + // GET /api/admin/reports/users 364 + http.get(`${API_URL}/api/admin/reports/users`, ({ request }) => { 365 + const auth = request.headers.get('Authorization') 366 + if (!auth?.startsWith('Bearer ')) { 367 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 368 + } 369 + return HttpResponse.json({ users: mockReportedUsers }) 370 + }), 371 + 372 + // GET /api/admin/users 373 + http.get(`${API_URL}/api/admin/users`, ({ request }) => { 374 + const auth = request.headers.get('Authorization') 375 + if (!auth?.startsWith('Bearer ')) { 376 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 377 + } 378 + const url = new URL(request.url) 379 + const limitParam = url.searchParams.get('limit') 380 + const limit = limitParam ? parseInt(limitParam, 10) : 20 381 + const limited = mockAdminUsers.slice(0, limit) 382 + return HttpResponse.json({ 383 + users: limited, 384 + cursor: null, 385 + total: mockAdminUsers.length, 386 + }) 387 + }), 388 + 389 + // POST /api/moderation/ban 390 + http.post(`${API_URL}/api/moderation/ban`, async ({ request }) => { 391 + const auth = request.headers.get('Authorization') 392 + if (!auth?.startsWith('Bearer ')) { 393 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 394 + } 395 + return new HttpResponse(null, { status: 204 }) 214 396 }), 215 397 ]