diff --git a/src/app/admin/pages/[id]/page.test.tsx b/src/app/admin/pages/[id]/page.test.tsx new file mode 100644 index 0000000..8ebe542 --- /dev/null +++ b/src/app/admin/pages/[id]/page.test.tsx @@ -0,0 +1,163 @@ +/** + * Tests for admin page editor. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'vitest-axe' +import AdminPageEditorPage from './page' + +const mockPush = vi.fn() +let mockParams = { id: 'new' } + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/admin/pages/new', + useParams: () => mockParams, +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +vi.mock('@/hooks/use-auth', () => { + const mockAuth = { + user: { + did: 'did:plc:user-jay-001', + handle: 'jay.bsky.team', + displayName: 'Jay', + avatarUrl: null, + }, + isAuthenticated: true, + isLoading: false, + getAccessToken: () => 'mock-access-token', + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + } + return { useAuth: () => mockAuth } +}) + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), +})) + +describe('AdminPageEditorPage', () => { + beforeEach(() => { + mockParams = { id: 'new' } + mockPush.mockClear() + }) + + describe('create mode (id === "new")', () => { + it('renders create page heading', () => { + render() + expect(screen.getByRole('heading', { name: /create page/i })).toBeInTheDocument() + }) + + it('renders title input', () => { + render() + expect(screen.getByLabelText(/title/i)).toBeInTheDocument() + }) + + it('renders slug input', () => { + render() + expect(screen.getByLabelText(/slug/i)).toBeInTheDocument() + }) + + it('renders status select', () => { + render() + expect(screen.getByLabelText(/status/i)).toBeInTheDocument() + }) + + it('renders meta description textarea', () => { + render() + expect(screen.getByLabelText(/meta description/i)).toBeInTheDocument() + }) + + it('renders save and cancel buttons', () => { + render() + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + it('does not render delete button in create mode', () => { + render() + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument() + }) + + it('auto-generates slug from title in create mode', async () => { + const user = userEvent.setup() + render() + const titleInput = screen.getByLabelText(/title/i) + await user.type(titleInput, 'Hello World') + const slugInput = screen.getByLabelText(/slug/i) as HTMLInputElement + expect(slugInput.value).toBe('hello-world') + }) + + it('shows character count for meta description', async () => { + const user = userEvent.setup() + render() + const metaInput = screen.getByLabelText(/meta description/i) + await user.type(metaInput, 'Test description') + expect(screen.getByText('16/320')).toBeInTheDocument() + }) + + it('navigates back on cancel', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: /cancel/i })) + expect(mockPush).toHaveBeenCalledWith('/admin/pages') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + }) + + describe('edit mode (id !== "new")', () => { + beforeEach(() => { + mockParams = { id: 'page-about' } + }) + + it('renders edit page heading', async () => { + render() + await waitFor(() => { + expect(screen.getByRole('heading', { name: /edit page/i })).toBeInTheDocument() + }) + }) + + it('populates form with existing page data', async () => { + render() + await waitFor(() => { + const titleInput = screen.getByLabelText(/title/i) as HTMLInputElement + expect(titleInput.value).toBe('About This Community') + }) + }) + + it('renders delete button in edit mode', async () => { + render() + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/app/admin/pages/[id]/page.tsx b/src/app/admin/pages/[id]/page.tsx new file mode 100644 index 0000000..cf03f15 --- /dev/null +++ b/src/app/admin/pages/[id]/page.tsx @@ -0,0 +1,191 @@ +/** + * Admin page editor - Create or edit a static page. + * URL: /admin/pages/new (create) or /admin/pages/{id} (edit) + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useRouter, useParams } from 'next/navigation' +import { AdminLayout } from '@/components/admin/admin-layout' +import { ErrorAlert } from '@/components/error-alert' +import { PageForm } from '@/components/admin/pages/page-form' +import { generateSlug } from '@/components/admin/pages/slug-generator' +import { getAdminPage, getAdminPages, createPage, updatePage, deletePage } from '@/lib/api/client' +import type { PageStatus, PageTreeNode } from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' +import { useToast } from '@/hooks/use-toast' + +interface PageFormState { + title: string + slug: string + status: PageStatus + parentId: string | null + metaDescription: string + content: string +} + +const INITIAL_FORM: PageFormState = { + title: '', + slug: '', + status: 'draft', + parentId: null, + metaDescription: '', + content: '', +} + +export default function AdminPageEditorPage() { + const router = useRouter() + const params = useParams() + const id = params.id as string + const isCreateMode = id === 'new' + + const { getAccessToken } = useAuth() + const { toast } = useToast() + + const [form, setForm] = useState(INITIAL_FORM) + const [availablePages, setAvailablePages] = useState([]) + const [loading, setLoading] = useState(!isCreateMode) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const flattenPages = useCallback( + (nodes: PageTreeNode[], result: PageTreeNode[] = []): PageTreeNode[] => { + for (const node of nodes) { + if (node.id !== id) { + result.push(node) + flattenPages(node.children, result) + } + // Skip descendants of the current page entirely + } + return result + }, + [id] + ) + + useEffect(() => { + const loadData = async () => { + const token = getAccessToken() ?? '' + try { + const pagesResponse = await getAdminPages(token) + setAvailablePages(flattenPages(pagesResponse.pages)) + + if (!isCreateMode) { + const page = await getAdminPage(id, token) + setForm({ + title: page.title, + slug: page.slug, + status: page.status, + parentId: page.parentId, + metaDescription: page.metaDescription ?? '', + content: page.content, + }) + } + } catch { + setError('Failed to load page data.') + } finally { + setLoading(false) + } + } + void loadData() + }, [id, isCreateMode, getAccessToken, flattenPages]) + + const handleTitleChange = (value: string) => { + setForm((prev) => ({ + ...prev, + title: value, + ...(isCreateMode ? { slug: generateSlug(value) } : {}), + })) + } + + const handleSave = async () => { + if (!form.title.trim() || !form.slug.trim()) { + setError('Title and slug are required.') + return + } + + setSaving(true) + setError(null) + + try { + const token = getAccessToken() ?? '' + const input = { + title: form.title, + slug: form.slug, + content: form.content, + status: form.status, + metaDescription: form.metaDescription || null, + parentId: form.parentId, + } + + if (isCreateMode) { + await createPage(input, token) + toast({ title: 'Page created' }) + } else { + await updatePage(id, input, token) + toast({ title: 'Page updated' }) + } + router.push('/admin/pages') + } catch { + setError('Failed to save page. Please try again.') + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + const confirmed = window.confirm('Are you sure you want to delete this page?') + if (!confirmed) return + + try { + await deletePage(id, getAccessToken() ?? '') + toast({ title: 'Page deleted' }) + router.push('/admin/pages') + } catch { + setError('Failed to delete page. Please try again.') + } + } + + if (loading) { + return ( + +

Loading page...

+
+ ) + } + + return ( + +
+

+ {isCreateMode ? 'Create Page' : 'Edit Page'} +

+ + {error && setError(null)} />} + + setForm((prev) => ({ ...prev, slug }))} + onContentChange={(content) => setForm((prev) => ({ ...prev, content }))} + onStatusChange={(status) => setForm((prev) => ({ ...prev, status }))} + onParentIdChange={(parentId) => setForm((prev) => ({ ...prev, parentId }))} + onMetaDescriptionChange={(metaDescription) => + setForm((prev) => ({ ...prev, metaDescription })) + } + onSave={() => void handleSave()} + onCancel={() => router.push('/admin/pages')} + onDelete={() => void handleDelete()} + saving={saving} + /> +
+
+ ) +} diff --git a/src/app/admin/pages/page.test.tsx b/src/app/admin/pages/page.test.tsx new file mode 100644 index 0000000..0bc23ec --- /dev/null +++ b/src/app/admin/pages/page.test.tsx @@ -0,0 +1,102 @@ +/** + * Tests for admin pages list page. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { axe } from 'vitest-axe' +import AdminPagesPage from './page' + +const mockPush = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/admin/pages', +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +vi.mock('@/hooks/use-auth', () => { + const mockAuth = { + user: { + did: 'did:plc:user-jay-001', + handle: 'jay.bsky.team', + displayName: 'Jay', + avatarUrl: null, + }, + isAuthenticated: true, + isLoading: false, + getAccessToken: () => 'mock-access-token', + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + } + return { useAuth: () => mockAuth } +}) + +vi.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ toast: vi.fn(), dismiss: vi.fn() }), +})) + +describe('AdminPagesPage', () => { + it('renders pages heading', () => { + render() + expect(screen.getByRole('heading', { name: /pages/i })).toBeInTheDocument() + }) + + it('renders add page button', () => { + render() + expect(screen.getByRole('button', { name: /add page/i })).toBeInTheDocument() + }) + + it('renders pages from API', async () => { + render() + await waitFor(() => { + expect(screen.getByText('About This Community')).toBeInTheDocument() + }) + expect(screen.getByText('Privacy Policy')).toBeInTheDocument() + expect(screen.getByText('Terms of Service')).toBeInTheDocument() + }) + + it('renders page status badges', async () => { + render() + await waitFor(() => { + expect(screen.getAllByText('Published').length).toBeGreaterThan(0) + }) + expect(screen.getByText('Draft')).toBeInTheDocument() + }) + + it('navigates to new page editor on add button click', async () => { + const user = (await import('@testing-library/user-event')).default.setup() + render() + await user.click(screen.getByRole('button', { name: /add page/i })) + expect(mockPush).toHaveBeenCalledWith('/admin/pages/new') + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + await waitFor(() => { + expect(screen.getByText('About This Community')).toBeInTheDocument() + }) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/app/admin/pages/page.tsx b/src/app/admin/pages/page.tsx new file mode 100644 index 0000000..c29b0e4 --- /dev/null +++ b/src/app/admin/pages/page.tsx @@ -0,0 +1,104 @@ +/** + * Admin pages list page. + * URL: /admin/pages + * Page tree with status badges and CRUD controls. + */ + +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { Plus } from '@phosphor-icons/react' +import { AdminLayout } from '@/components/admin/admin-layout' +import { ErrorAlert } from '@/components/error-alert' +import { PageRow } from '@/components/admin/pages/page-row' +import { getAdminPages, deletePage } from '@/lib/api/client' +import type { PageTreeNode } from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' +import { useToast } from '@/hooks/use-toast' + +export default function AdminPagesPage() { + const router = useRouter() + const { getAccessToken } = useAuth() + const { toast } = useToast() + const [pages, setPages] = useState([]) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + + const fetchPages = useCallback(async () => { + setLoadError(null) + try { + const response = await getAdminPages(getAccessToken() ?? '') + setPages(response.pages) + } catch { + setLoadError('Failed to load pages. The API may be unreachable.') + } finally { + setLoading(false) + } + }, [getAccessToken]) + + useEffect(() => { + void fetchPages() + }, [fetchPages]) + + const handleDelete = async (id: string) => { + setActionError(null) + const confirmed = window.confirm('Are you sure you want to delete this page?') + if (!confirmed) return + + try { + await deletePage(id, getAccessToken() ?? '') + void fetchPages() + toast({ title: 'Page deleted' }) + } catch { + setActionError('Failed to delete page. Please try again.') + } + } + + return ( + +
+
+

