Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(pages): add admin pages UI and public page rendering (#148)

* feat(pages): add admin pages UI and public page rendering

Add frontend for the admin pages mini-CMS feature, replacing hardcoded
/legal/* routes with dynamic CMS-powered pages served at /p/{slug}.

Changes:
- Page types and 7 API client functions (public + admin)
- Admin pages list at /admin/pages with tree display and CRUD
- Admin page editor at /admin/pages/[id] with markdown write/preview,
slug auto-generation, parent page select, and meta description
- PageForm extracted as reusable component (<150 lines each)
- Public page rendering at /p/[slug] with JSON-LD WebPage, OpenGraph,
breadcrumbs, and sanitized markdown content
- PageRow recursive tree component with status badges
- Admin nav: added Pages item with Article icon
- Footer links updated: /legal/* -> /p/*
- Deleted 6 hardcoded legal page files (3 pages + 3 tests)
- MSW handlers and mock data for all page endpoints
- 935 tests pass, TypeScript clean

Closes barazo-forum/barazo-workspace#TBD

* style(pages): fix Prettier formatting in 4 files

authored by

Guido X Jansen and committed by
GitHub
0bdd2d03 4f33f65d

+1800 -871
+163
src/app/admin/pages/[id]/page.test.tsx
··· 1 + /** 2 + * Tests for admin page editor. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } 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 AdminPageEditorPage from './page' 10 + 11 + const mockPush = vi.fn() 12 + let mockParams = { id: 'new' } 13 + 14 + vi.mock('next/navigation', () => ({ 15 + useRouter: () => ({ push: mockPush }), 16 + usePathname: () => '/admin/pages/new', 17 + useParams: () => mockParams, 18 + })) 19 + 20 + vi.mock('next/link', () => ({ 21 + default: ({ 22 + children, 23 + href, 24 + ...props 25 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 26 + <a href={href} {...props}> 27 + {children} 28 + </a> 29 + ), 30 + })) 31 + 32 + vi.mock('next/image', () => ({ 33 + default: (props: Record<string, unknown>) => { 34 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 35 + return <img {...props} /> 36 + }, 37 + })) 38 + 39 + vi.mock('@/hooks/use-auth', () => { 40 + const mockAuth = { 41 + user: { 42 + did: 'did:plc:user-jay-001', 43 + handle: 'jay.bsky.team', 44 + displayName: 'Jay', 45 + avatarUrl: null, 46 + }, 47 + isAuthenticated: true, 48 + isLoading: false, 49 + getAccessToken: () => 'mock-access-token', 50 + login: vi.fn(), 51 + logout: vi.fn(), 52 + setSessionFromCallback: vi.fn(), 53 + authFetch: vi.fn(), 54 + } 55 + return { useAuth: () => mockAuth } 56 + }) 57 + 58 + vi.mock('@/hooks/use-toast', () => ({ 59 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 60 + })) 61 + 62 + describe('AdminPageEditorPage', () => { 63 + beforeEach(() => { 64 + mockParams = { id: 'new' } 65 + mockPush.mockClear() 66 + }) 67 + 68 + describe('create mode (id === "new")', () => { 69 + it('renders create page heading', () => { 70 + render(<AdminPageEditorPage />) 71 + expect(screen.getByRole('heading', { name: /create page/i })).toBeInTheDocument() 72 + }) 73 + 74 + it('renders title input', () => { 75 + render(<AdminPageEditorPage />) 76 + expect(screen.getByLabelText(/title/i)).toBeInTheDocument() 77 + }) 78 + 79 + it('renders slug input', () => { 80 + render(<AdminPageEditorPage />) 81 + expect(screen.getByLabelText(/slug/i)).toBeInTheDocument() 82 + }) 83 + 84 + it('renders status select', () => { 85 + render(<AdminPageEditorPage />) 86 + expect(screen.getByLabelText(/status/i)).toBeInTheDocument() 87 + }) 88 + 89 + it('renders meta description textarea', () => { 90 + render(<AdminPageEditorPage />) 91 + expect(screen.getByLabelText(/meta description/i)).toBeInTheDocument() 92 + }) 93 + 94 + it('renders save and cancel buttons', () => { 95 + render(<AdminPageEditorPage />) 96 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 97 + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() 98 + }) 99 + 100 + it('does not render delete button in create mode', () => { 101 + render(<AdminPageEditorPage />) 102 + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument() 103 + }) 104 + 105 + it('auto-generates slug from title in create mode', async () => { 106 + const user = userEvent.setup() 107 + render(<AdminPageEditorPage />) 108 + const titleInput = screen.getByLabelText(/title/i) 109 + await user.type(titleInput, 'Hello World') 110 + const slugInput = screen.getByLabelText(/slug/i) as HTMLInputElement 111 + expect(slugInput.value).toBe('hello-world') 112 + }) 113 + 114 + it('shows character count for meta description', async () => { 115 + const user = userEvent.setup() 116 + render(<AdminPageEditorPage />) 117 + const metaInput = screen.getByLabelText(/meta description/i) 118 + await user.type(metaInput, 'Test description') 119 + expect(screen.getByText('16/320')).toBeInTheDocument() 120 + }) 121 + 122 + it('navigates back on cancel', async () => { 123 + const user = userEvent.setup() 124 + render(<AdminPageEditorPage />) 125 + await user.click(screen.getByRole('button', { name: /cancel/i })) 126 + expect(mockPush).toHaveBeenCalledWith('/admin/pages') 127 + }) 128 + 129 + it('passes axe accessibility check', async () => { 130 + const { container } = render(<AdminPageEditorPage />) 131 + const results = await axe(container) 132 + expect(results).toHaveNoViolations() 133 + }) 134 + }) 135 + 136 + describe('edit mode (id !== "new")', () => { 137 + beforeEach(() => { 138 + mockParams = { id: 'page-about' } 139 + }) 140 + 141 + it('renders edit page heading', async () => { 142 + render(<AdminPageEditorPage />) 143 + await waitFor(() => { 144 + expect(screen.getByRole('heading', { name: /edit page/i })).toBeInTheDocument() 145 + }) 146 + }) 147 + 148 + it('populates form with existing page data', async () => { 149 + render(<AdminPageEditorPage />) 150 + await waitFor(() => { 151 + const titleInput = screen.getByLabelText(/title/i) as HTMLInputElement 152 + expect(titleInput.value).toBe('About This Community') 153 + }) 154 + }) 155 + 156 + it('renders delete button in edit mode', async () => { 157 + render(<AdminPageEditorPage />) 158 + await waitFor(() => { 159 + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() 160 + }) 161 + }) 162 + }) 163 + })
+191
src/app/admin/pages/[id]/page.tsx
··· 1 + /** 2 + * Admin page editor - Create or edit a static page. 3 + * URL: /admin/pages/new (create) or /admin/pages/{id} (edit) 4 + */ 5 + 6 + 'use client' 7 + 8 + import { useState, useEffect, useCallback } from 'react' 9 + import { useRouter, useParams } from 'next/navigation' 10 + import { AdminLayout } from '@/components/admin/admin-layout' 11 + import { ErrorAlert } from '@/components/error-alert' 12 + import { PageForm } from '@/components/admin/pages/page-form' 13 + import { generateSlug } from '@/components/admin/pages/slug-generator' 14 + import { getAdminPage, getAdminPages, createPage, updatePage, deletePage } from '@/lib/api/client' 15 + import type { PageStatus, PageTreeNode } from '@/lib/api/types' 16 + import { useAuth } from '@/hooks/use-auth' 17 + import { useToast } from '@/hooks/use-toast' 18 + 19 + interface PageFormState { 20 + title: string 21 + slug: string 22 + status: PageStatus 23 + parentId: string | null 24 + metaDescription: string 25 + content: string 26 + } 27 + 28 + const INITIAL_FORM: PageFormState = { 29 + title: '', 30 + slug: '', 31 + status: 'draft', 32 + parentId: null, 33 + metaDescription: '', 34 + content: '', 35 + } 36 + 37 + export default function AdminPageEditorPage() { 38 + const router = useRouter() 39 + const params = useParams() 40 + const id = params.id as string 41 + const isCreateMode = id === 'new' 42 + 43 + const { getAccessToken } = useAuth() 44 + const { toast } = useToast() 45 + 46 + const [form, setForm] = useState<PageFormState>(INITIAL_FORM) 47 + const [availablePages, setAvailablePages] = useState<PageTreeNode[]>([]) 48 + const [loading, setLoading] = useState(!isCreateMode) 49 + const [saving, setSaving] = useState(false) 50 + const [error, setError] = useState<string | null>(null) 51 + 52 + const flattenPages = useCallback( 53 + (nodes: PageTreeNode[], result: PageTreeNode[] = []): PageTreeNode[] => { 54 + for (const node of nodes) { 55 + if (node.id !== id) { 56 + result.push(node) 57 + flattenPages(node.children, result) 58 + } 59 + // Skip descendants of the current page entirely 60 + } 61 + return result 62 + }, 63 + [id] 64 + ) 65 + 66 + useEffect(() => { 67 + const loadData = async () => { 68 + const token = getAccessToken() ?? '' 69 + try { 70 + const pagesResponse = await getAdminPages(token) 71 + setAvailablePages(flattenPages(pagesResponse.pages)) 72 + 73 + if (!isCreateMode) { 74 + const page = await getAdminPage(id, token) 75 + setForm({ 76 + title: page.title, 77 + slug: page.slug, 78 + status: page.status, 79 + parentId: page.parentId, 80 + metaDescription: page.metaDescription ?? '', 81 + content: page.content, 82 + }) 83 + } 84 + } catch { 85 + setError('Failed to load page data.') 86 + } finally { 87 + setLoading(false) 88 + } 89 + } 90 + void loadData() 91 + }, [id, isCreateMode, getAccessToken, flattenPages]) 92 + 93 + const handleTitleChange = (value: string) => { 94 + setForm((prev) => ({ 95 + ...prev, 96 + title: value, 97 + ...(isCreateMode ? { slug: generateSlug(value) } : {}), 98 + })) 99 + } 100 + 101 + const handleSave = async () => { 102 + if (!form.title.trim() || !form.slug.trim()) { 103 + setError('Title and slug are required.') 104 + return 105 + } 106 + 107 + setSaving(true) 108 + setError(null) 109 + 110 + try { 111 + const token = getAccessToken() ?? '' 112 + const input = { 113 + title: form.title, 114 + slug: form.slug, 115 + content: form.content, 116 + status: form.status, 117 + metaDescription: form.metaDescription || null, 118 + parentId: form.parentId, 119 + } 120 + 121 + if (isCreateMode) { 122 + await createPage(input, token) 123 + toast({ title: 'Page created' }) 124 + } else { 125 + await updatePage(id, input, token) 126 + toast({ title: 'Page updated' }) 127 + } 128 + router.push('/admin/pages') 129 + } catch { 130 + setError('Failed to save page. Please try again.') 131 + } finally { 132 + setSaving(false) 133 + } 134 + } 135 + 136 + const handleDelete = async () => { 137 + const confirmed = window.confirm('Are you sure you want to delete this page?') 138 + if (!confirmed) return 139 + 140 + try { 141 + await deletePage(id, getAccessToken() ?? '') 142 + toast({ title: 'Page deleted' }) 143 + router.push('/admin/pages') 144 + } catch { 145 + setError('Failed to delete page. Please try again.') 146 + } 147 + } 148 + 149 + if (loading) { 150 + return ( 151 + <AdminLayout> 152 + <p className="text-sm text-muted-foreground">Loading page...</p> 153 + </AdminLayout> 154 + ) 155 + } 156 + 157 + return ( 158 + <AdminLayout> 159 + <div className="mx-auto max-w-3xl space-y-6"> 160 + <h1 className="text-2xl font-bold text-foreground"> 161 + {isCreateMode ? 'Create Page' : 'Edit Page'} 162 + </h1> 163 + 164 + {error && <ErrorAlert message={error} onDismiss={() => setError(null)} />} 165 + 166 + <PageForm 167 + title={form.title} 168 + slug={form.slug} 169 + content={form.content} 170 + status={form.status} 171 + parentId={form.parentId} 172 + metaDescription={form.metaDescription} 173 + isEditMode={!isCreateMode} 174 + availableParents={availablePages} 175 + onTitleChange={handleTitleChange} 176 + onSlugChange={(slug) => setForm((prev) => ({ ...prev, slug }))} 177 + onContentChange={(content) => setForm((prev) => ({ ...prev, content }))} 178 + onStatusChange={(status) => setForm((prev) => ({ ...prev, status }))} 179 + onParentIdChange={(parentId) => setForm((prev) => ({ ...prev, parentId }))} 180 + onMetaDescriptionChange={(metaDescription) => 181 + setForm((prev) => ({ ...prev, metaDescription })) 182 + } 183 + onSave={() => void handleSave()} 184 + onCancel={() => router.push('/admin/pages')} 185 + onDelete={() => void handleDelete()} 186 + saving={saving} 187 + /> 188 + </div> 189 + </AdminLayout> 190 + ) 191 + }
+102
src/app/admin/pages/page.test.tsx
··· 1 + /** 2 + * Tests for admin pages list 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 AdminPagesPage from './page' 9 + 10 + const mockPush = vi.fn() 11 + 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: mockPush }), 14 + usePathname: () => '/admin/pages', 15 + })) 16 + 17 + vi.mock('next/link', () => ({ 18 + default: ({ 19 + children, 20 + href, 21 + ...props 22 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 23 + <a href={href} {...props}> 24 + {children} 25 + </a> 26 + ), 27 + })) 28 + 29 + vi.mock('next/image', () => ({ 30 + default: (props: Record<string, unknown>) => { 31 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 32 + return <img {...props} /> 33 + }, 34 + })) 35 + 36 + vi.mock('@/hooks/use-auth', () => { 37 + const mockAuth = { 38 + user: { 39 + did: 'did:plc:user-jay-001', 40 + handle: 'jay.bsky.team', 41 + displayName: 'Jay', 42 + avatarUrl: null, 43 + }, 44 + isAuthenticated: true, 45 + isLoading: false, 46 + getAccessToken: () => 'mock-access-token', 47 + login: vi.fn(), 48 + logout: vi.fn(), 49 + setSessionFromCallback: vi.fn(), 50 + authFetch: vi.fn(), 51 + } 52 + return { useAuth: () => mockAuth } 53 + }) 54 + 55 + vi.mock('@/hooks/use-toast', () => ({ 56 + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), 57 + })) 58 + 59 + describe('AdminPagesPage', () => { 60 + it('renders pages heading', () => { 61 + render(<AdminPagesPage />) 62 + expect(screen.getByRole('heading', { name: /pages/i })).toBeInTheDocument() 63 + }) 64 + 65 + it('renders add page button', () => { 66 + render(<AdminPagesPage />) 67 + expect(screen.getByRole('button', { name: /add page/i })).toBeInTheDocument() 68 + }) 69 + 70 + it('renders pages from API', async () => { 71 + render(<AdminPagesPage />) 72 + await waitFor(() => { 73 + expect(screen.getByText('About This Community')).toBeInTheDocument() 74 + }) 75 + expect(screen.getByText('Privacy Policy')).toBeInTheDocument() 76 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 77 + }) 78 + 79 + it('renders page status badges', async () => { 80 + render(<AdminPagesPage />) 81 + await waitFor(() => { 82 + expect(screen.getAllByText('Published').length).toBeGreaterThan(0) 83 + }) 84 + expect(screen.getByText('Draft')).toBeInTheDocument() 85 + }) 86 + 87 + it('navigates to new page editor on add button click', async () => { 88 + const user = (await import('@testing-library/user-event')).default.setup() 89 + render(<AdminPagesPage />) 90 + await user.click(screen.getByRole('button', { name: /add page/i })) 91 + expect(mockPush).toHaveBeenCalledWith('/admin/pages/new') 92 + }) 93 + 94 + it('passes axe accessibility check', async () => { 95 + const { container } = render(<AdminPagesPage />) 96 + await waitFor(() => { 97 + expect(screen.getByText('About This Community')).toBeInTheDocument() 98 + }) 99 + const results = await axe(container) 100 + expect(results).toHaveNoViolations() 101 + }) 102 + })
+104
src/app/admin/pages/page.tsx
··· 1 + /** 2 + * Admin pages list page. 3 + * URL: /admin/pages 4 + * Page tree with status badges and CRUD controls. 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useState, useEffect, useCallback } from 'react' 10 + import { useRouter } from 'next/navigation' 11 + import { Plus } from '@phosphor-icons/react' 12 + import { AdminLayout } from '@/components/admin/admin-layout' 13 + import { ErrorAlert } from '@/components/error-alert' 14 + import { PageRow } from '@/components/admin/pages/page-row' 15 + import { getAdminPages, deletePage } from '@/lib/api/client' 16 + import type { PageTreeNode } from '@/lib/api/types' 17 + import { useAuth } from '@/hooks/use-auth' 18 + import { useToast } from '@/hooks/use-toast' 19 + 20 + export default function AdminPagesPage() { 21 + const router = useRouter() 22 + const { getAccessToken } = useAuth() 23 + const { toast } = useToast() 24 + const [pages, setPages] = useState<PageTreeNode[]>([]) 25 + const [loading, setLoading] = useState(true) 26 + const [loadError, setLoadError] = useState<string | null>(null) 27 + const [actionError, setActionError] = useState<string | null>(null) 28 + 29 + const fetchPages = useCallback(async () => { 30 + setLoadError(null) 31 + try { 32 + const response = await getAdminPages(getAccessToken() ?? '') 33 + setPages(response.pages) 34 + } catch { 35 + setLoadError('Failed to load pages. The API may be unreachable.') 36 + } finally { 37 + setLoading(false) 38 + } 39 + }, [getAccessToken]) 40 + 41 + useEffect(() => { 42 + void fetchPages() 43 + }, [fetchPages]) 44 + 45 + const handleDelete = async (id: string) => { 46 + setActionError(null) 47 + const confirmed = window.confirm('Are you sure you want to delete this page?') 48 + if (!confirmed) return 49 + 50 + try { 51 + await deletePage(id, getAccessToken() ?? '') 52 + void fetchPages() 53 + toast({ title: 'Page deleted' }) 54 + } catch { 55 + setActionError('Failed to delete page. Please try again.') 56 + } 57 + } 58 + 59 + return ( 60 + <AdminLayout> 61 + <div className="space-y-6"> 62 + <div className="flex items-center justify-between"> 63 + <h1 className="text-2xl font-bold text-foreground">Pages</h1> 64 + <button 65 + type="button" 66 + onClick={() => router.push('/admin/pages/new')} 67 + 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" 68 + > 69 + <Plus size={16} aria-hidden="true" /> 70 + Add Page 71 + </button> 72 + </div> 73 + 74 + {actionError && <ErrorAlert message={actionError} onDismiss={() => setActionError(null)} />} 75 + 76 + {loadError && ( 77 + <ErrorAlert message={loadError} variant="page" onRetry={() => void fetchPages()} /> 78 + )} 79 + 80 + {loading && <p className="text-sm text-muted-foreground">Loading pages...</p>} 81 + 82 + {!loading && pages.length === 0 && ( 83 + <p className="py-8 text-center text-muted-foreground"> 84 + No pages yet. Create your first page for static content like About, Privacy Policy, or 85 + Terms of Service. 86 + </p> 87 + )} 88 + 89 + {!loading && pages.length > 0 && ( 90 + <div className="space-y-2"> 91 + {pages.map((page) => ( 92 + <PageRow 93 + key={page.id} 94 + page={page} 95 + depth={0} 96 + onDelete={(id) => void handleDelete(id)} 97 + /> 98 + ))} 99 + </div> 100 + )} 101 + </div> 102 + </AdminLayout> 103 + ) 104 + }
-86
src/app/legal/cookies/page.test.tsx
··· 1 - /** 2 - * Tests for cookie policy page. 3 - * @see decisions/legal.md 4 - */ 5 - 6 - import { describe, it, expect, vi } from 'vitest' 7 - import { render, screen } from '@testing-library/react' 8 - import { axe } from 'vitest-axe' 9 - import CookiePolicyPage from './page' 10 - 11 - // Mock next/navigation 12 - vi.mock('next/navigation', () => ({ 13 - usePathname: () => '/legal/cookies', 14 - useRouter: () => ({ push: vi.fn() }), 15 - })) 16 - 17 - // Mock next-themes 18 - vi.mock('next-themes', () => ({ 19 - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 20 - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 21 - })) 22 - 23 - // Mock useAuth hook 24 - vi.mock('@/hooks/use-auth', () => ({ 25 - useAuth: () => ({ 26 - user: null, 27 - isAuthenticated: false, 28 - isLoading: false, 29 - getAccessToken: () => null, 30 - login: vi.fn(), 31 - logout: vi.fn(), 32 - setSessionFromCallback: vi.fn(), 33 - authFetch: vi.fn(), 34 - }), 35 - })) 36 - 37 - describe('CookiePolicyPage', () => { 38 - it('renders page heading', async () => { 39 - const page = await CookiePolicyPage() 40 - render(page) 41 - expect(screen.getByRole('heading', { name: /cookie policy/i, level: 1 })).toBeInTheDocument() 42 - }) 43 - 44 - it('describes the single essential cookie', async () => { 45 - const page = await CookiePolicyPage() 46 - render(page) 47 - expect(screen.getByRole('heading', { name: /cookies we use/i })).toBeInTheDocument() 48 - // Table shows the refresh token cookie details 49 - expect(screen.getByRole('table')).toBeInTheDocument() 50 - expect(screen.getByText('Refresh token')).toBeInTheDocument() 51 - }) 52 - 53 - it('lists cookie security properties', async () => { 54 - const page = await CookiePolicyPage() 55 - render(page) 56 - expect(screen.getByText(/HTTP-only/i)).toBeInTheDocument() 57 - expect(screen.getByText(/SameSite=Strict/i)).toBeInTheDocument() 58 - }) 59 - 60 - it('states no tracking cookies are used', async () => { 61 - const page = await CookiePolicyPage() 62 - render(page) 63 - expect(screen.getByRole('heading', { name: /what we do not use/i })).toBeInTheDocument() 64 - expect(screen.getByText(/no tracking or advertising cookies/i)).toBeInTheDocument() 65 - }) 66 - 67 - it('explains cookie consent exemption', async () => { 68 - const page = await CookiePolicyPage() 69 - render(page) 70 - expect(screen.getByRole('heading', { name: /cookie consent/i })).toBeInTheDocument() 71 - expect(screen.getByText(/ePrivacy Directive/i)).toBeInTheDocument() 72 - }) 73 - 74 - it('renders breadcrumbs', async () => { 75 - const page = await CookiePolicyPage() 76 - render(page) 77 - expect(screen.getByText('Home')).toBeInTheDocument() 78 - }) 79 - 80 - it('passes axe accessibility check', async () => { 81 - const page = await CookiePolicyPage() 82 - const { container } = render(page) 83 - const results = await axe(container) 84 - expect(results).toHaveNoViolations() 85 - }) 86 - })
-136
src/app/legal/cookies/page.tsx
··· 1 - /** 2 - * Cookie policy page. 3 - * URL: /legal/cookies 4 - * Static placeholder content -- admin-editable in P3+. 5 - * @see decisions/legal.md 6 - */ 7 - 8 - import type { Metadata } from 'next' 9 - import { getPublicSettings } from '@/lib/api/client' 10 - import { ForumLayout } from '@/components/layout/forum-layout' 11 - import { Breadcrumbs } from '@/components/breadcrumbs' 12 - 13 - export const metadata: Metadata = { 14 - title: 'Cookie Policy', 15 - description: 16 - 'How Barazo uses cookies. We use a single essential cookie for authentication -- no tracking or analytics cookies.', 17 - alternates: { 18 - canonical: '/legal/cookies', 19 - }, 20 - } 21 - 22 - export default async function CookiePolicyPage() { 23 - let communityName = '' 24 - try { 25 - const settings = await getPublicSettings() 26 - communityName = settings.communityName 27 - } catch { 28 - // silently degrade 29 - } 30 - 31 - return ( 32 - <ForumLayout communityName={communityName}> 33 - <div className="mx-auto max-w-2xl space-y-8"> 34 - <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Cookie policy' }]} /> 35 - 36 - <h1 className="text-2xl font-bold text-foreground">Cookie policy</h1> 37 - 38 - <section className="space-y-3"> 39 - <h2 className="text-lg font-semibold text-foreground">Overview</h2> 40 - <p className="text-sm leading-relaxed text-muted-foreground"> 41 - Barazo uses a minimal number of cookies. We do not use tracking cookies, advertising 42 - cookies, or third-party analytics cookies. This page explains the cookies we do use and 43 - why. 44 - </p> 45 - </section> 46 - 47 - <section className="space-y-3"> 48 - <h2 className="text-lg font-semibold text-foreground">Cookies we use</h2> 49 - <p className="text-sm leading-relaxed text-muted-foreground"> 50 - Barazo uses a single essential cookie: 51 - </p> 52 - <div className="overflow-x-auto"> 53 - <table className="w-full text-sm text-muted-foreground"> 54 - <thead> 55 - <tr className="border-b border-border text-left"> 56 - <th className="pb-2 pr-4 font-semibold text-foreground">Cookie</th> 57 - <th className="pb-2 pr-4 font-semibold text-foreground">Purpose</th> 58 - <th className="pb-2 pr-4 font-semibold text-foreground">Duration</th> 59 - <th className="pb-2 font-semibold text-foreground">Type</th> 60 - </tr> 61 - </thead> 62 - <tbody> 63 - <tr className="border-b border-border/50"> 64 - <td className="py-2 pr-4">Refresh token</td> 65 - <td className="py-2 pr-4"> 66 - Keeps you logged in across page reloads by enabling silent access token renewal. 67 - </td> 68 - <td className="py-2 pr-4">Session</td> 69 - <td className="py-2">Essential</td> 70 - </tr> 71 - </tbody> 72 - </table> 73 - </div> 74 - </section> 75 - 76 - <section className="space-y-3"> 77 - <h2 className="text-lg font-semibold text-foreground">Technical details</h2> 78 - <p className="text-sm leading-relaxed text-muted-foreground"> 79 - The refresh token cookie has the following security properties: 80 - </p> 81 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 82 - <li> 83 - <strong>HTTP-only</strong> -- the cookie is not accessible to JavaScript, preventing 84 - cross-site scripting (XSS) attacks. 85 - </li> 86 - <li> 87 - <strong>Secure</strong> -- the cookie is only sent over HTTPS connections. 88 - </li> 89 - <li> 90 - <strong>SameSite=Strict</strong> -- the cookie is not sent with cross-site requests, 91 - preventing cross-site request forgery (CSRF) attacks. 92 - </li> 93 - </ul> 94 - <p className="text-sm leading-relaxed text-muted-foreground"> 95 - Access tokens (used to authenticate API requests) are held in memory only and are never 96 - stored in cookies, localStorage, or sessionStorage. 97 - </p> 98 - </section> 99 - 100 - <section className="space-y-3"> 101 - <h2 className="text-lg font-semibold text-foreground">What we do not use</h2> 102 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 103 - <li>No tracking or advertising cookies.</li> 104 - <li>No third-party analytics (Google Analytics, etc.).</li> 105 - <li>No social media tracking pixels.</li> 106 - <li>No fingerprinting or behavioral profiling.</li> 107 - </ul> 108 - </section> 109 - 110 - <section className="space-y-3"> 111 - <h2 className="text-lg font-semibold text-foreground">Cookie consent</h2> 112 - <p className="text-sm leading-relaxed text-muted-foreground"> 113 - Because we only use a single essential cookie required for the service to function, a 114 - cookie consent banner is not required under the ePrivacy Directive (EU Directive 115 - 2002/58/EC, Art. 5(3)). Essential cookies that are strictly necessary for the service 116 - requested by the user are exempt from the consent requirement. 117 - </p> 118 - </section> 119 - 120 - <section className="space-y-3"> 121 - <h2 className="text-lg font-semibold text-foreground">Theme preference</h2> 122 - <p className="text-sm leading-relaxed text-muted-foreground"> 123 - Your light/dark mode preference is stored in localStorage (not a cookie). This is a 124 - client-side preference that is never sent to our servers. 125 - </p> 126 - </section> 127 - 128 - <section className="space-y-3"> 129 - <p className="text-xs text-muted-foreground"> 130 - This policy was last updated on February 2026. 131 - </p> 132 - </section> 133 - </div> 134 - </ForumLayout> 135 - ) 136 - }
-124
src/app/legal/privacy/page.test.tsx
··· 1 - /** 2 - * Tests for privacy policy page. 3 - * @see decisions/legal.md 4 - */ 5 - 6 - import { describe, it, expect, vi } from 'vitest' 7 - import { render, screen } from '@testing-library/react' 8 - import { axe } from 'vitest-axe' 9 - import PrivacyPolicyPage from './page' 10 - 11 - // Mock next/navigation 12 - vi.mock('next/navigation', () => ({ 13 - usePathname: () => '/legal/privacy', 14 - useRouter: () => ({ push: vi.fn() }), 15 - })) 16 - 17 - // Mock next-themes 18 - vi.mock('next-themes', () => ({ 19 - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 20 - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 21 - })) 22 - 23 - // Mock useAuth hook 24 - vi.mock('@/hooks/use-auth', () => ({ 25 - useAuth: () => ({ 26 - user: null, 27 - isAuthenticated: false, 28 - isLoading: false, 29 - getAccessToken: () => null, 30 - login: vi.fn(), 31 - logout: vi.fn(), 32 - setSessionFromCallback: vi.fn(), 33 - authFetch: vi.fn(), 34 - }), 35 - })) 36 - 37 - describe('PrivacyPolicyPage', () => { 38 - it('renders page heading', async () => { 39 - const page = await PrivacyPolicyPage() 40 - render(page) 41 - expect(screen.getByRole('heading', { name: /privacy policy/i, level: 1 })).toBeInTheDocument() 42 - }) 43 - 44 - it('describes what data is collected', async () => { 45 - const page = await PrivacyPolicyPage() 46 - render(page) 47 - expect(screen.getByRole('heading', { name: /what we collect/i })).toBeInTheDocument() 48 - expect(screen.getByText(/AT Protocol identifiers/i)).toBeInTheDocument() 49 - }) 50 - 51 - it('describes authentication cookie instead of generic session data', async () => { 52 - const page = await PrivacyPolicyPage() 53 - render(page) 54 - expect(screen.getByText(/Authentication cookie/i)).toBeInTheDocument() 55 - expect(screen.getByText(/HTTP-only, Secure, SameSite=Strict/i)).toBeInTheDocument() 56 - }) 57 - 58 - it('lists age declaration and per-community preferences', async () => { 59 - const page = await PrivacyPolicyPage() 60 - render(page) 61 - expect(screen.getByText(/Age declaration/i)).toBeInTheDocument() 62 - expect(screen.getByText(/Per-community preferences/i)).toBeInTheDocument() 63 - }) 64 - 65 - it('describes what data is not collected', async () => { 66 - const page = await PrivacyPolicyPage() 67 - render(page) 68 - expect(screen.getByRole('heading', { name: /what we do not collect/i })).toBeInTheDocument() 69 - expect(screen.getByText(/device fingerprinting/i)).toBeInTheDocument() 70 - }) 71 - 72 - it('describes anonymize-on-deletion approach', async () => { 73 - const page = await PrivacyPolicyPage() 74 - render(page) 75 - expect( 76 - screen.getByRole('heading', { name: /data retention and deletion/i }) 77 - ).toBeInTheDocument() 78 - expect(screen.getByText(/deleted by author/i)).toBeInTheDocument() 79 - expect(screen.getByText(/personal data.*is stripped/i)).toBeInTheDocument() 80 - expect(screen.getByText(/anonymized content.*may be retained/i)).toBeInTheDocument() 81 - }) 82 - 83 - it('describes AI features', async () => { 84 - const page = await PrivacyPolicyPage() 85 - render(page) 86 - expect(screen.getByRole('heading', { name: /ai features/i })).toBeInTheDocument() 87 - expect(screen.getByText(/No training on your content/i)).toBeInTheDocument() 88 - expect(screen.getByText(/Local-first processing/i)).toBeInTheDocument() 89 - expect(screen.getByText(/Anonymized summaries/i)).toBeInTheDocument() 90 - }) 91 - 92 - it('lists user rights under GDPR', async () => { 93 - const page = await PrivacyPolicyPage() 94 - render(page) 95 - expect(screen.getByRole('heading', { name: /your rights/i })).toBeInTheDocument() 96 - expect(screen.getByText(/right to be forgotten/i)).toBeInTheDocument() 97 - }) 98 - 99 - it('links to barazo-workspace for issue tracking', async () => { 100 - const page = await PrivacyPolicyPage() 101 - render(page) 102 - const link = screen.getByRole('link', { name: /github issue tracker/i }) 103 - expect(link).toHaveAttribute('href', 'https://github.com/barazo-forum/barazo-workspace/issues') 104 - }) 105 - 106 - it('mentions GDPR compliance', async () => { 107 - const page = await PrivacyPolicyPage() 108 - render(page) 109 - expect(screen.getByText(/General Data Protection Regulation/i)).toBeInTheDocument() 110 - }) 111 - 112 - it('renders breadcrumbs', async () => { 113 - const page = await PrivacyPolicyPage() 114 - render(page) 115 - expect(screen.getByText('Home')).toBeInTheDocument() 116 - }) 117 - 118 - it('passes axe accessibility check', async () => { 119 - const page = await PrivacyPolicyPage() 120 - const { container } = render(page) 121 - const results = await axe(container) 122 - expect(results).toHaveNoViolations() 123 - }) 124 - })
-261
src/app/legal/privacy/page.tsx
··· 1 - /** 2 - * Privacy policy page. 3 - * URL: /legal/privacy 4 - * Static placeholder content -- admin-editable in P3+. 5 - * @see decisions/legal.md 6 - */ 7 - 8 - import type { Metadata } from 'next' 9 - import { getPublicSettings } from '@/lib/api/client' 10 - import { ForumLayout } from '@/components/layout/forum-layout' 11 - import { Breadcrumbs } from '@/components/breadcrumbs' 12 - 13 - export const metadata: Metadata = { 14 - title: 'Privacy Policy', 15 - description: 16 - 'How Barazo collects, uses, and protects your personal data. GDPR-compliant privacy policy.', 17 - alternates: { 18 - canonical: '/legal/privacy', 19 - }, 20 - } 21 - 22 - export default async function PrivacyPolicyPage() { 23 - let communityName = '' 24 - try { 25 - const settings = await getPublicSettings() 26 - communityName = settings.communityName 27 - } catch { 28 - // silently degrade 29 - } 30 - 31 - return ( 32 - <ForumLayout communityName={communityName}> 33 - <div className="mx-auto max-w-2xl space-y-8"> 34 - <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Privacy policy' }]} /> 35 - 36 - <h1 className="text-2xl font-bold text-foreground">Privacy policy</h1> 37 - 38 - <section className="space-y-3"> 39 - <h2 className="text-lg font-semibold text-foreground">Overview</h2> 40 - <p className="text-sm leading-relaxed text-muted-foreground"> 41 - Barazo is committed to protecting your privacy. This policy explains what personal data 42 - we collect, why we collect it, how long we keep it, and what rights you have. Barazo is 43 - operated from the Netherlands and complies with the General Data Protection Regulation 44 - (GDPR). 45 - </p> 46 - </section> 47 - 48 - <section className="space-y-3"> 49 - <h2 className="text-lg font-semibold text-foreground">What we collect</h2> 50 - <p className="text-sm leading-relaxed text-muted-foreground"> 51 - When you use Barazo, we process the following data: 52 - </p> 53 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 54 - <li> 55 - <strong>AT Protocol identifiers</strong> -- your DID (decentralized identifier) and 56 - handle, used to identify your account. 57 - </li> 58 - <li> 59 - <strong>Profile information</strong> -- display name and profile data retrieved from 60 - your AT Protocol PDS. 61 - </li> 62 - <li> 63 - <strong>Content</strong> -- posts, replies, and reactions you create on the forum, 64 - indexed from the AT Protocol firehose. 65 - </li> 66 - <li> 67 - <strong>IP addresses</strong> -- collected for API rate limiting and security 68 - purposes. 69 - </li> 70 - <li> 71 - <strong>Authentication cookie</strong> -- a single HTTP-only, Secure, SameSite=Strict 72 - refresh token cookie used to maintain your session. Access tokens are held in memory 73 - only and never stored in cookies or browser storage. 74 - </li> 75 - <li> 76 - <strong>Moderation records</strong> -- actions taken by moderators on your content or 77 - account. 78 - </li> 79 - <li> 80 - <strong>Age declaration</strong> -- stored in the forum database only (deliberately 81 - kept off your PDS to avoid broadcasting age data on a public network). 82 - </li> 83 - <li> 84 - <strong>Per-community preferences</strong> -- notification settings and content 85 - maturity overrides, stored locally in the forum database (not on your PDS) to protect 86 - your browsing patterns. 87 - </li> 88 - </ul> 89 - </section> 90 - 91 - <section className="space-y-3"> 92 - <h2 className="text-lg font-semibold text-foreground">What we do not collect</h2> 93 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 94 - <li> 95 - We do not collect or store your password (authentication is handled via AT Protocol 96 - OAuth). 97 - </li> 98 - <li> 99 - We do not collect email addresses unless provided by a community admin for billing. 100 - </li> 101 - <li>We do not collect payment card details (processed by our payment provider).</li> 102 - <li>We do not use tracking cookies or analytics that profile your behavior.</li> 103 - <li>We do not use device fingerprinting.</li> 104 - <li>We do not load third-party trackers, pixels, or analytics scripts.</li> 105 - </ul> 106 - </section> 107 - 108 - <section className="space-y-3"> 109 - <h2 className="text-lg font-semibold text-foreground">Legal basis</h2> 110 - <p className="text-sm leading-relaxed text-muted-foreground"> 111 - We process your data under the following legal bases (GDPR Art. 6): 112 - </p> 113 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 114 - <li> 115 - <strong>Contract performance</strong> -- processing necessary to provide the forum 116 - service you signed up for. 117 - </li> 118 - <li> 119 - <strong>Legitimate interest</strong> -- indexing public AT Protocol content, spam 120 - prevention, platform security, content moderation, and AI-generated discussion 121 - summaries. 122 - </li> 123 - </ul> 124 - </section> 125 - 126 - <section className="space-y-3"> 127 - <h2 className="text-lg font-semibold text-foreground">Data storage and transfers</h2> 128 - <p className="text-sm leading-relaxed text-muted-foreground"> 129 - Our servers are hosted in the European Union (Hetzner, Germany). We use the following 130 - sub-processors: 131 - </p> 132 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 133 - <li>Hetzner (EU) -- hosting infrastructure.</li> 134 - <li>Bunny.net (EU, Slovenia) -- content delivery network.</li> 135 - <li>Stripe (EU-US Data Privacy Framework certified) -- payment processing.</li> 136 - </ul> 137 - <p className="text-sm leading-relaxed text-muted-foreground"> 138 - A full sub-processor list is maintained at{' '} 139 - <strong>barazo.forum/legal/sub-processors</strong>. 140 - </p> 141 - </section> 142 - 143 - <section className="space-y-3"> 144 - <h2 className="text-lg font-semibold text-foreground">Data retention and deletion</h2> 145 - <p className="text-sm leading-relaxed text-muted-foreground"> 146 - Your indexed data is retained while the source exists on your AT Protocol PDS. When you 147 - delete content or your account via the AT Protocol, we process the deletion event 148 - immediately: 149 - </p> 150 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 151 - <li> 152 - Your post is removed from public view and replaced with a &quot;deleted by 153 - author&quot; notice. 154 - </li> 155 - <li> 156 - Your personal data (DID, handle, AT Protocol URI) is stripped from the database 157 - record. 158 - </li> 159 - <li> 160 - The anonymized content (with no link to your identity) may be retained to preserve 161 - community knowledge and enable AI-generated discussion summaries. This anonymized data 162 - falls outside GDPR scope (Recital 26) because it can no longer identify you. 163 - </li> 164 - </ul> 165 - <p className="text-sm leading-relaxed text-muted-foreground"> 166 - You may request full content deletion (including anonymized content) by contacting us 167 - directly, independent of AT Protocol signals. We respond to deletion requests within one 168 - month (GDPR Art. 12(3)). 169 - </p> 170 - <p className="text-sm leading-relaxed text-muted-foreground"> 171 - Barazo cannot guarantee deletion from external systems such as AT Protocol relays, other 172 - AppViews, search engine caches, or web archives. Our reasonable steps include: 173 - propagating AT Protocol delete events, submitting Google Search Console removal requests 174 - for deleted content URLs, and documenting which systems confirmed deletion. 175 - </p> 176 - </section> 177 - 178 - <section className="space-y-3"> 179 - <h2 className="text-lg font-semibold text-foreground">AI features</h2> 180 - <p className="text-sm leading-relaxed text-muted-foreground"> 181 - Barazo offers optional AI features including thread summaries, semantic search, and 182 - content moderation assistance. Here is how they work: 183 - </p> 184 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 185 - <li> 186 - <strong>No training on your content.</strong> We do not use member posts to train AI 187 - models, and we do not provide member content to others for training. 188 - </li> 189 - <li> 190 - <strong>Local-first processing.</strong> The default AI configuration uses local 191 - inference (Ollama) -- your content never leaves the server. Your forum administrator 192 - may choose a different AI provider; in that case, content is sent to that provider for 193 - processing. 194 - </li> 195 - <li> 196 - <strong>Anonymized summaries.</strong> AI-generated thread summaries are designed to 197 - exclude usernames, handles, and verbatim quotes. Summaries capture the 198 - discussion&apos;s substance, not who said what. Summaries may persist after individual 199 - content deletion because they contain no personal data. 200 - </li> 201 - </ul> 202 - </section> 203 - 204 - <section className="space-y-3"> 205 - <h2 className="text-lg font-semibold text-foreground">Content labels</h2> 206 - <p className="text-sm leading-relaxed text-muted-foreground"> 207 - We subscribe to content labeling services (such as Bluesky&apos;s Ozone) for spam 208 - detection and content moderation. Labels applied to your account may affect posting 209 - limits and content visibility. Labels are stored by the labeler service, not on your 210 - PDS. You can dispute labels by contacting us. 211 - </p> 212 - </section> 213 - 214 - <section className="space-y-3"> 215 - <h2 className="text-lg font-semibold text-foreground">Your rights</h2> 216 - <p className="text-sm leading-relaxed text-muted-foreground"> 217 - Under the GDPR, you have the right to: 218 - </p> 219 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 220 - <li>Access the personal data we hold about you.</li> 221 - <li>Rectify inaccurate data.</li> 222 - <li>Request erasure of your data (right to be forgotten).</li> 223 - <li>Object to processing based on legitimate interest.</li> 224 - <li>Data portability (built into the AT Protocol).</li> 225 - <li> 226 - Lodge a complaint with the Dutch Data Protection Authority (Autoriteit 227 - Persoonsgegevens). 228 - </li> 229 - </ul> 230 - <p className="text-sm leading-relaxed text-muted-foreground"> 231 - To exercise these rights, contact us through our{' '} 232 - <a 233 - href="https://github.com/barazo-forum/barazo-workspace/issues" 234 - className="text-primary underline hover:text-primary/80" 235 - target="_blank" 236 - rel="noopener noreferrer" 237 - > 238 - GitHub issue tracker 239 - </a>{' '} 240 - or via the contact details provided by your community administrator. 241 - </p> 242 - </section> 243 - 244 - <section className="space-y-3"> 245 - <h2 className="text-lg font-semibold text-foreground">Data breach notification</h2> 246 - <p className="text-sm leading-relaxed text-muted-foreground"> 247 - In the event of a data breach, we will notify the Dutch Data Protection Authority within 248 - 72 hours (GDPR Art. 33). For high-risk breaches, we will notify affected users without 249 - undue delay via AT Protocol notifications and public announcements. 250 - </p> 251 - </section> 252 - 253 - <section className="space-y-3"> 254 - <p className="text-xs text-muted-foreground"> 255 - This policy was last updated on February 2026. 256 - </p> 257 - </section> 258 - </div> 259 - </ForumLayout> 260 - ) 261 - }
-87
src/app/legal/terms/page.test.tsx
··· 1 - /** 2 - * Tests for terms of service page. 3 - * @see decisions/legal.md 4 - */ 5 - 6 - import { describe, it, expect, vi } from 'vitest' 7 - import { render, screen } from '@testing-library/react' 8 - import { axe } from 'vitest-axe' 9 - import TermsOfServicePage from './page' 10 - 11 - // Mock next/navigation 12 - vi.mock('next/navigation', () => ({ 13 - usePathname: () => '/legal/terms', 14 - useRouter: () => ({ push: vi.fn() }), 15 - })) 16 - 17 - // Mock next-themes 18 - vi.mock('next-themes', () => ({ 19 - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 20 - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 21 - })) 22 - 23 - // Mock useAuth hook 24 - vi.mock('@/hooks/use-auth', () => ({ 25 - useAuth: () => ({ 26 - user: null, 27 - isAuthenticated: false, 28 - isLoading: false, 29 - getAccessToken: () => null, 30 - login: vi.fn(), 31 - logout: vi.fn(), 32 - setSessionFromCallback: vi.fn(), 33 - authFetch: vi.fn(), 34 - }), 35 - })) 36 - 37 - describe('TermsOfServicePage', () => { 38 - it('renders page heading', async () => { 39 - const page = await TermsOfServicePage() 40 - render(page) 41 - expect(screen.getByRole('heading', { name: /terms of service/i, level: 1 })).toBeInTheDocument() 42 - }) 43 - 44 - it('states minimum age requirement', async () => { 45 - const page = await TermsOfServicePage() 46 - render(page) 47 - expect(screen.getByText(/at least 16 years old/i)).toBeInTheDocument() 48 - }) 49 - 50 - it('covers content and conduct rules', async () => { 51 - const page = await TermsOfServicePage() 52 - render(page) 53 - expect(screen.getByRole('heading', { name: /content and conduct/i })).toBeInTheDocument() 54 - }) 55 - 56 - it('discloses AI summary behavior', async () => { 57 - const page = await TermsOfServicePage() 58 - render(page) 59 - expect(screen.getByRole('heading', { name: /ai-generated summaries/i })).toBeInTheDocument() 60 - expect(screen.getByText(/summaries may persist/i)).toBeInTheDocument() 61 - }) 62 - 63 - it('discloses moderation labels', async () => { 64 - const page = await TermsOfServicePage() 65 - render(page) 66 - expect(screen.getByRole('heading', { name: /moderation and labels/i })).toBeInTheDocument() 67 - }) 68 - 69 - it('specifies governing law', async () => { 70 - const page = await TermsOfServicePage() 71 - render(page) 72 - expect(screen.getByText(/laws of the Netherlands/i)).toBeInTheDocument() 73 - }) 74 - 75 - it('renders breadcrumbs', async () => { 76 - const page = await TermsOfServicePage() 77 - render(page) 78 - expect(screen.getByText('Home')).toBeInTheDocument() 79 - }) 80 - 81 - it('passes axe accessibility check', async () => { 82 - const page = await TermsOfServicePage() 83 - const { container } = render(page) 84 - const results = await axe(container) 85 - expect(results).toHaveNoViolations() 86 - }) 87 - })
-174
src/app/legal/terms/page.tsx
··· 1 - /** 2 - * Terms of service page. 3 - * URL: /legal/terms 4 - * Static placeholder content -- admin-editable in P3+. 5 - * @see decisions/legal.md 6 - */ 7 - 8 - import type { Metadata } from 'next' 9 - import { getPublicSettings } from '@/lib/api/client' 10 - import { ForumLayout } from '@/components/layout/forum-layout' 11 - import { Breadcrumbs } from '@/components/breadcrumbs' 12 - 13 - export const metadata: Metadata = { 14 - title: 'Terms of Service', 15 - description: 16 - 'Terms and conditions for using Barazo forum communities. Covers usage rules, content policies, and user responsibilities.', 17 - alternates: { 18 - canonical: '/legal/terms', 19 - }, 20 - } 21 - 22 - export default async function TermsOfServicePage() { 23 - let communityName = '' 24 - try { 25 - const settings = await getPublicSettings() 26 - communityName = settings.communityName 27 - } catch { 28 - // silently degrade 29 - } 30 - 31 - return ( 32 - <ForumLayout communityName={communityName}> 33 - <div className="mx-auto max-w-2xl space-y-8"> 34 - <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Terms of service' }]} /> 35 - 36 - <h1 className="text-2xl font-bold text-foreground">Terms of service</h1> 37 - 38 - <section className="space-y-3"> 39 - <h2 className="text-lg font-semibold text-foreground">Acceptance of terms</h2> 40 - <p className="text-sm leading-relaxed text-muted-foreground"> 41 - By accessing or using Barazo, you agree to be bound by these Terms of Service. If you do 42 - not agree to these terms, you may not use the service. Barazo reserves the right to 43 - update these terms at any time, with notice provided through the platform. 44 - </p> 45 - </section> 46 - 47 - <section className="space-y-3"> 48 - <h2 className="text-lg font-semibold text-foreground">Eligibility</h2> 49 - <p className="text-sm leading-relaxed text-muted-foreground"> 50 - You must be at least 16 years old to use Barazo (in accordance with the Dutch 51 - implementation of GDPR, UAVG). By using the service, you confirm that you meet this age 52 - requirement. Access to mature content may require additional age verification as 53 - required by applicable law. 54 - </p> 55 - </section> 56 - 57 - <section className="space-y-3"> 58 - <h2 className="text-lg font-semibold text-foreground">Account and authentication</h2> 59 - <p className="text-sm leading-relaxed text-muted-foreground"> 60 - Barazo uses the AT Protocol for authentication. You log in using your existing AT 61 - Protocol identity (e.g., a Bluesky account). You are responsible for maintaining the 62 - security of your AT Protocol account. Barazo does not store your password. 63 - </p> 64 - </section> 65 - 66 - <section className="space-y-3"> 67 - <h2 className="text-lg font-semibold text-foreground">Content and conduct</h2> 68 - <p className="text-sm leading-relaxed text-muted-foreground"> 69 - You retain ownership of content you post on Barazo. By posting, you grant Barazo a 70 - license to display, index, and distribute your content as part of the forum service and 71 - via the AT Protocol. 72 - </p> 73 - <p className="text-sm leading-relaxed text-muted-foreground"> 74 - You agree not to post content that: 75 - </p> 76 - <ul className="list-inside list-disc space-y-2 text-sm text-muted-foreground"> 77 - <li>Violates applicable laws or regulations.</li> 78 - <li>Infringes on the intellectual property rights of others.</li> 79 - <li>Contains spam, malware, or deceptive content.</li> 80 - <li>Harasses, threatens, or promotes violence against individuals or groups.</li> 81 - <li>Contains child sexual abuse material (CSAM).</li> 82 - </ul> 83 - <p className="text-sm leading-relaxed text-muted-foreground"> 84 - Community administrators may enforce additional content policies specific to their 85 - community. Repeated violations may result in content removal, account restrictions, or 86 - bans. 87 - </p> 88 - </section> 89 - 90 - <section className="space-y-3"> 91 - <h2 className="text-lg font-semibold text-foreground">Content maturity ratings</h2> 92 - <p className="text-sm leading-relaxed text-muted-foreground"> 93 - Communities and categories may be rated for content maturity (Safe for Work, Mature, or 94 - Adult). You are responsible for accurately labeling your content. Communities may 95 - require age verification to access mature content. New accounts default to safe-mode 96 - with mature content hidden. 97 - </p> 98 - </section> 99 - 100 - <section className="space-y-3"> 101 - <h2 className="text-lg font-semibold text-foreground">Cross-posting</h2> 102 - <p className="text-sm leading-relaxed text-muted-foreground"> 103 - Barazo may cross-post your content to connected platforms (such as Bluesky or Frontpage) 104 - when you enable this feature. Cross-posting is optional and can be controlled in your 105 - settings. Cross-posted content is subject to the terms of the destination platform. 106 - </p> 107 - </section> 108 - 109 - <section className="space-y-3"> 110 - <h2 className="text-lg font-semibold text-foreground">Moderation and labels</h2> 111 - <p className="text-sm leading-relaxed text-muted-foreground"> 112 - Your account may be labeled by independent moderation services (such as Bluesky&apos;s 113 - Ozone). Labels affect posting limits and content visibility. You cannot delete labels 114 - applied by labeler services, but you can dispute inaccuracies by contacting us or the 115 - labeler service. Community administrators may also apply local moderation overrides. 116 - </p> 117 - </section> 118 - 119 - <section className="space-y-3"> 120 - <h2 className="text-lg font-semibold text-foreground">AI-generated summaries</h2> 121 - <p className="text-sm leading-relaxed text-muted-foreground"> 122 - Barazo may generate AI-powered summaries of discussion threads. These summaries are 123 - anonymized derivative works that do not contain personal data (no usernames or verbatim 124 - quotes). AI summaries may persist after individual content is deleted, as they are 125 - regenerated from remaining content. Community administrators can disable summary 126 - preservation. 127 - </p> 128 - </section> 129 - 130 - <section className="space-y-3"> 131 - <h2 className="text-lg font-semibold text-foreground">AT Protocol and federation</h2> 132 - <p className="text-sm leading-relaxed text-muted-foreground"> 133 - Barazo is built on the AT Protocol, which is a federated, open network. Content you post 134 - may be indexed by other services on the AT Protocol network. Barazo cannot control how 135 - third-party services handle your data once it is published via the protocol. 136 - </p> 137 - </section> 138 - 139 - <section className="space-y-3"> 140 - <h2 className="text-lg font-semibold text-foreground">Termination</h2> 141 - <p className="text-sm leading-relaxed text-muted-foreground"> 142 - Barazo may suspend or terminate your access if you violate these terms. You may stop 143 - using the service at any time. Deleting your AT Protocol account or content will trigger 144 - removal of indexed data from Barazo (see our Privacy Policy for details). 145 - </p> 146 - </section> 147 - 148 - <section className="space-y-3"> 149 - <h2 className="text-lg font-semibold text-foreground">Limitation of liability</h2> 150 - <p className="text-sm leading-relaxed text-muted-foreground"> 151 - Barazo is provided &quot;as is&quot; without warranties of any kind. We are not liable 152 - for any damages arising from your use of the service, including but not limited to loss 153 - of data, service interruptions, or actions taken by community moderators or 154 - administrators. 155 - </p> 156 - </section> 157 - 158 - <section className="space-y-3"> 159 - <h2 className="text-lg font-semibold text-foreground">Governing law</h2> 160 - <p className="text-sm leading-relaxed text-muted-foreground"> 161 - These terms are governed by the laws of the Netherlands. Any disputes arising from these 162 - terms will be subject to the exclusive jurisdiction of the courts of the Netherlands. 163 - </p> 164 - </section> 165 - 166 - <section className="space-y-3"> 167 - <p className="text-xs text-muted-foreground"> 168 - These terms were last updated on February 2026. 169 - </p> 170 - </section> 171 - </div> 172 - </ForumLayout> 173 - ) 174 - }
+70
src/app/p/[slug]/not-found.test.tsx
··· 1 + /** 2 + * Tests for page not found 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 PageNotFound from './not-found' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + usePathname: () => '/p/nonexistent', 12 + useRouter: () => ({ push: vi.fn() }), 13 + })) 14 + 15 + vi.mock('next-themes', () => ({ 16 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 17 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 18 + })) 19 + 20 + vi.mock('next/link', () => ({ 21 + default: ({ 22 + children, 23 + href, 24 + ...props 25 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 26 + <a href={href} {...props}> 27 + {children} 28 + </a> 29 + ), 30 + })) 31 + 32 + vi.mock('next/image', () => ({ 33 + default: (props: Record<string, unknown>) => { 34 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 35 + return <img {...props} /> 36 + }, 37 + })) 38 + 39 + vi.mock('@/hooks/use-auth', () => ({ 40 + useAuth: () => ({ 41 + user: null, 42 + isAuthenticated: false, 43 + isLoading: false, 44 + getAccessToken: () => null, 45 + login: vi.fn(), 46 + logout: vi.fn(), 47 + setSessionFromCallback: vi.fn(), 48 + authFetch: vi.fn(), 49 + }), 50 + })) 51 + 52 + describe('PageNotFound', () => { 53 + it('renders not found heading', () => { 54 + render(<PageNotFound />) 55 + expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument() 56 + }) 57 + 58 + it('renders helpful message', () => { 59 + render(<PageNotFound />) 60 + expect( 61 + screen.getByText(/the page you are looking for does not exist or has been removed/i) 62 + ).toBeInTheDocument() 63 + }) 64 + 65 + it('passes axe accessibility check', async () => { 66 + const { container } = render(<PageNotFound />) 67 + const results = await axe(container) 68 + expect(results).toHaveNoViolations() 69 + }) 70 + })
+19
src/app/p/[slug]/not-found.tsx
··· 1 + /** 2 + * 404 page for public pages. 3 + * Displayed when a page slug does not match any published page. 4 + */ 5 + 6 + import { ForumLayout } from '@/components/layout/forum-layout' 7 + 8 + export default function PageNotFound() { 9 + return ( 10 + <ForumLayout> 11 + <div className="py-16 text-center"> 12 + <h1 className="text-2xl font-bold text-foreground">Page not found</h1> 13 + <p className="mt-2 text-muted-foreground"> 14 + The page you are looking for does not exist or has been removed. 15 + </p> 16 + </div> 17 + </ForumLayout> 18 + ) 19 + }
+114
src/app/p/[slug]/page.test.tsx
··· 1 + /** 2 + * Tests for public page rendering. 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 PublicPage from './page' 9 + 10 + vi.mock('next/navigation', () => ({ 11 + usePathname: () => '/p/about', 12 + useRouter: () => ({ push: vi.fn() }), 13 + notFound: vi.fn(() => { 14 + throw new Error('NEXT_NOT_FOUND') 15 + }), 16 + })) 17 + 18 + vi.mock('next-themes', () => ({ 19 + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), 20 + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 21 + })) 22 + 23 + vi.mock('next/link', () => ({ 24 + default: ({ 25 + children, 26 + href, 27 + ...props 28 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 29 + <a href={href} {...props}> 30 + {children} 31 + </a> 32 + ), 33 + })) 34 + 35 + vi.mock('next/image', () => ({ 36 + default: (props: Record<string, unknown>) => { 37 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 38 + return <img {...props} /> 39 + }, 40 + })) 41 + 42 + vi.mock('@/hooks/use-auth', () => ({ 43 + useAuth: () => ({ 44 + user: null, 45 + isAuthenticated: false, 46 + isLoading: false, 47 + getAccessToken: () => null, 48 + login: vi.fn(), 49 + logout: vi.fn(), 50 + setSessionFromCallback: vi.fn(), 51 + authFetch: vi.fn(), 52 + }), 53 + })) 54 + 55 + describe('PublicPage', () => { 56 + it('renders page title as heading', async () => { 57 + const page = await PublicPage({ 58 + params: Promise.resolve({ slug: 'about' }), 59 + }) 60 + render(page) 61 + expect( 62 + screen.getByRole('heading', { name: /about this community/i, level: 1 }) 63 + ).toBeInTheDocument() 64 + }) 65 + 66 + it('renders page content as markdown', async () => { 67 + const page = await PublicPage({ 68 + params: Promise.resolve({ slug: 'about' }), 69 + }) 70 + render(page) 71 + // The markdown content "# About\n\nWelcome to our community forum." should render 72 + expect(screen.getByText(/welcome to our community forum/i)).toBeInTheDocument() 73 + }) 74 + 75 + it('renders breadcrumbs with Home and page title', async () => { 76 + const page = await PublicPage({ 77 + params: Promise.resolve({ slug: 'about' }), 78 + }) 79 + render(page) 80 + expect(screen.getByText('Home')).toBeInTheDocument() 81 + }) 82 + 83 + it('renders JSON-LD structured data with absolute URL', async () => { 84 + const page = await PublicPage({ 85 + params: Promise.resolve({ slug: 'about' }), 86 + }) 87 + const { container } = render(page) 88 + const jsonLdScript = container.querySelector('script[type="application/ld+json"]') 89 + expect(jsonLdScript).not.toBeNull() 90 + const jsonLd = JSON.parse(jsonLdScript!.textContent ?? '{}') 91 + expect(jsonLd['@type']).toBe('WebPage') 92 + expect(jsonLd.name).toBe('About This Community') 93 + expect(jsonLd.url).toBe('https://barazo.forum/p/about') 94 + }) 95 + 96 + it('calls notFound for non-existent page', async () => { 97 + const { notFound } = await import('next/navigation') 98 + await expect( 99 + PublicPage({ 100 + params: Promise.resolve({ slug: 'nonexistent' }), 101 + }) 102 + ).rejects.toThrow('NEXT_NOT_FOUND') 103 + expect(notFound).toHaveBeenCalled() 104 + }) 105 + 106 + it('passes axe accessibility check', async () => { 107 + const page = await PublicPage({ 108 + params: Promise.resolve({ slug: 'about' }), 109 + }) 110 + const { container } = render(page) 111 + const results = await axe(container) 112 + expect(results).toHaveNoViolations() 113 + }) 114 + })
+95
src/app/p/[slug]/page.tsx
··· 1 + /** 2 + * Public page rendering - Displays admin-created static pages. 3 + * URL: /p/{slug} 4 + * Server-side rendered with JSON-LD WebPage and OpenGraph metadata. 5 + */ 6 + 7 + import type { Metadata } from 'next' 8 + import { notFound } from 'next/navigation' 9 + import { getPageBySlug, getPublicSettings, ApiError } from '@/lib/api/client' 10 + import { ForumLayout } from '@/components/layout/forum-layout' 11 + import { Breadcrumbs } from '@/components/breadcrumbs' 12 + import { MarkdownContent } from '@/components/markdown-content' 13 + 14 + export const dynamic = 'force-dynamic' 15 + 16 + const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' 17 + 18 + /** Truncate content to a max length suitable for meta descriptions. */ 19 + function truncateDescription(text: string, maxLength = 157): string { 20 + return text.length > maxLength ? text.slice(0, maxLength) + '...' : text 21 + } 22 + 23 + interface PublicPageProps { 24 + params: Promise<{ slug: string }> 25 + } 26 + 27 + export async function generateMetadata({ params }: PublicPageProps): Promise<Metadata> { 28 + const { slug } = await params 29 + try { 30 + const page = await getPageBySlug(slug) 31 + const description = page.metaDescription ?? truncateDescription(page.content) 32 + 33 + return { 34 + title: page.title, 35 + description, 36 + alternates: { 37 + canonical: `/p/${slug}`, 38 + }, 39 + openGraph: { 40 + title: page.title, 41 + description, 42 + type: 'website', 43 + }, 44 + } 45 + } catch { 46 + return { title: 'Page Not Found' } 47 + } 48 + } 49 + 50 + export default async function PublicPage({ params }: PublicPageProps) { 51 + const { slug } = await params 52 + 53 + let page 54 + try { 55 + page = await getPageBySlug(slug) 56 + } catch (error) { 57 + if (error instanceof ApiError && error.status === 404) { 58 + notFound() 59 + } 60 + throw error 61 + } 62 + 63 + let communityName = '' 64 + try { 65 + const settings = await getPublicSettings() 66 + communityName = settings.communityName 67 + } catch { 68 + // silently degrade 69 + } 70 + 71 + const jsonLd = { 72 + '@context': 'https://schema.org', 73 + '@type': 'WebPage', 74 + name: page.title, 75 + description: page.metaDescription ?? truncateDescription(page.content), 76 + dateModified: page.updatedAt, 77 + url: `${SITE_URL}/p/${slug}`, 78 + } 79 + 80 + return ( 81 + <ForumLayout communityName={communityName}> 82 + <script 83 + type="application/ld+json" 84 + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} 85 + /> 86 + 87 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: page.title }]} /> 88 + 89 + <div className="mx-auto max-w-2xl space-y-6"> 90 + <h1 className="text-2xl font-bold text-foreground">{page.title}</h1> 91 + <MarkdownContent content={page.content} /> 92 + </div> 93 + </ForumLayout> 94 + ) 95 + }
+2
src/components/admin/admin-layout.tsx
··· 9 9 import Link from 'next/link' 10 10 import { usePathname } from 'next/navigation' 11 11 import { 12 + Article, 12 13 ChartBar, 13 14 FolderSimple, 14 15 ShieldCheck, ··· 31 32 const NAV_ITEMS = [ 32 33 { href: '/admin', label: 'Dashboard', icon: ChartBar }, 33 34 { href: '/admin/categories', label: 'Categories', icon: FolderSimple }, 35 + { href: '/admin/pages', label: 'Pages', icon: Article }, 34 36 { href: '/admin/moderation', label: 'Moderation', icon: ShieldCheck }, 35 37 { href: '/admin/sybil-detection', label: 'Sybil Detection', icon: ShieldWarning }, 36 38 { href: '/admin/trust-seeds', label: 'Trust Seeds', icon: SealCheck },
+192
src/components/admin/pages/page-form.test.tsx
··· 1 + /** 2 + * Tests for PageForm component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { PageForm } from './page-form' 10 + import type { PageFormProps } from './page-form' 11 + import type { PageTreeNode } from '@/lib/api/types' 12 + 13 + const mockParents: PageTreeNode[] = [ 14 + { 15 + id: 'page-about', 16 + slug: 'about', 17 + title: 'About This Community', 18 + content: '# About', 19 + status: 'published', 20 + metaDescription: 'About page', 21 + parentId: null, 22 + sortOrder: 0, 23 + communityDid: 'did:plc:test-community-123', 24 + createdAt: '2026-01-01T00:00:00Z', 25 + updatedAt: '2026-01-01T00:00:00Z', 26 + children: [], 27 + }, 28 + { 29 + id: 'page-privacy', 30 + slug: 'privacy-policy', 31 + title: 'Privacy Policy', 32 + content: '# Privacy', 33 + status: 'published', 34 + metaDescription: 'Privacy page', 35 + parentId: null, 36 + sortOrder: 1, 37 + communityDid: 'did:plc:test-community-123', 38 + createdAt: '2026-01-01T00:00:00Z', 39 + updatedAt: '2026-01-01T00:00:00Z', 40 + children: [], 41 + }, 42 + ] 43 + 44 + function renderPageForm(overrides: Partial<PageFormProps> = {}) { 45 + const defaultProps: PageFormProps = { 46 + title: '', 47 + slug: '', 48 + content: '', 49 + status: 'draft', 50 + parentId: null, 51 + metaDescription: '', 52 + isEditMode: false, 53 + availableParents: mockParents, 54 + onTitleChange: vi.fn(), 55 + onSlugChange: vi.fn(), 56 + onContentChange: vi.fn(), 57 + onStatusChange: vi.fn(), 58 + onParentIdChange: vi.fn(), 59 + onMetaDescriptionChange: vi.fn(), 60 + onSave: vi.fn(), 61 + onCancel: vi.fn(), 62 + saving: false, 63 + ...overrides, 64 + } 65 + return { ...render(<PageForm {...defaultProps} />), props: defaultProps } 66 + } 67 + 68 + describe('PageForm', () => { 69 + it('renders title input', () => { 70 + renderPageForm() 71 + expect(screen.getByLabelText(/title/i)).toBeInTheDocument() 72 + }) 73 + 74 + it('renders slug input', () => { 75 + renderPageForm() 76 + expect(screen.getByLabelText(/slug/i)).toBeInTheDocument() 77 + }) 78 + 79 + it('renders status select with draft and published options', () => { 80 + renderPageForm() 81 + const select = screen.getByLabelText(/status/i) 82 + expect(select).toBeInTheDocument() 83 + expect(screen.getByRole('option', { name: 'Draft' })).toBeInTheDocument() 84 + expect(screen.getByRole('option', { name: 'Published' })).toBeInTheDocument() 85 + }) 86 + 87 + it('renders parent page select with available parents', () => { 88 + renderPageForm() 89 + const select = screen.getByLabelText(/parent page/i) 90 + expect(select).toBeInTheDocument() 91 + expect(screen.getByRole('option', { name: 'None (top-level)' })).toBeInTheDocument() 92 + expect(screen.getByRole('option', { name: 'About This Community' })).toBeInTheDocument() 93 + expect(screen.getByRole('option', { name: 'Privacy Policy' })).toBeInTheDocument() 94 + }) 95 + 96 + it('renders meta description textarea with character count', () => { 97 + renderPageForm({ metaDescription: 'Test description' }) 98 + expect(screen.getByLabelText(/meta description/i)).toBeInTheDocument() 99 + expect(screen.getByText('16/320')).toBeInTheDocument() 100 + }) 101 + 102 + it('renders save and cancel buttons', () => { 103 + renderPageForm() 104 + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() 105 + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() 106 + }) 107 + 108 + it('shows "Saving..." text when saving is true', () => { 109 + renderPageForm({ saving: true }) 110 + expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled() 111 + }) 112 + 113 + it('does not render delete button in create mode', () => { 114 + renderPageForm({ isEditMode: false }) 115 + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument() 116 + }) 117 + 118 + it('renders delete button in edit mode', () => { 119 + renderPageForm({ isEditMode: true, onDelete: vi.fn() }) 120 + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() 121 + }) 122 + 123 + it('calls onTitleChange when title is typed', async () => { 124 + const onTitleChange = vi.fn() 125 + const user = userEvent.setup() 126 + renderPageForm({ onTitleChange }) 127 + await user.type(screen.getByLabelText(/title/i), 'H') 128 + expect(onTitleChange).toHaveBeenCalledWith('H') 129 + }) 130 + 131 + it('calls onSlugChange when slug is typed', async () => { 132 + const onSlugChange = vi.fn() 133 + const user = userEvent.setup() 134 + renderPageForm({ onSlugChange }) 135 + await user.type(screen.getByLabelText(/slug/i), 'h') 136 + expect(onSlugChange).toHaveBeenCalledWith('h') 137 + }) 138 + 139 + it('calls onSave when save button is clicked', async () => { 140 + const onSave = vi.fn() 141 + const user = userEvent.setup() 142 + renderPageForm({ onSave }) 143 + await user.click(screen.getByRole('button', { name: /save/i })) 144 + expect(onSave).toHaveBeenCalledOnce() 145 + }) 146 + 147 + it('calls onCancel when cancel button is clicked', async () => { 148 + const onCancel = vi.fn() 149 + const user = userEvent.setup() 150 + renderPageForm({ onCancel }) 151 + await user.click(screen.getByRole('button', { name: /cancel/i })) 152 + expect(onCancel).toHaveBeenCalledOnce() 153 + }) 154 + 155 + it('calls onDelete when delete button is clicked', async () => { 156 + const onDelete = vi.fn() 157 + const user = userEvent.setup() 158 + renderPageForm({ isEditMode: true, onDelete }) 159 + await user.click(screen.getByRole('button', { name: /delete/i })) 160 + expect(onDelete).toHaveBeenCalledOnce() 161 + }) 162 + 163 + it('calls onStatusChange when status is changed', async () => { 164 + const onStatusChange = vi.fn() 165 + const user = userEvent.setup() 166 + renderPageForm({ onStatusChange }) 167 + await user.selectOptions(screen.getByLabelText(/status/i), 'published') 168 + expect(onStatusChange).toHaveBeenCalledWith('published') 169 + }) 170 + 171 + it('calls onParentIdChange when parent is changed', async () => { 172 + const onParentIdChange = vi.fn() 173 + const user = userEvent.setup() 174 + renderPageForm({ onParentIdChange }) 175 + await user.selectOptions(screen.getByLabelText(/parent page/i), 'page-about') 176 + expect(onParentIdChange).toHaveBeenCalledWith('page-about') 177 + }) 178 + 179 + it('calls onParentIdChange with null when "None" is selected', async () => { 180 + const onParentIdChange = vi.fn() 181 + const user = userEvent.setup() 182 + renderPageForm({ onParentIdChange, parentId: 'page-about' }) 183 + await user.selectOptions(screen.getByLabelText(/parent page/i), '') 184 + expect(onParentIdChange).toHaveBeenCalledWith(null) 185 + }) 186 + 187 + it('passes axe accessibility check', async () => { 188 + const { container } = renderPageForm() 189 + const results = await axe(container) 190 + expect(results).toHaveNoViolations() 191 + }) 192 + })
+166
src/components/admin/pages/page-form.tsx
··· 1 + /** 2 + * PageForm - Edit/create form for a static page. 3 + * Extracted from the admin page editor to keep components under ~150 lines. 4 + * @see specs/prd-web.md Section M12 5 + */ 6 + 7 + import { TopicContentEditor } from '@/components/topic-content-editor' 8 + import type { PageStatus, PageTreeNode } from '@/lib/api/types' 9 + 10 + export interface PageFormProps { 11 + title: string 12 + slug: string 13 + content: string 14 + status: PageStatus 15 + parentId: string | null 16 + metaDescription: string 17 + isEditMode: boolean 18 + availableParents: PageTreeNode[] 19 + onTitleChange: (title: string) => void 20 + onSlugChange: (slug: string) => void 21 + onContentChange: (content: string) => void 22 + onStatusChange: (status: PageStatus) => void 23 + onParentIdChange: (parentId: string | null) => void 24 + onMetaDescriptionChange: (metaDescription: string) => void 25 + onSave: () => void 26 + onCancel: () => void 27 + onDelete?: () => void 28 + saving: boolean 29 + } 30 + 31 + export function PageForm({ 32 + title, 33 + slug, 34 + content, 35 + status, 36 + parentId, 37 + metaDescription, 38 + isEditMode, 39 + availableParents, 40 + onTitleChange, 41 + onSlugChange, 42 + onContentChange, 43 + onStatusChange, 44 + onParentIdChange, 45 + onMetaDescriptionChange, 46 + onSave, 47 + onCancel, 48 + onDelete, 49 + saving, 50 + }: PageFormProps) { 51 + return ( 52 + <> 53 + <div className="space-y-4"> 54 + <div> 55 + <label htmlFor="page-title" className="block text-sm font-medium text-foreground"> 56 + Title 57 + </label> 58 + <input 59 + id="page-title" 60 + type="text" 61 + value={title} 62 + onChange={(e) => onTitleChange(e.target.value)} 63 + className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 64 + placeholder="Page title" 65 + /> 66 + </div> 67 + 68 + <div> 69 + <label htmlFor="page-slug" className="block text-sm font-medium text-foreground"> 70 + Slug 71 + </label> 72 + <input 73 + id="page-slug" 74 + type="text" 75 + value={slug} 76 + onChange={(e) => onSlugChange(e.target.value)} 77 + className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 78 + placeholder="url-slug" 79 + /> 80 + </div> 81 + 82 + <div> 83 + <label htmlFor="page-status" className="block text-sm font-medium text-foreground"> 84 + Status 85 + </label> 86 + <select 87 + id="page-status" 88 + value={status} 89 + onChange={(e) => onStatusChange(e.target.value as PageStatus)} 90 + className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 91 + > 92 + <option value="draft">Draft</option> 93 + <option value="published">Published</option> 94 + </select> 95 + </div> 96 + 97 + <div> 98 + <label htmlFor="page-parent" className="block text-sm font-medium text-foreground"> 99 + Parent Page 100 + </label> 101 + <select 102 + id="page-parent" 103 + value={parentId ?? ''} 104 + onChange={(e) => onParentIdChange(e.target.value || null)} 105 + className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 106 + > 107 + <option value="">None (top-level)</option> 108 + {availableParents.map((p) => ( 109 + <option key={p.id} value={p.id}> 110 + {p.title} 111 + </option> 112 + ))} 113 + </select> 114 + </div> 115 + 116 + <div> 117 + <label 118 + htmlFor="page-meta-description" 119 + className="block text-sm font-medium text-foreground" 120 + > 121 + Meta Description 122 + </label> 123 + <textarea 124 + id="page-meta-description" 125 + value={metaDescription} 126 + onChange={(e) => onMetaDescriptionChange(e.target.value)} 127 + maxLength={320} 128 + rows={2} 129 + className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 130 + placeholder="Brief description for search engines" 131 + /> 132 + <p className="mt-1 text-xs text-muted-foreground">{metaDescription.length}/320</p> 133 + </div> 134 + 135 + <TopicContentEditor content={content} onChange={onContentChange} /> 136 + </div> 137 + 138 + <div className="flex items-center gap-3"> 139 + <button 140 + type="button" 141 + onClick={onSave} 142 + disabled={saving} 143 + 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" 144 + > 145 + {saving ? 'Saving...' : 'Save'} 146 + </button> 147 + <button 148 + type="button" 149 + onClick={onCancel} 150 + className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted" 151 + > 152 + Cancel 153 + </button> 154 + {isEditMode && onDelete && ( 155 + <button 156 + type="button" 157 + onClick={onDelete} 158 + className="ml-auto rounded-md border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" 159 + > 160 + Delete 161 + </button> 162 + )} 163 + </div> 164 + </> 165 + ) 166 + }
+119
src/components/admin/pages/page-row.test.tsx
··· 1 + /** 2 + * Tests for PageRow component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { axe } from 'vitest-axe' 9 + import { PageRow } from './page-row' 10 + import type { PageTreeNode } from '@/lib/api/types' 11 + 12 + vi.mock('next/link', () => ({ 13 + default: ({ 14 + children, 15 + href, 16 + ...props 17 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 18 + <a href={href} {...props}> 19 + {children} 20 + </a> 21 + ), 22 + })) 23 + 24 + const mockPage: PageTreeNode = { 25 + id: 'page-about', 26 + slug: 'about', 27 + title: 'About This Community', 28 + content: '# About\n\nWelcome.', 29 + status: 'published', 30 + metaDescription: 'About page description.', 31 + parentId: null, 32 + sortOrder: 0, 33 + communityDid: 'did:plc:test-community-123', 34 + createdAt: '2026-02-12T12:00:00.000Z', 35 + updatedAt: '2026-02-13T12:00:00.000Z', 36 + children: [], 37 + } 38 + 39 + const mockDraftPage: PageTreeNode = { 40 + ...mockPage, 41 + id: 'page-draft', 42 + slug: 'draft-page', 43 + title: 'Draft Page', 44 + status: 'draft', 45 + children: [], 46 + } 47 + 48 + describe('PageRow', () => { 49 + it('renders page title', () => { 50 + render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 51 + expect(screen.getByText('About This Community')).toBeInTheDocument() 52 + }) 53 + 54 + it('renders slug in muted text', () => { 55 + render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 56 + expect(screen.getByText('/p/about')).toBeInTheDocument() 57 + }) 58 + 59 + it('renders Published status badge for published page', () => { 60 + render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 61 + expect(screen.getByText('Published')).toBeInTheDocument() 62 + }) 63 + 64 + it('renders Draft status badge for draft page', () => { 65 + render(<PageRow page={mockDraftPage} depth={0} onDelete={vi.fn()} />) 66 + expect(screen.getByText('Draft')).toBeInTheDocument() 67 + }) 68 + 69 + it('renders edit link to admin page editor', () => { 70 + render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 71 + const editLink = screen.getByRole('link', { name: /edit about this community/i }) 72 + expect(editLink).toHaveAttribute('href', '/admin/pages/page-about') 73 + }) 74 + 75 + it('renders delete button', () => { 76 + render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 77 + expect(screen.getByRole('button', { name: /delete about this community/i })).toBeInTheDocument() 78 + }) 79 + 80 + it('calls onDelete when delete button is clicked', async () => { 81 + const user = userEvent.setup() 82 + const onDelete = vi.fn() 83 + render(<PageRow page={mockPage} depth={0} onDelete={onDelete} />) 84 + await user.click(screen.getByRole('button', { name: /delete about this community/i })) 85 + expect(onDelete).toHaveBeenCalledWith('page-about') 86 + }) 87 + 88 + it('indents children with increased depth', () => { 89 + render(<PageRow page={mockPage} depth={1} onDelete={vi.fn()} />) 90 + const container = screen.getByText('About This Community').closest('[data-depth]') 91 + expect(container).toHaveAttribute('data-depth', '1') 92 + }) 93 + 94 + it('renders children recursively', () => { 95 + const parentPage: PageTreeNode = { 96 + ...mockPage, 97 + id: 'page-parent', 98 + title: 'Parent Page', 99 + children: [ 100 + { 101 + ...mockPage, 102 + id: 'page-child', 103 + title: 'Child Page', 104 + parentId: 'page-parent', 105 + children: [], 106 + }, 107 + ], 108 + } 109 + render(<PageRow page={parentPage} depth={0} onDelete={vi.fn()} />) 110 + expect(screen.getByText('Parent Page')).toBeInTheDocument() 111 + expect(screen.getByText('Child Page')).toBeInTheDocument() 112 + }) 113 + 114 + it('passes axe accessibility check', async () => { 115 + const { container } = render(<PageRow page={mockPage} depth={0} onDelete={vi.fn()} />) 116 + const results = await axe(container) 117 + expect(results).toHaveNoViolations() 118 + }) 119 + })
+74
src/components/admin/pages/page-row.tsx
··· 1 + /** 2 + * PageRow - Recursive tree row for a single page with edit/delete controls. 3 + * Follows CategoryRow pattern for consistency. 4 + */ 5 + 6 + import Link from 'next/link' 7 + import { PencilSimple, TrashSimple } from '@phosphor-icons/react' 8 + import { cn } from '@/lib/utils' 9 + import type { PageTreeNode, PageStatus } from '@/lib/api/types' 10 + 11 + const STATUS_LABELS: Record<PageStatus, string> = { 12 + draft: 'Draft', 13 + published: 'Published', 14 + } 15 + 16 + const STATUS_COLORS: Record<PageStatus, string> = { 17 + draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', 18 + published: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', 19 + } 20 + 21 + interface PageRowProps { 22 + page: PageTreeNode 23 + depth: number 24 + onDelete: (id: string) => void 25 + } 26 + 27 + export function PageRow({ page, depth, onDelete }: PageRowProps) { 28 + return ( 29 + <> 30 + <div 31 + data-depth={depth} 32 + className={cn( 33 + 'flex items-center justify-between rounded-md border border-border bg-card p-3', 34 + depth > 0 && 'ml-6' 35 + )} 36 + > 37 + <div className="flex items-center gap-3"> 38 + <div> 39 + <p className="text-sm font-medium text-foreground">{page.title}</p> 40 + <p className="text-xs text-muted-foreground">/p/{page.slug}</p> 41 + </div> 42 + </div> 43 + <div className="flex items-center gap-2"> 44 + <span 45 + className={cn( 46 + 'rounded-full px-2 py-0.5 text-xs font-medium', 47 + STATUS_COLORS[page.status] 48 + )} 49 + > 50 + {STATUS_LABELS[page.status]} 51 + </span> 52 + <Link 53 + href={`/admin/pages/${page.id}`} 54 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 55 + aria-label={`Edit ${page.title}`} 56 + > 57 + <PencilSimple size={16} aria-hidden="true" /> 58 + </Link> 59 + <button 60 + type="button" 61 + onClick={() => onDelete(page.id)} 62 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 63 + aria-label={`Delete ${page.title}`} 64 + > 65 + <TrashSimple size={16} aria-hidden="true" /> 66 + </button> 67 + </div> 68 + </div> 69 + {page.children.map((child) => ( 70 + <PageRow key={child.id} page={child} depth={depth + 1} onDelete={onDelete} /> 71 + ))} 72 + </> 73 + ) 74 + }
+41
src/components/admin/pages/slug-generator.test.ts
··· 1 + /** 2 + * Tests for slug generator utility. 3 + */ 4 + 5 + import { describe, it, expect } from 'vitest' 6 + import { generateSlug } from './slug-generator' 7 + 8 + describe('generateSlug', () => { 9 + it('converts title to lowercase slug', () => { 10 + expect(generateSlug('Hello World')).toBe('hello-world') 11 + }) 12 + 13 + it('replaces non-alphanumeric characters with hyphens', () => { 14 + expect(generateSlug('Privacy & Terms!')).toBe('privacy-terms') 15 + }) 16 + 17 + it('collapses multiple hyphens into one', () => { 18 + expect(generateSlug('Hello World --- Test')).toBe('hello-world-test') 19 + }) 20 + 21 + it('trims leading and trailing hyphens', () => { 22 + expect(generateSlug('---Hello World---')).toBe('hello-world') 23 + }) 24 + 25 + it('truncates to 100 characters', () => { 26 + const longTitle = 'a'.repeat(150) 27 + expect(generateSlug(longTitle).length).toBe(100) 28 + }) 29 + 30 + it('handles empty string', () => { 31 + expect(generateSlug('')).toBe('') 32 + }) 33 + 34 + it('handles special characters', () => { 35 + expect(generateSlug("What's New? (2026)")).toBe('what-s-new-2026') 36 + }) 37 + 38 + it('handles numbers', () => { 39 + expect(generateSlug('Version 2.0 Release')).toBe('version-2-0-release') 40 + }) 41 + })
+11
src/components/admin/pages/slug-generator.ts
··· 1 + /** 2 + * Generate a URL-safe slug from a title string. 3 + * Lowercase, replace non-alphanumeric with hyphens, trim edges, max 100 chars. 4 + */ 5 + export function generateSlug(title: string): string { 6 + return title 7 + .toLowerCase() 8 + .replace(/[^a-z0-9]+/g, '-') 9 + .replace(/^-+|-+$/g, '') 10 + .slice(0, 100) 11 + }
+18
src/components/layout/forum-layout.test.tsx
··· 111 111 expect(logos.length).toBeGreaterThan(0) 112 112 }) 113 113 114 + it('links footer privacy to /p/privacy-policy', () => { 115 + render(<ForumLayout>Content</ForumLayout>) 116 + const privacyLink = screen.getByRole('link', { name: /privacy/i }) 117 + expect(privacyLink).toHaveAttribute('href', '/p/privacy-policy') 118 + }) 119 + 120 + it('links footer terms to /p/terms-of-service', () => { 121 + render(<ForumLayout>Content</ForumLayout>) 122 + const termsLink = screen.getByRole('link', { name: /terms/i }) 123 + expect(termsLink).toHaveAttribute('href', '/p/terms-of-service') 124 + }) 125 + 126 + it('links footer cookies to /p/cookie-policy', () => { 127 + render(<ForumLayout>Content</ForumLayout>) 128 + const cookiesLink = screen.getByRole('link', { name: /cookies/i }) 129 + expect(cookiesLink).toHaveAttribute('href', '/p/cookie-policy') 130 + }) 131 + 114 132 it('passes axe accessibility check', async () => { 115 133 const { container } = render( 116 134 <ForumLayout>
+6 -3
src/components/layout/forum-layout.tsx
··· 114 114 </Link> 115 115 </li> 116 116 <li> 117 - <Link href="/legal/privacy" className="transition-colors hover:text-foreground"> 117 + <Link href="/p/privacy-policy" className="transition-colors hover:text-foreground"> 118 118 Privacy 119 119 </Link> 120 120 </li> 121 121 <li> 122 - <Link href="/legal/terms" className="transition-colors hover:text-foreground"> 122 + <Link 123 + href="/p/terms-of-service" 124 + className="transition-colors hover:text-foreground" 125 + > 123 126 Terms 124 127 </Link> 125 128 </li> 126 129 <li> 127 - <Link href="/legal/cookies" className="transition-colors hover:text-foreground"> 130 + <Link href="/p/cookie-policy" className="transition-colors hover:text-foreground"> 128 131 Cookies 129 132 </Link> 130 133 </li>
+69
src/lib/api/client.ts
··· 16 16 CommunitySettings, 17 17 CommunityStats, 18 18 CommunityPreferenceOverride, 19 + CreatePageInput, 19 20 CreateTopicInput, 20 21 InitializeCommunityInput, 21 22 InitializeResponse, 23 + Page, 24 + PagesResponse, 22 25 PublicSettings, 23 26 SetupStatus, 24 27 Topic, 25 28 TopicsResponse, 26 29 UpdateCommunityPreferenceInput, 30 + UpdatePageInput, 27 31 UpdatePreferencesInput, 28 32 UpdateTopicInput, 29 33 UserPreferences, ··· 1241 1245 limit: params.limit, 1242 1246 }) 1243 1247 return apiFetch<ReactionsResponse>(`/api/reactions${query}`, options) 1248 + } 1249 + 1250 + // --- Page endpoints (public) --- 1251 + 1252 + export function getPages(options?: FetchOptions): Promise<PagesResponse> { 1253 + return apiFetch<PagesResponse>('/api/pages', options) 1254 + } 1255 + 1256 + export function getPageBySlug(slug: string, options?: FetchOptions): Promise<Page> { 1257 + return apiFetch<Page>(`/api/pages/${encodeURIComponent(slug)}`, options) 1258 + } 1259 + 1260 + // --- Admin page endpoints --- 1261 + 1262 + export function getAdminPages(accessToken: string, options?: FetchOptions): Promise<PagesResponse> { 1263 + return apiFetch<PagesResponse>('/api/admin/pages', { 1264 + ...options, 1265 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1266 + }) 1267 + } 1268 + 1269 + export function getAdminPage( 1270 + id: string, 1271 + accessToken: string, 1272 + options?: FetchOptions 1273 + ): Promise<Page> { 1274 + return apiFetch<Page>(`/api/admin/pages/${encodeURIComponent(id)}`, { 1275 + ...options, 1276 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1277 + }) 1278 + } 1279 + 1280 + export function createPage( 1281 + input: CreatePageInput, 1282 + accessToken: string, 1283 + options?: FetchOptions 1284 + ): Promise<Page> { 1285 + return apiFetch<Page>('/api/admin/pages', { 1286 + ...options, 1287 + method: 'POST', 1288 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1289 + body: input, 1290 + }) 1291 + } 1292 + 1293 + export function updatePage( 1294 + id: string, 1295 + input: UpdatePageInput, 1296 + accessToken: string, 1297 + options?: FetchOptions 1298 + ): Promise<Page> { 1299 + return apiFetch<Page>(`/api/admin/pages/${encodeURIComponent(id)}`, { 1300 + ...options, 1301 + method: 'PUT', 1302 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1303 + body: input, 1304 + }) 1305 + } 1306 + 1307 + export function deletePage(id: string, accessToken: string, options?: FetchOptions): Promise<void> { 1308 + return apiFetch<void>(`/api/admin/pages/${encodeURIComponent(id)}`, { 1309 + ...options, 1310 + method: 'DELETE', 1311 + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 1312 + }) 1244 1313 } 1245 1314 1246 1315 export { ApiError }
+46
src/lib/api/types.ts
··· 49 49 categories: CategoryTreeNode[] 50 50 } 51 51 52 + // --- Pages --- 53 + 54 + export type PageStatus = 'draft' | 'published' 55 + 56 + export interface Page { 57 + id: string 58 + slug: string 59 + title: string 60 + content: string 61 + status: PageStatus 62 + metaDescription: string | null 63 + parentId: string | null 64 + sortOrder: number 65 + communityDid: string 66 + createdAt: string 67 + updatedAt: string 68 + } 69 + 70 + export interface PageTreeNode extends Page { 71 + children: PageTreeNode[] 72 + } 73 + 74 + export interface PagesResponse { 75 + pages: PageTreeNode[] 76 + } 77 + 78 + export interface CreatePageInput { 79 + title: string 80 + slug: string 81 + content?: string 82 + status?: PageStatus 83 + metaDescription?: string | null 84 + parentId?: string | null 85 + sortOrder?: number 86 + } 87 + 88 + export interface UpdatePageInput { 89 + title?: string 90 + slug?: string 91 + content?: string 92 + status?: PageStatus 93 + metaDescription?: string | null 94 + parentId?: string | null 95 + sortOrder?: number 96 + } 97 + 52 98 // --- Author Profile (enriched by AppView) --- 53 99 54 100 export interface AuthorProfile {
+76
src/mocks/data.ts
··· 28 28 UserProfile, 29 29 OnboardingField, 30 30 MyReport, 31 + PageTreeNode, 31 32 SybilCluster, 32 33 SybilClusterDetail, 33 34 TrustSeed, ··· 1217 1218 bio: 'Community admin and AT Protocol enthusiast.', 1218 1219 }, 1219 1220 } 1221 + 1222 + // --- Pages --- 1223 + 1224 + export const mockPages: PageTreeNode[] = [ 1225 + { 1226 + id: 'page-about', 1227 + slug: 'about', 1228 + title: 'About This Community', 1229 + content: '# About\n\nWelcome to our community forum.', 1230 + status: 'published', 1231 + metaDescription: 'Learn about our community forum.', 1232 + parentId: null, 1233 + sortOrder: 0, 1234 + communityDid: COMMUNITY_DID, 1235 + createdAt: TWO_DAYS_AGO, 1236 + updatedAt: YESTERDAY, 1237 + children: [], 1238 + }, 1239 + { 1240 + id: 'page-privacy', 1241 + slug: 'privacy-policy', 1242 + title: 'Privacy Policy', 1243 + content: '# Privacy Policy\n\nYour privacy matters to us.', 1244 + status: 'published', 1245 + metaDescription: 'How we handle your data.', 1246 + parentId: null, 1247 + sortOrder: 1, 1248 + communityDid: COMMUNITY_DID, 1249 + createdAt: TWO_DAYS_AGO, 1250 + updatedAt: TWO_DAYS_AGO, 1251 + children: [], 1252 + }, 1253 + { 1254 + id: 'page-terms', 1255 + slug: 'terms-of-service', 1256 + title: 'Terms of Service', 1257 + content: '# Terms of Service\n\nBy using this forum, you agree to these terms.', 1258 + status: 'published', 1259 + metaDescription: 'Terms and conditions for using this forum.', 1260 + parentId: null, 1261 + sortOrder: 2, 1262 + communityDid: COMMUNITY_DID, 1263 + createdAt: TWO_DAYS_AGO, 1264 + updatedAt: TWO_DAYS_AGO, 1265 + children: [], 1266 + }, 1267 + { 1268 + id: 'page-cookie', 1269 + slug: 'cookie-policy', 1270 + title: 'Cookie Policy', 1271 + content: '## Overview\n\nBarazo uses a minimal number of cookies...', 1272 + status: 'published', 1273 + metaDescription: 'How Barazo uses cookies.', 1274 + parentId: null, 1275 + sortOrder: 3, 1276 + communityDid: COMMUNITY_DID, 1277 + createdAt: TWO_DAYS_AGO, 1278 + updatedAt: TWO_DAYS_AGO, 1279 + children: [], 1280 + }, 1281 + { 1282 + id: 'page-draft', 1283 + slug: 'upcoming-features', 1284 + title: 'Upcoming Features', 1285 + content: '# Coming Soon\n\nStay tuned for new features.', 1286 + status: 'draft', 1287 + metaDescription: null, 1288 + parentId: null, 1289 + sortOrder: 4, 1290 + communityDid: COMMUNITY_DID, 1291 + createdAt: YESTERDAY, 1292 + updatedAt: YESTERDAY, 1293 + children: [], 1294 + }, 1295 + ] 1220 1296 1221 1297 // --- Sybil Detection --- 1222 1298
+122
src/mocks/handlers.ts
··· 29 29 mockUserProfiles, 30 30 mockPublicSettings, 31 31 mockCommunityProfile, 32 + mockPages, 32 33 mockSybilClusters, 33 34 mockSybilClusterDetail, 34 35 mockTrustSeeds, ··· 827 828 appealStatus: 'pending', 828 829 status: 'pending', 829 830 }) 831 + }), 832 + 833 + // --- Page endpoints (public) --- 834 + 835 + // GET /api/pages 836 + http.get(`${API_URL}/api/pages`, () => { 837 + const published = mockPages.filter((p) => p.status === 'published') 838 + return HttpResponse.json({ pages: published }) 839 + }), 840 + 841 + // GET /api/pages/:slug 842 + http.get(`${API_URL}/api/pages/:slug`, ({ params }) => { 843 + const slug = params['slug'] as string 844 + const findPage = (nodes: typeof mockPages): (typeof mockPages)[number] | undefined => { 845 + for (const node of nodes) { 846 + if (node.slug === slug) return node 847 + const found = findPage(node.children) 848 + if (found) return found 849 + } 850 + return undefined 851 + } 852 + const page = findPage(mockPages) 853 + if (!page || page.status !== 'published') { 854 + return HttpResponse.json({ error: 'Page not found' }, { status: 404 }) 855 + } 856 + return HttpResponse.json(page) 857 + }), 858 + 859 + // --- Admin page endpoints --- 860 + 861 + // GET /api/admin/pages 862 + http.get(`${API_URL}/api/admin/pages`, ({ request }) => { 863 + const auth = request.headers.get('Authorization') 864 + if (!auth?.startsWith('Bearer ')) { 865 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 866 + } 867 + return HttpResponse.json({ pages: mockPages }) 868 + }), 869 + 870 + // GET /api/admin/pages/:id 871 + http.get(`${API_URL}/api/admin/pages/:id`, ({ request, params }) => { 872 + const auth = request.headers.get('Authorization') 873 + if (!auth?.startsWith('Bearer ')) { 874 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 875 + } 876 + const id = params['id'] as string 877 + const findPage = (nodes: typeof mockPages): (typeof mockPages)[number] | undefined => { 878 + for (const node of nodes) { 879 + if (node.id === id) return node 880 + const found = findPage(node.children) 881 + if (found) return found 882 + } 883 + return undefined 884 + } 885 + const page = findPage(mockPages) 886 + if (!page) { 887 + return HttpResponse.json({ error: 'Page not found' }, { status: 404 }) 888 + } 889 + return HttpResponse.json(page) 890 + }), 891 + 892 + // POST /api/admin/pages 893 + http.post(`${API_URL}/api/admin/pages`, async ({ request }) => { 894 + const auth = request.headers.get('Authorization') 895 + if (!auth?.startsWith('Bearer ')) { 896 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 897 + } 898 + const body = (await request.json()) as Record<string, unknown> 899 + const now = new Date().toISOString() 900 + const newPage = { 901 + id: `page-${Date.now()}`, 902 + slug: body.slug ?? 'new-page', 903 + title: body.title ?? 'New Page', 904 + content: body.content ?? '', 905 + status: body.status ?? 'draft', 906 + metaDescription: body.metaDescription ?? null, 907 + parentId: body.parentId ?? null, 908 + sortOrder: body.sortOrder ?? 0, 909 + communityDid: 'did:plc:test-community-123', 910 + createdAt: now, 911 + updatedAt: now, 912 + children: [], 913 + } 914 + return HttpResponse.json(newPage, { status: 201 }) 915 + }), 916 + 917 + // PUT /api/admin/pages/:id 918 + http.put(`${API_URL}/api/admin/pages/:id`, async ({ request, params }) => { 919 + const auth = request.headers.get('Authorization') 920 + if (!auth?.startsWith('Bearer ')) { 921 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 922 + } 923 + const id = params['id'] as string 924 + const findPage = (nodes: typeof mockPages): (typeof mockPages)[number] | undefined => { 925 + for (const node of nodes) { 926 + if (node.id === id) return node 927 + const found = findPage(node.children) 928 + if (found) return found 929 + } 930 + return undefined 931 + } 932 + const existing = findPage(mockPages) 933 + if (!existing) { 934 + return HttpResponse.json({ error: 'Page not found' }, { status: 404 }) 935 + } 936 + const body = (await request.json()) as Record<string, unknown> 937 + return HttpResponse.json({ 938 + ...existing, 939 + ...body, 940 + children: [], 941 + updatedAt: new Date().toISOString(), 942 + }) 943 + }), 944 + 945 + // DELETE /api/admin/pages/:id 946 + http.delete(`${API_URL}/api/admin/pages/:id`, ({ request }) => { 947 + const auth = request.headers.get('Authorization') 948 + if (!auth?.startsWith('Bearer ')) { 949 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 950 + } 951 + return new HttpResponse(null, { status: 204 }) 830 952 }), 831 953 832 954 // --- Sybil Detection endpoints ---