diff --git a/src/app/admin/onboarding/page.test.tsx b/src/app/admin/onboarding/page.test.tsx index 6a4a160..ca5b2d7 100644 --- a/src/app/admin/onboarding/page.test.tsx +++ b/src/app/admin/onboarding/page.test.tsx @@ -6,6 +6,9 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' +import { http, HttpResponse } from 'msw' +import { server } from '@/mocks/server' +import { mockOnboardingFields } from '@/mocks/data' import AdminOnboardingPage from './page' vi.mock('next/navigation', () => ({ @@ -67,6 +70,7 @@ describe('AdminOnboardingPage', () => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) expect(screen.getByText('Introduce yourself')).toBeInTheDocument() + expect(screen.getByText('Age Declaration')).toBeInTheDocument() }) it('renders field type badges', async () => { @@ -83,7 +87,8 @@ describe('AdminOnboardingPage', () => { await waitFor(() => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) - expect(screen.getByText('Required')).toBeInTheDocument() + // ToS and Age Declaration are both mandatory + expect(screen.getAllByText('Required')).toHaveLength(2) }) it('renders add field button', () => { @@ -143,8 +148,8 @@ describe('AdminOnboardingPage', () => { await waitFor(() => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) - expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(2) - expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(2) + expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(3) + expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(3) }) it('disables move up on first field and move down on last field', async () => { @@ -163,6 +168,67 @@ describe('AdminOnboardingPage', () => { expect(screen.getByText(/configure fields that users must complete/i)).toBeInTheDocument() }) + it('shows Platform badge for platform-sourced fields', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + expect(screen.getByText('Platform')).toBeInTheDocument() + }) + + it('does not show Platform badge for admin-sourced fields', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Terms of Service')).toBeInTheDocument() + }) + // Only one Platform badge (for the age_confirmation field) + const badges = screen.getAllByText('Platform') + expect(badges).toHaveLength(1) + }) + + it('does not disable controls for platform fields in selfhosted mode', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + // All edit/delete buttons should be enabled in selfhosted mode + const editButtons = screen.getAllByRole('button', { name: /edit/i }) + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }) + editButtons.forEach((btn) => expect(btn).not.toBeDisabled()) + deleteButtons.forEach((btn) => expect(btn).not.toBeDisabled()) + }) + + it('disables controls for platform fields in SaaS mode', async () => { + server.use( + http.get('/api/admin/onboarding-fields', () => { + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) + }) + ) + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + // Platform field buttons should be disabled + expect(screen.getByRole('button', { name: /edit age declaration/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /delete age declaration/i })).toBeDisabled() + // Admin field buttons should remain enabled + expect(screen.getByRole('button', { name: /edit terms of service/i })).not.toBeDisabled() + expect(screen.getByRole('button', { name: /delete terms of service/i })).not.toBeDisabled() + }) + + it('shows SaaS note for platform fields in SaaS mode', async () => { + server.use( + http.get('/api/admin/onboarding-fields', () => { + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) + }) + ) + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + expect(screen.getByText(/this field is required by the barazo platform/i)).toBeInTheDocument() + }) + it('passes axe accessibility check', async () => { const { container } = render() await waitFor(() => { diff --git a/src/app/admin/onboarding/page.tsx b/src/app/admin/onboarding/page.tsx index 8a7b1ee..93b5795 100644 --- a/src/app/admin/onboarding/page.tsx +++ b/src/app/admin/onboarding/page.tsx @@ -16,6 +16,7 @@ import { useOnboardingFields } from '@/hooks/admin/use-onboarding-fields' export default function AdminOnboardingPage() { const { fields, + hostingMode, loading, editing, setEditing, @@ -87,6 +88,7 @@ export default function AdminOnboardingPage() { field={field} index={index} totalCount={fields.length} + hostingMode={hostingMode} onMoveUp={(i) => void handleMoveUp(i)} onMoveDown={(i) => void handleMoveDown(i)} onEdit={handleEdit} diff --git a/src/components/admin/onboarding/onboarding-field-item.tsx b/src/components/admin/onboarding/onboarding-field-item.tsx index 1ca30d0..6fe2d08 100644 --- a/src/components/admin/onboarding/onboarding-field-item.tsx +++ b/src/components/admin/onboarding/onboarding-field-item.tsx @@ -5,7 +5,7 @@ import { PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' import { cn } from '@/lib/utils' -import type { OnboardingField, OnboardingFieldType } from '@/lib/api/types' +import type { OnboardingField, OnboardingFieldType, HostingMode } from '@/lib/api/types' const FIELD_TYPE_LABELS: Record = { age_confirmation: 'Age Confirmation', @@ -20,6 +20,7 @@ interface OnboardingFieldItemProps { field: OnboardingField index: number totalCount: number + hostingMode: HostingMode onMoveUp: (index: number) => void onMoveDown: (index: number) => void onEdit: (field: OnboardingField) => void @@ -30,70 +31,88 @@ export function OnboardingFieldItem({ field, index, totalCount, + hostingMode, onMoveUp, onMoveDown, onEdit, onDelete, }: OnboardingFieldItemProps) { + const isPlatform = field.source === 'platform' + const isLockedBySaas = isPlatform && hostingMode === 'saas' + return ( -
-
-
-

{field.label}