Pages

+ +
+ + {actionError && setActionError(null)} />} + + {loadError && ( + void fetchPages()} /> + )} + + {loading &&

Loading pages...

} + + {!loading && pages.length === 0 && ( +

+ No pages yet. Create your first page for static content like About, Privacy Policy, or + Terms of Service. +

+ )} + + {!loading && pages.length > 0 && ( +
+ {pages.map((page) => ( + void handleDelete(id)} + /> + ))} +
+ )} +
+
+ ) +} diff --git a/src/app/legal/cookies/page.test.tsx b/src/app/legal/cookies/page.test.tsx deleted file mode 100644 index b5b6313..0000000 --- a/src/app/legal/cookies/page.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Tests for cookie policy page. - * @see decisions/legal.md - */ - -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { axe } from 'vitest-axe' -import CookiePolicyPage from './page' - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - usePathname: () => '/legal/cookies', - useRouter: () => ({ push: vi.fn() }), -})) - -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, -})) - -// Mock useAuth hook -vi.mock('@/hooks/use-auth', () => ({ - useAuth: () => ({ - user: null, - isAuthenticated: false, - isLoading: false, - getAccessToken: () => null, - login: vi.fn(), - logout: vi.fn(), - setSessionFromCallback: vi.fn(), - authFetch: vi.fn(), - }), -})) - -describe('CookiePolicyPage', () => { - it('renders page heading', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /cookie policy/i, level: 1 })).toBeInTheDocument() - }) - - it('describes the single essential cookie', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /cookies we use/i })).toBeInTheDocument() - // Table shows the refresh token cookie details - expect(screen.getByRole('table')).toBeInTheDocument() - expect(screen.getByText('Refresh token')).toBeInTheDocument() - }) - - it('lists cookie security properties', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByText(/HTTP-only/i)).toBeInTheDocument() - expect(screen.getByText(/SameSite=Strict/i)).toBeInTheDocument() - }) - - it('states no tracking cookies are used', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /what we do not use/i })).toBeInTheDocument() - expect(screen.getByText(/no tracking or advertising cookies/i)).toBeInTheDocument() - }) - - it('explains cookie consent exemption', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /cookie consent/i })).toBeInTheDocument() - expect(screen.getByText(/ePrivacy Directive/i)).toBeInTheDocument() - }) - - it('renders breadcrumbs', async () => { - const page = await CookiePolicyPage() - render(page) - expect(screen.getByText('Home')).toBeInTheDocument() - }) - - it('passes axe accessibility check', async () => { - const page = await CookiePolicyPage() - const { container } = render(page) - const results = await axe(container) - expect(results).toHaveNoViolations() - }) -}) diff --git a/src/app/legal/cookies/page.tsx b/src/app/legal/cookies/page.tsx deleted file mode 100644 index abc108e..0000000 --- a/src/app/legal/cookies/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Cookie policy page. - * URL: /legal/cookies - * Static placeholder content -- admin-editable in P3+. - * @see decisions/legal.md - */ - -import type { Metadata } from 'next' -import { getPublicSettings } from '@/lib/api/client' -import { ForumLayout } from '@/components/layout/forum-layout' -import { Breadcrumbs } from '@/components/breadcrumbs' - -export const metadata: Metadata = { - title: 'Cookie Policy', - description: - 'How Barazo uses cookies. We use a single essential cookie for authentication -- no tracking or analytics cookies.', - alternates: { - canonical: '/legal/cookies', - }, -} - -export default async function CookiePolicyPage() { - let communityName = '' - try { - const settings = await getPublicSettings() - communityName = settings.communityName - } catch { - // silently degrade - } - - return ( - -
- - -

