Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat: onboarding source awareness in admin UI (#145)

* feat(types): add source and hostingMode to onboarding types

Add OnboardingFieldSource, HostingMode, and AdminOnboardingFieldsResponse
types. Update getOnboardingFields client to return new response shape.
Update hook to extract fields and hostingMode. Update all mocks to include
source field.

* feat(admin): show source badges and SaaS read-only state for platform fields

Platform fields display a "Platform" badge in the admin onboarding list.
In SaaS hosting mode, platform field controls (edit, delete, reorder) are
disabled with an explanatory note. In selfhosted mode all fields remain
fully editable. Adds platform age_confirmation to mock data.

* style: fix Prettier formatting

authored by

Guido X Jansen and committed by
GitHub
e31a7788 1f2ba19b

+180 -64
+69 -3
src/app/admin/onboarding/page.test.tsx
··· 6 6 import { render, screen, waitFor } from '@testing-library/react' 7 7 import userEvent from '@testing-library/user-event' 8 8 import { axe } from 'vitest-axe' 9 + import { http, HttpResponse } from 'msw' 10 + import { server } from '@/mocks/server' 11 + import { mockOnboardingFields } from '@/mocks/data' 9 12 import AdminOnboardingPage from './page' 10 13 11 14 vi.mock('next/navigation', () => ({ ··· 67 70 expect(screen.getByText('Terms of Service')).toBeInTheDocument() 68 71 }) 69 72 expect(screen.getByText('Introduce yourself')).toBeInTheDocument() 73 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 70 74 }) 71 75 72 76 it('renders field type badges', async () => { ··· 83 87 await waitFor(() => { 84 88 expect(screen.getByText('Terms of Service')).toBeInTheDocument() 85 89 }) 86 - expect(screen.getByText('Required')).toBeInTheDocument() 90 + // ToS and Age Declaration are both mandatory 91 + expect(screen.getAllByText('Required')).toHaveLength(2) 87 92 }) 88 93 89 94 it('renders add field button', () => { ··· 143 148 await waitFor(() => { 144 149 expect(screen.getByText('Terms of Service')).toBeInTheDocument() 145 150 }) 146 - expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(2) 147 - expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(2) 151 + expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(3) 152 + expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(3) 148 153 }) 149 154 150 155 it('disables move up on first field and move down on last field', async () => { ··· 161 166 it('renders description text', () => { 162 167 render(<AdminOnboardingPage />) 163 168 expect(screen.getByText(/configure fields that users must complete/i)).toBeInTheDocument() 169 + }) 170 + 171 + it('shows Platform badge for platform-sourced fields', async () => { 172 + render(<AdminOnboardingPage />) 173 + await waitFor(() => { 174 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 175 + }) 176 + expect(screen.getByText('Platform')).toBeInTheDocument() 177 + }) 178 + 179 + it('does not show Platform badge for admin-sourced fields', async () => { 180 + render(<AdminOnboardingPage />) 181 + await waitFor(() => { 182 + expect(screen.getByText('Terms of Service')).toBeInTheDocument() 183 + }) 184 + // Only one Platform badge (for the age_confirmation field) 185 + const badges = screen.getAllByText('Platform') 186 + expect(badges).toHaveLength(1) 187 + }) 188 + 189 + it('does not disable controls for platform fields in selfhosted mode', async () => { 190 + render(<AdminOnboardingPage />) 191 + await waitFor(() => { 192 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 193 + }) 194 + // All edit/delete buttons should be enabled in selfhosted mode 195 + const editButtons = screen.getAllByRole('button', { name: /edit/i }) 196 + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }) 197 + editButtons.forEach((btn) => expect(btn).not.toBeDisabled()) 198 + deleteButtons.forEach((btn) => expect(btn).not.toBeDisabled()) 199 + }) 200 + 201 + it('disables controls for platform fields in SaaS mode', async () => { 202 + server.use( 203 + http.get('/api/admin/onboarding-fields', () => { 204 + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) 205 + }) 206 + ) 207 + render(<AdminOnboardingPage />) 208 + await waitFor(() => { 209 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 210 + }) 211 + // Platform field buttons should be disabled 212 + expect(screen.getByRole('button', { name: /edit age declaration/i })).toBeDisabled() 213 + expect(screen.getByRole('button', { name: /delete age declaration/i })).toBeDisabled() 214 + // Admin field buttons should remain enabled 215 + expect(screen.getByRole('button', { name: /edit terms of service/i })).not.toBeDisabled() 216 + expect(screen.getByRole('button', { name: /delete terms of service/i })).not.toBeDisabled() 217 + }) 218 + 219 + it('shows SaaS note for platform fields in SaaS mode', async () => { 220 + server.use( 221 + http.get('/api/admin/onboarding-fields', () => { 222 + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) 223 + }) 224 + ) 225 + render(<AdminOnboardingPage />) 226 + await waitFor(() => { 227 + expect(screen.getByText('Age Declaration')).toBeInTheDocument() 228 + }) 229 + expect(screen.getByText(/this field is required by the barazo platform/i)).toBeInTheDocument() 164 230 }) 165 231 166 232 it('passes axe accessibility check', async () => {
+2
src/app/admin/onboarding/page.tsx
··· 16 16 export default function AdminOnboardingPage() { 17 17 const { 18 18 fields, 19 + hostingMode, 19 20 loading, 20 21 editing, 21 22 setEditing, ··· 87 88 field={field} 88 89 index={index} 89 90 totalCount={fields.length} 91 + hostingMode={hostingMode} 90 92 onMoveUp={(i) => void handleMoveUp(i)} 91 93 onMoveDown={(i) => void handleMoveDown(i)} 92 94 onEdit={handleEdit}
+74 -55
src/components/admin/onboarding/onboarding-field-item.tsx
··· 5 5 6 6 import { PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' 7 7 import { cn } from '@/lib/utils' 8 - import type { OnboardingField, OnboardingFieldType } from '@/lib/api/types' 8 + import type { OnboardingField, OnboardingFieldType, HostingMode } from '@/lib/api/types' 9 9 10 10 const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 11 11 age_confirmation: 'Age Confirmation', ··· 20 20 field: OnboardingField 21 21 index: number 22 22 totalCount: number 23 + hostingMode: HostingMode 23 24 onMoveUp: (index: number) => void 24 25 onMoveDown: (index: number) => void 25 26 onEdit: (field: OnboardingField) => void ··· 30 31 field, 31 32 index, 32 33 totalCount, 34 + hostingMode, 33 35 onMoveUp, 34 36 onMoveDown, 35 37 onEdit, 36 38 onDelete, 37 39 }: OnboardingFieldItemProps) { 40 + const isPlatform = field.source === 'platform' 41 + const isLockedBySaas = isPlatform && hostingMode === 'saas' 42 + 38 43 return ( 39 - <div className="flex items-center justify-between rounded-md border border-border bg-card p-3"> 40 - <div className="min-w-0 flex-1"> 41 - <div className="flex items-center gap-2"> 42 - <p className="text-sm font-medium text-foreground">{field.label}</p> 43 - <span 44 - className={cn( 45 - 'rounded-full px-2 py-0.5 text-xs font-medium', 46 - 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' 44 + <div className="rounded-md border border-border bg-card p-3"> 45 + <div className="flex items-center justify-between"> 46 + <div className="min-w-0 flex-1"> 47 + <div className="flex items-center gap-2"> 48 + <p className="text-sm font-medium text-foreground">{field.label}</p> 49 + <span 50 + className={cn( 51 + 'rounded-full px-2 py-0.5 text-xs font-medium', 52 + 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' 53 + )} 54 + > 55 + {FIELD_TYPE_LABELS[field.fieldType]} 56 + </span> 57 + {isPlatform && ( 58 + <span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"> 59 + Platform 60 + </span> 61 + )} 62 + {field.isMandatory && ( 63 + <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"> 64 + Required 65 + </span> 47 66 )} 67 + </div> 68 + {field.description && ( 69 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 70 + )} 71 + </div> 72 + <div className="flex items-center gap-1"> 73 + <button 74 + type="button" 75 + onClick={() => onMoveUp(index)} 76 + disabled={index === 0 || isLockedBySaas} 77 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 78 + aria-label={`Move ${field.label} up`} 48 79 > 49 - {FIELD_TYPE_LABELS[field.fieldType]} 50 - </span> 51 - {field.isMandatory && ( 52 - <span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400"> 53 - Required 54 - </span> 55 - )} 80 + <ArrowUp size={16} aria-hidden="true" /> 81 + </button> 82 + <button 83 + type="button" 84 + onClick={() => onMoveDown(index)} 85 + disabled={index === totalCount - 1 || isLockedBySaas} 86 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 87 + aria-label={`Move ${field.label} down`} 88 + > 89 + <ArrowDown size={16} aria-hidden="true" /> 90 + </button> 91 + <button 92 + type="button" 93 + onClick={() => onEdit(field)} 94 + disabled={isLockedBySaas} 95 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 96 + aria-label={`Edit ${field.label}`} 97 + > 98 + <PencilSimple size={16} aria-hidden="true" /> 99 + </button> 100 + <button 101 + type="button" 102 + onClick={() => onDelete(field.id)} 103 + disabled={isLockedBySaas} 104 + className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-30" 105 + aria-label={`Delete ${field.label}`} 106 + > 107 + <TrashSimple size={16} aria-hidden="true" /> 108 + </button> 56 109 </div> 57 - {field.description && ( 58 - <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 59 - )} 60 110 </div> 61 - <div className="flex items-center gap-1"> 62 - <button 63 - type="button" 64 - onClick={() => onMoveUp(index)} 65 - disabled={index === 0} 66 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 67 - aria-label={`Move ${field.label} up`} 68 - > 69 - <ArrowUp size={16} aria-hidden="true" /> 70 - </button> 71 - <button 72 - type="button" 73 - onClick={() => onMoveDown(index)} 74 - disabled={index === totalCount - 1} 75 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30" 76 - aria-label={`Move ${field.label} down`} 77 - > 78 - <ArrowDown size={16} aria-hidden="true" /> 79 - </button> 80 - <button 81 - type="button" 82 - onClick={() => onEdit(field)} 83 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" 84 - aria-label={`Edit ${field.label}`} 85 - > 86 - <PencilSimple size={16} aria-hidden="true" /> 87 - </button> 88 - <button 89 - type="button" 90 - onClick={() => onDelete(field.id)} 91 - className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" 92 - aria-label={`Delete ${field.label}`} 93 - > 94 - <TrashSimple size={16} aria-hidden="true" /> 95 - </button> 96 - </div> 111 + {isLockedBySaas && ( 112 + <p className="mt-2 text-xs text-muted-foreground"> 113 + This field is required by the Barazo platform. Self-host to customize. 114 + </p> 115 + )} 97 116 </div> 98 117 ) 99 118 }
+1
src/components/onboarding-modal.test.tsx
··· 20 20 description: 'A brief introduction.', 21 21 isMandatory: true, 22 22 sortOrder: 0, 23 + source: 'admin', 23 24 config: null, 24 25 createdAt: NOW, 25 26 updatedAt: NOW,
+5 -2
src/hooks/admin/use-onboarding-fields.ts
··· 12 12 deleteOnboardingField, 13 13 reorderOnboardingFields, 14 14 } from '@/lib/api/client' 15 - import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' 15 + import type { OnboardingField, CreateOnboardingFieldInput, HostingMode } from '@/lib/api/types' 16 16 import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' 17 17 import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' 18 18 import { useAuth } from '@/hooks/use-auth' ··· 22 22 const { getAccessToken } = useAuth() 23 23 const { toast } = useToast() 24 24 const [fields, setFields] = useState<OnboardingField[]>([]) 25 + const [hostingMode, setHostingMode] = useState<HostingMode>('selfhosted') 25 26 const [loading, setLoading] = useState(true) 26 27 const [editing, setEditing] = useState<EditingField | null>(null) 27 28 const [saving, setSaving] = useState(false) ··· 32 33 const fetchFields = useCallback(async () => { 33 34 setLoadError(null) 34 35 try { 35 - const fields = await getOnboardingFields(getAccessToken() ?? '') 36 + const { fields, hostingMode } = await getOnboardingFields(getAccessToken() ?? '') 36 37 setFields(fields) 38 + setHostingMode(hostingMode) 37 39 } catch { 38 40 setLoadError('Failed to load onboarding fields. The API may be unreachable.') 39 41 } finally { ··· 143 145 144 146 return { 145 147 fields, 148 + hostingMode, 146 149 loading, 147 150 editing, 148 151 setEditing,
+3 -2
src/lib/api/client.ts
··· 43 43 MaturityRating, 44 44 PluginsResponse, 45 45 OnboardingField, 46 + AdminOnboardingFieldsResponse, 46 47 CreateOnboardingFieldInput, 47 48 UpdateOnboardingFieldInput, 48 49 OnboardingStatus, ··· 773 774 export function getOnboardingFields( 774 775 accessToken: string, 775 776 options?: FetchOptions 776 - ): Promise<OnboardingField[]> { 777 - return apiFetch<OnboardingField[]>('/api/admin/onboarding-fields', { 777 + ): Promise<AdminOnboardingFieldsResponse> { 778 + return apiFetch<AdminOnboardingFieldsResponse>('/api/admin/onboarding-fields', { 778 779 ...options, 779 780 headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, 780 781 })
+7 -1
src/lib/api/types.ts
··· 549 549 | 'custom_select' 550 550 | 'custom_checkbox' 551 551 552 + export type OnboardingFieldSource = 'platform' | 'admin' 553 + 554 + export type HostingMode = 'saas' | 'selfhosted' 555 + 552 556 export interface OnboardingField { 553 557 id: string 554 558 communityDid: string ··· 557 561 description: string | null 558 562 isMandatory: boolean 559 563 sortOrder: number 564 + source: OnboardingFieldSource 560 565 config: Record<string, unknown> | null 561 566 createdAt: string 562 567 updatedAt: string 563 568 } 564 569 565 - export interface OnboardingFieldsResponse { 570 + export interface AdminOnboardingFieldsResponse { 566 571 fields: OnboardingField[] 572 + hostingMode: HostingMode 567 573 } 568 574 569 575 export interface CreateOnboardingFieldInput {
+16
src/mocks/data.ts
··· 1026 1026 description: 'You must accept our community rules to participate.', 1027 1027 isMandatory: true, 1028 1028 sortOrder: 0, 1029 + source: 'admin', 1029 1030 config: { tosUrl: 'https://example.com/tos' }, 1030 1031 createdAt: TWO_DAYS_AGO, 1031 1032 updatedAt: TWO_DAYS_AGO, ··· 1038 1039 description: 'Tell us a bit about yourself and why you joined.', 1039 1040 isMandatory: false, 1040 1041 sortOrder: 1, 1042 + source: 'admin', 1043 + config: null, 1044 + createdAt: TWO_DAYS_AGO, 1045 + updatedAt: TWO_DAYS_AGO, 1046 + }, 1047 + { 1048 + id: 'platform:age_confirmation', 1049 + communityDid: COMMUNITY_DID, 1050 + fieldType: 'age_confirmation', 1051 + label: 'Age Declaration', 1052 + description: 1053 + 'Please select your age bracket. This determines which content is available to you.', 1054 + isMandatory: true, 1055 + sortOrder: -1, 1056 + source: 'platform', 1041 1057 config: null, 1042 1058 createdAt: TWO_DAYS_AGO, 1043 1059 updatedAt: TWO_DAYS_AGO,
+2 -1
src/mocks/handlers.ts
··· 641 641 if (!auth?.startsWith('Bearer ')) { 642 642 return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 643 643 } 644 - return HttpResponse.json(mockOnboardingFields) 644 + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'selfhosted' }) 645 645 }), 646 646 647 647 // POST /api/admin/onboarding-fields ··· 660 660 description: body.description ?? null, 661 661 isMandatory: body.isMandatory ?? true, 662 662 sortOrder: body.sortOrder ?? 0, 663 + source: 'admin' as const, 663 664 config: body.config ?? null, 664 665 createdAt: now, 665 666 updatedAt: now,
+1
src/test/mock-onboarding.tsx
··· 45 45 description: null, 46 46 isMandatory: true, 47 47 sortOrder: 0, 48 + source: 'platform', 48 49 config: null, 49 50 createdAt: '2026-01-01T00:00:00.000Z', 50 51 updatedAt: '2026-01-01T00:00:00.000Z',