- - {FIELD_TYPE_LABELS[field.fieldType]} - - {field.isMandatory && ( - - Required +
+
+
+
+

{field.label}

+ + {FIELD_TYPE_LABELS[field.fieldType]} + {isPlatform && ( + + Platform + + )} + {field.isMandatory && ( + + Required + + )} +
+ {field.description && ( +

{field.description}

)}
- {field.description && ( -

{field.description}

- )} -
-
- - - - +
+ + + + +
+ {isLockedBySaas && ( +

+ This field is required by the Barazo platform. Self-host to customize. +

+ )}
) } diff --git a/src/components/onboarding-modal.test.tsx b/src/components/onboarding-modal.test.tsx index e80d167..e13f946 100644 --- a/src/components/onboarding-modal.test.tsx +++ b/src/components/onboarding-modal.test.tsx @@ -20,6 +20,7 @@ function makeField(overrides: Partial = {}): OnboardingField { description: 'A brief introduction.', isMandatory: true, sortOrder: 0, + source: 'admin', config: null, createdAt: NOW, updatedAt: NOW, diff --git a/src/hooks/admin/use-onboarding-fields.ts b/src/hooks/admin/use-onboarding-fields.ts index 004d5a7..741d942 100644 --- a/src/hooks/admin/use-onboarding-fields.ts +++ b/src/hooks/admin/use-onboarding-fields.ts @@ -12,7 +12,7 @@ import { deleteOnboardingField, reorderOnboardingFields, } from '@/lib/api/client' -import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' +import type { OnboardingField, CreateOnboardingFieldInput, HostingMode } from '@/lib/api/types' import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' import { useAuth } from '@/hooks/use-auth' @@ -22,6 +22,7 @@ export function useOnboardingFields() { const { getAccessToken } = useAuth() const { toast } = useToast() const [fields, setFields] = useState([]) + const [hostingMode, setHostingMode] = useState('selfhosted') const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) @@ -32,8 +33,9 @@ export function useOnboardingFields() { const fetchFields = useCallback(async () => { setLoadError(null) try { - const fields = await getOnboardingFields(getAccessToken() ?? '') + const { fields, hostingMode } = await getOnboardingFields(getAccessToken() ?? '') setFields(fields) + setHostingMode(hostingMode) } catch { setLoadError('Failed to load onboarding fields. The API may be unreachable.') } finally { @@ -143,6 +145,7 @@ export function useOnboardingFields() { return { fields, + hostingMode, loading, editing, setEditing, diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5d36c12..b1a59dc 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -43,6 +43,7 @@ import type { MaturityRating, PluginsResponse, OnboardingField, + AdminOnboardingFieldsResponse, CreateOnboardingFieldInput, UpdateOnboardingFieldInput, OnboardingStatus, @@ -773,8 +774,8 @@ export function unmuteUser( export function getOnboardingFields( accessToken: string, options?: FetchOptions -): Promise { - return apiFetch('/api/admin/onboarding-fields', { +): Promise { + return apiFetch('/api/admin/onboarding-fields', { ...options, headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, }) diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 6af21f4..7a20299 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -549,6 +549,10 @@ export type OnboardingFieldType = | 'custom_select' | 'custom_checkbox' +export type OnboardingFieldSource = 'platform' | 'admin' + +export type HostingMode = 'saas' | 'selfhosted' + export interface OnboardingField { id: string communityDid: string @@ -557,13 +561,15 @@ export interface OnboardingField { description: string | null isMandatory: boolean sortOrder: number + source: OnboardingFieldSource config: Record | null createdAt: string updatedAt: string } -export interface OnboardingFieldsResponse { +export interface AdminOnboardingFieldsResponse { fields: OnboardingField[] + hostingMode: HostingMode } export interface CreateOnboardingFieldInput { diff --git a/src/mocks/data.ts b/src/mocks/data.ts index a22e7ae..267672c 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -1026,6 +1026,7 @@ export const mockOnboardingFields: OnboardingField[] = [ description: 'You must accept our community rules to participate.', isMandatory: true, sortOrder: 0, + source: 'admin', config: { tosUrl: 'https://example.com/tos' }, createdAt: TWO_DAYS_AGO, updatedAt: TWO_DAYS_AGO, @@ -1038,6 +1039,21 @@ export const mockOnboardingFields: OnboardingField[] = [ description: 'Tell us a bit about yourself and why you joined.', isMandatory: false, sortOrder: 1, + source: 'admin', + config: null, + createdAt: TWO_DAYS_AGO, + updatedAt: TWO_DAYS_AGO, + }, + { + id: 'platform:age_confirmation', + communityDid: COMMUNITY_DID, + fieldType: 'age_confirmation', + label: 'Age Declaration', + description: + 'Please select your age bracket. This determines which content is available to you.', + isMandatory: true, + sortOrder: -1, + source: 'platform', config: null, createdAt: TWO_DAYS_AGO, updatedAt: TWO_DAYS_AGO, diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index ea6b93f..9b5cbcd 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -641,7 +641,7 @@ export const handlers = [ if (!auth?.startsWith('Bearer ')) { return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - return HttpResponse.json(mockOnboardingFields) + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'selfhosted' }) }), // POST /api/admin/onboarding-fields @@ -660,6 +660,7 @@ export const handlers = [ description: body.description ?? null, isMandatory: body.isMandatory ?? true, sortOrder: body.sortOrder ?? 0, + source: 'admin' as const, config: body.config ?? null, createdAt: now, updatedAt: now, diff --git a/src/test/mock-onboarding.tsx b/src/test/mock-onboarding.tsx index c44bbd7..6d77423 100644 --- a/src/test/mock-onboarding.tsx +++ b/src/test/mock-onboarding.tsx @@ -45,6 +45,7 @@ export function createIncompleteOnboardingContext( description: null, isMandatory: true, sortOrder: 0, + source: 'platform', config: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',