Cookie policy

- -
-

Overview

-

- Barazo uses a minimal number of cookies. We do not use tracking cookies, advertising - cookies, or third-party analytics cookies. This page explains the cookies we do use and - why. -

-
- -
-

Cookies we use

-

- Barazo uses a single essential cookie: -

-
- - - - - - - - - - - - - - - - - -
CookiePurposeDurationType
Refresh token - Keeps you logged in across page reloads by enabling silent access token renewal. - SessionEssential
-
-
- -
-

Technical details

-

- The refresh token cookie has the following security properties: -

-
    -
  • - HTTP-only -- the cookie is not accessible to JavaScript, preventing - cross-site scripting (XSS) attacks. -
  • -
  • - Secure -- the cookie is only sent over HTTPS connections. -
  • -
  • - SameSite=Strict -- the cookie is not sent with cross-site requests, - preventing cross-site request forgery (CSRF) attacks. -
  • -
-

- Access tokens (used to authenticate API requests) are held in memory only and are never - stored in cookies, localStorage, or sessionStorage. -

-
- -
-

What we do not use

-
    -
  • No tracking or advertising cookies.
  • -
  • No third-party analytics (Google Analytics, etc.).
  • -
  • No social media tracking pixels.
  • -
  • No fingerprinting or behavioral profiling.
  • -
