Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(onboarding): add global OnboardingProvider to gate all write actions (#139)

Create a global onboarding context that any write action (likes, replies,
new topics) can use to check onboarding completion before proceeding.
Previously only the New Topic page had this gate, causing 403 errors
when unboarded users tried to like or reply.

- Add OnboardingProvider context with ensureOnboarded() fail-open gate
- Add shared test utility (mock-onboarding.tsx)
- Gate LikeButton.handleToggle with ensureOnboarded()
- Gate ReplyComposer.handleSubmit with ensureOnboarded()
- Migrate New Topic page from standalone hook to context
- Wire OnboardingProvider into layout.tsx provider stack
- Add onboarding context mocks to transitive test files

authored by

Guido X Jansen and committed by
GitHub
bb325c65 c2361249

+713 -39
+4 -1
src/app/layout.tsx
··· 4 4 import { ThemeProvider } from '@/components/theme-provider' 5 5 import { AuthProvider } from '@/context/auth-context' 6 6 import { AppToastProvider } from '@/context/toast-context' 7 + import { OnboardingProvider } from '@/context/onboarding-context' 7 8 import { SetupGuard } from '@/components/setup-guard' 8 9 9 10 const sourceCodePro = Source_Code_Pro({ ··· 55 56 > 56 57 <AuthProvider> 57 58 <AppToastProvider> 58 - <SetupGuard>{children}</SetupGuard> 59 + <OnboardingProvider> 60 + <SetupGuard>{children}</SetupGuard> 61 + </OnboardingProvider> 59 62 </AppToastProvider> 60 63 </AuthProvider> 61 64 </ThemeProvider>
+5
src/app/new/page.test.tsx
··· 6 6 import { render, screen, cleanup } from '@testing-library/react' 7 7 import { setupServer } from 'msw/node' 8 8 import { handlers } from '@/mocks/handlers' 9 + import { createMockOnboardingContext } from '@/test/mock-onboarding' 9 10 import NewTopicPage from './page' 11 + 12 + vi.mock('@/context/onboarding-context', () => ({ 13 + useOnboardingContext: () => createMockOnboardingContext(), 14 + })) 10 15 11 16 const server = setupServer(...handlers) 12 17
+6 -36
src/app/new/page.tsx
··· 7 7 8 8 'use client' 9 9 10 - import { useState, useRef, useEffect } from 'react' 10 + import { useState, useEffect } from 'react' 11 11 import { useRouter, useSearchParams } from 'next/navigation' 12 12 import type { CreateTopicInput } from '@/lib/api/types' 13 13 import { createTopic, getPublicSettings } from '@/lib/api/client' ··· 15 15 import { ForumLayout } from '@/components/layout/forum-layout' 16 16 import { Breadcrumbs } from '@/components/breadcrumbs' 17 17 import { TopicForm } from '@/components/topic-form' 18 - import { OnboardingModal } from '@/components/onboarding-modal' 19 - import { useOnboarding } from '@/hooks/use-onboarding' 18 + import { useOnboardingContext } from '@/context/onboarding-context' 20 19 import { useAuth } from '@/hooks/use-auth' 21 20 22 21 export default function NewTopicPage() { ··· 24 23 const searchParams = useSearchParams() 25 24 const initialCategory = searchParams.get('category') ?? '' 26 25 const { getAccessToken } = useAuth() 26 + const { ensureOnboarded } = useOnboardingContext() 27 27 const [submitting, setSubmitting] = useState(false) 28 28 const [error, setError] = useState<string | null>(null) 29 29 const [communityName, setCommunityName] = useState('') 30 - const onboarding = useOnboarding() 31 - const pendingValues = useRef<CreateTopicInput | null>(null) 32 30 33 31 useEffect(() => { 34 32 getPublicSettings() ··· 36 34 .catch(() => {}) 37 35 }, []) 38 36 39 - const doSubmit = async (values: CreateTopicInput) => { 37 + const handleSubmit = async (values: CreateTopicInput) => { 38 + if (!ensureOnboarded()) return 39 + 40 40 setSubmitting(true) 41 41 setError(null) 42 42 ··· 50 50 } 51 51 } 52 52 53 - const handleSubmit = async (values: CreateTopicInput) => { 54 - if (!onboarding.loading && !onboarding.complete) { 55 - pendingValues.current = values 56 - onboarding.openModal() 57 - return 58 - } 59 - await doSubmit(values) 60 - } 61 - 62 - const handleOnboardingComplete = async ( 63 - responses: Array<{ fieldId: string; response: unknown }> 64 - ) => { 65 - const success = await onboarding.submit(responses) 66 - if (success && pendingValues.current) { 67 - await doSubmit(pendingValues.current) 68 - pendingValues.current = null 69 - } 70 - return success 71 - } 72 - 73 53 return ( 74 54 <ForumLayout communityName={communityName}> 75 55 <div className="space-y-6"> ··· 90 70 onSubmit={handleSubmit} 91 71 submitting={submitting} 92 72 initialValues={{ category: initialCategory }} 93 - /> 94 - 95 - <OnboardingModal 96 - open={onboarding.showModal} 97 - fields={onboarding.status?.fields ?? []} 98 - onSubmit={handleOnboardingComplete} 99 - onCancel={() => { 100 - onboarding.closeModal() 101 - pendingValues.current = null 102 - }} 103 73 /> 104 74 </div> 105 75 </ForumLayout>
+46
src/components/like-button.test.tsx
··· 8 8 import userEvent from '@testing-library/user-event' 9 9 import { axe } from 'vitest-axe' 10 10 import { LikeButton } from './like-button' 11 + import { createMockOnboardingContext } from '@/test/mock-onboarding' 12 + import type { OnboardingContextValue } from '@/context/onboarding-context' 11 13 12 14 // --- Mocks --- 13 15 ··· 35 37 }), 36 38 })) 37 39 40 + let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() 41 + 42 + vi.mock('@/context/onboarding-context', () => ({ 43 + useOnboardingContext: () => mockOnboardingContext, 44 + })) 45 + 38 46 vi.mock('@/lib/api/client', () => ({ 39 47 getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), 40 48 createReaction: vi.fn().mockResolvedValue({ ··· 62 70 mockToast.mockReset() 63 71 mockGetAccessToken.mockReturnValue('mock-access-token') 64 72 mockAuthFetch.mockReset() 73 + mockOnboardingContext = createMockOnboardingContext() 65 74 vi.mocked(getReactions).mockResolvedValue({ reactions: [], cursor: null }) 66 75 vi.mocked(createReaction).mockResolvedValue({ 67 76 uri: 'at://did:plc:user-test-001/forum.barazo.interaction.reaction/abc123', ··· 352 361 mockGetAccessToken.mockReturnValue(null as unknown as string) 353 362 render(<LikeButton {...defaultProps} />) 354 363 expect(screen.getByRole('button')).toBeDisabled() 364 + }) 365 + }) 366 + 367 + describe('onboarding gate', () => { 368 + it('does not call createReaction when ensureOnboarded returns false', async () => { 369 + mockOnboardingContext = createMockOnboardingContext({ 370 + ensureOnboarded: vi.fn(() => false), 371 + }) 372 + 373 + const user = userEvent.setup() 374 + render(<LikeButton {...defaultProps} />) 375 + 376 + await waitFor(() => { 377 + expect(getReactions).toHaveBeenCalled() 378 + }) 379 + 380 + await user.click(screen.getByRole('button')) 381 + 382 + expect(createReaction).not.toHaveBeenCalled() 383 + expect(deleteReaction).not.toHaveBeenCalled() 384 + }) 385 + 386 + it('proceeds normally when ensureOnboarded returns true', async () => { 387 + mockOnboardingContext = createMockOnboardingContext({ 388 + ensureOnboarded: vi.fn(() => true), 389 + }) 390 + 391 + const user = userEvent.setup() 392 + render(<LikeButton {...defaultProps} />) 393 + 394 + await waitFor(() => { 395 + expect(getReactions).toHaveBeenCalled() 396 + }) 397 + 398 + await user.click(screen.getByRole('button')) 399 + 400 + expect(createReaction).toHaveBeenCalled() 355 401 }) 356 402 }) 357 403
+4 -1
src/components/like-button.tsx
··· 9 9 import { useState, useEffect, useCallback, useRef } from 'react' 10 10 import { Heart } from '@phosphor-icons/react' 11 11 import { useAuth } from '@/hooks/use-auth' 12 + import { useOnboardingContext } from '@/context/onboarding-context' 12 13 import { useToast } from '@/hooks/use-toast' 13 14 import { getReactions, createReaction, deleteReaction } from '@/lib/api/client' 14 15 import { cn } from '@/lib/utils' ··· 30 31 className, 31 32 }: LikeButtonProps) { 32 33 const { user, isAuthenticated, getAccessToken } = useAuth() 34 + const { ensureOnboarded } = useOnboardingContext() 33 35 const { toast } = useToast() 34 36 const [liked, setLiked] = useState(false) 35 37 const [count, setCount] = useState(initialCount) ··· 73 75 }, [subjectUri, isAuthenticated, user, getAccessToken]) 74 76 75 77 const handleToggle = useCallback(async () => { 78 + if (!ensureOnboarded()) return 76 79 const token = getAccessToken() 77 80 if (!token || pending) return 78 81 ··· 109 112 } finally { 110 113 setPending(false) 111 114 } 112 - }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, toast]) 115 + }, [liked, count, pending, subjectUri, subjectCid, getAccessToken, ensureOnboarded, toast]) 113 116 114 117 const iconSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4' 115 118
+62
src/components/reply-composer.test.tsx
··· 8 8 import { axe } from 'vitest-axe' 9 9 import { ReplyComposer } from './reply-composer' 10 10 import type { ReplyTarget } from './reply-composer' 11 + import { createMockOnboardingContext } from '@/test/mock-onboarding' 12 + import type { OnboardingContextValue } from '@/context/onboarding-context' 11 13 12 14 const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 13 15 const mockToast = vi.fn() ··· 43 45 createReply: (...args: unknown[]) => mockCreateReply(...args), 44 46 })) 45 47 48 + let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() 49 + 50 + vi.mock('@/context/onboarding-context', () => ({ 51 + useOnboardingContext: () => mockOnboardingContext, 52 + })) 53 + 46 54 const defaultProps = { 47 55 topicUri: 'at://did:plc:abc/forum.barazo.topic/123', 48 56 topicCid: 'bafyreiabc123', ··· 59 67 60 68 beforeEach(() => { 61 69 vi.clearAllMocks() 70 + mockOnboardingContext = createMockOnboardingContext() 62 71 mockCreateReply.mockResolvedValue({ 63 72 uri: 'at://did:plc:user-test-001/forum.barazo.reply/789', 64 73 cid: 'bafyrei789', ··· 325 334 }) 326 335 // Should still be expanded with content intact 327 336 expect(screen.getByRole('textbox', { name: 'Reply' })).toBeInTheDocument() 337 + }) 338 + }) 339 + 340 + describe('onboarding gate', () => { 341 + it('does not call createReply when ensureOnboarded returns false', async () => { 342 + mockOnboardingContext = createMockOnboardingContext({ 343 + ensureOnboarded: vi.fn(() => false), 344 + }) 345 + 346 + const user = userEvent.setup() 347 + render(<ReplyComposer {...defaultProps} />) 348 + 349 + await user.click(screen.getByText('Write a reply...')) 350 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 351 + await user.type(textarea, 'My test reply') 352 + await user.click(screen.getByRole('button', { name: 'Reply' })) 353 + 354 + expect(mockCreateReply).not.toHaveBeenCalled() 355 + }) 356 + 357 + it('preserves content when ensureOnboarded returns false', async () => { 358 + mockOnboardingContext = createMockOnboardingContext({ 359 + ensureOnboarded: vi.fn(() => false), 360 + }) 361 + 362 + const user = userEvent.setup() 363 + render(<ReplyComposer {...defaultProps} />) 364 + 365 + await user.click(screen.getByText('Write a reply...')) 366 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 367 + await user.type(textarea, 'My test reply') 368 + await user.click(screen.getByRole('button', { name: 'Reply' })) 369 + 370 + // Content should still be there 371 + expect(screen.getByRole('textbox', { name: 'Reply' })).toHaveValue('My test reply') 372 + }) 373 + 374 + it('proceeds normally when ensureOnboarded returns true', async () => { 375 + mockOnboardingContext = createMockOnboardingContext({ 376 + ensureOnboarded: vi.fn(() => true), 377 + }) 378 + 379 + const user = userEvent.setup() 380 + render(<ReplyComposer {...defaultProps} />) 381 + 382 + await user.click(screen.getByText('Write a reply...')) 383 + const textarea = screen.getByRole('textbox', { name: 'Reply' }) 384 + await user.type(textarea, 'My test reply') 385 + await user.click(screen.getByRole('button', { name: 'Reply' })) 386 + 387 + await waitFor(() => { 388 + expect(mockCreateReply).toHaveBeenCalled() 389 + }) 328 390 }) 329 391 }) 330 392
+4 -1
src/components/reply-composer.tsx
··· 9 9 import { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from 'react' 10 10 import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' 11 11 import { useAuth } from '@/hooks/use-auth' 12 + import { useOnboardingContext } from '@/context/onboarding-context' 12 13 import { useToast } from '@/hooks/use-toast' 13 14 import { createReply } from '@/lib/api/client' 14 15 import { MarkdownEditor } from '@/components/markdown-editor' ··· 53 54 ref 54 55 ) { 55 56 const { getAccessToken } = useAuth() 57 + const { ensureOnboarded } = useOnboardingContext() 56 58 const { toast } = useToast() 57 59 const [isExpanded, setIsExpanded] = useState(false) 58 60 const [content, setContent] = useState(initialContent) ··· 124 126 }, []) 125 127 126 128 const handleSubmit = useCallback(async () => { 129 + if (!ensureOnboarded()) return 127 130 const trimmed = content.trim() 128 131 if (!trimmed) return 129 132 ··· 148 151 } finally { 149 152 setSubmitting(false) 150 153 } 151 - }, [content, topicUri, replyTarget, getAccessToken, onReplyCreated, toast]) 154 + }, [content, topicUri, replyTarget, getAccessToken, ensureOnboarded, onReplyCreated, toast]) 152 155 153 156 if (isLocked) { 154 157 return (
+5
src/components/topic-detail-client.test.tsx
··· 8 8 import { axe } from 'vitest-axe' 9 9 import { TopicDetailClient } from './topic-detail-client' 10 10 import { mockTopics, mockReplies } from '@/mocks/data' 11 + import { createMockOnboardingContext } from '@/test/mock-onboarding' 12 + 13 + vi.mock('@/context/onboarding-context', () => ({ 14 + useOnboardingContext: () => createMockOnboardingContext(), 15 + })) 11 16 12 17 const mockRouterRefresh = vi.fn() 13 18
+370
src/context/onboarding-context.test.tsx
··· 1 + /** 2 + * Tests for OnboardingProvider context. 3 + * Validates onboarding status fetching, ensureOnboarded gate logic, 4 + * modal rendering, and submission flow. 5 + */ 6 + 7 + import { describe, it, expect, vi, beforeEach } from 'vitest' 8 + import { render, screen, waitFor, act } from '@testing-library/react' 9 + import userEvent from '@testing-library/user-event' 10 + import { OnboardingProvider, useOnboardingContext } from './onboarding-context' 11 + 12 + // --- Mocks --- 13 + 14 + const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 15 + let mockIsAuthenticated = true 16 + let mockAuthLoading = false 17 + 18 + vi.mock('@/hooks/use-auth', () => ({ 19 + useAuth: () => ({ 20 + user: mockIsAuthenticated ? { did: 'did:plc:user-test-001', handle: 'test.bsky.social' } : null, 21 + isAuthenticated: mockIsAuthenticated, 22 + isLoading: mockAuthLoading, 23 + getAccessToken: mockGetAccessToken, 24 + login: vi.fn(), 25 + logout: vi.fn(), 26 + setSessionFromCallback: vi.fn(), 27 + authFetch: vi.fn(), 28 + crossPostScopesGranted: false, 29 + requestCrossPostAuth: vi.fn(), 30 + }), 31 + })) 32 + 33 + const mockGetOnboardingStatus = vi.fn() 34 + const mockSubmitOnboarding = vi.fn() 35 + 36 + vi.mock('@/lib/api/client', () => ({ 37 + getOnboardingStatus: (...args: unknown[]) => mockGetOnboardingStatus(...args), 38 + submitOnboarding: (...args: unknown[]) => mockSubmitOnboarding(...args), 39 + })) 40 + 41 + const completeStatus = { 42 + complete: true, 43 + fields: [], 44 + responses: {}, 45 + missingFields: [], 46 + } 47 + 48 + const incompleteStatus = { 49 + complete: false, 50 + fields: [ 51 + { 52 + id: 'age-field', 53 + communityDid: 'did:plc:community-001', 54 + fieldType: 'age_confirmation', 55 + label: 'Confirm your age', 56 + description: null, 57 + isMandatory: true, 58 + sortOrder: 0, 59 + config: null, 60 + createdAt: '2026-01-01T00:00:00.000Z', 61 + updatedAt: '2026-01-01T00:00:00.000Z', 62 + }, 63 + ], 64 + responses: {}, 65 + missingFields: [{ id: 'age-field', label: 'Confirm your age', fieldType: 'age_confirmation' }], 66 + } 67 + 68 + beforeEach(() => { 69 + vi.clearAllMocks() 70 + mockIsAuthenticated = true 71 + mockAuthLoading = false 72 + mockGetAccessToken.mockReturnValue('mock-access-token') 73 + mockGetOnboardingStatus.mockResolvedValue(completeStatus) 74 + mockSubmitOnboarding.mockResolvedValue({ success: true }) 75 + }) 76 + 77 + /** Test consumer that exposes context values */ 78 + function TestConsumer() { 79 + const ctx = useOnboardingContext() 80 + return ( 81 + <div> 82 + <span data-testid="loading">{String(ctx.loading)}</span> 83 + <span data-testid="complete">{String(ctx.complete)}</span> 84 + <span data-testid="showModal">{String(ctx.showModal)}</span> 85 + <button type="button" onClick={() => ctx.ensureOnboarded()} data-testid="ensure-btn"> 86 + Ensure 87 + </button> 88 + <button 89 + type="button" 90 + onClick={() => void ctx.submit([{ fieldId: 'age-field', response: true }])} 91 + data-testid="submit-btn" 92 + > 93 + Submit 94 + </button> 95 + </div> 96 + ) 97 + } 98 + 99 + function renderWithProvider() { 100 + return render( 101 + <OnboardingProvider> 102 + <TestConsumer /> 103 + </OnboardingProvider> 104 + ) 105 + } 106 + 107 + describe('OnboardingProvider', () => { 108 + describe('rendering', () => { 109 + it('renders children', () => { 110 + render( 111 + <OnboardingProvider> 112 + <span>child content</span> 113 + </OnboardingProvider> 114 + ) 115 + expect(screen.getByText('child content')).toBeInTheDocument() 116 + }) 117 + }) 118 + 119 + describe('fetching status', () => { 120 + it('fetches onboarding status when authenticated', async () => { 121 + renderWithProvider() 122 + 123 + await waitFor(() => { 124 + expect(mockGetOnboardingStatus).toHaveBeenCalledWith('mock-access-token') 125 + }) 126 + }) 127 + 128 + it('does not fetch status when unauthenticated', async () => { 129 + mockIsAuthenticated = false 130 + mockGetAccessToken.mockReturnValue(null) 131 + 132 + renderWithProvider() 133 + 134 + await act(async () => { 135 + await new Promise((r) => setTimeout(r, 50)) 136 + }) 137 + 138 + expect(mockGetOnboardingStatus).not.toHaveBeenCalled() 139 + }) 140 + 141 + it('does not fetch status while auth is loading', async () => { 142 + mockAuthLoading = true 143 + 144 + renderWithProvider() 145 + 146 + await act(async () => { 147 + await new Promise((r) => setTimeout(r, 50)) 148 + }) 149 + 150 + expect(mockGetOnboardingStatus).not.toHaveBeenCalled() 151 + }) 152 + 153 + it('sets complete to true when status.complete is true', async () => { 154 + mockGetOnboardingStatus.mockResolvedValue(completeStatus) 155 + 156 + renderWithProvider() 157 + 158 + await waitFor(() => { 159 + expect(screen.getByTestId('complete')).toHaveTextContent('true') 160 + }) 161 + }) 162 + 163 + it('sets complete to false when status.complete is false', async () => { 164 + mockGetOnboardingStatus.mockResolvedValue(incompleteStatus) 165 + 166 + renderWithProvider() 167 + 168 + await waitFor(() => { 169 + expect(screen.getByTestId('complete')).toHaveTextContent('false') 170 + }) 171 + }) 172 + 173 + it('sets loading to false after fetch completes', async () => { 174 + renderWithProvider() 175 + 176 + await waitFor(() => { 177 + expect(screen.getByTestId('loading')).toHaveTextContent('false') 178 + }) 179 + }) 180 + }) 181 + 182 + describe('ensureOnboarded', () => { 183 + it('returns true when onboarding is complete', async () => { 184 + mockGetOnboardingStatus.mockResolvedValue(completeStatus) 185 + 186 + let result: boolean | undefined 187 + function Consumer() { 188 + const ctx = useOnboardingContext() 189 + return ( 190 + <button 191 + type="button" 192 + onClick={() => { 193 + result = ctx.ensureOnboarded() 194 + }} 195 + > 196 + Check 197 + </button> 198 + ) 199 + } 200 + 201 + render( 202 + <OnboardingProvider> 203 + <Consumer /> 204 + </OnboardingProvider> 205 + ) 206 + 207 + await waitFor(() => { 208 + expect(mockGetOnboardingStatus).toHaveBeenCalled() 209 + }) 210 + 211 + const user = userEvent.setup() 212 + await user.click(screen.getByRole('button', { name: 'Check' })) 213 + 214 + expect(result).toBe(true) 215 + }) 216 + 217 + it('returns true when loading (fail-open)', async () => { 218 + mockGetOnboardingStatus.mockImplementation(() => new Promise(() => {})) 219 + 220 + let result: boolean | undefined 221 + function Consumer() { 222 + const ctx = useOnboardingContext() 223 + return ( 224 + <button 225 + type="button" 226 + onClick={() => { 227 + result = ctx.ensureOnboarded() 228 + }} 229 + > 230 + Check 231 + </button> 232 + ) 233 + } 234 + 235 + render( 236 + <OnboardingProvider> 237 + <Consumer /> 238 + </OnboardingProvider> 239 + ) 240 + 241 + const user = userEvent.setup() 242 + await user.click(screen.getByRole('button', { name: 'Check' })) 243 + 244 + expect(result).toBe(true) 245 + }) 246 + 247 + it('returns true when unauthenticated (fail-open)', async () => { 248 + mockIsAuthenticated = false 249 + mockGetAccessToken.mockReturnValue(null) 250 + 251 + let result: boolean | undefined 252 + function Consumer() { 253 + const ctx = useOnboardingContext() 254 + return ( 255 + <button 256 + type="button" 257 + onClick={() => { 258 + result = ctx.ensureOnboarded() 259 + }} 260 + > 261 + Check 262 + </button> 263 + ) 264 + } 265 + 266 + render( 267 + <OnboardingProvider> 268 + <Consumer /> 269 + </OnboardingProvider> 270 + ) 271 + 272 + const user = userEvent.setup() 273 + await user.click(screen.getByRole('button', { name: 'Check' })) 274 + 275 + expect(result).toBe(true) 276 + }) 277 + 278 + it('returns false and opens modal when incomplete', async () => { 279 + mockGetOnboardingStatus.mockResolvedValue(incompleteStatus) 280 + 281 + let result: boolean | undefined 282 + function Consumer() { 283 + const ctx = useOnboardingContext() 284 + return ( 285 + <> 286 + <span data-testid="modal-state">{String(ctx.showModal)}</span> 287 + <button 288 + type="button" 289 + onClick={() => { 290 + result = ctx.ensureOnboarded() 291 + }} 292 + > 293 + Check 294 + </button> 295 + </> 296 + ) 297 + } 298 + 299 + render( 300 + <OnboardingProvider> 301 + <Consumer /> 302 + </OnboardingProvider> 303 + ) 304 + 305 + await waitFor(() => { 306 + expect(mockGetOnboardingStatus).toHaveBeenCalled() 307 + }) 308 + 309 + await act(async () => { 310 + await new Promise((r) => setTimeout(r, 10)) 311 + }) 312 + 313 + const user = userEvent.setup() 314 + await user.click(screen.getByRole('button', { name: 'Check' })) 315 + 316 + expect(result).toBe(false) 317 + expect(screen.getByTestId('modal-state')).toHaveTextContent('true') 318 + }) 319 + }) 320 + 321 + describe('submit', () => { 322 + it('calls API, refreshes status, and closes modal on success', async () => { 323 + mockGetOnboardingStatus 324 + .mockResolvedValueOnce(incompleteStatus) 325 + .mockResolvedValueOnce(completeStatus) 326 + 327 + renderWithProvider() 328 + 329 + await waitFor(() => { 330 + expect(screen.getByTestId('complete')).toHaveTextContent('false') 331 + }) 332 + 333 + const user = userEvent.setup() 334 + await user.click(screen.getByTestId('ensure-btn')) 335 + 336 + expect(screen.getByTestId('showModal')).toHaveTextContent('true') 337 + 338 + await user.click(screen.getByTestId('submit-btn')) 339 + 340 + await waitFor(() => { 341 + expect(mockSubmitOnboarding).toHaveBeenCalledWith( 342 + { responses: [{ fieldId: 'age-field', response: true }] }, 343 + 'mock-access-token' 344 + ) 345 + }) 346 + 347 + await waitFor(() => { 348 + expect(screen.getByTestId('complete')).toHaveTextContent('true') 349 + expect(screen.getByTestId('showModal')).toHaveTextContent('false') 350 + }) 351 + }) 352 + }) 353 + 354 + describe('useOnboardingContext outside provider', () => { 355 + it('throws when used outside OnboardingProvider', () => { 356 + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 357 + 358 + function BadConsumer() { 359 + useOnboardingContext() 360 + return null 361 + } 362 + 363 + expect(() => render(<BadConsumer />)).toThrow( 364 + 'useOnboardingContext must be used within an OnboardingProvider' 365 + ) 366 + 367 + consoleSpy.mockRestore() 368 + }) 369 + }) 370 + })
+144
src/context/onboarding-context.tsx
··· 1 + /** 2 + * Global onboarding context provider. 3 + * Fetches onboarding status on mount (when authenticated) and provides 4 + * ensureOnboarded() for any write action to gate behind onboarding completion. 5 + * Renders the OnboardingModal globally so individual pages don't need to. 6 + * @see specs/prd-web.md Section M5 (Onboarding) 7 + */ 8 + 9 + 'use client' 10 + 11 + import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' 12 + import type { ReactNode } from 'react' 13 + import type { OnboardingStatus } from '@/lib/api/types' 14 + import { getOnboardingStatus, submitOnboarding } from '@/lib/api/client' 15 + import { useAuth } from '@/hooks/use-auth' 16 + import { OnboardingModal } from '@/components/onboarding-modal' 17 + 18 + export interface OnboardingContextValue { 19 + /** Whether onboarding status is still loading */ 20 + loading: boolean 21 + /** Whether onboarding is complete (false when status is null/loading) */ 22 + complete: boolean 23 + /** Whether the onboarding modal is currently shown */ 24 + showModal: boolean 25 + /** Full onboarding status from the API */ 26 + status: OnboardingStatus | null 27 + /** 28 + * Gate function for write actions. Returns true if the user may proceed. 29 + * Fail-open: returns true when loading, unauthenticated, or status is null. 30 + * Returns false and opens the modal when onboarding is incomplete. 31 + */ 32 + ensureOnboarded: () => boolean 33 + /** Submit onboarding responses, refresh status, close modal on success */ 34 + submit: (responses: Array<{ fieldId: string; response: unknown }>) => Promise<boolean> 35 + /** Close the onboarding modal */ 36 + closeModal: () => void 37 + } 38 + 39 + const OnboardingContext = createContext<OnboardingContextValue | null>(null) 40 + 41 + interface OnboardingProviderProps { 42 + children: ReactNode 43 + } 44 + 45 + export function OnboardingProvider({ children }: OnboardingProviderProps) { 46 + const { isAuthenticated, isLoading: authLoading, getAccessToken } = useAuth() 47 + const [loading, setLoading] = useState(true) 48 + const [status, setStatus] = useState<OnboardingStatus | null>(null) 49 + const [showModal, setShowModal] = useState(false) 50 + 51 + const fetchStatus = useCallback(async () => { 52 + const token = getAccessToken() 53 + if (!token) { 54 + setLoading(false) 55 + return 56 + } 57 + 58 + try { 59 + const result = await getOnboardingStatus(token) 60 + setStatus(result) 61 + } catch { 62 + setStatus(null) 63 + } finally { 64 + setLoading(false) 65 + } 66 + }, [getAccessToken]) 67 + 68 + useEffect(() => { 69 + if (!isAuthenticated || authLoading) { 70 + setLoading(false) 71 + return 72 + } 73 + void fetchStatus() 74 + }, [isAuthenticated, authLoading, fetchStatus]) 75 + 76 + const ensureOnboarded = useCallback((): boolean => { 77 + // Fail-open: allow action when loading, unauthenticated, or status unknown 78 + if (loading || !isAuthenticated || status === null) { 79 + return true 80 + } 81 + 82 + if (status.complete) { 83 + return true 84 + } 85 + 86 + // Onboarding incomplete -- open modal 87 + setShowModal(true) 88 + return false 89 + }, [loading, isAuthenticated, status]) 90 + 91 + const submit = useCallback( 92 + async (responses: Array<{ fieldId: string; response: unknown }>): Promise<boolean> => { 93 + const token = getAccessToken() 94 + if (!token) return false 95 + 96 + try { 97 + await submitOnboarding({ responses }, token) 98 + await fetchStatus() 99 + setShowModal(false) 100 + return true 101 + } catch { 102 + return false 103 + } 104 + }, 105 + [fetchStatus, getAccessToken] 106 + ) 107 + 108 + const closeModal = useCallback(() => { 109 + setShowModal(false) 110 + }, []) 111 + 112 + const value = useMemo<OnboardingContextValue>( 113 + () => ({ 114 + loading, 115 + complete: status?.complete ?? false, 116 + showModal, 117 + status, 118 + ensureOnboarded, 119 + submit, 120 + closeModal, 121 + }), 122 + [loading, showModal, status, ensureOnboarded, submit, closeModal] 123 + ) 124 + 125 + return ( 126 + <OnboardingContext.Provider value={value}> 127 + {children} 128 + <OnboardingModal 129 + open={showModal} 130 + fields={status?.fields ?? []} 131 + onSubmit={submit} 132 + onCancel={closeModal} 133 + /> 134 + </OnboardingContext.Provider> 135 + ) 136 + } 137 + 138 + export function useOnboardingContext(): OnboardingContextValue { 139 + const context = useContext(OnboardingContext) 140 + if (!context) { 141 + throw new Error('useOnboardingContext must be used within an OnboardingProvider') 142 + } 143 + return context 144 + }
+63
src/test/mock-onboarding.tsx
··· 1 + /** 2 + * Shared test utility for mocking onboarding context. 3 + * Use createMockOnboardingContext() to get default overrides for tests 4 + * that render components gated by onboarding. 5 + */ 6 + 7 + import { vi } from 'vitest' 8 + import type { OnboardingContextValue } from '@/context/onboarding-context' 9 + 10 + export function createMockOnboardingContext( 11 + overrides: Partial<OnboardingContextValue> = {} 12 + ): OnboardingContextValue { 13 + return { 14 + loading: false, 15 + complete: true, 16 + showModal: false, 17 + status: { 18 + complete: true, 19 + fields: [], 20 + responses: {}, 21 + missingFields: [], 22 + }, 23 + ensureOnboarded: vi.fn(() => true), 24 + submit: vi.fn().mockResolvedValue(true), 25 + closeModal: vi.fn(), 26 + ...overrides, 27 + } 28 + } 29 + 30 + export function createIncompleteOnboardingContext( 31 + overrides: Partial<OnboardingContextValue> = {} 32 + ): OnboardingContextValue { 33 + return { 34 + loading: false, 35 + complete: false, 36 + showModal: false, 37 + status: { 38 + complete: false, 39 + fields: [ 40 + { 41 + id: 'age-field', 42 + communityDid: 'did:plc:community-001', 43 + fieldType: 'age_confirmation', 44 + label: 'Confirm your age', 45 + description: null, 46 + isMandatory: true, 47 + sortOrder: 0, 48 + config: null, 49 + createdAt: '2026-01-01T00:00:00.000Z', 50 + updatedAt: '2026-01-01T00:00:00.000Z', 51 + }, 52 + ], 53 + responses: {}, 54 + missingFields: [ 55 + { id: 'age-field', label: 'Confirm your age', fieldType: 'age_confirmation' }, 56 + ], 57 + }, 58 + ensureOnboarded: vi.fn(() => false), 59 + submit: vi.fn().mockResolvedValue(true), 60 + closeModal: vi.fn(), 61 + ...overrides, 62 + } 63 + }