Harden the API and the data layer after a security and performance audit

Security
- Every route now requires a login and refuses ids outside the caller's
  workspace with 404: plan, leave-wishes, day-overrides and vacation-days
  were fully anonymous; every other router trusted client-supplied
  workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
  redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
  production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
  accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
  logs no scraped values or page text, clears the BankID QR as soon as it
  is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
  follows); the parser tests use a synthetic fixture.

Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
  instead of one commit per row, so a failure mid-batch leaves nothing
  half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
  filters that were full scans; argon2 hashing runs off the event loop;
  responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
  no longer die silently after 15 minutes; every data hook discards
  stale responses; logout clears state even when the server call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
2026-09-18 07:54:21 +02:00
parent ae16d71970
commit 02db5430eb
81 changed files with 3616 additions and 600 deletions
@@ -128,4 +128,20 @@ describe('Layout', () => {
expect(logout).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/auth', { replace: true })
})
it('still navigates to /auth when logout rejects (S3 — best-effort server call)', async () => {
const logout = vi.fn().mockRejectedValue(new Error('network down'))
mockUseAuth.mockReturnValue({
accessToken: 'token',
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
register: vi.fn(),
logout,
})
renderLayout()
await userEvent.click(screen.getByTestId('logout-button'))
expect(logout).toHaveBeenCalled()
expect(mockNavigate).toHaveBeenCalledWith('/auth', { replace: true })
})
})
+10 -2
View File
@@ -19,8 +19,16 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
const selectedChild = childrenList.find((c) => c.id === selectedChildId)
const handleLogout = useCallback(async () => {
await logout()
navigate('/auth', { replace: true })
try {
await logout()
} catch {
// Best-effort server call — AuthContext already clears local
// token/state in its own `finally` even when this rejects. Swallow it
// here so a network failure during logout doesn't surface as an
// unhandled rejection; still navigate away below.
} finally {
navigate('/auth', { replace: true })
}
}, [logout, navigate])
const headerProps: HeaderProps = {
+41
View File
@@ -122,6 +122,47 @@ describe('AuthContext', () => {
expect(result.current.accessToken).toBeNull()
})
it('clears local state even when the server logout call rejects (S3 — offline logout)', async () => {
vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse)
vi.mocked(api.logout).mockRejectedValue(new Error('network down'))
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => expect(result.current.isAuthenticated).toBe(true))
// The rejection still propagates to the caller (Layout decides how to
// handle a failed best-effort server call) — but local state must
// already be cleared by the time it does.
await act(async () => {
await expect(result.current.logout()).rejects.toThrow('network down')
})
expect(api.setAccessToken).toHaveBeenCalledWith(null)
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.accessToken).toBeNull()
})
it('logs the user out when apiFetch reports a failed refresh-and-retry (S1 interceptor wiring)', async () => {
vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse)
const { result } = renderHook(() => useAuth(), { wrapper })
await waitFor(() => expect(result.current.isAuthenticated).toBe(true))
// Simulate services/api.ts's apiFetch invoking the handler AuthContext
// registered via api.setUnauthorizedHandler after a mid-session 401's
// refresh-and-retry gives up.
const registeredHandler = vi.mocked(api.setUnauthorizedHandler).mock.calls.find(
(call) => call[0] !== null,
)?.[0]
expect(registeredHandler).toBeInstanceOf(Function)
act(() => {
registeredHandler?.()
})
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.accessToken).toBeNull()
})
it('throws when useAuth is used outside an AuthProvider', () => {
expect(() => renderHook(() => useAuth())).toThrow(
'useAuth must be used inside <AuthProvider>',
+21 -3
View File
@@ -42,6 +42,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
})
}, [])
// apiFetch's own refresh-and-retry (see services/api.ts) handles a 401 hit
// mid-session by refreshing once and retrying the original request. If
// that refresh itself fails, this is the hook that actually logs the user
// out — without it, isAuthenticated would stay true forever while every
// subsequent request 401s.
useEffect(() => {
api.setUnauthorizedHandler(() => {
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
})
return () => api.setUnauthorizedHandler(null)
}, [])
const handleTokenResponse = useCallback((res: TokenResponse) => {
api.setAccessToken(res.access_token)
setState({ accessToken: res.access_token, isAuthenticated: true, isLoading: false })
@@ -64,9 +76,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
)
const logout = useCallback(async () => {
await api.logout()
api.setAccessToken(null)
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
// Clear local state even if the server call fails (offline logout must
// still work) — but still let the failure propagate to the caller so it
// can decide how to handle a best-effort server call that didn't land.
try {
await api.logout()
} finally {
api.setAccessToken(null)
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
}
}, [])
return (
+45 -1
View File
@@ -217,7 +217,12 @@ describe('WorkspaceContext', () => {
expect(result.current.selectedChildId).toBe('c2')
})
it('setWorkspaceId stores the id and updates state', async () => {
it('setWorkspaceId stores the id, updates state, and fetches that workspace directly', async () => {
// P2 in the frontend audit: this used to rely on a reactive effect that
// only fired while `workspace` was still null, so switching workspaces
// after the initial load silently never fetched the new one. Fetching
// explicitly here fixes that without reintroducing the cold-load
// duplicate GET /workspace the same effect used to cause.
vi.mocked(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
@@ -225,12 +230,19 @@ describe('WorkspaceContext', () => {
const { result } = renderHook(() => useWorkspace(), { wrapper })
await waitFor(() => expect(result.current.workspace).not.toBeNull())
const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' }
vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace)
vi.mocked(api.getWorkspace).mockClear()
act(() => {
result.current.setWorkspaceId('ws2')
})
expect(result.current.workspaceId).toBe('ws2')
expect(localStorage.getItem('workspaceId')).toBe('ws2')
await waitFor(() => expect(api.getWorkspace).toHaveBeenCalledWith('ws2'))
await waitFor(() => expect(result.current.workspace).toEqual(otherWorkspace))
})
it('refetch re-fetches the current workspace', async () => {
@@ -322,6 +334,38 @@ describe('WorkspaceContext', () => {
expect(result.current.workspace).toEqual(otherWorkspace)
})
it('discards a refreshContext response that resolves after logout (S4)', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated)
let resolveContext: (value: api.UserContext) => void = () => {}
const pending = new Promise<api.UserContext>((resolve) => {
resolveContext = resolve
})
vi.mocked(api.getUserContext).mockReturnValue(pending)
const { result, rerender } = renderHook(() => useWorkspace(), { wrapper })
// Logout happens while the initial refreshContext() call is still
// in flight (e.g. a slow /auth/context response).
vi.mocked(useAuth).mockReturnValue(unauthenticated)
rerender()
await waitFor(() => expect(result.current.workspace).toBeNull())
expect(result.current.workspaceId).toBeNull()
// The stale response arrives after logout — it must be discarded
// rather than repopulating this account's context.
await act(async () => {
resolveContext(userContext)
await pending
})
expect(result.current.workspaceId).toBeNull()
expect(result.current.parentName).toBeNull()
expect(result.current.personalDataUuid).toBeNull()
expect(result.current.children).toEqual([])
expect(localStorage.getItem('workspaceId')).toBeNull()
})
it('throws when useWorkspace is used outside a WorkspaceProvider', () => {
vi.mocked(useAuth).mockReturnValue(unauthenticated)
+37 -12
View File
@@ -43,25 +43,38 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
// absence) of cached data — see the effect for why that distinction is
// what makes a login swap actually refetch.
const wasAuthenticatedRef = useRef(false)
// Bumped by clearWorkspaceState() (logout). A refreshContext/fetchWorkspace
// call captures the generation it started with; if that generation is
// stale by the time the response arrives (logout happened mid-flight),
// the response is discarded instead of repopulating state for the
// account that just logged out — see S4 in the frontend audit.
const generationRef = useRef(0)
const fetchWorkspace = useCallback(async (id: string) => {
const generation = generationRef.current
setIsLoading(true)
setError(null)
try {
const ws = await api.getWorkspace(id)
if (generation !== generationRef.current) return
setWorkspace(ws)
} catch (err: unknown) {
if (generation !== generationRef.current) return
setError(err instanceof Error ? err.message : 'Failed to load workspace')
setWorkspace(null)
} finally {
setIsLoading(false)
if (generation === generationRef.current) {
setIsLoading(false)
}
}
}, [])
const refreshContext = useCallback(async () => {
const generation = generationRef.current
setIsLoading(true)
try {
const context = await api.getUserContext()
if (generation !== generationRef.current) return
localStorage.setItem('workspaceId', context.workspace_id)
setWorkspaceId(context.workspace_id)
setPersonalDataUuid(context.personal_data_uuid)
@@ -73,15 +86,20 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
}
try {
const ws = await api.getWorkspace(context.workspace_id)
if (generation !== generationRef.current) return
setWorkspace(ws)
} catch (err: unknown) {
if (generation !== generationRef.current) return
setError(err instanceof Error ? err.message : 'Failed to load workspace')
setWorkspace(null)
}
} catch (err: unknown) {
if (generation !== generationRef.current) return
setError(err instanceof Error ? err.message : 'Failed to load user context')
} finally {
setIsLoading(false)
if (generation === generationRef.current) {
setIsLoading(false)
}
}
}, [])
@@ -92,6 +110,7 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
// one account's data could otherwise flash under another account's
// session.
const clearWorkspaceState = useCallback(() => {
generationRef.current += 1
localStorage.removeItem('workspaceId')
setWorkspace(null)
setWorkspaceId(null)
@@ -123,16 +142,22 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
}
}, [isAuthenticated, refreshContext, clearWorkspaceState])
useEffect(() => {
if (isAuthenticated && workspaceId && !workspace) {
void fetchWorkspace(workspaceId)
}
}, [isAuthenticated, workspaceId, workspace, fetchWorkspace])
const handleSetWorkspaceId = useCallback((id: string) => {
localStorage.setItem('workspaceId', id)
setWorkspaceId(id)
}, [])
// NOTE (P2 in the frontend audit): a cold login already fetches the
// workspace once inside refreshContext() above. This used to be a
// reactive `useEffect` on `[isAuthenticated, workspaceId, workspace]` that
// fired a *second* GET /workspace as soon as `workspaceId` state landed
// but before `workspace` itself did — a duplicate cold-load request.
// Fetching explicitly here, only when the id is set imperatively (e.g. a
// future workspace switcher), removes that duplicate without losing the
// ability to load a workspace picked outside the login flow.
const handleSetWorkspaceId = useCallback(
(id: string) => {
localStorage.setItem('workspaceId', id)
setWorkspaceId(id)
void fetchWorkspace(id)
},
[fetchWorkspace],
)
const handleSelectChild = useCallback((id: string) => {
setSelectedChildId(id)
+56
View File
@@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { useAnalytics } from './useAnalytics'
import * as api from '@/services/api'
import { makeWorkspaceOverview } from '../../tests/fixtures'
import type { WorkspaceOverview } from '@/types'
vi.mock('@/services/api')
@@ -123,4 +124,59 @@ describe('useAnalytics', () => {
expect(result.current.data).toBeNull()
})
it('discards a stale response for a previous childId (P1 stale-response guard)', async () => {
let resolveFirst: (value: WorkspaceOverview) => void = () => {}
let resolveSecond: (value: WorkspaceOverview) => void = () => {}
const firstPromise = new Promise<WorkspaceOverview>((resolve) => {
resolveFirst = resolve
})
const secondPromise = new Promise<WorkspaceOverview>((resolve) => {
resolveSecond = resolve
})
vi.mocked(api.getWorkspaceOverview)
.mockReturnValueOnce(firstPromise)
.mockReturnValueOnce(secondPromise)
const { result, rerender } = renderHook(
({ childId }: { childId: string }) => useAnalytics('ws1', childId),
{ initialProps: { childId: 'child1' } },
)
// Switching childId while the first (slower) request is still in
// flight fires a second request for the new child.
rerender({ childId: 'child2' })
const child1Data = makeWorkspaceOverview({
workspaceId: 'ws1',
childId: 'child1',
parentProjections: [],
combinedDaysUsed: 1,
combinedDaysLeft: 1,
combinedExpiringAtB4: 0,
})
const child2Data = makeWorkspaceOverview({
workspaceId: 'ws1',
childId: 'child2',
parentProjections: [],
combinedDaysUsed: 5,
combinedDaysLeft: 5,
combinedExpiringAtB4: 0,
})
// The newer (child2) request resolves first...
await act(async () => {
resolveSecond(child2Data)
await secondPromise
})
expect(result.current.data).toEqual(child2Data)
// ...then the stale child1 response arrives late and must not overwrite it.
await act(async () => {
resolveFirst(child1Data)
await firstPromise
})
expect(result.current.data).toEqual(child2Data)
})
})
+9 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { WorkspaceOverview } from '@/types'
interface UseAnalyticsResult {
@@ -13,8 +14,10 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
const [data, setData] = useState<WorkspaceOverview | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId || !childId) {
// The identity this data belongs to is gone (e.g. a logout cleared
// the workspace/child selection) — clear rather than leave the
@@ -27,14 +30,18 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
setError(null)
try {
const response = await api.getWorkspaceOverview(workspaceId, childId)
if (!isCurrent(requestId)) return
setData(response)
} catch (err) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
setData(null)
} finally {
setIsLoading(false)
if (isCurrent(requestId)) {
setIsLoading(false)
}
}
}, [workspaceId, childId])
}, [workspaceId, childId, start, isCurrent])
useEffect(() => {
fetch()
+7 -2
View File
@@ -15,6 +15,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { CalendarWindow } from '@/types'
interface UseCalendarDaysResult {
@@ -32,20 +33,24 @@ export function useCalendarDays(
const [data, setData] = useState<CalendarWindow | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetchWindow = useCallback(async () => {
const requestId = start()
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const result = await api.getCalendarDays(workspaceId, startYear, years)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load calendar days')
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId, startYear, years])
}, [workspaceId, startYear, years, start, isCurrent])
useEffect(() => {
void fetchWindow()
+7 -2
View File
@@ -6,6 +6,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { Child } from '@/types'
export interface ChildSettingsUpdatePayload {
@@ -24,8 +25,10 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
const [data, setData] = useState<Child | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!childId) {
setData(null)
return
@@ -34,13 +37,15 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
setError(null)
try {
const result = await api.getChild(childId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load child')
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [childId])
}, [childId, start, isCurrent])
useEffect(() => {
void fetch()
+7 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
interface UseDayOverridesResult {
@@ -14,20 +15,24 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
const [data, setData] = useState<DayOverride[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const result = await api.listDayOverridesByWorkspace(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load day overrides')
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId])
}, [workspaceId, start, isCurrent])
useEffect(() => {
void fetch()
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useRef } from 'react'
interface UseLatestRequestResult {
/** Call at the start of a fetch to obtain this call's request id. */
start: () => number
/** True if `id` is still the most recently started request — i.e. no
* newer call to `start()` has happened since. Guard every `setState` in
* an async fetch with this so a stale response (e.g. from a fetch kicked
* off for a since-replaced childId/workspaceId) can't overwrite state a
* newer, faster request already set. */
isCurrent: (id: number) => boolean
}
/**
* Shared stale-response guard for the data-fetching hooks in this
* directory. Each hook has no cache/dedup layer (no TanStack Query), so
* switching identity (e.g. the selected child) while a fetch is in flight
* can otherwise let an older, slower response land after — and overwrite —
* a newer one. See P1 in the frontend audit.
*
* Usage:
* const { start, isCurrent } = useLatestRequest()
* const fetch = useCallback(async () => {
* const requestId = start()
* const result = await api.get(...)
* if (!isCurrent(requestId)) return
* setData(result)
* }, [...])
*/
export function useLatestRequest(): UseLatestRequestResult {
const idRef = useRef(0)
const start = useCallback((): number => {
idRef.current += 1
return idRef.current
}, [])
const isCurrent = useCallback((id: number): boolean => id === idRef.current, [])
return { start, isCurrent }
}
+7 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { LeaveWish, LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types'
interface UseLeaveWishesResult {
@@ -34,9 +35,11 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(
async (options?: { background?: boolean }) => {
const requestId = start()
if (!workspaceId) return
const background = options?.background ?? false
const setBusy = background ? setIsRefreshing : setIsLoading
@@ -44,14 +47,16 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
setError(null)
try {
const result = await api.getLeaveWishes(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
} finally {
setBusy(false)
if (isCurrent(requestId)) setBusy(false)
}
},
[workspaceId],
[workspaceId, start, isCurrent],
)
useEffect(() => {
+7 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { Tip } from '@/types'
interface UseOptimizationTipsResult {
@@ -15,21 +16,25 @@ export function useOptimizationTips(workspaceId: string | null): UseOptimization
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const response = await api.getTips(workspaceId)
if (!isCurrent(requestId)) return
setData(response)
} catch (err) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to fetch tips')
setData(null)
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId])
}, [workspaceId, start, isCurrent])
const dismiss = useCallback(
async (tipId: string) => {
+7 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { ParentalLeaveFetch } from '@/types'
interface UseParentalLeaveResult {
@@ -16,8 +17,10 @@ export function useParentalLeave(
const [data, setData] = useState<ParentalLeaveFetch | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!personalDataUuid || !childId) {
// The identity this data belongs to is gone (e.g. a logout cleared
// the parent/child selection) — clear rather than leave the previous
@@ -30,14 +33,16 @@ export function useParentalLeave(
setError(null)
try {
const response = await api.getParentalLeave(personalDataUuid, childId)
if (!isCurrent(requestId)) return
setData(response)
} catch (err) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
setData(null)
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [personalDataUuid, childId])
}, [personalDataUuid, childId, start, isCurrent])
useEffect(() => {
fetch()
+59
View File
@@ -339,6 +339,65 @@ describe('usePlannedDays', () => {
expect(updateError?.message).toBe('Update failed')
})
it('discards a stale response for a previous childId (P1 stale-response guard)', async () => {
let resolveFirst: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
let resolveSecond: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
const firstPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((resolve) => {
resolveFirst = resolve
})
const secondPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((resolve) => {
resolveSecond = resolve
})
vi.mocked(api.getPlannedDays).mockReturnValueOnce(firstPromise).mockReturnValueOnce(secondPromise)
const { result, rerender } = renderHook(
({ childId }: { childId: string }) => usePlannedDays(childId),
{ initialProps: { childId: 'child1' } },
)
// Switching childId while the first (slower) request is still in
// flight fires a second request for the new child.
rerender({ childId: 'child2' })
const child1Data = [
makePlannedLeaveDay({
id: 'p1',
workspaceId: 'ws1',
childId: 'child1',
parentPersonalDataUuid: 'parent1',
date: '2026-06-01',
dayType: 'vacation',
isDubbeldag: false,
}),
]
const child2Data = [
makePlannedLeaveDay({
id: 'p2',
workspaceId: 'ws1',
childId: 'child2',
parentPersonalDataUuid: 'parent1',
date: '2026-07-01',
dayType: 'vacation',
isDubbeldag: false,
}),
]
// The newer (child2) request resolves first...
await act(async () => {
resolveSecond(child2Data)
await secondPromise
})
expect(result.current.data).toEqual(child2Data)
// ...then the stale child1 response arrives late and must not overwrite it.
await act(async () => {
resolveFirst(child1Data)
await firstPromise
})
expect(result.current.data).toEqual(child2Data)
})
it('handles remove error gracefully', async () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
vi.mocked(api.deletePlannedDay).mockRejectedValue(new Error('Delete failed'))
+10 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { PlannedLeaveDay } from '@/types'
interface UsePlannedDaysResult {
@@ -20,8 +21,10 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!childId) {
// The child this data belongs to is gone (e.g. a logout cleared the
// selection) — clear rather than leave the previous account's
@@ -34,16 +37,18 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
setError(null)
try {
const response = await api.getPlannedDays(childId)
setData(response)
if (isCurrent(requestId)) setData(response)
return response
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
setData(null)
if (isCurrent(requestId)) {
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
setData(null)
}
return null
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [childId])
}, [childId, start, isCurrent])
const add = useCallback(
async (payload: api.CreatePlannedDayPayload) => {
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useScraperFetch } from './useScraperFetch'
import type { PollResponse } from './useScraperFetch'
import * as api from '@/services/api'
import * as errors from '@/services/errors'
import { makeScraperSessionStartResponse, makePollResponse } from '../../tests/fixtures'
@@ -335,6 +336,81 @@ describe('useScraperFetch', () => {
expect(result.current.sessionId).toBeNull()
})
it('skips a poll tick while the previous one is still in flight (P4 overlap guard)', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue(
makeScraperSessionStartResponse({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
})
)
let resolvePoll: (value: PollResponse) => void = () => {}
const pending = new Promise<PollResponse>((resolve) => {
resolvePoll = resolve
})
vi.mocked(api.pollScraperSession).mockReturnValueOnce(pending)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
// First tick starts the still-pending poll.
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(api.pollScraperSession).toHaveBeenCalledTimes(1)
// A second tick fires while the first poll is still in flight — it must
// be skipped rather than stacking a second concurrent request.
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(api.pollScraperSession).toHaveBeenCalledTimes(1)
// Resolve the first poll; the next tick is then free to run normally.
vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' }))
await act(async () => {
resolvePoll(makePollResponse({ status: 'authenticating' }))
await pending
})
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(api.pollScraperSession).toHaveBeenCalledTimes(2)
})
it('clears the polling interval on unmount (P4)', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue(
makeScraperSessionStartResponse({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
})
)
vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' }))
const { result, unmount } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
unmount()
vi.mocked(api.pollScraperSession).mockClear()
await act(async () => {
vi.advanceTimersByTime(5000)
})
expect(api.pollScraperSession).not.toHaveBeenCalled()
})
it('handles poll error', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue(
makeScraperSessionStartResponse({
+16 -1
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from 'react'
import { useState, useRef, useCallback, useEffect } from 'react'
import * as api from '@/services/api'
import { isUnauthorized } from '@/services/errors'
@@ -41,6 +41,10 @@ export function useScraperFetch(): UseScraperFetchResult {
const [unauthorizedError, setUnauthorizedError] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Guards against overlapping ticks: if a poll response hasn't come back
// yet when the next 1s tick fires (a slow/backed-up server), skip that
// tick rather than stacking a second in-flight request on top of it.
const pollInFlightRef = useRef(false)
const stopPolling = useCallback(() => {
if (intervalRef.current) {
@@ -55,6 +59,8 @@ export function useScraperFetch(): UseScraperFetchResult {
const startPolling = useCallback((sid: string) => {
stopPolling()
intervalRef.current = setInterval(async () => {
if (pollInFlightRef.current) return
pollInFlightRef.current = true
try {
const data = await api.pollScraperSession(sid)
setPollData(data as PollResponse)
@@ -64,10 +70,19 @@ export function useScraperFetch(): UseScraperFetchResult {
} catch (err) {
setError(err instanceof Error ? err.message : 'Poll error')
stopPolling()
} finally {
pollInFlightRef.current = false
}
}, POLL_INTERVAL_MS)
}, [stopPolling])
// The interval otherwise outlives the component: a poll response arriving
// after unmount would still call setState, and the timer itself would
// keep firing (and keep holding the closure alive) forever.
useEffect(() => {
return () => stopPolling()
}, [stopPolling])
const start = useCallback(
async (childId?: string) => {
if (sessionId && pollData && !isTerminalStatus(pollData.status)) {
+7 -2
View File
@@ -10,6 +10,7 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { VacationDays, VacationDaysCreateRequest } from '@/types'
interface UseVacationDaysResult {
@@ -23,20 +24,24 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
const [data, setData] = useState<VacationDays[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const result = await api.getVacationDaysByWorkspace(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load vacation days')
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId])
}, [workspaceId, start, isCurrent])
useEffect(() => {
void fetch()
+7 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { PersonalData } from '@/types'
interface UseWorkspaceMembersResult {
@@ -14,20 +15,24 @@ export function useWorkspaceMembers(workspaceId: string | null): UseWorkspaceMem
const [data, setData] = useState<PersonalData[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const fetch = useCallback(async () => {
const requestId = start()
if (!workspaceId) return
setIsLoading(true)
setError(null)
try {
const result = await api.listWorkspaceMembers(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load workspace members')
} finally {
setIsLoading(false)
if (isCurrent(requestId)) setIsLoading(false)
}
}, [workspaceId])
}, [workspaceId, start, isCurrent])
useEffect(() => {
void fetch()
+108
View File
@@ -1774,6 +1774,114 @@ describe('endpoint functions (representative coverage)', () => {
})
})
describe('apiFetch — 401 refresh-and-retry (S1)', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
afterEach(() => {
vi.clearAllMocks()
api.setAccessToken(null)
api.setUnauthorizedHandler(null)
})
function unauthorizedResponse(detail = 'Unauthorized') {
return { ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ detail }) }
}
function tokenResponse(access_token: string) {
return {
ok: true,
status: 200,
json: async () => ({ access_token, token_type: 'bearer', expires_in: 3600 }),
}
}
it('refreshes the token once and retries the original request after a 401', async () => {
api.setAccessToken('old-token')
fetchMock
.mockResolvedValueOnce(unauthorizedResponse())
.mockResolvedValueOnce(tokenResponse('new-token'))
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => [] })
const result = await api.getTips('ws1')
expect(result).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock.mock.calls[1]![0]).toBe('/api/auth/refresh')
const [retryUrl, retryOptions] = fetchMock.mock.calls[2] as [string, RequestInit]
expect(retryUrl).toBe('/api/tips/ws1')
expect((retryOptions.headers as Record<string, string>)['Authorization']).toBe('Bearer new-token')
})
it('dedupes concurrent 401s from parallel requests into a single refresh call', async () => {
api.setAccessToken('old-token')
fetchMock.mockImplementation(async (url: string, options?: RequestInit) => {
if (url === '/api/auth/refresh') {
return tokenResponse('new-token')
}
const headers = options?.headers as Record<string, string> | undefined
if (headers?.['Authorization'] === 'Bearer new-token') {
return { ok: true, status: 200, json: async () => [] }
}
return unauthorizedResponse()
})
const [r1, r2] = await Promise.all([api.getTips('ws1'), api.getTips('ws1')])
expect(r1).toEqual([])
expect(r2).toEqual([])
const refreshCalls = fetchMock.mock.calls.filter(([url]) => url === '/api/auth/refresh')
expect(refreshCalls).toHaveLength(1)
})
it('clears the token, notifies the unauthorized handler, and surfaces the original 401 when refresh fails', async () => {
api.setAccessToken('old-token')
const onUnauthorized = vi.fn()
api.setUnauthorizedHandler(onUnauthorized)
fetchMock
.mockResolvedValueOnce(unauthorizedResponse('session expired'))
.mockResolvedValueOnce(unauthorizedResponse('refresh token expired'))
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 401,
error: { message: 'session expired' },
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(onUnauthorized).toHaveBeenCalledTimes(1)
})
it('does not loop when the retried request also 401s', async () => {
api.setAccessToken('old-token')
fetchMock
.mockResolvedValueOnce(unauthorizedResponse())
.mockResolvedValueOnce(tokenResponse('new-token'))
.mockResolvedValueOnce(unauthorizedResponse('still unauthorized'))
await expect(api.getTips('ws1')).rejects.toMatchObject({
status: 401,
error: { message: 'still unauthorized' },
})
// Original + refresh + one retry — no second refresh attempt.
expect(fetchMock).toHaveBeenCalledTimes(3)
})
it('does not attempt a refresh-and-retry for the auth endpoints themselves', async () => {
fetchMock.mockResolvedValue(unauthorizedResponse())
await expect(api.login({ email: 'a@b.com', password: 'x' })).rejects.toMatchObject({ status: 401 })
await expect(api.refreshToken()).rejects.toMatchObject({ status: 401 })
await expect(api.logout()).rejects.toMatchObject({ status: 401 })
expect(fetchMock).toHaveBeenCalledTimes(3)
})
})
describe('apiFetch — structured {message_key, data} errors', () => {
let fetchMock: ReturnType<typeof vi.fn>
+46
View File
@@ -520,9 +520,30 @@ export function setAccessToken(token: string | null): void {
_accessToken = token
}
// Endpoints that must never trigger a refresh-and-retry on 401 — retrying
// them would either be nonsensical (the refresh call retrying itself) or
// mask the real "not logged in" outcome (login/logout).
const AUTH_NO_RETRY_PATHS = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
// Dedupe concurrent 401s (e.g. Dashboard's ~7 parallel requests all
// expiring at once) into a single in-flight refresh call, shared across
// every apiFetch caller.
let refreshPromise: Promise<TokenResponse> | null = null
// AuthContext registers this so a refresh-and-retry failure can log the
// user out immediately, instead of leaving `isAuthenticated: true` while
// every subsequent request 401s. Kept out of this module so api.ts stays
// framework-agnostic.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler
}
async function apiFetch<T>(
path: string,
options: RequestInit = {},
isRetry = false,
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -532,6 +553,31 @@ async function apiFetch<T>(
headers['Authorization'] = `Bearer ${_accessToken}`
}
const response = await fetch(`${BASE}${path}`, { ...options, headers })
if (response.status === 401 && !isRetry && !AUTH_NO_RETRY_PATHS.has(path)) {
let refreshed: TokenResponse | null = null
try {
refreshPromise ??= refreshToken().finally(() => {
refreshPromise = null
})
refreshed = await refreshPromise
} catch {
// Refresh itself failed (expired/invalid refresh cookie, or a
// network error) — clear the token and let the caller's original
// 401 surface below so AuthContext can log the user out.
setAccessToken(null)
onUnauthorized?.()
}
if (refreshed) {
setAccessToken(refreshed.access_token)
// Retry exactly once, outside the try/catch above — `isRetry: true`
// means a 401 on this attempt is final and must propagate as-is
// rather than being caught here and re-reported as the original
// (now stale) 401.
return await apiFetch<T>(path, options, true)
}
}
if (!response.ok) {
let error: { code: string; message: string; details?: Record<string, unknown> } = {
code: String(response.status),