-
- -
-

Cookie consent

-

- Because we only use a single essential cookie required for the service to function, a - cookie consent banner is not required under the ePrivacy Directive (EU Directive - 2002/58/EC, Art. 5(3)). Essential cookies that are strictly necessary for the service - requested by the user are exempt from the consent requirement. -

-
- -
-

Theme preference

-

- Your light/dark mode preference is stored in localStorage (not a cookie). This is a - client-side preference that is never sent to our servers. -

-
- -
-

- This policy was last updated on February 2026. -

-
-
-
- ) -} diff --git a/src/app/legal/privacy/page.test.tsx b/src/app/legal/privacy/page.test.tsx deleted file mode 100644 index 4fc261f..0000000 --- a/src/app/legal/privacy/page.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Tests for privacy policy page. - * @see decisions/legal.md - */ - -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { axe } from 'vitest-axe' -import PrivacyPolicyPage from './page' - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - usePathname: () => '/legal/privacy', - useRouter: () => ({ push: vi.fn() }), -})) - -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, -})) - -// Mock useAuth hook -vi.mock('@/hooks/use-auth', () => ({ - useAuth: () => ({ - user: null, - isAuthenticated: false, - isLoading: false, - getAccessToken: () => null, - login: vi.fn(), - logout: vi.fn(), - setSessionFromCallback: vi.fn(), - authFetch: vi.fn(), - }), -})) - -describe('PrivacyPolicyPage', () => { - it('renders page heading', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /privacy policy/i, level: 1 })).toBeInTheDocument() - }) - - it('describes what data is collected', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /what we collect/i })).toBeInTheDocument() - expect(screen.getByText(/AT Protocol identifiers/i)).toBeInTheDocument() - }) - - it('describes authentication cookie instead of generic session data', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByText(/Authentication cookie/i)).toBeInTheDocument() - expect(screen.getByText(/HTTP-only, Secure, SameSite=Strict/i)).toBeInTheDocument() - }) - - it('lists age declaration and per-community preferences', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByText(/Age declaration/i)).toBeInTheDocument() - expect(screen.getByText(/Per-community preferences/i)).toBeInTheDocument() - }) - - it('describes what data is not collected', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /what we do not collect/i })).toBeInTheDocument() - expect(screen.getByText(/device fingerprinting/i)).toBeInTheDocument() - }) - - it('describes anonymize-on-deletion approach', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect( - screen.getByRole('heading', { name: /data retention and deletion/i }) - ).toBeInTheDocument() - expect(screen.getByText(/deleted by author/i)).toBeInTheDocument() - expect(screen.getByText(/personal data.*is stripped/i)).toBeInTheDocument() - expect(screen.getByText(/anonymized content.*may be retained/i)).toBeInTheDocument() - }) - - it('describes AI features', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /ai features/i })).toBeInTheDocument() - expect(screen.getByText(/No training on your content/i)).toBeInTheDocument() - expect(screen.getByText(/Local-first processing/i)).toBeInTheDocument() - expect(screen.getByText(/Anonymized summaries/i)).toBeInTheDocument() - }) - - it('lists user rights under GDPR', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByRole('heading', { name: /your rights/i })).toBeInTheDocument() - expect(screen.getByText(/right to be forgotten/i)).toBeInTheDocument() - }) - - it('links to barazo-workspace for issue tracking', async () => { - const page = await PrivacyPolicyPage() - render(page) - const link = screen.getByRole('link', { name: /github issue tracker/i }) - expect(link).toHaveAttribute('href', 'https://github.com/barazo-forum/barazo-workspace/issues') - }) - - it('mentions GDPR compliance', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByText(/General Data Protection Regulation/i)).toBeInTheDocument() - }) - - it('renders breadcrumbs', async () => { - const page = await PrivacyPolicyPage() - render(page) - expect(screen.getByText('Home')).toBeInTheDocument() - }) - - it('passes axe accessibility check', async () => { - const page = await PrivacyPolicyPage() - const { container } = render(page) - const results = await axe(container) - expect(results).toHaveNoViolations() - }) -}) diff --git a/src/app/legal/privacy/page.tsx b/src/app/legal/privacy/page.tsx deleted file mode 100644 index 362b065..0000000 --- a/src/app/legal/privacy/page.tsx +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Privacy policy page. - * URL: /legal/privacy - * Static placeholder content -- admin-editable in P3+. - * @see decisions/legal.md - */ - -import type { Metadata } from 'next' -import { getPublicSettings } from '@/lib/api/client' -import { ForumLayout } from '@/components/layout/forum-layout' -import { Breadcrumbs } from '@/components/breadcrumbs' - -export const metadata: Metadata = { - title: 'Privacy Policy', - description: - 'How Barazo collects, uses, and protects your personal data. GDPR-compliant privacy policy.', - alternates: { - canonical: '/legal/privacy', - }, -} - -export default async function PrivacyPolicyPage() { - let communityName = '' - try { - const settings = await getPublicSettings() - communityName = settings.communityName - } catch { - // silently degrade - } - - return ( - -
- - -

