Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(auth): M3 auth flow -- AT Protocol OAuth (#26)

* feat(auth): add auth API types, client functions, and MSW handlers

Add AuthSession/AuthUser types with displayName and avatarUrl fields.
Add initiateLogin, handleCallback, refreshSession, logout, and
getCurrentUser API client functions. Add corresponding MSW mock
handlers for all auth endpoints.

* feat(auth): add auth-aware fetch wrapper with 401 retry

Creates createAuthFetch factory that intercepts 401 responses,
attempts silent token refresh, and retries the request once.
Deduplicates concurrent refresh requests.

* feat(auth): add AuthProvider context and useAuth hook

AuthProvider stores access token in useRef (memory only, never
localStorage). Attempts silent refresh on mount via HTTP-only cookie.
Exposes login, logout, setSessionFromCallback, getAccessToken, and
authFetch. useAuth hook provides runtime guard for provider boundary.
Wraps children in root layout.

* feat(auth): ✨ add login, callback, and protected route pages

Login page accepts AT Protocol handle and redirects to PDS OAuth.
Callback page processes code/state params and sets session in context.
Both wrapped in Suspense for useSearchParams. ProtectedRoute redirects
unauthenticated users to /login with returnTo param.

* feat(auth): ✨ add UserMenu dropdown and integrate in forum layout

UserMenu shows avatar dropdown for authenticated users with profile,
settings, and logout links. Shows login button for guests. Uses Radix
DropdownMenu via shadcn/ui. Replaces notification bell area in header.

* refactor(auth): replace MOCK_TOKEN and localStorage with useAuth

Migrate all pages from hardcoded MOCK_TOKEN and localStorage-based
token retrieval to getAccessToken() from the useAuth hook. Removes
all direct token storage access in favor of memory-only tokens.

* test(auth): add auth flow tests and update existing test mocks

Add tests for AuthProvider, auth-fetch wrapper, login page, callback
page, protected route, and user menu. Add useAuth mock to all existing
tests that render components using the auth context. Make getAccessToken
mock configurable for tests checking unauthenticated behavior.

* fix(auth): type mock getAccessToken to accept null return value

authored by

Guido X Jansen and committed by
GitHub
13dd73b8 37d080b5

+2083 -77
+113
src/__tests__/auth/auth-context.test.tsx
··· 1 + /** 2 + * Tests for AuthProvider and auth context. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor, act } from '@testing-library/react' 7 + import { AuthProvider } from '@/context/auth-context' 8 + import { useAuth } from '@/hooks/use-auth' 9 + import { http, HttpResponse } from 'msw' 10 + import { server } from '@/mocks/server' 11 + 12 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 13 + 14 + // Test component that exposes auth context values 15 + function AuthDisplay() { 16 + const { user, isAuthenticated, isLoading } = useAuth() 17 + return ( 18 + <div> 19 + <span data-testid="loading">{String(isLoading)}</span> 20 + <span data-testid="authenticated">{String(isAuthenticated)}</span> 21 + <span data-testid="handle">{user?.handle ?? 'none'}</span> 22 + </div> 23 + ) 24 + } 25 + 26 + function LogoutButton() { 27 + const { logout } = useAuth() 28 + return <button onClick={() => void logout()}>Logout</button> 29 + } 30 + 31 + describe('AuthProvider', () => { 32 + it('renders children', () => { 33 + render( 34 + <AuthProvider> 35 + <span>child content</span> 36 + </AuthProvider> 37 + ) 38 + expect(screen.getByText('child content')).toBeInTheDocument() 39 + }) 40 + 41 + it('starts in loading state', () => { 42 + // Override refresh to never resolve 43 + server.use( 44 + http.post(`${API_URL}/api/auth/refresh`, () => { 45 + return new Promise(() => {}) 46 + }) 47 + ) 48 + render( 49 + <AuthProvider> 50 + <AuthDisplay /> 51 + </AuthProvider> 52 + ) 53 + expect(screen.getByTestId('loading')).toHaveTextContent('true') 54 + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') 55 + }) 56 + 57 + it('authenticates after successful silent refresh', async () => { 58 + render( 59 + <AuthProvider> 60 + <AuthDisplay /> 61 + </AuthProvider> 62 + ) 63 + await waitFor(() => { 64 + expect(screen.getByTestId('loading')).toHaveTextContent('false') 65 + }) 66 + expect(screen.getByTestId('authenticated')).toHaveTextContent('true') 67 + expect(screen.getByTestId('handle')).toHaveTextContent('alice.bsky.social') 68 + }) 69 + 70 + it('stays unauthenticated when refresh fails', async () => { 71 + server.use( 72 + http.post(`${API_URL}/api/auth/refresh`, () => { 73 + return HttpResponse.json({ error: 'No session' }, { status: 401 }) 74 + }) 75 + ) 76 + render( 77 + <AuthProvider> 78 + <AuthDisplay /> 79 + </AuthProvider> 80 + ) 81 + await waitFor(() => { 82 + expect(screen.getByTestId('loading')).toHaveTextContent('false') 83 + }) 84 + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') 85 + expect(screen.getByTestId('handle')).toHaveTextContent('none') 86 + }) 87 + 88 + it('clears user on logout', async () => { 89 + render( 90 + <AuthProvider> 91 + <AuthDisplay /> 92 + <LogoutButton /> 93 + </AuthProvider> 94 + ) 95 + await waitFor(() => { 96 + expect(screen.getByTestId('authenticated')).toHaveTextContent('true') 97 + }) 98 + 99 + await act(async () => { 100 + screen.getByText('Logout').click() 101 + }) 102 + 103 + expect(screen.getByTestId('authenticated')).toHaveTextContent('false') 104 + }) 105 + }) 106 + 107 + describe('useAuth', () => { 108 + it('throws when used outside AuthProvider', () => { 109 + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) 110 + expect(() => render(<AuthDisplay />)).toThrow('useAuth must be used within an AuthProvider') 111 + consoleSpy.mockRestore() 112 + }) 113 + })
+108
src/__tests__/auth/auth-fetch.test.ts
··· 1 + /** 2 + * Tests for auth-aware fetch wrapper. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { http, HttpResponse } from 'msw' 7 + import { server } from '@/mocks/server' 8 + import { createAuthFetch } from '@/lib/api/auth-fetch' 9 + import { mockAuthSession } from '@/mocks/data' 10 + 11 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 12 + 13 + describe('createAuthFetch', () => { 14 + let getToken: ReturnType<typeof vi.fn> 15 + let setToken: ReturnType<typeof vi.fn> 16 + let onAuthFailure: ReturnType<typeof vi.fn> 17 + let authFetch: ReturnType<typeof createAuthFetch> 18 + 19 + beforeEach(() => { 20 + getToken = vi.fn(() => 'valid-token') 21 + setToken = vi.fn() 22 + onAuthFailure = vi.fn() 23 + authFetch = createAuthFetch({ getToken, setToken, onAuthFailure }) 24 + }) 25 + 26 + it('makes successful requests with token', async () => { 27 + server.use( 28 + http.get(`${API_URL}/api/test`, ({ request }) => { 29 + const auth = request.headers.get('Authorization') 30 + if (auth !== 'Bearer valid-token') { 31 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 32 + } 33 + return HttpResponse.json({ data: 'success' }) 34 + }) 35 + ) 36 + 37 + const result = await authFetch<{ data: string }>('/api/test') 38 + expect(result.data).toBe('success') 39 + }) 40 + 41 + it('retries on 401 after successful refresh', async () => { 42 + let callCount = 0 43 + server.use( 44 + http.get(`${API_URL}/api/test`, () => { 45 + callCount++ 46 + if (callCount === 1) { 47 + return HttpResponse.json({ error: 'Expired' }, { status: 401 }) 48 + } 49 + return HttpResponse.json({ data: 'refreshed' }) 50 + }) 51 + ) 52 + 53 + // After refresh, getToken returns new token 54 + getToken.mockReturnValueOnce('old-token').mockReturnValue('new-token') 55 + 56 + const result = await authFetch<{ data: string }>('/api/test') 57 + expect(result.data).toBe('refreshed') 58 + expect(setToken).toHaveBeenCalledWith(mockAuthSession) 59 + expect(callCount).toBe(2) 60 + }) 61 + 62 + it('calls onAuthFailure when refresh fails', async () => { 63 + server.use( 64 + http.get(`${API_URL}/api/test`, () => { 65 + return HttpResponse.json({ error: 'Expired' }, { status: 401 }) 66 + }), 67 + http.post(`${API_URL}/api/auth/refresh`, () => { 68 + return HttpResponse.json({ error: 'No session' }, { status: 401 }) 69 + }) 70 + ) 71 + 72 + await expect(authFetch('/api/test')).rejects.toThrow('Session expired') 73 + expect(onAuthFailure).toHaveBeenCalled() 74 + }) 75 + 76 + it('does not retry when no token exists', async () => { 77 + getToken.mockReturnValue(null) 78 + server.use( 79 + http.get(`${API_URL}/api/test`, () => { 80 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 81 + }) 82 + ) 83 + 84 + await expect(authFetch('/api/test')).rejects.toThrow('API 401') 85 + expect(onAuthFailure).not.toHaveBeenCalled() 86 + }) 87 + 88 + it('throws on non-401 errors without retrying', async () => { 89 + server.use( 90 + http.get(`${API_URL}/api/test`, () => { 91 + return HttpResponse.json({ error: 'Not found' }, { status: 404 }) 92 + }) 93 + ) 94 + 95 + await expect(authFetch('/api/test')).rejects.toThrow('API 404') 96 + }) 97 + 98 + it('handles 204 responses', async () => { 99 + server.use( 100 + http.delete(`${API_URL}/api/test`, () => { 101 + return new HttpResponse(null, { status: 204 }) 102 + }) 103 + ) 104 + 105 + const result = await authFetch('/api/test', { method: 'DELETE' }) 106 + expect(result).toBeUndefined() 107 + }) 108 + })
+110
src/__tests__/auth/callback-page.test.tsx
··· 1 + /** 2 + * Tests for auth callback page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { http, HttpResponse } from 'msw' 8 + import { server } from '@/mocks/server' 9 + import CallbackPage from '@/app/auth/callback/page' 10 + 11 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 12 + 13 + const mockSetSessionFromCallback = vi.fn() 14 + 15 + let mockSearchParams = new URLSearchParams('code=test-code&state=test-state') 16 + 17 + vi.mock('next/navigation', () => ({ 18 + useSearchParams: () => mockSearchParams, 19 + useRouter: () => ({ push: vi.fn() }), 20 + })) 21 + 22 + vi.mock('next/link', () => ({ 23 + default: ({ 24 + children, 25 + href, 26 + ...props 27 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 28 + <a href={href} {...props}> 29 + {children} 30 + </a> 31 + ), 32 + })) 33 + 34 + vi.mock('@/hooks/use-auth', () => ({ 35 + useAuth: () => ({ 36 + setSessionFromCallback: mockSetSessionFromCallback, 37 + }), 38 + })) 39 + 40 + // Mock sessionStorage 41 + const mockSessionStorage: Record<string, string> = {} 42 + Object.defineProperty(window, 'sessionStorage', { 43 + value: { 44 + getItem: (key: string) => mockSessionStorage[key] ?? null, 45 + setItem: (key: string, value: string) => { 46 + mockSessionStorage[key] = value 47 + }, 48 + removeItem: (key: string) => { 49 + delete mockSessionStorage[key] 50 + }, 51 + }, 52 + writable: true, 53 + }) 54 + 55 + describe('AuthCallbackPage', () => { 56 + beforeEach(() => { 57 + mockSetSessionFromCallback.mockClear() 58 + mockSearchParams = new URLSearchParams('code=test-code&state=test-state') 59 + }) 60 + 61 + it('shows loading spinner while processing', () => { 62 + // Override callback to never resolve 63 + server.use( 64 + http.get(`${API_URL}/api/auth/callback`, () => { 65 + return new Promise(() => {}) 66 + }) 67 + ) 68 + render(<CallbackPage />) 69 + expect(screen.getByText(/completing login/i)).toBeInTheDocument() 70 + }) 71 + 72 + it('calls setSessionFromCallback on success', async () => { 73 + render(<CallbackPage />) 74 + await waitFor(() => { 75 + expect(mockSetSessionFromCallback).toHaveBeenCalled() 76 + }) 77 + }) 78 + 79 + it('shows error when code or state is missing', () => { 80 + mockSearchParams = new URLSearchParams('') 81 + render(<CallbackPage />) 82 + expect(screen.getByRole('alert')).toHaveTextContent(/missing authorization code or state/i) 83 + }) 84 + 85 + it('shows error on API failure', async () => { 86 + server.use( 87 + http.get(`${API_URL}/api/auth/callback`, () => { 88 + return HttpResponse.json({ error: 'Invalid code' }, { status: 400 }) 89 + }) 90 + ) 91 + 92 + render(<CallbackPage />) 93 + await waitFor(() => { 94 + expect(screen.getByRole('alert')).toBeInTheDocument() 95 + }) 96 + }) 97 + 98 + it('shows retry link on error', async () => { 99 + server.use( 100 + http.get(`${API_URL}/api/auth/callback`, () => { 101 + return HttpResponse.json({ error: 'Failed' }, { status: 500 }) 102 + }) 103 + ) 104 + 105 + render(<CallbackPage />) 106 + await waitFor(() => { 107 + expect(screen.getByRole('link', { name: /try again/i })).toHaveAttribute('href', '/login') 108 + }) 109 + }) 110 + })
+92
src/__tests__/auth/login-page.test.tsx
··· 1 + /** 2 + * Tests for login page. 3 + */ 4 + 5 + import { describe, it, expect, vi, beforeEach } from 'vitest' 6 + import { render, screen } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import LoginPage from '@/app/login/page' 9 + 10 + const mockLogin = vi.fn() 11 + 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: vi.fn() }), 14 + useSearchParams: () => new URLSearchParams(''), 15 + })) 16 + 17 + vi.mock('next/link', () => ({ 18 + default: ({ 19 + children, 20 + href, 21 + ...props 22 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 23 + <a href={href} {...props}> 24 + {children} 25 + </a> 26 + ), 27 + })) 28 + 29 + vi.mock('next/image', () => ({ 30 + default: (props: Record<string, unknown>) => { 31 + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text 32 + return <img {...props} /> 33 + }, 34 + })) 35 + 36 + vi.mock('@/hooks/use-auth', () => ({ 37 + useAuth: () => ({ 38 + login: mockLogin, 39 + isAuthenticated: false, 40 + isLoading: false, 41 + }), 42 + })) 43 + 44 + describe('LoginPage', () => { 45 + beforeEach(() => { 46 + mockLogin.mockClear() 47 + }) 48 + 49 + it('renders login heading', () => { 50 + render(<LoginPage />) 51 + expect(screen.getByRole('heading', { name: /log in/i })).toBeInTheDocument() 52 + }) 53 + 54 + it('renders handle input', () => { 55 + render(<LoginPage />) 56 + const input = screen.getByLabelText(/handle/i) 57 + expect(input).toBeInTheDocument() 58 + expect(input).toHaveAttribute('placeholder', 'alice.bsky.social') 59 + }) 60 + 61 + it('renders continue button', () => { 62 + render(<LoginPage />) 63 + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument() 64 + }) 65 + 66 + it('shows error when submitting empty handle', async () => { 67 + const user = userEvent.setup() 68 + render(<LoginPage />) 69 + 70 + await user.click(screen.getByRole('button', { name: /continue/i })) 71 + 72 + expect(screen.getByRole('alert')).toHaveTextContent('Please enter your handle') 73 + expect(mockLogin).not.toHaveBeenCalled() 74 + }) 75 + 76 + it('calls login with handle on submit', async () => { 77 + const user = userEvent.setup() 78 + render(<LoginPage />) 79 + 80 + await user.type(screen.getByLabelText(/handle/i), 'test.bsky.social') 81 + await user.click(screen.getByRole('button', { name: /continue/i })) 82 + 83 + expect(mockLogin).toHaveBeenCalledWith('test.bsky.social') 84 + }) 85 + 86 + it('has link to create account', () => { 87 + render(<LoginPage />) 88 + const link = screen.getByRole('link', { name: /create one on bluesky/i }) 89 + expect(link).toHaveAttribute('href', 'https://bsky.app') 90 + expect(link).toHaveAttribute('target', '_blank') 91 + }) 92 + })
+77
src/__tests__/auth/protected-route.test.tsx
··· 1 + /** 2 + * Tests for ProtectedRoute component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import { ProtectedRoute } from '@/components/auth/protected-route' 8 + 9 + const mockPush = vi.fn() 10 + const mockReplace = vi.fn() 11 + 12 + vi.mock('next/navigation', () => ({ 13 + useRouter: () => ({ push: mockPush, replace: mockReplace }), 14 + usePathname: () => '/protected-page', 15 + })) 16 + 17 + const mockUseAuth = vi.fn() 18 + vi.mock('@/hooks/use-auth', () => ({ 19 + useAuth: () => mockUseAuth(), 20 + })) 21 + 22 + describe('ProtectedRoute', () => { 23 + beforeEach(() => { 24 + mockPush.mockClear() 25 + mockReplace.mockClear() 26 + }) 27 + 28 + it('shows loading skeleton while auth state initializes', () => { 29 + mockUseAuth.mockReturnValue({ 30 + isAuthenticated: false, 31 + isLoading: true, 32 + }) 33 + 34 + render( 35 + <ProtectedRoute> 36 + <div>Protected content</div> 37 + </ProtectedRoute> 38 + ) 39 + 40 + expect(screen.queryByText('Protected content')).not.toBeInTheDocument() 41 + // Loading skeleton divs should be present (animate-pulse) 42 + expect(document.querySelector('.animate-pulse')).toBeInTheDocument() 43 + }) 44 + 45 + it('renders children when authenticated', () => { 46 + mockUseAuth.mockReturnValue({ 47 + isAuthenticated: true, 48 + isLoading: false, 49 + }) 50 + 51 + render( 52 + <ProtectedRoute> 53 + <div>Protected content</div> 54 + </ProtectedRoute> 55 + ) 56 + 57 + expect(screen.getByText('Protected content')).toBeInTheDocument() 58 + }) 59 + 60 + it('redirects to login when not authenticated', async () => { 61 + mockUseAuth.mockReturnValue({ 62 + isAuthenticated: false, 63 + isLoading: false, 64 + }) 65 + 66 + render( 67 + <ProtectedRoute> 68 + <div>Protected content</div> 69 + </ProtectedRoute> 70 + ) 71 + 72 + await waitFor(() => { 73 + expect(mockReplace).toHaveBeenCalledWith('/login?returnTo=%2Fprotected-page') 74 + }) 75 + expect(screen.queryByText('Protected content')).not.toBeInTheDocument() 76 + }) 77 + })
+118
src/__tests__/auth/user-menu.test.tsx
··· 1 + /** 2 + * Tests for UserMenu component. 3 + */ 4 + 5 + import { describe, it, expect, vi } from 'vitest' 6 + import { render, screen, waitFor } from '@testing-library/react' 7 + import userEvent from '@testing-library/user-event' 8 + import { UserMenu } from '@/components/auth/user-menu' 9 + import { 10 + createMockAuthContext, 11 + createUnauthenticatedMockAuthContext, 12 + mockUser, 13 + } from '@/test/mock-auth' 14 + 15 + const mockUseAuth = vi.fn() 16 + 17 + vi.mock('@/hooks/use-auth', () => ({ 18 + useAuth: () => mockUseAuth(), 19 + })) 20 + 21 + vi.mock('next/link', () => ({ 22 + default: ({ 23 + children, 24 + href, 25 + ...props 26 + }: { children: React.ReactNode; href: string } & Record<string, unknown>) => ( 27 + <a href={href} {...props}> 28 + {children} 29 + </a> 30 + ), 31 + })) 32 + 33 + describe('UserMenu', () => { 34 + it('shows login link when not authenticated', () => { 35 + mockUseAuth.mockReturnValue(createUnauthenticatedMockAuthContext()) 36 + 37 + render(<UserMenu />) 38 + const link = screen.getByRole('link', { name: /log in/i }) 39 + expect(link).toHaveAttribute('href', '/login') 40 + }) 41 + 42 + it('shows loading skeleton when auth is loading', () => { 43 + mockUseAuth.mockReturnValue( 44 + createMockAuthContext({ isLoading: true, user: null, isAuthenticated: false }) 45 + ) 46 + 47 + render(<UserMenu />) 48 + expect(document.querySelector('.animate-pulse')).toBeInTheDocument() 49 + }) 50 + 51 + it('shows user menu button when authenticated', () => { 52 + mockUseAuth.mockReturnValue(createMockAuthContext()) 53 + 54 + render(<UserMenu />) 55 + expect(screen.getByRole('button', { name: /user menu/i })).toBeInTheDocument() 56 + }) 57 + 58 + it('opens dropdown on click', async () => { 59 + mockUseAuth.mockReturnValue(createMockAuthContext()) 60 + const user = userEvent.setup() 61 + 62 + render(<UserMenu />) 63 + await user.click(screen.getByRole('button', { name: /user menu/i })) 64 + 65 + await waitFor(() => { 66 + expect(screen.getByText(`@${mockUser.handle}`)).toBeInTheDocument() 67 + }) 68 + }) 69 + 70 + it('shows display name and handle in dropdown', async () => { 71 + mockUseAuth.mockReturnValue(createMockAuthContext()) 72 + const user = userEvent.setup() 73 + 74 + render(<UserMenu />) 75 + await user.click(screen.getByRole('button', { name: /user menu/i })) 76 + 77 + await waitFor(() => { 78 + expect(screen.getByText('Alice')).toBeInTheDocument() 79 + expect(screen.getByText('@alice.bsky.social')).toBeInTheDocument() 80 + }) 81 + }) 82 + 83 + it('has profile link in dropdown', async () => { 84 + mockUseAuth.mockReturnValue(createMockAuthContext()) 85 + const user = userEvent.setup() 86 + 87 + render(<UserMenu />) 88 + await user.click(screen.getByRole('button', { name: /user menu/i })) 89 + 90 + await waitFor(() => { 91 + expect(screen.getByRole('menuitem', { name: /profile/i })).toBeInTheDocument() 92 + }) 93 + }) 94 + 95 + it('has settings link in dropdown', async () => { 96 + mockUseAuth.mockReturnValue(createMockAuthContext()) 97 + const user = userEvent.setup() 98 + 99 + render(<UserMenu />) 100 + await user.click(screen.getByRole('button', { name: /user menu/i })) 101 + 102 + await waitFor(() => { 103 + expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument() 104 + }) 105 + }) 106 + 107 + it('has logout option in dropdown', async () => { 108 + mockUseAuth.mockReturnValue(createMockAuthContext()) 109 + const user = userEvent.setup() 110 + 111 + render(<UserMenu />) 112 + await user.click(screen.getByRole('button', { name: /user menu/i })) 113 + 114 + await waitFor(() => { 115 + expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument() 116 + }) 117 + }) 118 + })
+14
src/app/accessibility/page.test.tsx
··· 20 20 ThemeProvider: ({ children }: { children: React.ReactNode }) => children, 21 21 })) 22 22 23 + // Mock useAuth hook 24 + vi.mock('@/hooks/use-auth', () => ({ 25 + useAuth: () => ({ 26 + user: null, 27 + isAuthenticated: false, 28 + isLoading: false, 29 + getAccessToken: () => null, 30 + login: vi.fn(), 31 + logout: vi.fn(), 32 + setSessionFromCallback: vi.fn(), 33 + authFetch: vi.fn(), 34 + }), 35 + })) 36 + 23 37 describe('AccessibilityPage', () => { 24 38 it('renders page heading', () => { 25 39 render(<AccessibilityPage />)
+18
src/app/admin/categories/page.test.tsx
··· 32 32 }, 33 33 })) 34 34 35 + vi.mock('@/hooks/use-auth', () => ({ 36 + useAuth: () => ({ 37 + user: { 38 + did: 'did:plc:user-alice-001', 39 + handle: 'alice.bsky.social', 40 + displayName: 'Alice', 41 + avatarUrl: null, 42 + }, 43 + isAuthenticated: true, 44 + isLoading: false, 45 + getAccessToken: () => 'mock-access-token', 46 + login: vi.fn(), 47 + logout: vi.fn(), 48 + setSessionFromCallback: vi.fn(), 49 + authFetch: vi.fn(), 50 + }), 51 + })) 52 + 35 53 describe('AdminCategoriesPage', () => { 36 54 it('renders categories heading', () => { 37 55 render(<AdminCategoriesPage />)
+5 -6
src/app/admin/categories/page.tsx
··· 13 13 import { getCategories, createCategory, updateCategory, deleteCategory } from '@/lib/api/client' 14 14 import { cn } from '@/lib/utils' 15 15 import type { CategoryTreeNode, MaturityRating } from '@/lib/api/types' 16 - 17 - // TODO: Replace with actual auth token from session 18 - const MOCK_TOKEN = 'mock-access-token' 16 + import { useAuth } from '@/hooks/use-auth' 19 17 20 18 const MATURITY_LABELS: Record<MaturityRating, string> = { 21 19 safe: 'Safe', ··· 107 105 } 108 106 109 107 export default function AdminCategoriesPage() { 108 + const { getAccessToken } = useAuth() 110 109 const [categories, setCategories] = useState<CategoryTreeNode[]>([]) 111 110 const [loading, setLoading] = useState(true) 112 111 const [editing, setEditing] = useState<EditingCategory | null>(null) ··· 150 149 151 150 const handleDelete = async (id: string) => { 152 151 try { 153 - await deleteCategory(id, MOCK_TOKEN) 152 + await deleteCategory(id, getAccessToken() ?? '') 154 153 void fetchCategories() 155 154 } catch { 156 155 // Silently handle ··· 171 170 parentId: editing.parentId, 172 171 maturityRating: editing.maturityRating, 173 172 }, 174 - MOCK_TOKEN 173 + getAccessToken() ?? '' 175 174 ) 176 175 } else { 177 176 await createCategory( ··· 183 182 sortOrder: categories.length, 184 183 maturityRating: editing.maturityRating, 185 184 }, 186 - MOCK_TOKEN 185 + getAccessToken() ?? '' 187 186 ) 188 187 } 189 188 setEditing(null)
+18
src/app/admin/moderation/page.test.tsx
··· 32 32 }, 33 33 })) 34 34 35 + vi.mock('@/hooks/use-auth', () => ({ 36 + useAuth: () => ({ 37 + user: { 38 + did: 'did:plc:user-alice-001', 39 + handle: 'alice.bsky.social', 40 + displayName: 'Alice', 41 + avatarUrl: null, 42 + }, 43 + isAuthenticated: true, 44 + isLoading: false, 45 + getAccessToken: () => 'mock-access-token', 46 + login: vi.fn(), 47 + logout: vi.fn(), 48 + setSessionFromCallback: vi.fn(), 49 + authFetch: vi.fn(), 50 + }), 51 + })) 52 + 35 53 describe('AdminModerationPage', () => { 36 54 it('renders moderation heading', () => { 37 55 render(<AdminModerationPage />)
+12 -13
src/app/admin/moderation/page.tsx
··· 29 29 ModerationThresholds, 30 30 ReportResolution, 31 31 } from '@/lib/api/types' 32 - 33 - // TODO: Replace with actual auth token from session 34 - const MOCK_TOKEN = 'mock-access-token' 32 + import { useAuth } from '@/hooks/use-auth' 35 33 36 34 type TabId = 'reports' | 'first-post' | 'action-log' | 'reported-users' | 'thresholds' 37 35 ··· 496 494 } 497 495 498 496 export default function AdminModerationPage() { 497 + const { getAccessToken } = useAuth() 499 498 const [activeTab, setActiveTab] = useState<TabId>('reports') 500 499 const [reports, setReports] = useState<ModerationReport[]>([]) 501 500 const [firstPostQueue, setFirstPostQueue] = useState<FirstPostQueueItem[]>([]) ··· 507 506 const fetchData = useCallback(async () => { 508 507 try { 509 508 const [reportsRes, queueRes, logRes, usersRes, thresholdsRes] = await Promise.all([ 510 - getModerationReports(MOCK_TOKEN), 511 - getFirstPostQueue(MOCK_TOKEN), 512 - getModerationLog(MOCK_TOKEN), 513 - getReportedUsers(MOCK_TOKEN), 514 - getModerationThresholds(MOCK_TOKEN), 509 + getModerationReports(getAccessToken() ?? ''), 510 + getFirstPostQueue(getAccessToken() ?? ''), 511 + getModerationLog(getAccessToken() ?? ''), 512 + getReportedUsers(getAccessToken() ?? ''), 513 + getModerationThresholds(getAccessToken() ?? ''), 515 514 ]) 516 515 setReports(reportsRes.reports) 517 516 setFirstPostQueue(queueRes.items) ··· 523 522 } finally { 524 523 setLoading(false) 525 524 } 526 - }, []) 525 + }, [getAccessToken]) 527 526 528 527 useEffect(() => { 529 528 void fetchData() ··· 531 530 532 531 const handleResolveReport = async (id: string, resolution: ReportResolution) => { 533 532 try { 534 - await resolveReport(id, resolution, MOCK_TOKEN) 533 + await resolveReport(id, resolution, getAccessToken() ?? '') 535 534 setReports((prev) => prev.filter((r) => r.id !== id)) 536 535 } catch { 537 536 // Silently handle ··· 540 539 541 540 const handleResolveFirstPost = async (id: string, action: 'approved' | 'rejected') => { 542 541 try { 543 - await resolveFirstPost(id, action, MOCK_TOKEN) 542 + await resolveFirstPost(id, action, getAccessToken() ?? '') 544 543 setFirstPostQueue((prev) => prev.filter((item) => item.id !== id)) 545 544 } catch { 546 545 // Silently handle ··· 549 548 550 549 const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { 551 550 try { 552 - await Promise.all(ids.map((id) => resolveFirstPost(id, action, MOCK_TOKEN))) 551 + await Promise.all(ids.map((id) => resolveFirstPost(id, action, getAccessToken() ?? ''))) 553 552 setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 554 553 } catch { 555 554 // Silently handle ··· 558 557 559 558 const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 560 559 try { 561 - const result = await updateModerationThresholds(updated, MOCK_TOKEN) 560 + const result = await updateModerationThresholds(updated, getAccessToken() ?? '') 562 561 setThresholds(result) 563 562 } catch { 564 563 // Silently handle
+18
src/app/admin/onboarding/page.test.tsx
··· 32 32 }, 33 33 })) 34 34 35 + vi.mock('@/hooks/use-auth', () => ({ 36 + useAuth: () => ({ 37 + user: { 38 + did: 'did:plc:user-alice-001', 39 + handle: 'alice.bsky.social', 40 + displayName: 'Alice', 41 + avatarUrl: null, 42 + }, 43 + isAuthenticated: true, 44 + isLoading: false, 45 + getAccessToken: () => 'mock-access-token', 46 + login: vi.fn(), 47 + logout: vi.fn(), 48 + setSessionFromCallback: vi.fn(), 49 + authFetch: vi.fn(), 50 + }), 51 + })) 52 + 35 53 describe('AdminOnboardingPage', () => { 36 54 it('renders onboarding fields heading', () => { 37 55 render(<AdminOnboardingPage />)
+9 -10
src/app/admin/onboarding/page.tsx
··· 22 22 OnboardingFieldType, 23 23 CreateOnboardingFieldInput, 24 24 } from '@/lib/api/types' 25 - 26 - // TODO: Replace with actual auth token from session 27 - const MOCK_TOKEN = 'mock-access-token' 25 + import { useAuth } from '@/hooks/use-auth' 28 26 29 27 const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 30 28 age_confirmation: 'Age Confirmation', ··· 54 52 } 55 53 56 54 export default function AdminOnboardingPage() { 55 + const { getAccessToken } = useAuth() 57 56 const [fields, setFields] = useState<OnboardingField[]>([]) 58 57 const [loading, setLoading] = useState(true) 59 58 const [editing, setEditing] = useState<EditingField | null>(null) ··· 62 61 63 62 const fetchFields = useCallback(async () => { 64 63 try { 65 - const response = await getOnboardingFields(MOCK_TOKEN) 64 + const response = await getOnboardingFields(getAccessToken() ?? '') 66 65 setFields(response.fields) 67 66 } catch { 68 67 // Silently handle 69 68 } finally { 70 69 setLoading(false) 71 70 } 72 - }, []) 71 + }, [getAccessToken]) 73 72 74 73 useEffect(() => { 75 74 void fetchFields() ··· 94 93 95 94 const handleDelete = async (id: string) => { 96 95 try { 97 - await deleteOnboardingField(id, MOCK_TOKEN) 96 + await deleteOnboardingField(id, getAccessToken() ?? '') 98 97 void fetchFields() 99 98 } catch { 100 99 // Silently handle ··· 120 119 isMandatory: editing.isMandatory, 121 120 config: editing.config, 122 121 }, 123 - MOCK_TOKEN 122 + getAccessToken() ?? '' 124 123 ) 125 124 } else { 126 125 const input: CreateOnboardingFieldInput = { ··· 131 130 sortOrder: fields.length, 132 131 config: editing.config ?? undefined, 133 132 } 134 - await createOnboardingField(input, MOCK_TOKEN) 133 + await createOnboardingField(input, getAccessToken() ?? '') 135 134 } 136 135 setEditing(null) 137 136 void fetchFields() ··· 151 150 setFields(newFields) 152 151 await reorderOnboardingFields( 153 152 newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 154 - MOCK_TOKEN 153 + getAccessToken() ?? '' 155 154 ) 156 155 } 157 156 ··· 164 163 setFields(newFields) 165 164 await reorderOnboardingFields( 166 165 newFields.map((f, i) => ({ id: f.id, sortOrder: i })), 167 - MOCK_TOKEN 166 + getAccessToken() ?? '' 168 167 ) 169 168 } 170 169
+18
src/app/admin/page.test.tsx
··· 31 31 }, 32 32 })) 33 33 34 + vi.mock('@/hooks/use-auth', () => ({ 35 + useAuth: () => ({ 36 + user: { 37 + did: 'did:plc:user-alice-001', 38 + handle: 'alice.bsky.social', 39 + displayName: 'Alice', 40 + avatarUrl: null, 41 + }, 42 + isAuthenticated: true, 43 + isLoading: false, 44 + getAccessToken: () => 'mock-access-token', 45 + login: vi.fn(), 46 + logout: vi.fn(), 47 + setSessionFromCallback: vi.fn(), 48 + authFetch: vi.fn(), 49 + }), 50 + })) 51 + 34 52 describe('AdminDashboardPage', () => { 35 53 it('renders dashboard heading', async () => { 36 54 render(<AdminDashboardPage />)
+4 -5
src/app/admin/page.tsx
··· 12 12 import { AdminLayout } from '@/components/admin/admin-layout' 13 13 import { getCommunityStats } from '@/lib/api/client' 14 14 import type { CommunityStats } from '@/lib/api/types' 15 - 16 - // TODO: Replace with actual auth token from session 17 - const MOCK_TOKEN = 'mock-access-token' 15 + import { useAuth } from '@/hooks/use-auth' 18 16 19 17 interface StatCardProps { 20 18 label: string ··· 44 42 } 45 43 46 44 export default function AdminDashboardPage() { 45 + const { getAccessToken } = useAuth() 47 46 const [stats, setStats] = useState<CommunityStats | null>(null) 48 47 const [loading, setLoading] = useState(true) 49 48 50 49 const fetchStats = useCallback(async () => { 51 50 try { 52 - const data = await getCommunityStats(MOCK_TOKEN) 51 + const data = await getCommunityStats(getAccessToken() ?? '') 53 52 setStats(data) 54 53 } catch { 55 54 // Silently handle 56 55 } finally { 57 56 setLoading(false) 58 57 } 59 - }, []) 58 + }, [getAccessToken]) 60 59 61 60 useEffect(() => { 62 61 void fetchStats()
+18
src/app/admin/plugins/page.test.tsx
··· 14 14 usePathname: () => '/admin/plugins', 15 15 })) 16 16 17 + vi.mock('@/hooks/use-auth', () => ({ 18 + useAuth: () => ({ 19 + user: { 20 + did: 'did:plc:user-alice-001', 21 + handle: 'alice.bsky.social', 22 + displayName: 'Alice', 23 + avatarUrl: null, 24 + }, 25 + isAuthenticated: true, 26 + isLoading: false, 27 + getAccessToken: () => 'mock-access-token', 28 + login: vi.fn(), 29 + logout: vi.fn(), 30 + setSessionFromCallback: vi.fn(), 31 + authFetch: vi.fn(), 32 + }), 33 + })) 34 + 17 35 describe('AdminPluginsPage', () => { 18 36 it('renders page heading', async () => { 19 37 render(<AdminPluginsPage />)
+8 -9
src/app/admin/plugins/page.tsx
··· 14 14 import { getPlugins, togglePlugin, updatePluginSettings, uninstallPlugin } from '@/lib/api/client' 15 15 import { cn } from '@/lib/utils' 16 16 import type { Plugin, PluginSettingsSchema } from '@/lib/api/types' 17 - 18 - // TODO: Replace with actual auth token from session 19 - const MOCK_TOKEN = 'mock-access-token' 17 + import { useAuth } from '@/hooks/use-auth' 20 18 21 19 const SOURCE_STYLES: Record<string, string> = { 22 20 core: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', ··· 193 191 } 194 192 195 193 export default function AdminPluginsPage() { 194 + const { getAccessToken } = useAuth() 196 195 const [plugins, setPlugins] = useState<Plugin[]>([]) 197 196 const [loading, setLoading] = useState(true) 198 197 const [settingsPlugin, setSettingsPlugin] = useState<Plugin | null>(null) ··· 203 202 204 203 const fetchPlugins = useCallback(async () => { 205 204 try { 206 - const response = await getPlugins(MOCK_TOKEN) 205 + const response = await getPlugins(getAccessToken() ?? '') 207 206 setPlugins(response.plugins) 208 207 } catch { 209 208 // Silently handle 210 209 } finally { 211 210 setLoading(false) 212 211 } 213 - }, []) 212 + }, [getAccessToken]) 214 213 215 214 useEffect(() => { 216 215 void fetchPlugins() ··· 231 230 } 232 231 233 232 try { 234 - await togglePlugin(plugin.id, !plugin.enabled, MOCK_TOKEN) 233 + await togglePlugin(plugin.id, !plugin.enabled, getAccessToken() ?? '') 235 234 setPlugins((prev) => 236 235 prev.map((p) => (p.id === plugin.id ? { ...p, enabled: !p.enabled } : p)) 237 236 ) ··· 243 242 const confirmDisable = async () => { 244 243 if (!dependencyWarning) return 245 244 try { 246 - await togglePlugin(dependencyWarning.plugin.id, false, MOCK_TOKEN) 245 + await togglePlugin(dependencyWarning.plugin.id, false, getAccessToken() ?? '') 247 246 setPlugins((prev) => 248 247 prev.map((p) => (p.id === dependencyWarning.plugin.id ? { ...p, enabled: false } : p)) 249 248 ) ··· 256 255 const handleSaveSettings = async (settings: Record<string, boolean | string | number>) => { 257 256 if (!settingsPlugin) return 258 257 try { 259 - await updatePluginSettings(settingsPlugin.id, settings, MOCK_TOKEN) 258 + await updatePluginSettings(settingsPlugin.id, settings, getAccessToken() ?? '') 260 259 setPlugins((prev) => prev.map((p) => (p.id === settingsPlugin.id ? { ...p, settings } : p))) 261 260 } catch { 262 261 // Silently handle ··· 266 265 267 266 const handleUninstall = async (plugin: Plugin) => { 268 267 try { 269 - await uninstallPlugin(plugin.id, MOCK_TOKEN) 268 + await uninstallPlugin(plugin.id, getAccessToken() ?? '') 270 269 setPlugins((prev) => prev.filter((p) => p.id !== plugin.id)) 271 270 } catch { 272 271 // Silently handle
+18
src/app/admin/settings/page.test.tsx
··· 31 31 }, 32 32 })) 33 33 34 + vi.mock('@/hooks/use-auth', () => ({ 35 + useAuth: () => ({ 36 + user: { 37 + did: 'did:plc:user-alice-001', 38 + handle: 'alice.bsky.social', 39 + displayName: 'Alice', 40 + avatarUrl: null, 41 + }, 42 + isAuthenticated: true, 43 + isLoading: false, 44 + getAccessToken: () => 'mock-access-token', 45 + login: vi.fn(), 46 + logout: vi.fn(), 47 + setSessionFromCallback: vi.fn(), 48 + authFetch: vi.fn(), 49 + }), 50 + })) 51 + 34 52 describe('AdminSettingsPage', () => { 35 53 it('renders community settings heading', () => { 36 54 render(<AdminSettingsPage />)
+3 -4
src/app/admin/settings/page.tsx
··· 11 11 import { AdminLayout } from '@/components/admin/admin-layout' 12 12 import { getCommunitySettings, updateCommunitySettings } from '@/lib/api/client' 13 13 import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 14 - 15 - // TODO: Replace with actual auth token from session 16 - const MOCK_TOKEN = 'mock-access-token' 14 + import { useAuth } from '@/hooks/use-auth' 17 15 18 16 export default function AdminSettingsPage() { 17 + const { getAccessToken } = useAuth() 19 18 const [settings, setSettings] = useState<CommunitySettings | null>(null) 20 19 const [loading, setLoading] = useState(true) 21 20 const [saving, setSaving] = useState(false) ··· 48 47 primaryColor: settings.primaryColor, 49 48 accentColor: settings.accentColor, 50 49 }, 51 - MOCK_TOKEN 50 + getAccessToken() ?? '' 52 51 ) 53 52 setSettings(updated) 54 53 } catch {
+18
src/app/admin/users/page.test.tsx
··· 31 31 }, 32 32 })) 33 33 34 + vi.mock('@/hooks/use-auth', () => ({ 35 + useAuth: () => ({ 36 + user: { 37 + did: 'did:plc:user-alice-001', 38 + handle: 'alice.bsky.social', 39 + displayName: 'Alice', 40 + avatarUrl: null, 41 + }, 42 + isAuthenticated: true, 43 + isLoading: false, 44 + getAccessToken: () => 'mock-access-token', 45 + login: vi.fn(), 46 + logout: vi.fn(), 47 + setSessionFromCallback: vi.fn(), 48 + authFetch: vi.fn(), 49 + }), 50 + })) 51 + 34 52 describe('AdminUsersPage', () => { 35 53 it('renders user management heading', () => { 36 54 render(<AdminUsersPage />)
+6 -7
src/app/admin/users/page.tsx
··· 13 13 import { getAdminUsers, banUser, unbanUser } from '@/lib/api/client' 14 14 import { cn } from '@/lib/utils' 15 15 import type { AdminUser } from '@/lib/api/types' 16 - 17 - // TODO: Replace with actual auth token from session 18 - const MOCK_TOKEN = 'mock-access-token' 16 + import { useAuth } from '@/hooks/use-auth' 19 17 20 18 const ROLE_COLORS: Record<string, string> = { 21 19 admin: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400', ··· 32 30 } 33 31 34 32 export default function AdminUsersPage() { 33 + const { getAccessToken } = useAuth() 35 34 const [users, setUsers] = useState<AdminUser[]>([]) 36 35 const [loading, setLoading] = useState(true) 37 36 38 37 const fetchUsers = useCallback(async () => { 39 38 try { 40 - const response = await getAdminUsers(MOCK_TOKEN) 39 + const response = await getAdminUsers(getAccessToken() ?? '') 41 40 setUsers(response.users) 42 41 } catch { 43 42 // Silently handle 44 43 } finally { 45 44 setLoading(false) 46 45 } 47 - }, []) 46 + }, [getAccessToken]) 48 47 49 48 useEffect(() => { 50 49 void fetchUsers() ··· 52 51 53 52 const handleBan = async (did: string) => { 54 53 try { 55 - await banUser(did, 'Banned by admin', MOCK_TOKEN) 54 + await banUser(did, 'Banned by admin', getAccessToken() ?? '') 56 55 setUsers((prev) => 57 56 prev.map((u) => 58 57 u.did === did ··· 72 71 73 72 const handleUnban = async (did: string) => { 74 73 try { 75 - await unbanUser(did, MOCK_TOKEN) 74 + await unbanUser(did, getAccessToken() ?? '') 76 75 setUsers((prev) => 77 76 prev.map((u) => 78 77 u.did === did ? { ...u, isBanned: false, bannedAt: null, banReason: null } : u
+100
src/app/auth/callback/page.tsx
··· 1 + /** 2 + * OAuth callback page -- processes auth response from PDS. 3 + * URL: /auth/callback?code=...&state=... 4 + * On success: stores token in auth context (memory), redirects to returnTo or /. 5 + * On failure: shows error with retry link. 6 + * @see specs/prd-web.md Section M3 (Auth Flow) 7 + */ 8 + 9 + 'use client' 10 + 11 + import { Suspense, useEffect, useMemo, useState, useRef } from 'react' 12 + import { useSearchParams } from 'next/navigation' 13 + import Link from 'next/link' 14 + import { handleCallback } from '@/lib/api/client' 15 + import { useAuth } from '@/hooks/use-auth' 16 + 17 + function CallbackContent() { 18 + const searchParams = useSearchParams() 19 + const { setSessionFromCallback } = useAuth() 20 + const processedRef = useRef(false) 21 + 22 + const code = searchParams.get('code') 23 + const state = searchParams.get('state') 24 + 25 + // Derive missing-params error synchronously (not in effect) 26 + const missingParamsError = useMemo( 27 + () => (!code || !state ? 'Missing authorization code or state parameter' : null), 28 + [code, state] 29 + ) 30 + 31 + const [error, setError] = useState<string | null>(missingParamsError) 32 + 33 + useEffect(() => { 34 + if (missingParamsError || processedRef.current) return 35 + processedRef.current = true 36 + 37 + async function processCallback() { 38 + try { 39 + const session = await handleCallback(code!, state!) 40 + setSessionFromCallback(session) 41 + 42 + const returnTo = sessionStorage.getItem('auth_returnTo') ?? '/' 43 + sessionStorage.removeItem('auth_returnTo') 44 + window.location.href = returnTo 45 + } catch (err) { 46 + setError(err instanceof Error ? err.message : 'Authentication failed') 47 + } 48 + } 49 + 50 + void processCallback() 51 + }, [code, state, missingParamsError, setSessionFromCallback]) 52 + 53 + if (error) { 54 + return ( 55 + <div className="flex min-h-screen items-center justify-center bg-background px-4"> 56 + <div className="w-full max-w-sm space-y-6 text-center"> 57 + <h1 className="text-2xl font-bold text-foreground">Login failed</h1> 58 + <p 59 + className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive" 60 + role="alert" 61 + > 62 + {error} 63 + </p> 64 + <Link 65 + href="/login" 66 + className="inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" 67 + > 68 + Try again 69 + </Link> 70 + </div> 71 + </div> 72 + ) 73 + } 74 + 75 + return ( 76 + <div className="flex min-h-screen items-center justify-center bg-background"> 77 + <div className="space-y-4 text-center"> 78 + <div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary" /> 79 + <p className="text-sm text-muted-foreground">Completing login...</p> 80 + </div> 81 + </div> 82 + ) 83 + } 84 + 85 + export default function AuthCallbackPage() { 86 + return ( 87 + <Suspense 88 + fallback={ 89 + <div className="flex min-h-screen items-center justify-center bg-background"> 90 + <div className="space-y-4 text-center"> 91 + <div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary" /> 92 + <p className="text-sm text-muted-foreground">Completing login...</p> 93 + </div> 94 + </div> 95 + } 96 + > 97 + <CallbackContent /> 98 + </Suspense> 99 + ) 100 + }
+14
src/app/c/[slug]/page.test.tsx
··· 40 40 ), 41 41 })) 42 42 43 + // Mock useAuth hook 44 + vi.mock('@/hooks/use-auth', () => ({ 45 + useAuth: () => ({ 46 + user: null, 47 + isAuthenticated: false, 48 + isLoading: false, 49 + getAccessToken: () => null, 50 + login: vi.fn(), 51 + logout: vi.fn(), 52 + setSessionFromCallback: vi.fn(), 53 + authFetch: vi.fn(), 54 + }), 55 + })) 56 + 43 57 import { getCategoryBySlug, getCategories, getTopics } from '@/lib/api/client' 44 58 import { mockCategories, mockCategoryWithTopicCount, mockTopics } from '@/mocks/data' 45 59 import CategoryPage from './page'
+2 -1
src/app/layout.tsx
··· 2 2 import { Source_Sans_3, Source_Code_Pro } from 'next/font/google' 3 3 import './globals.css' 4 4 import { ThemeProvider } from '@/components/theme-provider' 5 + import { AuthProvider } from '@/context/auth-context' 5 6 6 7 const sourceSans = Source_Sans_3({ 7 8 subsets: ['latin'], ··· 61 62 enableSystem={true} 62 63 disableTransitionOnChange 63 64 > 64 - {children} 65 + <AuthProvider>{children}</AuthProvider> 65 66 </ThemeProvider> 66 67 </body> 67 68 </html>
+162
src/app/login/page.tsx
··· 1 + /** 2 + * Login page -- AT Protocol OAuth via handle input. 3 + * URL: /login?returnTo=/some/path 4 + * Redirects to PDS OAuth flow on submit. 5 + * @see specs/prd-web.md Section M3 (Auth Flow) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { Suspense, useState, useEffect } from 'react' 11 + import { useSearchParams } from 'next/navigation' 12 + import Link from 'next/link' 13 + import Image from 'next/image' 14 + import { useAuth } from '@/hooks/use-auth' 15 + import { cn } from '@/lib/utils' 16 + 17 + function LoginContent() { 18 + const { login, isAuthenticated, isLoading } = useAuth() 19 + const searchParams = useSearchParams() 20 + const returnTo = searchParams.get('returnTo') ?? '/' 21 + 22 + const [handle, setHandle] = useState('') 23 + const [error, setError] = useState<string | null>(null) 24 + const [submitting, setSubmitting] = useState(false) 25 + 26 + // If already authenticated, redirect 27 + useEffect(() => { 28 + if (isAuthenticated && !isLoading) { 29 + window.location.href = returnTo 30 + } 31 + }, [isAuthenticated, isLoading, returnTo]) 32 + 33 + if (isAuthenticated && !isLoading) { 34 + return null 35 + } 36 + 37 + const handleSubmit = async (e: React.FormEvent) => { 38 + e.preventDefault() 39 + const trimmed = handle.trim() 40 + if (!trimmed) { 41 + setError('Please enter your handle') 42 + return 43 + } 44 + 45 + setError(null) 46 + setSubmitting(true) 47 + 48 + try { 49 + // Store returnTo so callback can redirect back 50 + sessionStorage.setItem('auth_returnTo', returnTo) 51 + await login(trimmed) 52 + } catch (err) { 53 + setError(err instanceof Error ? err.message : 'Failed to start login') 54 + setSubmitting(false) 55 + } 56 + } 57 + 58 + return ( 59 + <div className="flex min-h-screen items-center justify-center bg-background px-4"> 60 + <div className="w-full max-w-sm space-y-8"> 61 + {/* Logo */} 62 + <div className="flex justify-center"> 63 + <Link href="/"> 64 + <Image 65 + src="/barazo-logo-light.svg" 66 + alt="Barazo" 67 + width={160} 68 + height={42} 69 + className="h-10 w-auto dark:hidden" 70 + priority 71 + /> 72 + <Image 73 + src="/barazo-logo-dark.svg" 74 + alt="Barazo" 75 + width={160} 76 + height={42} 77 + className="hidden h-10 w-auto dark:block" 78 + priority 79 + /> 80 + </Link> 81 + </div> 82 + 83 + <div className="space-y-2 text-center"> 84 + <h1 className="text-2xl font-bold text-foreground">Log in</h1> 85 + <p className="text-sm text-muted-foreground">Sign in with your AT Protocol identity</p> 86 + </div> 87 + 88 + <form onSubmit={(e) => void handleSubmit(e)} className="space-y-4" noValidate> 89 + {error && ( 90 + <p 91 + className="rounded-md bg-destructive/10 px-4 py-2 text-sm text-destructive" 92 + role="alert" 93 + > 94 + {error} 95 + </p> 96 + )} 97 + 98 + <div className="space-y-1"> 99 + <label htmlFor="handle" className="block text-sm font-medium text-foreground"> 100 + Handle 101 + </label> 102 + <input 103 + id="handle" 104 + type="text" 105 + value={handle} 106 + onChange={(e) => setHandle(e.target.value)} 107 + placeholder="alice.bsky.social" 108 + autoComplete="username" 109 + disabled={submitting} 110 + className={cn( 111 + 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 112 + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 113 + 'disabled:cursor-not-allowed disabled:opacity-50' 114 + )} 115 + /> 116 + <p className="text-xs text-muted-foreground"> 117 + Your Bluesky or AT Protocol handle (e.g. alice.bsky.social) 118 + </p> 119 + </div> 120 + 121 + <button 122 + type="submit" 123 + disabled={submitting || isLoading} 124 + className={cn( 125 + 'w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 126 + 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 127 + 'disabled:cursor-not-allowed disabled:opacity-50' 128 + )} 129 + > 130 + {submitting ? 'Redirecting...' : 'Continue'} 131 + </button> 132 + </form> 133 + 134 + <p className="text-center text-sm text-muted-foreground"> 135 + Don&rsquo;t have an account?{' '} 136 + <a 137 + href="https://bsky.app" 138 + target="_blank" 139 + rel="noopener noreferrer" 140 + className="text-primary underline decoration-primary/50 hover:text-primary-hover hover:decoration-primary" 141 + > 142 + Create one on Bluesky 143 + </a> 144 + </p> 145 + </div> 146 + </div> 147 + ) 148 + } 149 + 150 + export default function LoginPage() { 151 + return ( 152 + <Suspense 153 + fallback={ 154 + <div className="flex min-h-screen items-center justify-center bg-background"> 155 + <div className="h-8 w-8 animate-pulse rounded-full bg-muted" /> 156 + </div> 157 + } 158 + > 159 + <LoginContent /> 160 + </Suspense> 161 + ) 162 + }
+18
src/app/new/page.test.tsx
··· 44 44 redirect: vi.fn(), 45 45 })) 46 46 47 + vi.mock('@/hooks/use-auth', () => ({ 48 + useAuth: () => ({ 49 + user: { 50 + did: 'did:plc:user-alice-001', 51 + handle: 'alice.bsky.social', 52 + displayName: 'Alice', 53 + avatarUrl: null, 54 + }, 55 + isAuthenticated: true, 56 + isLoading: false, 57 + getAccessToken: () => 'mock-access-token', 58 + login: vi.fn(), 59 + logout: vi.fn(), 60 + setSessionFromCallback: vi.fn(), 61 + authFetch: vi.fn(), 62 + }), 63 + })) 64 + 47 65 describe('NewTopicPage', () => { 48 66 it('renders create topic heading', () => { 49 67 render(<NewTopicPage />)
+3 -2
src/app/new/page.tsx
··· 17 17 import { TopicForm } from '@/components/topic-form' 18 18 import { OnboardingModal } from '@/components/onboarding-modal' 19 19 import { useOnboarding } from '@/hooks/use-onboarding' 20 + import { useAuth } from '@/hooks/use-auth' 20 21 21 22 export default function NewTopicPage() { 22 23 const router = useRouter() 24 + const { getAccessToken } = useAuth() 23 25 const [submitting, setSubmitting] = useState(false) 24 26 const [error, setError] = useState<string | null>(null) 25 27 const onboarding = useOnboarding() ··· 30 32 setError(null) 31 33 32 34 try { 33 - // TODO: Get access token from auth context when auth is implemented 34 - const accessToken = '' 35 + const accessToken = getAccessToken() ?? '' 35 36 const topic = await createTopic(values, accessToken) 36 37 router.push(getTopicUrl(topic)) 37 38 } catch (err) {
+18
src/app/notifications/page.test.tsx
··· 46 46 markNotificationsRead: vi.fn(), 47 47 })) 48 48 49 + vi.mock('@/hooks/use-auth', () => ({ 50 + useAuth: () => ({ 51 + user: { 52 + did: 'did:plc:user-alice-001', 53 + handle: 'alice.bsky.social', 54 + displayName: 'Alice', 55 + avatarUrl: null, 56 + }, 57 + isAuthenticated: true, 58 + isLoading: false, 59 + getAccessToken: () => 'mock-access-token', 60 + login: vi.fn(), 61 + logout: vi.fn(), 62 + setSessionFromCallback: vi.fn(), 63 + authFetch: vi.fn(), 64 + }), 65 + })) 66 + 49 67 import { getNotifications, markNotificationsRead } from '@/lib/api/client' 50 68 51 69 const mockGetNotifications = vi.mocked(getNotifications)
+6 -7
src/app/notifications/page.tsx
··· 15 15 import { getNotifications, markNotificationsRead } from '@/lib/api/client' 16 16 import { cn } from '@/lib/utils' 17 17 import type { Notification, NotificationType } from '@/lib/api/types' 18 - 19 - // TODO: Replace with actual auth token from session 20 - const MOCK_TOKEN = 'mock-access-token' 18 + import { useAuth } from '@/hooks/use-auth' 21 19 22 20 const NOTIFICATION_ICONS: Record<NotificationType, typeof ChatCircle> = { 23 21 reply: ChatCircle, ··· 27 25 } 28 26 29 27 export default function NotificationsPage() { 28 + const { getAccessToken } = useAuth() 30 29 const [notifications, setNotifications] = useState<Notification[]>([]) 31 30 const [loading, setLoading] = useState(true) 32 31 33 32 const fetchNotifications = useCallback(async () => { 34 33 try { 35 - const response = await getNotifications(MOCK_TOKEN) 34 + const response = await getNotifications(getAccessToken() ?? '') 36 35 setNotifications(response.notifications) 37 36 } catch { 38 37 // Silently handle - notifications are non-critical 39 38 } finally { 40 39 setLoading(false) 41 40 } 42 - }, []) 41 + }, [getAccessToken]) 43 42 44 43 useEffect(() => { 45 44 void fetchNotifications() ··· 50 49 if (unreadIds.length === 0) return 51 50 52 51 try { 53 - await markNotificationsRead(MOCK_TOKEN, unreadIds) 52 + await markNotificationsRead(getAccessToken() ?? '', unreadIds) 54 53 setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))) 55 54 } catch { 56 55 // Silently handle 57 56 } 58 - }, [notifications]) 57 + }, [notifications, getAccessToken]) 59 58 60 59 const formatDate = (dateStr: string) => { 61 60 return new Date(dateStr).toLocaleDateString('en-US', {
+14
src/app/page.test.tsx
··· 39 39 ), 40 40 })) 41 41 42 + // Mock useAuth hook 43 + vi.mock('@/hooks/use-auth', () => ({ 44 + useAuth: () => ({ 45 + user: null, 46 + isAuthenticated: false, 47 + isLoading: false, 48 + getAccessToken: () => null, 49 + login: vi.fn(), 50 + logout: vi.fn(), 51 + setSessionFromCallback: vi.fn(), 52 + authFetch: vi.fn(), 53 + }), 54 + })) 55 + 42 56 import { getCategories, getTopics } from '@/lib/api/client' 43 57 import { mockCategories, mockTopics } from '@/mocks/data' 44 58 import HomePage from './page'
+14
src/app/search/page.test.tsx
··· 42 42 ), 43 43 })) 44 44 45 + // Mock useAuth hook 46 + vi.mock('@/hooks/use-auth', () => ({ 47 + useAuth: () => ({ 48 + user: null, 49 + isAuthenticated: false, 50 + isLoading: false, 51 + getAccessToken: () => null, 52 + login: vi.fn(), 53 + logout: vi.fn(), 54 + setSessionFromCallback: vi.fn(), 55 + authFetch: vi.fn(), 56 + }), 57 + })) 58 + 45 59 // Mock API client 46 60 vi.mock('@/lib/api/client', () => ({ 47 61 searchContent: vi.fn(),
+18
src/app/settings/page.test.tsx
··· 16 16 redirect: vi.fn(), 17 17 })) 18 18 19 + vi.mock('@/hooks/use-auth', () => ({ 20 + useAuth: () => ({ 21 + user: { 22 + did: 'did:plc:user-alice-001', 23 + handle: 'alice.bsky.social', 24 + displayName: 'Alice', 25 + avatarUrl: null, 26 + }, 27 + isAuthenticated: true, 28 + isLoading: false, 29 + getAccessToken: () => 'mock-access-token', 30 + login: vi.fn(), 31 + logout: vi.fn(), 32 + setSessionFromCallback: vi.fn(), 33 + authFetch: vi.fn(), 34 + }), 35 + })) 36 + 19 37 // Mock localStorage for jsdom environment 20 38 const localStorageMock = (() => { 21 39 let store: Record<string, string> = {}
+6 -4
src/app/settings/page.tsx
··· 14 14 import { AgeGateDialog } from '@/components/age-gate-dialog' 15 15 import { cn } from '@/lib/utils' 16 16 import { getPreferences, updatePreferences } from '@/lib/api/client' 17 + import { useAuth } from '@/hooks/use-auth' 17 18 18 19 type MaturityLevel = 'sfw' | 'sfw-mature' 19 20 ··· 28 29 } 29 30 30 31 export default function SettingsPage() { 32 + const { getAccessToken } = useAuth() 31 33 const [values, setValues] = useState<SettingsValues>({ 32 34 maturityLevel: 'sfw', 33 35 mutedWords: '', ··· 46 48 47 49 // Load preferences on mount 48 50 useEffect(() => { 49 - const token = localStorage.getItem('accessToken') 51 + const token = getAccessToken() 50 52 if (!token) { 51 53 setLoading(false) 52 54 return ··· 67 69 }) 68 70 .catch(() => setError('Failed to load preferences')) 69 71 .finally(() => setLoading(false)) 70 - }, []) 72 + }, [getAccessToken]) 71 73 72 74 const handleSave = useCallback( 73 75 async (e: React.FormEvent) => { ··· 76 78 setError(null) 77 79 setSuccess(false) 78 80 79 - const token = localStorage.getItem('accessToken') 81 + const token = getAccessToken() 80 82 if (!token) { 81 83 setError('Not authenticated') 82 84 setSaving(false) ··· 112 114 setSaving(false) 113 115 } 114 116 }, 115 - [values, declaredAge] 117 + [values, declaredAge, getAccessToken] 116 118 ) 117 119 118 120 return (
+14
src/app/t/[slug]/[rkey]/edit/page.test.tsx
··· 14 14 afterEach(() => server.resetHandlers()) 15 15 afterAll(() => server.close()) 16 16 17 + // Mock useAuth hook 18 + vi.mock('@/hooks/use-auth', () => ({ 19 + useAuth: () => ({ 20 + user: null, 21 + isAuthenticated: false, 22 + isLoading: false, 23 + getAccessToken: () => null, 24 + login: vi.fn(), 25 + logout: vi.fn(), 26 + setSessionFromCallback: vi.fn(), 27 + authFetch: vi.fn(), 28 + }), 29 + })) 30 + 17 31 // Mock next/navigation 18 32 vi.mock('next/navigation', () => ({ 19 33 useRouter: () => ({
+14
src/app/t/[slug]/[rkey]/page.test.tsx
··· 7 7 import TopicPage from './page' 8 8 import { mockTopics, mockReplies, mockCategories } from '@/mocks/data' 9 9 10 + // Mock useAuth hook 11 + vi.mock('@/hooks/use-auth', () => ({ 12 + useAuth: () => ({ 13 + user: null, 14 + isAuthenticated: false, 15 + isLoading: false, 16 + getAccessToken: () => null, 17 + login: vi.fn(), 18 + logout: vi.fn(), 19 + setSessionFromCallback: vi.fn(), 20 + authFetch: vi.fn(), 21 + }), 22 + })) 23 + 10 24 // Mock notFound 11 25 vi.mock('next/navigation', () => ({ 12 26 useRouter: () => ({ push: vi.fn() }),
+14
src/app/u/[handle]/page.test.tsx
··· 6 6 import { render, screen } from '@testing-library/react' 7 7 import UserProfilePage from './page' 8 8 9 + // Mock useAuth hook 10 + vi.mock('@/hooks/use-auth', () => ({ 11 + useAuth: () => ({ 12 + user: null, 13 + isAuthenticated: false, 14 + isLoading: false, 15 + getAccessToken: () => null, 16 + login: vi.fn(), 17 + logout: vi.fn(), 18 + setSessionFromCallback: vi.fn(), 19 + authFetch: vi.fn(), 20 + }), 21 + })) 22 + 9 23 // Mock next/navigation 10 24 vi.mock('next/navigation', () => ({ 11 25 useRouter: () => ({
+21 -1
src/components/age-gate-dialog.test.tsx
··· 7 7 import userEvent from '@testing-library/user-event' 8 8 import { AgeGateDialog } from './age-gate-dialog' 9 9 10 + const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 11 + 12 + vi.mock('@/hooks/use-auth', () => ({ 13 + useAuth: () => ({ 14 + user: { 15 + did: 'did:plc:user-alice-001', 16 + handle: 'alice.bsky.social', 17 + displayName: 'Alice', 18 + avatarUrl: null, 19 + }, 20 + isAuthenticated: true, 21 + isLoading: false, 22 + getAccessToken: mockGetAccessToken, 23 + login: vi.fn(), 24 + logout: vi.fn(), 25 + setSessionFromCallback: vi.fn(), 26 + authFetch: vi.fn(), 27 + }), 28 + })) 29 + 10 30 // Mock localStorage 11 31 const mockStorage: Record<string, string> = {} 12 32 ··· 119 139 }) 120 140 121 141 it('shows error when not authenticated', async () => { 122 - delete mockStorage['accessToken'] 142 + mockGetAccessToken.mockReturnValue(null) 123 143 render(<AgeGateDialog open={true} onConfirm={vi.fn()} onCancel={vi.fn()} />) 124 144 125 145 const user = userEvent.setup()
+3 -1
src/components/age-gate-dialog.tsx
··· 10 10 import { useState } from 'react' 11 11 import { cn } from '@/lib/utils' 12 12 import { declareAge } from '@/lib/api/client' 13 + import { useAuth } from '@/hooks/use-auth' 13 14 14 15 /** Valid age bracket options. 0 = "Rather not say". */ 15 16 const AGE_OPTIONS = [ ··· 28 29 } 29 30 30 31 export function AgeGateDialog({ open, onConfirm, onCancel }: AgeGateDialogProps) { 32 + const { getAccessToken } = useAuth() 31 33 const [selectedAge, setSelectedAge] = useState<number | null>(null) 32 34 const [confirming, setConfirming] = useState(false) 33 35 const [error, setError] = useState<string | null>(null) ··· 43 45 setConfirming(true) 44 46 setError(null) 45 47 46 - const token = localStorage.getItem('accessToken') 48 + const token = getAccessToken() 47 49 if (!token) { 48 50 setError('Not authenticated') 49 51 setConfirming(false)
+45
src/components/auth/protected-route.tsx
··· 1 + /** 2 + * Protected route wrapper. 3 + * Redirects unauthenticated users to /login?returnTo={currentPath}. 4 + * Shows loading skeleton while auth state initializes. 5 + * @see specs/prd-web.md Section M3 (Auth Flow) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { useEffect } from 'react' 11 + import { usePathname, useRouter } from 'next/navigation' 12 + import { useAuth } from '@/hooks/use-auth' 13 + 14 + interface ProtectedRouteProps { 15 + children: React.ReactNode 16 + } 17 + 18 + export function ProtectedRoute({ children }: ProtectedRouteProps) { 19 + const { isAuthenticated, isLoading } = useAuth() 20 + const pathname = usePathname() 21 + const router = useRouter() 22 + 23 + useEffect(() => { 24 + if (!isLoading && !isAuthenticated) { 25 + router.replace(`/login?returnTo=${encodeURIComponent(pathname)}`) 26 + } 27 + }, [isLoading, isAuthenticated, pathname, router]) 28 + 29 + if (isLoading) { 30 + return ( 31 + <div className="animate-pulse space-y-4 p-6"> 32 + <div className="h-8 w-48 rounded bg-muted" /> 33 + <div className="h-4 w-full rounded bg-muted" /> 34 + <div className="h-4 w-3/4 rounded bg-muted" /> 35 + <div className="h-32 rounded bg-muted" /> 36 + </div> 37 + ) 38 + } 39 + 40 + if (!isAuthenticated) { 41 + return null 42 + } 43 + 44 + return <>{children}</> 45 + }
+99
src/components/auth/user-menu.tsx
··· 1 + /** 2 + * User menu -- header dropdown for authenticated users, login button for guests. 3 + * Uses Radix DropdownMenu (via shadcn/ui). 4 + * @see specs/prd-web.md Section M3 (Auth Flow) 5 + */ 6 + 7 + 'use client' 8 + 9 + import Link from 'next/link' 10 + import { User, SignOut, GearSix } from '@phosphor-icons/react' 11 + import { useAuth } from '@/hooks/use-auth' 12 + import { 13 + DropdownMenu, 14 + DropdownMenuContent, 15 + DropdownMenuItem, 16 + DropdownMenuSeparator, 17 + DropdownMenuTrigger, 18 + } from '@/components/ui/dropdown-menu' 19 + import { cn } from '@/lib/utils' 20 + 21 + export function UserMenu() { 22 + const { user, isAuthenticated, isLoading, logout } = useAuth() 23 + 24 + if (isLoading) { 25 + return <div className="h-8 w-8 animate-pulse rounded-full bg-muted" /> 26 + } 27 + 28 + if (!isAuthenticated || !user) { 29 + return ( 30 + <Link 31 + href="/login" 32 + className={cn( 33 + 'inline-flex items-center rounded-md px-3 py-1.5 text-sm font-medium text-foreground transition-colors', 34 + 'hover:bg-card-hover focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2' 35 + )} 36 + > 37 + Log in 38 + </Link> 39 + ) 40 + } 41 + 42 + const handleLogout = async () => { 43 + await logout() 44 + } 45 + 46 + return ( 47 + <DropdownMenu> 48 + <DropdownMenuTrigger asChild> 49 + <button 50 + type="button" 51 + className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ring-offset-background transition-colors hover:bg-card-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" 52 + aria-label="User menu" 53 + > 54 + {user.avatarUrl ? ( 55 + // eslint-disable-next-line @next/next/no-img-element 56 + <img src={user.avatarUrl} alt="" className="h-full w-full object-cover" /> 57 + ) : ( 58 + <User size={16} weight="bold" aria-hidden="true" /> 59 + )} 60 + </button> 61 + </DropdownMenuTrigger> 62 + 63 + <DropdownMenuContent align="end" className="w-56"> 64 + <div className="px-2 py-1.5"> 65 + {user.displayName && ( 66 + <p className="text-sm font-medium text-foreground">{user.displayName}</p> 67 + )} 68 + <p className="text-xs text-muted-foreground">@{user.handle}</p> 69 + </div> 70 + 71 + <DropdownMenuSeparator /> 72 + 73 + <DropdownMenuItem asChild> 74 + <Link href={`/u/${encodeURIComponent(user.handle)}`} className="flex items-center gap-2"> 75 + <User size={16} aria-hidden="true" /> 76 + Profile 77 + </Link> 78 + </DropdownMenuItem> 79 + 80 + <DropdownMenuItem asChild> 81 + <Link href="/settings" className="flex items-center gap-2"> 82 + <GearSix size={16} aria-hidden="true" /> 83 + Settings 84 + </Link> 85 + </DropdownMenuItem> 86 + 87 + <DropdownMenuSeparator /> 88 + 89 + <DropdownMenuItem 90 + onSelect={() => void handleLogout()} 91 + className="flex items-center gap-2 text-destructive focus:text-destructive" 92 + > 93 + <SignOut size={16} aria-hidden="true" /> 94 + Log out 95 + </DropdownMenuItem> 96 + </DropdownMenuContent> 97 + </DropdownMenu> 98 + ) 99 + }
+21 -1
src/components/block-mute-button.test.tsx
··· 7 7 import userEvent from '@testing-library/user-event' 8 8 import { BlockMuteButton } from './block-mute-button' 9 9 10 + const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 11 + 12 + vi.mock('@/hooks/use-auth', () => ({ 13 + useAuth: () => ({ 14 + user: { 15 + did: 'did:plc:user-alice-001', 16 + handle: 'alice.bsky.social', 17 + displayName: 'Alice', 18 + avatarUrl: null, 19 + }, 20 + isAuthenticated: true, 21 + isLoading: false, 22 + getAccessToken: mockGetAccessToken, 23 + login: vi.fn(), 24 + logout: vi.fn(), 25 + setSessionFromCallback: vi.fn(), 26 + authFetch: vi.fn(), 27 + }), 28 + })) 29 + 10 30 // Mock localStorage 11 31 const mockStorage: Record<string, string> = {} 12 32 ··· 123 143 }) 124 144 125 145 it('does not call onToggle without auth token', async () => { 126 - delete mockStorage['accessToken'] 146 + mockGetAccessToken.mockReturnValue(null) 127 147 const onToggle = vi.fn() 128 148 render( 129 149 <BlockMuteButton
+3 -1
src/components/block-mute-button.tsx
··· 10 10 import { Prohibit, SpeakerSimpleSlash } from '@phosphor-icons/react' 11 11 import { cn } from '@/lib/utils' 12 12 import { blockUser, unblockUser, muteUser, unmuteUser } from '@/lib/api/client' 13 + import { useAuth } from '@/hooks/use-auth' 13 14 14 15 interface BlockMuteButtonProps { 15 16 targetDid: string ··· 26 27 onToggle, 27 28 className, 28 29 }: BlockMuteButtonProps) { 30 + const { getAccessToken } = useAuth() 29 31 const [loading, setLoading] = useState(false) 30 32 31 33 const handleClick = async () => { 32 34 setLoading(true) 33 35 34 - const token = localStorage.getItem('accessToken') 36 + const token = getAccessToken() 35 37 if (!token) { 36 38 setLoading(false) 37 39 return
+18
src/components/layout/forum-layout.test.tsx
··· 35 35 ), 36 36 })) 37 37 38 + vi.mock('@/hooks/use-auth', () => ({ 39 + useAuth: () => ({ 40 + user: { 41 + did: 'did:plc:user-alice-001', 42 + handle: 'alice.bsky.social', 43 + displayName: 'Alice', 44 + avatarUrl: null, 45 + }, 46 + isAuthenticated: true, 47 + isLoading: false, 48 + getAccessToken: () => 'mock-access-token', 49 + login: vi.fn(), 50 + logout: vi.fn(), 51 + setSessionFromCallback: vi.fn(), 52 + authFetch: vi.fn(), 53 + }), 54 + })) 55 + 38 56 describe('ForumLayout', () => { 39 57 it('renders header with logo', () => { 40 58 render(
+2
src/components/layout/forum-layout.tsx
··· 11 11 import { ThemeToggle } from '@/components/theme-toggle' 12 12 import { SearchInput } from '@/components/search-input' 13 13 import { NotificationBell } from '@/components/notification-bell' 14 + import { UserMenu } from '@/components/auth/user-menu' 14 15 import { MagnifyingGlass } from '@phosphor-icons/react/dist/ssr' 15 16 16 17 interface ForumLayoutProps { ··· 63 64 </Link> 64 65 <NotificationBell unreadCount={0} /> 65 66 <ThemeToggle /> 67 + <UserMenu /> 66 68 </div> 67 69 </div> 68 70 </header>
+187
src/components/ui/dropdown-menu.tsx
··· 1 + 'use client' 2 + 3 + import * as React from 'react' 4 + import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' 5 + import { Check, CaretRight, Circle } from '@phosphor-icons/react' 6 + 7 + import { cn } from '@/lib/utils' 8 + 9 + const DropdownMenu = DropdownMenuPrimitive.Root 10 + 11 + const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 + 13 + const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 + 15 + const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 + 17 + const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 + 19 + const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 + 21 + const DropdownMenuSubTrigger = React.forwardRef< 22 + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, 23 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { 24 + inset?: boolean 25 + } 26 + >(({ className, inset, children, ...props }, ref) => ( 27 + <DropdownMenuPrimitive.SubTrigger 28 + ref={ref} 29 + className={cn( 30 + 'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 31 + inset && 'pl-8', 32 + className 33 + )} 34 + {...props} 35 + > 36 + {children} 37 + <span className="ml-auto"> 38 + <CaretRight size={16} aria-hidden="true" /> 39 + </span> 40 + </DropdownMenuPrimitive.SubTrigger> 41 + )) 42 + DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName 43 + 44 + const DropdownMenuSubContent = React.forwardRef< 45 + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, 46 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> 47 + >(({ className, ...props }, ref) => ( 48 + <DropdownMenuPrimitive.SubContent 49 + ref={ref} 50 + className={cn( 51 + 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]', 52 + className 53 + )} 54 + {...props} 55 + /> 56 + )) 57 + DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName 58 + 59 + const DropdownMenuContent = React.forwardRef< 60 + React.ElementRef<typeof DropdownMenuPrimitive.Content>, 61 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> 62 + >(({ className, sideOffset = 4, ...props }, ref) => ( 63 + <DropdownMenuPrimitive.Portal> 64 + <DropdownMenuPrimitive.Content 65 + ref={ref} 66 + sideOffset={sideOffset} 67 + className={cn( 68 + 'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]', 69 + className 70 + )} 71 + {...props} 72 + /> 73 + </DropdownMenuPrimitive.Portal> 74 + )) 75 + DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 + 77 + const DropdownMenuItem = React.forwardRef< 78 + React.ElementRef<typeof DropdownMenuPrimitive.Item>, 79 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { 80 + inset?: boolean 81 + } 82 + >(({ className, inset, ...props }, ref) => ( 83 + <DropdownMenuPrimitive.Item 84 + ref={ref} 85 + className={cn( 86 + 'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 87 + inset && 'pl-8', 88 + className 89 + )} 90 + {...props} 91 + /> 92 + )) 93 + DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 + 95 + const DropdownMenuCheckboxItem = React.forwardRef< 96 + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, 97 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> 98 + >(({ className, children, checked, ...props }, ref) => ( 99 + <DropdownMenuPrimitive.CheckboxItem 100 + ref={ref} 101 + className={cn( 102 + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 103 + className 104 + )} 105 + checked={checked} 106 + {...props} 107 + > 108 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 109 + <DropdownMenuPrimitive.ItemIndicator> 110 + <Check size={16} /> 111 + </DropdownMenuPrimitive.ItemIndicator> 112 + </span> 113 + {children} 114 + </DropdownMenuPrimitive.CheckboxItem> 115 + )) 116 + DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName 117 + 118 + const DropdownMenuRadioItem = React.forwardRef< 119 + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, 120 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> 121 + >(({ className, children, ...props }, ref) => ( 122 + <DropdownMenuPrimitive.RadioItem 123 + ref={ref} 124 + className={cn( 125 + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', 126 + className 127 + )} 128 + {...props} 129 + > 130 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 131 + <DropdownMenuPrimitive.ItemIndicator> 132 + <Circle size={8} weight="fill" /> 133 + </DropdownMenuPrimitive.ItemIndicator> 134 + </span> 135 + {children} 136 + </DropdownMenuPrimitive.RadioItem> 137 + )) 138 + DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 139 + 140 + const DropdownMenuLabel = React.forwardRef< 141 + React.ElementRef<typeof DropdownMenuPrimitive.Label>, 142 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { 143 + inset?: boolean 144 + } 145 + >(({ className, inset, ...props }, ref) => ( 146 + <DropdownMenuPrimitive.Label 147 + ref={ref} 148 + className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)} 149 + {...props} 150 + /> 151 + )) 152 + DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 153 + 154 + const DropdownMenuSeparator = React.forwardRef< 155 + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, 156 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> 157 + >(({ className, ...props }, ref) => ( 158 + <DropdownMenuPrimitive.Separator 159 + ref={ref} 160 + className={cn('-mx-1 my-1 h-px bg-muted', className)} 161 + {...props} 162 + /> 163 + )) 164 + DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 165 + 166 + const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { 167 + return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} /> 168 + } 169 + DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' 170 + 171 + export { 172 + DropdownMenu, 173 + DropdownMenuTrigger, 174 + DropdownMenuContent, 175 + DropdownMenuItem, 176 + DropdownMenuCheckboxItem, 177 + DropdownMenuRadioItem, 178 + DropdownMenuLabel, 179 + DropdownMenuSeparator, 180 + DropdownMenuShortcut, 181 + DropdownMenuGroup, 182 + DropdownMenuPortal, 183 + DropdownMenuSub, 184 + DropdownMenuSubContent, 185 + DropdownMenuSubTrigger, 186 + DropdownMenuRadioGroup, 187 + }
+142
src/context/auth-context.tsx
··· 1 + /** 2 + * Auth context provider for AT Protocol OAuth. 3 + * Access token held in useRef (memory only, never localStorage/sessionStorage). 4 + * Silent refresh on mount via HTTP-only cookie. 5 + * @see specs/prd-web.md Section M3 (Auth Flow) 6 + */ 7 + 8 + 'use client' 9 + 10 + import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react' 11 + import type { ReactNode } from 'react' 12 + import type { AuthSession, AuthUser } from '@/lib/api/types' 13 + import { initiateLogin, refreshSession, logout as apiLogout } from '@/lib/api/client' 14 + import { createAuthFetch } from '@/lib/api/auth-fetch' 15 + 16 + export interface AuthContextValue { 17 + /** The current authenticated user, or null */ 18 + user: AuthUser | null 19 + /** Whether the user is authenticated */ 20 + isAuthenticated: boolean 21 + /** Whether auth state is still loading (initial refresh) */ 22 + isLoading: boolean 23 + /** Get the current access token (stable function ref) */ 24 + getAccessToken: () => string | null 25 + /** Initiate login flow -- redirects to PDS OAuth */ 26 + login: (handle: string) => Promise<void> 27 + /** Log out and clear auth state */ 28 + logout: () => Promise<void> 29 + /** Set session from OAuth callback (stores token in memory) */ 30 + setSessionFromCallback: (session: AuthSession) => void 31 + /** Auth-aware fetch that auto-refreshes on 401 */ 32 + authFetch: <T>( 33 + path: string, 34 + options?: { 35 + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 36 + headers?: Record<string, string> 37 + body?: unknown 38 + signal?: AbortSignal 39 + } 40 + ) => Promise<T> 41 + } 42 + 43 + export const AuthContext = createContext<AuthContextValue | null>(null) 44 + 45 + interface AuthProviderProps { 46 + children: ReactNode 47 + } 48 + 49 + export function AuthProvider({ children }: AuthProviderProps) { 50 + const [user, setUser] = useState<AuthUser | null>(null) 51 + const [isLoading, setIsLoading] = useState(true) 52 + const tokenRef = useRef<string | null>(null) 53 + 54 + const getAccessToken = useCallback(() => tokenRef.current, []) 55 + 56 + const setSession = useCallback((session: AuthSession) => { 57 + tokenRef.current = session.accessToken 58 + setUser({ 59 + did: session.did, 60 + handle: session.handle, 61 + displayName: session.displayName, 62 + avatarUrl: session.avatarUrl, 63 + }) 64 + }, []) 65 + 66 + const clearSession = useCallback(() => { 67 + tokenRef.current = null 68 + setUser(null) 69 + }, []) 70 + 71 + const handleAuthFailure = useCallback(() => { 72 + clearSession() 73 + }, [clearSession]) 74 + 75 + const authFetch = useMemo( 76 + () => 77 + createAuthFetch({ 78 + getToken: () => tokenRef.current, 79 + setToken: setSession, 80 + onAuthFailure: handleAuthFailure, 81 + }), 82 + [setSession, handleAuthFailure] 83 + ) 84 + 85 + // Silent refresh on mount 86 + useEffect(() => { 87 + let cancelled = false 88 + 89 + async function attemptRefresh() { 90 + try { 91 + const session = await refreshSession() 92 + if (!cancelled) { 93 + setSession(session) 94 + } 95 + } catch { 96 + // No valid refresh cookie -- user is not logged in 97 + } finally { 98 + if (!cancelled) { 99 + setIsLoading(false) 100 + } 101 + } 102 + } 103 + 104 + void attemptRefresh() 105 + return () => { 106 + cancelled = true 107 + } 108 + }, [setSession]) 109 + 110 + const login = useCallback(async (handle: string) => { 111 + const { redirectUrl } = await initiateLogin(handle) 112 + window.location.href = redirectUrl 113 + }, []) 114 + 115 + const logout = useCallback(async () => { 116 + const token = tokenRef.current 117 + if (token) { 118 + try { 119 + await apiLogout(token) 120 + } catch { 121 + // Best-effort server-side logout 122 + } 123 + } 124 + clearSession() 125 + }, [clearSession]) 126 + 127 + const value = useMemo<AuthContextValue>( 128 + () => ({ 129 + user, 130 + isAuthenticated: user !== null, 131 + isLoading, 132 + getAccessToken, 133 + login, 134 + logout, 135 + setSessionFromCallback: setSession, 136 + authFetch, 137 + }), 138 + [user, isLoading, getAccessToken, login, logout, setSession, authFetch] 139 + ) 140 + 141 + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> 142 + }
+19
src/hooks/use-auth.ts
··· 1 + /** 2 + * Hook to access auth context. 3 + * Throws if used outside AuthProvider. 4 + * @see specs/prd-web.md Section M3 (Auth Flow) 5 + */ 6 + 7 + 'use client' 8 + 9 + import { useContext } from 'react' 10 + import { AuthContext } from '@/context/auth-context' 11 + import type { AuthContextValue } from '@/context/auth-context' 12 + 13 + export function useAuth(): AuthContextValue { 14 + const context = useContext(AuthContext) 15 + if (!context) { 16 + throw new Error('useAuth must be used within an AuthProvider') 17 + } 18 + return context 19 + }
+21 -1
src/hooks/use-onboarding.test.ts
··· 8 8 import { server } from '@/mocks/server' 9 9 import { useOnboarding } from './use-onboarding' 10 10 11 + const mockGetAccessToken = vi.fn<() => string | null>(() => 'mock-access-token') 12 + 13 + vi.mock('@/hooks/use-auth', () => ({ 14 + useAuth: () => ({ 15 + user: { 16 + did: 'did:plc:user-alice-001', 17 + handle: 'alice.bsky.social', 18 + displayName: 'Alice', 19 + avatarUrl: null, 20 + }, 21 + isAuthenticated: true, 22 + isLoading: false, 23 + getAccessToken: mockGetAccessToken, 24 + login: vi.fn(), 25 + logout: vi.fn(), 26 + setSessionFromCallback: vi.fn(), 27 + authFetch: vi.fn(), 28 + }), 29 + })) 30 + 11 31 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 12 32 13 33 const mockStorage: Record<string, string> = {} ··· 114 134 }) 115 135 116 136 it('handles missing auth token gracefully', async () => { 117 - delete mockStorage['accessToken'] 137 + mockGetAccessToken.mockReturnValue(null) 118 138 119 139 const { result } = renderHook(() => useOnboarding()) 120 140
+6 -4
src/hooks/use-onboarding.ts
··· 8 8 import { useState, useEffect, useCallback } from 'react' 9 9 import { getOnboardingStatus, submitOnboarding } from '@/lib/api/client' 10 10 import type { OnboardingStatus, OnboardingFieldType } from '@/lib/api/types' 11 + import { useAuth } from '@/hooks/use-auth' 11 12 12 13 export interface UseOnboardingResult { 13 14 /** Whether onboarding status has been loaded */ ··· 29 30 } 30 31 31 32 export function useOnboarding(): UseOnboardingResult { 33 + const { getAccessToken } = useAuth() 32 34 const [loading, setLoading] = useState(true) 33 35 const [status, setStatus] = useState<OnboardingStatus | null>(null) 34 36 const [showModal, setShowModal] = useState(false) 35 37 36 38 const fetchStatus = useCallback(async () => { 37 - const token = localStorage.getItem('accessToken') 39 + const token = getAccessToken() 38 40 if (!token) { 39 41 setLoading(false) 40 42 return ··· 49 51 } finally { 50 52 setLoading(false) 51 53 } 52 - }, []) 54 + }, [getAccessToken]) 53 55 54 56 useEffect(() => { 55 57 void fetchStatus() ··· 57 59 58 60 const submit = useCallback( 59 61 async (responses: Array<{ fieldId: string; response: unknown }>): Promise<boolean> => { 60 - const token = localStorage.getItem('accessToken') 62 + const token = getAccessToken() 61 63 if (!token) return false 62 64 63 65 try { ··· 69 71 return false 70 72 } 71 73 }, 72 - [fetchStatus] 74 + [fetchStatus, getAccessToken] 73 75 ) 74 76 75 77 return {
+110
src/lib/api/auth-fetch.ts
··· 1 + /** 2 + * Auth-aware fetch wrapper with 401 interception and silent token refresh. 3 + * Wraps apiFetch to automatically retry on 401 after refreshing the session. 4 + * @see specs/prd-web.md Section M3 (Auth Flow) 5 + */ 6 + 7 + import { refreshSession } from './client' 8 + import type { AuthSession } from './types' 9 + 10 + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 11 + 12 + interface AuthFetchOptions { 13 + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 14 + headers?: Record<string, string> 15 + body?: unknown 16 + signal?: AbortSignal 17 + } 18 + 19 + interface AuthFetchDeps { 20 + getToken: () => string | null 21 + setToken: (session: AuthSession) => void 22 + onAuthFailure: () => void 23 + } 24 + 25 + class ApiError extends Error { 26 + constructor( 27 + public readonly status: number, 28 + message: string 29 + ) { 30 + super(message) 31 + this.name = 'ApiError' 32 + } 33 + } 34 + 35 + async function rawFetch( 36 + path: string, 37 + accessToken: string | null, 38 + options: AuthFetchOptions = {} 39 + ): Promise<Response> { 40 + const url = `${API_URL}${path}` 41 + const headers: Record<string, string> = { 42 + 'Content-Type': 'application/json', 43 + ...options.headers, 44 + } 45 + if (accessToken) { 46 + headers['Authorization'] = `Bearer ${accessToken}` 47 + } 48 + 49 + return fetch(url, { 50 + method: options.method ?? 'GET', 51 + headers, 52 + signal: options.signal, 53 + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), 54 + }) 55 + } 56 + 57 + /** 58 + * Creates an auth-aware fetch function that automatically handles 401 responses 59 + * by refreshing the session token and retrying the request once. 60 + */ 61 + export function createAuthFetch(deps: AuthFetchDeps) { 62 + let refreshPromise: Promise<AuthSession> | null = null 63 + 64 + return async function authFetch<T>(path: string, options: AuthFetchOptions = {}): Promise<T> { 65 + const token = deps.getToken() 66 + const response = await rawFetch(path, token, options) 67 + 68 + if (response.ok) { 69 + if (response.status === 204) { 70 + return undefined as T 71 + } 72 + return response.json() as Promise<T> 73 + } 74 + 75 + if (response.status !== 401 || !token) { 76 + const body = await response.text().catch(() => 'Unknown error') 77 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 78 + } 79 + 80 + // 401 -- attempt refresh (deduplicate concurrent refreshes) 81 + try { 82 + if (!refreshPromise) { 83 + refreshPromise = refreshSession() 84 + } 85 + const session = await refreshPromise 86 + deps.setToken(session) 87 + } catch { 88 + deps.onAuthFailure() 89 + throw new ApiError(401, 'Session expired') 90 + } finally { 91 + refreshPromise = null 92 + } 93 + 94 + // Retry with new token 95 + const retryToken = deps.getToken() 96 + const retryResponse = await rawFetch(path, retryToken, options) 97 + 98 + if (retryResponse.ok) { 99 + if (retryResponse.status === 204) { 100 + return undefined as T 101 + } 102 + return retryResponse.json() as Promise<T> 103 + } 104 + 105 + const body = await retryResponse.text().catch(() => 'Unknown error') 106 + throw new ApiError(retryResponse.status, `API ${retryResponse.status}: ${body}`) 107 + } 108 + } 109 + 110 + export { ApiError }
+43
src/lib/api/client.ts
··· 6 6 7 7 import type { 8 8 AgeDeclarationResponse, 9 + AuthSession, 10 + AuthUser, 9 11 CategoriesResponse, 10 12 CategoryTreeNode, 11 13 CategoryWithTopicCount, ··· 83 85 ) 84 86 if (entries.length === 0) return '' 85 87 return '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString() 88 + } 89 + 90 + // --- Auth endpoints --- 91 + 92 + export function initiateLogin(handle: string): Promise<{ redirectUrl: string }> { 93 + const query = buildQuery({ handle }) 94 + return apiFetch<{ redirectUrl: string }>(`/api/auth/login${query}`) 95 + } 96 + 97 + export function handleCallback(code: string, state: string): Promise<AuthSession> { 98 + const query = buildQuery({ code, state }) 99 + return apiFetch<AuthSession>(`/api/auth/callback${query}`) 100 + } 101 + 102 + export async function refreshSession(): Promise<AuthSession> { 103 + const url = `${API_URL}/api/auth/refresh` 104 + const response = await fetch(url, { 105 + method: 'POST', 106 + credentials: 'include', 107 + headers: { 'Content-Type': 'application/json' }, 108 + }) 109 + 110 + if (!response.ok) { 111 + const body = await response.text().catch(() => 'Unknown error') 112 + throw new ApiError(response.status, `API ${response.status}: ${body}`) 113 + } 114 + 115 + return response.json() as Promise<AuthSession> 116 + } 117 + 118 + export function logout(accessToken: string): Promise<void> { 119 + return apiFetch<void>('/api/auth/session', { 120 + method: 'DELETE', 121 + headers: { Authorization: `Bearer ${accessToken}` }, 122 + }) 123 + } 124 + 125 + export function getCurrentUser(accessToken: string): Promise<AuthUser> { 126 + return apiFetch<AuthUser>('/api/auth/me', { 127 + headers: { Authorization: `Bearer ${accessToken}` }, 128 + }) 86 129 } 87 130 88 131 // --- Category endpoints ---
+4
src/lib/api/types.ts
··· 189 189 expiresAt: string 190 190 did: string 191 191 handle: string 192 + displayName: string | null 193 + avatarUrl: string | null 192 194 } 193 195 194 196 export interface AuthUser { 195 197 did: string 196 198 handle: string 199 + displayName: string | null 200 + avatarUrl: string | null 197 201 } 198 202 199 203 // --- Notifications ---
+20
src/mocks/data.ts
··· 4 4 */ 5 5 6 6 import type { 7 + AuthSession, 8 + AuthUser, 7 9 CategoryTreeNode, 8 10 CategoryWithTopicCount, 9 11 Topic, ··· 38 40 { did: 'did:plc:user-dave-004', handle: 'dave.bsky.social' }, 39 41 { did: 'did:plc:user-eve-005', handle: 'eve.forum.example' }, 40 42 ] as const 43 + 44 + // --- Auth --- 45 + 46 + export const mockAuthSession: AuthSession = { 47 + accessToken: 'mock-access-token-xyz', 48 + expiresAt: '2026-02-15T13:00:00.000Z', 49 + did: 'did:plc:user-alice-001', 50 + handle: 'alice.bsky.social', 51 + displayName: 'Alice', 52 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 53 + } 54 + 55 + export const mockAuthUser: AuthUser = { 56 + did: 'did:plc:user-alice-001', 57 + handle: 'alice.bsky.social', 58 + displayName: 'Alice', 59 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 60 + } 41 61 42 62 // --- Categories --- 43 63
+51
src/mocks/handlers.ts
··· 6 6 7 7 import { http, HttpResponse } from 'msw' 8 8 import { 9 + mockAuthSession, 10 + mockAuthUser, 9 11 mockCategories, 10 12 mockCategoryWithTopicCount, 11 13 mockTopics, ··· 28 30 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000' 29 31 30 32 export const handlers = [ 33 + // --- Auth endpoints --- 34 + 35 + // GET /api/auth/login 36 + http.get(`${API_URL}/api/auth/login`, ({ request }) => { 37 + const url = new URL(request.url) 38 + const handle = url.searchParams.get('handle') 39 + if (!handle) { 40 + return HttpResponse.json({ error: 'handle is required' }, { status: 400 }) 41 + } 42 + return HttpResponse.json({ 43 + redirectUrl: `https://bsky.social/oauth/authorize?handle=${encodeURIComponent(handle)}&state=mock-state-123`, 44 + }) 45 + }), 46 + 47 + // GET /api/auth/callback 48 + http.get(`${API_URL}/api/auth/callback`, ({ request }) => { 49 + const url = new URL(request.url) 50 + const code = url.searchParams.get('code') 51 + const state = url.searchParams.get('state') 52 + if (!code || !state) { 53 + return HttpResponse.json({ error: 'code and state are required' }, { status: 400 }) 54 + } 55 + return HttpResponse.json(mockAuthSession) 56 + }), 57 + 58 + // POST /api/auth/refresh 59 + http.post(`${API_URL}/api/auth/refresh`, () => { 60 + // In tests, always succeed by default; override per-test for failure cases 61 + return HttpResponse.json(mockAuthSession) 62 + }), 63 + 64 + // DELETE /api/auth/session 65 + http.delete(`${API_URL}/api/auth/session`, ({ request }) => { 66 + const auth = request.headers.get('Authorization') 67 + if (!auth?.startsWith('Bearer ')) { 68 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 69 + } 70 + return new HttpResponse(null, { status: 204 }) 71 + }), 72 + 73 + // GET /api/auth/me 74 + http.get(`${API_URL}/api/auth/me`, ({ request }) => { 75 + const auth = request.headers.get('Authorization') 76 + if (!auth?.startsWith('Bearer ')) { 77 + return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) 78 + } 79 + return HttpResponse.json(mockAuthUser) 80 + }), 81 + 31 82 // GET /api/notifications 32 83 http.get(`${API_URL}/api/notifications`, ({ request }) => { 33 84 const auth = request.headers.get('Authorization')
+46
src/test/mock-auth.tsx
··· 1 + /** 2 + * Shared test utility for mocking auth context. 3 + * Use mockAuthContext() to get a vi.mock factory for @/hooks/use-auth, 4 + * and wrapWithAuth() to render components inside an AuthProvider mock. 5 + */ 6 + 7 + import { vi } from 'vitest' 8 + import type { AuthContextValue } from '@/context/auth-context' 9 + import type { AuthUser } from '@/lib/api/types' 10 + 11 + export const mockUser: AuthUser = { 12 + did: 'did:plc:user-alice-001', 13 + handle: 'alice.bsky.social', 14 + displayName: 'Alice', 15 + avatarUrl: 'https://cdn.bsky.social/avatar/alice.jpg', 16 + } 17 + 18 + export function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue { 19 + return { 20 + user: mockUser, 21 + isAuthenticated: true, 22 + isLoading: false, 23 + getAccessToken: vi.fn(() => 'mock-access-token'), 24 + login: vi.fn(), 25 + logout: vi.fn(), 26 + setSessionFromCallback: vi.fn(), 27 + authFetch: vi.fn(), 28 + ...overrides, 29 + } 30 + } 31 + 32 + export function createUnauthenticatedMockAuthContext( 33 + overrides: Partial<AuthContextValue> = {} 34 + ): AuthContextValue { 35 + return { 36 + user: null, 37 + isAuthenticated: false, 38 + isLoading: false, 39 + getAccessToken: vi.fn(() => null), 40 + login: vi.fn(), 41 + logout: vi.fn(), 42 + setSessionFromCallback: vi.fn(), 43 + authFetch: vi.fn(), 44 + ...overrides, 45 + } 46 + }