Privacy policy

- -
-

Overview

-

- Barazo is committed to protecting your privacy. This policy explains what personal data - we collect, why we collect it, how long we keep it, and what rights you have. Barazo is - operated from the Netherlands and complies with the General Data Protection Regulation - (GDPR). -

-
- -
-

What we collect

-

- When you use Barazo, we process the following data: -

-
    -
  • - AT Protocol identifiers -- your DID (decentralized identifier) and - handle, used to identify your account. -
  • -
  • - Profile information -- display name and profile data retrieved from - your AT Protocol PDS. -
  • -
  • - Content -- posts, replies, and reactions you create on the forum, - indexed from the AT Protocol firehose. -
  • -
  • - IP addresses -- collected for API rate limiting and security - purposes. -
  • -
  • - Authentication cookie -- a single HTTP-only, Secure, SameSite=Strict - refresh token cookie used to maintain your session. Access tokens are held in memory - only and never stored in cookies or browser storage. -
  • -
  • - Moderation records -- actions taken by moderators on your content or - account. -
  • -
  • - Age declaration -- stored in the forum database only (deliberately - kept off your PDS to avoid broadcasting age data on a public network). -
  • -
  • - Per-community preferences -- notification settings and content - maturity overrides, stored locally in the forum database (not on your PDS) to protect - your browsing patterns. -
  • -
-
- -
-

What we do not collect

-
    -
  • - We do not collect or store your password (authentication is handled via AT Protocol - OAuth). -
  • -
  • - We do not collect email addresses unless provided by a community admin for billing. -
  • -
  • We do not collect payment card details (processed by our payment provider).
  • -
  • We do not use tracking cookies or analytics that profile your behavior.
  • -
  • We do not use device fingerprinting.
  • -
  • We do not load third-party trackers, pixels, or analytics scripts.
  • -
-
- -
-

Legal basis

-

- We process your data under the following legal bases (GDPR Art. 6): -

-
    -
  • - Contract performance -- processing necessary to provide the forum - service you signed up for. -
  • -
  • - Legitimate interest -- indexing public AT Protocol content, spam - prevention, platform security, content moderation, and AI-generated discussion - summaries. -
  • -
-
- -
-

Data storage and transfers

-

- Our servers are hosted in the European Union (Hetzner, Germany). We use the following - sub-processors: -

-
    -
  • Hetzner (EU) -- hosting infrastructure.
  • -
  • Bunny.net (EU, Slovenia) -- content delivery network.
  • -
  • Stripe (EU-US Data Privacy Framework certified) -- payment processing.
  • -
-

- A full sub-processor list is maintained at{' '} - barazo.forum/legal/sub-processors. -

-
- -
-

Data retention and deletion

-

- Your indexed data is retained while the source exists on your AT Protocol PDS. When you - delete content or your account via the AT Protocol, we process the deletion event - immediately: -

-
    -
  • - Your post is removed from public view and replaced with a "deleted by - author" notice. -
  • -
  • - Your personal data (DID, handle, AT Protocol URI) is stripped from the database - record. -
  • -
  • - The anonymized content (with no link to your identity) may be retained to preserve - community knowledge and enable AI-generated discussion summaries. This anonymized data - falls outside GDPR scope (Recital 26) because it can no longer identify you. -
  • -
-

- You may request full content deletion (including anonymized content) by contacting us - directly, independent of AT Protocol signals. We respond to deletion requests within one - month (GDPR Art. 12(3)). -

-

- Barazo cannot guarantee deletion from external systems such as AT Protocol relays, other - AppViews, search engine caches, or web archives. Our reasonable steps include: - propagating AT Protocol delete events, submitting Google Search Console removal requests - for deleted content URLs, and documenting which systems confirmed deletion. -

-
- -
-

AI features

-

- Barazo offers optional AI features including thread summaries, semantic search, and - content moderation assistance. Here is how they work: -

-
    -
  • - No training on your content. We do not use member posts to train AI - models, and we do not provide member content to others for training. -
  • -
  • - Local-first processing. The default AI configuration uses local - inference (Ollama) -- your content never leaves the server. Your forum administrator - may choose a different AI provider; in that case, content is sent to that provider for - processing. -
  • -
  • - Anonymized summaries. AI-generated thread summaries are designed to - exclude usernames, handles, and verbatim quotes. Summaries capture the - discussion's substance, not who said what. Summaries may persist after individual - content deletion because they contain no personal data. -
  • -
-
- -
-

Content labels

-

- We subscribe to content labeling services (such as Bluesky's Ozone) for spam - detection and content moderation. Labels applied to your account may affect posting - limits and content visibility. Labels are stored by the labeler service, not on your - PDS. You can dispute labels by contacting us. -

-
- -
-

Your rights

-

- Under the GDPR, you have the right to: -

-
    -
  • Access the personal data we hold about you.
  • -
  • Rectify inaccurate data.
  • -
  • Request erasure of your data (right to be forgotten).
  • -
  • Object to processing based on legitimate interest.
  • -
  • Data portability (built into the AT Protocol).
  • -
  • - Lodge a complaint with the Dutch Data Protection Authority (Autoriteit - Persoonsgegevens). -
  • -
-

- To exercise these rights, contact us through our{' '} - - GitHub issue tracker - {' '} - or via the contact details provided by your community administrator. -

-
- -
-

Data breach notification

-

- In the event of a data breach, we will notify the Dutch Data Protection Authority within - 72 hours (GDPR Art. 33). For high-risk breaches, we will notify affected users without - undue delay via AT Protocol notifications and public announcements. -

-
- -
-

- This policy was last updated on February 2026. -

-
-
-
- ) -} diff --git a/src/app/legal/terms/page.test.tsx b/src/app/legal/terms/page.test.tsx deleted file mode 100644 index aa19242..0000000 --- a/src/app/legal/terms/page.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Tests for terms of service page. - * @see decisions/legal.md - */ - -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { axe } from 'vitest-axe' -import TermsOfServicePage from './page' - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - usePathname: () => '/legal/terms', - useRouter: () => ({ push: vi.fn() }), -})) - -// Mock next-themes -vi.mock('next-themes', () => ({ - useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), - ThemeProvider: ({ children }: { children: React.ReactNode }) => children, -})) - -// Mock useAuth hook -vi.mock('@/hooks/use-auth', () => ({ - useAuth: () => ({ - user: null, - isAuthenticated: false, - isLoading: false, - getAccessToken: () => null, - login: vi.fn(), - logout: vi.fn(), - setSessionFromCallback: vi.fn(), - authFetch: vi.fn(), - }), -})) - -describe('TermsOfServicePage', () => { - it('renders page heading', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByRole('heading', { name: /terms of service/i, level: 1 })).toBeInTheDocument() - }) - - it('states minimum age requirement', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByText(/at least 16 years old/i)).toBeInTheDocument() - }) - - it('covers content and conduct rules', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByRole('heading', { name: /content and conduct/i })).toBeInTheDocument() - }) - - it('discloses AI summary behavior', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByRole('heading', { name: /ai-generated summaries/i })).toBeInTheDocument() - expect(screen.getByText(/summaries may persist/i)).toBeInTheDocument() - }) - - it('discloses moderation labels', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByRole('heading', { name: /moderation and labels/i })).toBeInTheDocument() - }) - - it('specifies governing law', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByText(/laws of the Netherlands/i)).toBeInTheDocument() - }) - - it('renders breadcrumbs', async () => { - const page = await TermsOfServicePage() - render(page) - expect(screen.getByText('Home')).toBeInTheDocument() - }) - - it('passes axe accessibility check', async () => { - const page = await TermsOfServicePage() - const { container } = render(page) - const results = await axe(container) - expect(results).toHaveNoViolations() - }) -}) diff --git a/src/app/legal/terms/page.tsx b/src/app/legal/terms/page.tsx deleted file mode 100644 index aaa1c02..0000000 --- a/src/app/legal/terms/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Terms of service page. - * URL: /legal/terms - * Static placeholder content -- admin-editable in P3+. - * @see decisions/legal.md - */ - -import type { Metadata } from 'next' -import { getPublicSettings } from '@/lib/api/client' -import { ForumLayout } from '@/components/layout/forum-layout' -import { Breadcrumbs } from '@/components/breadcrumbs' - -export const metadata: Metadata = { - title: 'Terms of Service', - description: - 'Terms and conditions for using Barazo forum communities. Covers usage rules, content policies, and user responsibilities.', - alternates: { - canonical: '/legal/terms', - }, -} - -export default async function TermsOfServicePage() { - let communityName = '' - try { - const settings = await getPublicSettings() - communityName = settings.communityName - } catch { - // silently degrade - } - - return ( - -
- - -

Terms of service

- -
-

Acceptance of terms

-

- By accessing or using Barazo, you agree to be bound by these Terms of Service. If you do - not agree to these terms, you may not use the service. Barazo reserves the right to - update these terms at any time, with notice provided through the platform. -

-
- -
-

Eligibility

-

- You must be at least 16 years old to use Barazo (in accordance with the Dutch - implementation of GDPR, UAVG). By using the service, you confirm that you meet this age - requirement. Access to mature content may require additional age verification as - required by applicable law. -

-
- -
-

Account and authentication

-

- Barazo uses the AT Protocol for authentication. You log in using your existing AT - Protocol identity (e.g., a Bluesky account). You are responsible for maintaining the - security of your AT Protocol account. Barazo does not store your password. -

-
- -
-

Content and conduct

-

- You retain ownership of content you post on Barazo. By posting, you grant Barazo a - license to display, index, and distribute your content as part of the forum service and - via the AT Protocol. -

-

- You agree not to post content that: -

-
    -
  • Violates applicable laws or regulations.
  • -
  • Infringes on the intellectual property rights of others.
  • -
  • Contains spam, malware, or deceptive content.
  • -
  • Harasses, threatens, or promotes violence against individuals or groups.
  • -
  • Contains child sexual abuse material (CSAM).
  • -
-

- Community administrators may enforce additional content policies specific to their - community. Repeated violations may result in content removal, account restrictions, or - bans. -

-
- -
-

Content maturity ratings

-

- Communities and categories may be rated for content maturity (Safe for Work, Mature, or - Adult). You are responsible for accurately labeling your content. Communities may - require age verification to access mature content. New accounts default to safe-mode - with mature content hidden. -

-
- -
-

Cross-posting

-

- Barazo may cross-post your content to connected platforms (such as Bluesky or Frontpage) - when you enable this feature. Cross-posting is optional and can be controlled in your - settings. Cross-posted content is subject to the terms of the destination platform. -

-
- -
-

Moderation and labels

-

- Your account may be labeled by independent moderation services (such as Bluesky's - Ozone). Labels affect posting limits and content visibility. You cannot delete labels - applied by labeler services, but you can dispute inaccuracies by contacting us or the - labeler service. Community administrators may also apply local moderation overrides. -

-
- -
-

AI-generated summaries

-

- Barazo may generate AI-powered summaries of discussion threads. These summaries are - anonymized derivative works that do not contain personal data (no usernames or verbatim - quotes). AI summaries may persist after individual content is deleted, as they are - regenerated from remaining content. Community administrators can disable summary - preservation. -

-
- -
-

AT Protocol and federation

-

- Barazo is built on the AT Protocol, which is a federated, open network. Content you post - may be indexed by other services on the AT Protocol network. Barazo cannot control how - third-party services handle your data once it is published via the protocol. -

-
- -
-

Termination

-

- Barazo may suspend or terminate your access if you violate these terms. You may stop - using the service at any time. Deleting your AT Protocol account or content will trigger - removal of indexed data from Barazo (see our Privacy Policy for details). -

-
- -
-

Limitation of liability

-

- Barazo is provided "as is" without warranties of any kind. We are not liable - for any damages arising from your use of the service, including but not limited to loss - of data, service interruptions, or actions taken by community moderators or - administrators. -

-
- -
-

Governing law

-

- These terms are governed by the laws of the Netherlands. Any disputes arising from these - terms will be subject to the exclusive jurisdiction of the courts of the Netherlands. -

-
- -
-

- These terms were last updated on February 2026. -

-
-
-
- ) -} diff --git a/src/app/p/[slug]/not-found.test.tsx b/src/app/p/[slug]/not-found.test.tsx new file mode 100644 index 0000000..45a06b6 --- /dev/null +++ b/src/app/p/[slug]/not-found.test.tsx @@ -0,0 +1,70 @@ +/** + * Tests for page not found component. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import PageNotFound from './not-found' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/p/nonexistent', + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: false, + isLoading: false, + getAccessToken: () => null, + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + }), +})) + +describe('PageNotFound', () => { + it('renders not found heading', () => { + render() + expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument() + }) + + it('renders helpful message', () => { + render() + expect( + screen.getByText(/the page you are looking for does not exist or has been removed/i) + ).toBeInTheDocument() + }) + + it('passes axe accessibility check', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/app/p/[slug]/not-found.tsx b/src/app/p/[slug]/not-found.tsx new file mode 100644 index 0000000..87d2089 --- /dev/null +++ b/src/app/p/[slug]/not-found.tsx @@ -0,0 +1,19 @@ +/** + * 404 page for public pages. + * Displayed when a page slug does not match any published page. + */ + +import { ForumLayout } from '@/components/layout/forum-layout' + +export default function PageNotFound() { + return ( + +
+

Page not found

+

+ The page you are looking for does not exist or has been removed. +

+
+
+ ) +} diff --git a/src/app/p/[slug]/page.test.tsx b/src/app/p/[slug]/page.test.tsx new file mode 100644 index 0000000..0364e17 --- /dev/null +++ b/src/app/p/[slug]/page.test.tsx @@ -0,0 +1,114 @@ +/** + * Tests for public page rendering. + */ + +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { axe } from 'vitest-axe' +import PublicPage from './page' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/p/about', + useRouter: () => ({ push: vi.fn() }), + notFound: vi.fn(() => { + throw new Error('NEXT_NOT_FOUND') + }), +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})) + +vi.mock('next/link', () => ({ + default: ({ + children, + href, + ...props + }: { children: React.ReactNode; href: string } & Record) => ( + + {children} + + ), +})) + +vi.mock('next/image', () => ({ + default: (props: Record) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +vi.mock('@/hooks/use-auth', () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: false, + isLoading: false, + getAccessToken: () => null, + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + }), +})) + +describe('PublicPage', () => { + it('renders page title as heading', async () => { + const page = await PublicPage({ + params: Promise.resolve({ slug: 'about' }), + }) + render(page) + expect( + screen.getByRole('heading', { name: /about this community/i, level: 1 }) + ).toBeInTheDocument() + }) + + it('renders page content as markdown', async () => { + const page = await PublicPage({ + params: Promise.resolve({ slug: 'about' }), + }) + render(page) + // The markdown content "# About\n\nWelcome to our community forum." should render + expect(screen.getByText(/welcome to our community forum/i)).toBeInTheDocument() + }) + + it('renders breadcrumbs with Home and page title', async () => { + const page = await PublicPage({ + params: Promise.resolve({ slug: 'about' }), + }) + render(page) + expect(screen.getByText('Home')).toBeInTheDocument() + }) + + it('renders JSON-LD structured data with absolute URL', async () => { + const page = await PublicPage({ + params: Promise.resolve({ slug: 'about' }), + }) + const { container } = render(page) + const jsonLdScript = container.querySelector('script[type="application/ld+json"]') + expect(jsonLdScript).not.toBeNull() + const jsonLd = JSON.parse(jsonLdScript!.textContent ?? '{}') + expect(jsonLd['@type']).toBe('WebPage') + expect(jsonLd.name).toBe('About This Community') + expect(jsonLd.url).toBe('https://barazo.forum/p/about') + }) + + it('calls notFound for non-existent page', async () => { + const { notFound } = await import('next/navigation') + await expect( + PublicPage({ + params: Promise.resolve({ slug: 'nonexistent' }), + }) + ).rejects.toThrow('NEXT_NOT_FOUND') + expect(notFound).toHaveBeenCalled() + }) + + it('passes axe accessibility check', async () => { + const page = await PublicPage({ + params: Promise.resolve({ slug: 'about' }), + }) + const { container } = render(page) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/src/app/p/[slug]/page.tsx b/src/app/p/[slug]/page.tsx new file mode 100644 index 0000000..546b6ab --- /dev/null +++ b/src/app/p/[slug]/page.tsx @@ -0,0 +1,95 @@ +/** + * Public page rendering - Displays admin-created static pages. + * URL: /p/{slug} + * Server-side rendered with JSON-LD WebPage and OpenGraph metadata. + */ + +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' +import { getPageBySlug, getPublicSettings, ApiError } from '@/lib/api/client' +import { ForumLayout } from '@/components/layout/forum-layout' +import { Breadcrumbs } from '@/components/breadcrumbs' +import { MarkdownContent } from '@/components/markdown-content' + +export const dynamic = 'force-dynamic' + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://barazo.forum' + +/** Truncate content to a max length suitable for meta descriptions. */ +function truncateDescription(text: string, maxLength = 157): string { + return text.length > maxLength ? text.slice(0, maxLength) + '...' : text +} + +interface PublicPageProps { + params: Promise<{ slug: string }> +} + +export async function generateMetadata({ params }: PublicPageProps): Promise { + const { slug } = await params + try { + const page = await getPageBySlug(slug) + const description = page.metaDescription ?? truncateDescription(page.content) + + return { + title: page.title, + description, + alternates: { + canonical: `/p/${slug}`, + }, + openGraph: { + title: page.title, + description, + type: 'website', + }, + } + } catch { + return { title: 'Page Not Found' } + } +} + +export default async function PublicPage({ params }: PublicPageProps) { + const { slug } = await params + + let page + try { + page = await getPageBySlug(slug) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + notFound() + } + throw error + } + + let communityName = '' + try { + const settings = await getPublicSettings() + communityName = settings.communityName + } catch { + // silently degrade + } + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: page.title, + description: page.metaDescription ?? truncateDescription(page.content), + dateModified: page.updatedAt, + url: `${SITE_URL}/p/${slug}`, + } + + return ( + +