feat: implement security, error resilience, and query optimization proposals
Security Validation (enhance-security-validation): - JWT secret validation with entropy checking and pattern detection - CSRF protection middleware with token generation/validation - Frontend CSRF token auto-injection for DELETE/PUT/PATCH requests - MIME type validation with magic bytes detection for file uploads Error Resilience (add-error-resilience): - React ErrorBoundary component with fallback UI and retry functionality - ErrorBoundaryWithI18n wrapper for internationalization support - Page-level and section-level error boundaries in App.tsx Query Performance (optimize-query-performance): - Query monitoring utility with threshold warnings - N+1 query fixes using joinedload/selectinload - Optimized project members, tasks, and subtasks endpoints Bug Fixes: - WebSocket session management (P0): Return primitives instead of ORM objects - LIKE query injection (P1): Escape special characters in search queries Tests: 543 backend tests, 56 frontend tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,5 +112,22 @@
|
||||
"of": "of {{total}}",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}}",
|
||||
"itemsPerPage": "Items per page"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"retry": "Try Again",
|
||||
"page": {
|
||||
"title": "Something went wrong",
|
||||
"message": "We apologize for the inconvenience. Please try refreshing the page or contact support if the problem persists."
|
||||
},
|
||||
"section": {
|
||||
"title": "Unable to load this section",
|
||||
"message": "This section encountered an error. Other parts of the page may still work.",
|
||||
"messageWithName": "{{section}} encountered an error. Other parts of the page may still work."
|
||||
},
|
||||
"widget": {
|
||||
"title": "Widget error",
|
||||
"message": "Unable to display this widget.",
|
||||
"errorSuffix": "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,5 +112,22 @@
|
||||
"of": "共 {{total}} 頁",
|
||||
"showing": "顯示 {{from}}-{{to}} 筆,共 {{total}} 筆",
|
||||
"itemsPerPage": "每頁顯示"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"retry": "重試",
|
||||
"page": {
|
||||
"title": "發生錯誤",
|
||||
"message": "非常抱歉造成不便。請嘗試重新整理頁面,如果問題持續發生,請聯繫技術支援。"
|
||||
},
|
||||
"section": {
|
||||
"title": "無法載入此區塊",
|
||||
"message": "此區塊發生錯誤,但頁面的其他部分可能仍然正常運作。",
|
||||
"messageWithName": "{{section}} 發生錯誤,但頁面的其他部分可能仍然正常運作。"
|
||||
},
|
||||
"widget": {
|
||||
"title": "元件錯誤",
|
||||
"message": "無法顯示此元件。",
|
||||
"errorSuffix": "發生錯誤"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import { Skeleton } from './components/Skeleton'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { SectionErrorBoundary } from './components/ErrorBoundaryWithI18n'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
@@ -27,102 +29,122 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Spaces />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces/:spaceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Projects />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Tasks />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ProjectSettings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<AuditPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workload"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<WorkloadPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-health"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ProjectHealthPage />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<MySettings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<ErrorBoundary variant="page">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Dashboard">
|
||||
<Dashboard />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Spaces">
|
||||
<Spaces />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces/:spaceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Projects">
|
||||
<Projects />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Tasks">
|
||||
<Tasks />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Settings">
|
||||
<ProjectSettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Audit">
|
||||
<AuditPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workload"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Workload">
|
||||
<WorkloadPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-health"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Health">
|
||||
<ProjectHealthPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Settings">
|
||||
<MySettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
512
frontend/src/components/ErrorBoundary.test.tsx
Normal file
512
frontend/src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import {
|
||||
ErrorBoundary,
|
||||
ErrorFallback,
|
||||
logError,
|
||||
getErrorLogs,
|
||||
clearErrorLogs,
|
||||
withErrorBoundary,
|
||||
} from './ErrorBoundary'
|
||||
|
||||
// Component that throws an error for testing
|
||||
function ThrowError({ shouldThrow = true }: { shouldThrow?: boolean }) {
|
||||
if (shouldThrow) {
|
||||
throw new Error('Test error message')
|
||||
}
|
||||
return <div>No error</div>
|
||||
}
|
||||
|
||||
// Component that can be toggled to throw
|
||||
function ToggleableError({ error }: { error: boolean }) {
|
||||
if (error) {
|
||||
throw new Error('Toggled error')
|
||||
}
|
||||
return <div>Safe content</div>
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
// Suppress console.error during tests since we're testing error handling
|
||||
const originalError = console.error
|
||||
const originalGroup = console.group
|
||||
const originalGroupEnd = console.groupEnd
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn()
|
||||
console.group = vi.fn()
|
||||
console.groupEnd = vi.fn()
|
||||
clearErrorLogs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
console.group = originalGroup
|
||||
console.groupEnd = originalGroupEnd
|
||||
})
|
||||
|
||||
describe('Basic Functionality', () => {
|
||||
it('renders children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>Child content</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Child content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders fallback UI when error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unable to load this section')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('catches errors in child components', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Should display fallback UI, not crash
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom fallback when provided', () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Custom error message</div>}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom error message')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant Styles', () => {
|
||||
it('renders page variant with appropriate styles', () => {
|
||||
render(
|
||||
<ErrorBoundary variant="page">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section variant with appropriate styles', () => {
|
||||
render(
|
||||
<ErrorBoundary variant="section">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Unable to load this section')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders widget variant with appropriate styles', () => {
|
||||
render(
|
||||
<ErrorBoundary variant="widget">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Widget error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Recovery', () => {
|
||||
it('shows reset button by default', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides reset button when showReset is false', () => {
|
||||
render(
|
||||
<ErrorBoundary showReset={false}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom reset button text', () => {
|
||||
render(
|
||||
<ErrorBoundary resetButtonText="Retry Now">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Retry Now' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets error state when retry button is clicked', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ToggleableError error={true} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Error is displayed
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
|
||||
// First rerender with fixed props (error boundary still shows error UI)
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ToggleableError error={false} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Error UI is still shown until reset is clicked
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
|
||||
// Click retry button to reset error state
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }))
|
||||
|
||||
// Now children render successfully with error={false}
|
||||
expect(screen.getByText('Safe content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Messages', () => {
|
||||
it('uses custom error title', () => {
|
||||
render(
|
||||
<ErrorBoundary errorTitle="Custom Title">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom error message', () => {
|
||||
render(
|
||||
<ErrorBoundary errorMessage="Custom error description">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom error description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Callback', () => {
|
||||
it('calls onError callback when error occurs', () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
componentStack: expect.any(String),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has role="alert" for screen readers', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for dynamic updates', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'polite')
|
||||
})
|
||||
|
||||
it('has accessible button label', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: /try again/i })
|
||||
expect(button).toHaveAttribute('aria-label', 'Try Again')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ErrorFallback', () => {
|
||||
it('renders with page variant', () => {
|
||||
render(<ErrorFallback variant="page" error={null} />)
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with section variant', () => {
|
||||
render(<ErrorFallback variant="section" error={null} />)
|
||||
|
||||
expect(screen.getByText('Unable to load this section')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with widget variant', () => {
|
||||
render(<ErrorFallback variant="widget" error={null} />)
|
||||
|
||||
expect(screen.getByText('Widget error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onReset when button clicked', () => {
|
||||
const onReset = vi.fn()
|
||||
render(<ErrorFallback variant="section" error={null} onReset={onReset} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }))
|
||||
|
||||
expect(onReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides button when showReset is false', () => {
|
||||
render(<ErrorFallback variant="section" error={null} showReset={false} />)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Logging', () => {
|
||||
const originalError = console.error
|
||||
const originalGroup = console.group
|
||||
const originalGroupEnd = console.groupEnd
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn()
|
||||
console.group = vi.fn()
|
||||
console.groupEnd = vi.fn()
|
||||
clearErrorLogs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
console.group = originalGroup
|
||||
console.groupEnd = originalGroupEnd
|
||||
})
|
||||
|
||||
it('logs error when caught by ErrorBoundary', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const logs = getErrorLogs()
|
||||
expect(logs).toHaveLength(1)
|
||||
expect(logs[0].error.message).toBe('Test error message')
|
||||
})
|
||||
|
||||
it('logs error with component stack', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const logs = getErrorLogs()
|
||||
expect(logs[0].componentStack).toBeDefined()
|
||||
})
|
||||
|
||||
it('logs error with timestamp', () => {
|
||||
const beforeTime = new Date()
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const afterTime = new Date()
|
||||
const logs = getErrorLogs()
|
||||
|
||||
expect(logs[0].timestamp.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime())
|
||||
expect(logs[0].timestamp.getTime()).toBeLessThanOrEqual(afterTime.getTime())
|
||||
})
|
||||
|
||||
it('logs error with URL', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const logs = getErrorLogs()
|
||||
expect(logs[0].url).toBe(window.location.href)
|
||||
})
|
||||
|
||||
it('logs error with user agent', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
const logs = getErrorLogs()
|
||||
expect(logs[0].userAgent).toBe(navigator.userAgent)
|
||||
})
|
||||
|
||||
it('clears error logs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
expect(getErrorLogs()).toHaveLength(1)
|
||||
|
||||
clearErrorLogs()
|
||||
|
||||
expect(getErrorLogs()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('logError function returns ErrorLog object', () => {
|
||||
const error = new Error('Direct log test')
|
||||
const errorInfo = { componentStack: 'test stack' }
|
||||
|
||||
const log = logError(error, errorInfo as any)
|
||||
|
||||
expect(log.error).toBe(error)
|
||||
expect(log.componentStack).toBe('test stack')
|
||||
expect(log.timestamp).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withErrorBoundary HOC', () => {
|
||||
const originalError = console.error
|
||||
const originalGroup = console.group
|
||||
const originalGroupEnd = console.groupEnd
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn()
|
||||
console.group = vi.fn()
|
||||
console.groupEnd = vi.fn()
|
||||
clearErrorLogs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
console.group = originalGroup
|
||||
console.groupEnd = originalGroupEnd
|
||||
})
|
||||
|
||||
function SafeComponent(): JSX.Element {
|
||||
return <div>Safe component content</div>
|
||||
}
|
||||
|
||||
function UnsafeComponent(): JSX.Element {
|
||||
throw new Error('HOC test error')
|
||||
}
|
||||
|
||||
it('wraps component with error boundary', () => {
|
||||
const WrappedSafe = withErrorBoundary(SafeComponent)
|
||||
render(<WrappedSafe />)
|
||||
|
||||
expect(screen.getByText('Safe component content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('catches errors in wrapped component', () => {
|
||||
const WrappedUnsafe = withErrorBoundary(UnsafeComponent)
|
||||
render(<WrappedUnsafe />)
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies error boundary props', () => {
|
||||
const WrappedUnsafe = withErrorBoundary(UnsafeComponent, {
|
||||
variant: 'page',
|
||||
errorTitle: 'HOC Error Title',
|
||||
})
|
||||
render(<WrappedUnsafe />)
|
||||
|
||||
expect(screen.getByText('HOC Error Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets correct displayName', () => {
|
||||
const WrappedSafe = withErrorBoundary(SafeComponent)
|
||||
|
||||
expect(WrappedSafe.displayName).toBe('withErrorBoundary(SafeComponent)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Error Boundaries', () => {
|
||||
const originalError = console.error
|
||||
const originalGroup = console.group
|
||||
const originalGroupEnd = console.groupEnd
|
||||
|
||||
beforeEach(() => {
|
||||
console.error = vi.fn()
|
||||
console.group = vi.fn()
|
||||
console.groupEnd = vi.fn()
|
||||
clearErrorLogs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError
|
||||
console.group = originalGroup
|
||||
console.groupEnd = originalGroupEnd
|
||||
})
|
||||
|
||||
it('isolates errors to their boundary', () => {
|
||||
render(
|
||||
<div>
|
||||
<ErrorBoundary>
|
||||
<div data-testid="section-1">
|
||||
<ThrowError />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<div data-testid="section-2">Section 2 content</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Section 1 should show error
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
|
||||
// Section 2 should still work
|
||||
expect(screen.getByTestId('section-2')).toBeInTheDocument()
|
||||
expect(screen.getByText('Section 2 content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('nested boundaries catch innermost errors', () => {
|
||||
render(
|
||||
<ErrorBoundary errorTitle="Outer Error">
|
||||
<div>Outer content</div>
|
||||
<ErrorBoundary errorTitle="Inner Error">
|
||||
<ThrowError />
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
// Should show inner error, not outer
|
||||
expect(screen.getByText('Inner Error')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Outer Error')).not.toBeInTheDocument()
|
||||
|
||||
// Outer content should still be visible
|
||||
expect(screen.getByText('Outer content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
459
frontend/src/components/ErrorBoundary.tsx
Normal file
459
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
// Error logging service - can be extended to send to external service
|
||||
export interface ErrorLog {
|
||||
error: Error
|
||||
errorInfo: ErrorInfo
|
||||
componentStack: string
|
||||
timestamp: Date
|
||||
userAgent: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// In-memory error log store (could be sent to backend in production)
|
||||
const errorLogs: ErrorLog[] = []
|
||||
|
||||
export function logError(error: Error, errorInfo: ErrorInfo): ErrorLog {
|
||||
const log: ErrorLog = {
|
||||
error,
|
||||
errorInfo,
|
||||
componentStack: errorInfo.componentStack || '',
|
||||
timestamp: new Date(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
}
|
||||
|
||||
errorLogs.push(log)
|
||||
|
||||
// Log to console for debugging
|
||||
console.group('ErrorBoundary caught an error')
|
||||
console.error('Error:', error)
|
||||
console.error('Component Stack:', errorInfo.componentStack)
|
||||
console.error('Timestamp:', log.timestamp.toISOString())
|
||||
console.error('URL:', log.url)
|
||||
console.groupEnd()
|
||||
|
||||
// In production, could send to error tracking service
|
||||
// sendToErrorTrackingService(log)
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
export function getErrorLogs(): ErrorLog[] {
|
||||
return [...errorLogs]
|
||||
}
|
||||
|
||||
export function clearErrorLogs(): void {
|
||||
errorLogs.length = 0
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
/** Custom fallback UI to show when error occurs */
|
||||
fallback?: ReactNode
|
||||
/** Callback when error is caught */
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
||||
/** Whether to show reset button */
|
||||
showReset?: boolean
|
||||
/** Custom reset button text */
|
||||
resetButtonText?: string
|
||||
/** Custom error title */
|
||||
errorTitle?: string
|
||||
/** Custom error message */
|
||||
errorMessage?: string
|
||||
/** Variant style: 'page' for full page errors, 'section' for section-level */
|
||||
variant?: 'page' | 'section' | 'widget'
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* React Error Boundary component that catches JavaScript errors in child components.
|
||||
* Provides graceful degradation with user-friendly error UI and retry functionality.
|
||||
*
|
||||
* @example
|
||||
* // Page-level boundary
|
||||
* <ErrorBoundary variant="page">
|
||||
* <App />
|
||||
* </ErrorBoundary>
|
||||
*
|
||||
* @example
|
||||
* // Section-level boundary with custom message
|
||||
* <ErrorBoundary
|
||||
* variant="section"
|
||||
* errorTitle="Dashboard Error"
|
||||
* errorMessage="Unable to load dashboard widgets"
|
||||
* >
|
||||
* <DashboardWidgets />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
this.setState({ errorInfo })
|
||||
|
||||
// Log the error
|
||||
logError(error, errorInfo)
|
||||
|
||||
// Call optional error callback
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
})
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { hasError, error } = this.state
|
||||
const {
|
||||
children,
|
||||
fallback,
|
||||
showReset = true,
|
||||
resetButtonText,
|
||||
errorTitle,
|
||||
errorMessage,
|
||||
variant = 'section',
|
||||
} = this.props
|
||||
|
||||
if (hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (fallback) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Render default error UI based on variant
|
||||
return (
|
||||
<ErrorFallback
|
||||
variant={variant}
|
||||
error={error}
|
||||
title={errorTitle}
|
||||
message={errorMessage}
|
||||
showReset={showReset}
|
||||
resetButtonText={resetButtonText}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
variant: 'page' | 'section' | 'widget'
|
||||
error: Error | null
|
||||
title?: string
|
||||
message?: string
|
||||
showReset?: boolean
|
||||
resetButtonText?: string
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Default error fallback UI component.
|
||||
* Can be used independently for functional component error handling.
|
||||
*/
|
||||
export function ErrorFallback({
|
||||
variant,
|
||||
error,
|
||||
title,
|
||||
message,
|
||||
showReset = true,
|
||||
resetButtonText,
|
||||
onReset,
|
||||
}: ErrorFallbackProps): JSX.Element {
|
||||
const styles = getVariantStyles(variant)
|
||||
|
||||
const defaultTitle = getDefaultTitle(variant)
|
||||
const defaultMessage = getDefaultMessage(variant)
|
||||
const defaultButtonText = getDefaultButtonText()
|
||||
|
||||
return (
|
||||
<div style={styles.container} role="alert" aria-live="polite">
|
||||
<div style={styles.content}>
|
||||
<div style={styles.iconWrapper}>
|
||||
<span style={styles.icon} aria-hidden="true">!</span>
|
||||
</div>
|
||||
<h3 style={styles.title}>{title || defaultTitle}</h3>
|
||||
<p style={styles.message}>{message || defaultMessage}</p>
|
||||
{import.meta.env.DEV && error && (
|
||||
<details style={styles.details}>
|
||||
<summary style={styles.summary}>Error Details</summary>
|
||||
<pre style={styles.errorText}>{error.message}</pre>
|
||||
<pre style={styles.stackTrace}>{error.stack}</pre>
|
||||
</details>
|
||||
)}
|
||||
{showReset && onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
style={styles.resetButton}
|
||||
type="button"
|
||||
aria-label={resetButtonText || defaultButtonText}
|
||||
>
|
||||
{resetButtonText || defaultButtonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultTitle(variant: 'page' | 'section' | 'widget'): string {
|
||||
switch (variant) {
|
||||
case 'page':
|
||||
return 'Something went wrong'
|
||||
case 'section':
|
||||
return 'Unable to load this section'
|
||||
case 'widget':
|
||||
return 'Widget error'
|
||||
default:
|
||||
return 'An error occurred'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultMessage(variant: 'page' | 'section' | 'widget'): string {
|
||||
switch (variant) {
|
||||
case 'page':
|
||||
return 'We apologize for the inconvenience. Please try refreshing the page or contact support if the problem persists.'
|
||||
case 'section':
|
||||
return 'This section encountered an error. Other parts of the page may still work.'
|
||||
case 'widget':
|
||||
return 'Unable to display this widget.'
|
||||
default:
|
||||
return 'An unexpected error occurred.'
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultButtonText(): string {
|
||||
return 'Try Again'
|
||||
}
|
||||
|
||||
interface StyleSet {
|
||||
container: React.CSSProperties
|
||||
content: React.CSSProperties
|
||||
iconWrapper: React.CSSProperties
|
||||
icon: React.CSSProperties
|
||||
title: React.CSSProperties
|
||||
message: React.CSSProperties
|
||||
details: React.CSSProperties
|
||||
summary: React.CSSProperties
|
||||
errorText: React.CSSProperties
|
||||
stackTrace: React.CSSProperties
|
||||
resetButton: React.CSSProperties
|
||||
}
|
||||
|
||||
function getVariantStyles(variant: 'page' | 'section' | 'widget'): StyleSet {
|
||||
const baseStyles: StyleSet = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
content: {
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ffebee',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
color: '#f44336',
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
},
|
||||
message: {
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
maxWidth: '400px',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
details: {
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
textAlign: 'left',
|
||||
marginTop: '8px',
|
||||
},
|
||||
summary: {
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
errorText: {
|
||||
margin: '8px 0',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: '#d32f2f',
|
||||
overflow: 'auto',
|
||||
maxHeight: '80px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
stackTrace: {
|
||||
margin: '8px 0',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
color: '#666',
|
||||
overflow: 'auto',
|
||||
maxHeight: '150px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
resetButton: {
|
||||
padding: '10px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
backgroundColor: '#2196f3',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
},
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case 'page':
|
||||
return {
|
||||
...baseStyles,
|
||||
container: {
|
||||
...baseStyles.container,
|
||||
minHeight: '100vh',
|
||||
padding: '24px',
|
||||
},
|
||||
iconWrapper: {
|
||||
...baseStyles.iconWrapper,
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
},
|
||||
icon: {
|
||||
...baseStyles.icon,
|
||||
fontSize: '40px',
|
||||
},
|
||||
title: {
|
||||
...baseStyles.title,
|
||||
fontSize: '24px',
|
||||
},
|
||||
message: {
|
||||
...baseStyles.message,
|
||||
fontSize: '16px',
|
||||
maxWidth: '500px',
|
||||
},
|
||||
}
|
||||
|
||||
case 'section':
|
||||
return {
|
||||
...baseStyles,
|
||||
container: {
|
||||
...baseStyles.container,
|
||||
padding: '40px 24px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
case 'widget':
|
||||
return {
|
||||
...baseStyles,
|
||||
container: {
|
||||
...baseStyles.container,
|
||||
padding: '20px 16px',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
minHeight: '120px',
|
||||
},
|
||||
iconWrapper: {
|
||||
...baseStyles.iconWrapper,
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
},
|
||||
icon: {
|
||||
...baseStyles.icon,
|
||||
fontSize: '20px',
|
||||
},
|
||||
title: {
|
||||
...baseStyles.title,
|
||||
fontSize: '14px',
|
||||
},
|
||||
message: {
|
||||
...baseStyles.message,
|
||||
fontSize: '12px',
|
||||
},
|
||||
resetButton: {
|
||||
...baseStyles.resetButton,
|
||||
padding: '6px 16px',
|
||||
fontSize: '12px',
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
return baseStyles
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component to wrap a component with error boundary.
|
||||
*
|
||||
* @example
|
||||
* const SafeDashboard = withErrorBoundary(Dashboard, { variant: 'page' })
|
||||
*/
|
||||
export function withErrorBoundary<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>,
|
||||
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>
|
||||
): React.FC<P> {
|
||||
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
||||
|
||||
const ComponentWithErrorBoundary: React.FC<P> = (props) => (
|
||||
<ErrorBoundary {...errorBoundaryProps}>
|
||||
<WrappedComponent {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
ComponentWithErrorBoundary.displayName = `withErrorBoundary(${displayName})`
|
||||
|
||||
return ComponentWithErrorBoundary
|
||||
}
|
||||
|
||||
export default ErrorBoundary
|
||||
174
frontend/src/components/ErrorBoundaryWithI18n.tsx
Normal file
174
frontend/src/components/ErrorBoundaryWithI18n.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ErrorBoundary, ErrorFallback } from './ErrorBoundary'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface ErrorBoundaryWithI18nProps {
|
||||
children: ReactNode
|
||||
/** Custom fallback component */
|
||||
fallback?: ReactNode
|
||||
/** Callback when error is caught */
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
||||
/** Whether to show reset button */
|
||||
showReset?: boolean
|
||||
/** i18n key for reset button text */
|
||||
resetButtonKey?: string
|
||||
/** i18n key for error title */
|
||||
errorTitleKey?: string
|
||||
/** i18n key for error message */
|
||||
errorMessageKey?: string
|
||||
/** Variant style: 'page' for full page errors, 'section' for section-level */
|
||||
variant?: 'page' | 'section' | 'widget'
|
||||
/** Translation namespace to use */
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary wrapper with i18n support.
|
||||
* Uses the common namespace for error-related translations.
|
||||
*/
|
||||
export function ErrorBoundaryWithI18n({
|
||||
children,
|
||||
fallback,
|
||||
onError,
|
||||
showReset = true,
|
||||
resetButtonKey = 'errorBoundary.retry',
|
||||
errorTitleKey,
|
||||
errorMessageKey,
|
||||
variant = 'section',
|
||||
namespace = 'common',
|
||||
}: ErrorBoundaryWithI18nProps): JSX.Element {
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
// Get translated strings
|
||||
const resetButtonText = t(resetButtonKey)
|
||||
const errorTitle = errorTitleKey ? t(errorTitleKey) : undefined
|
||||
const errorMessage = errorMessageKey ? t(errorMessageKey) : undefined
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={fallback}
|
||||
onError={onError}
|
||||
showReset={showReset}
|
||||
resetButtonText={resetButtonText}
|
||||
errorTitle={errorTitle}
|
||||
errorMessage={errorMessage}
|
||||
variant={variant}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized Error Fallback component for use in functional components
|
||||
* or as custom fallback in ErrorBoundary.
|
||||
*/
|
||||
export function LocalizedErrorFallback({
|
||||
variant = 'section',
|
||||
error,
|
||||
titleKey,
|
||||
messageKey,
|
||||
showReset = true,
|
||||
resetButtonKey = 'errorBoundary.retry',
|
||||
onReset,
|
||||
namespace = 'common',
|
||||
}: {
|
||||
variant?: 'page' | 'section' | 'widget'
|
||||
error?: Error | null
|
||||
titleKey?: string
|
||||
messageKey?: string
|
||||
showReset?: boolean
|
||||
resetButtonKey?: string
|
||||
onReset?: () => void
|
||||
namespace?: string
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
// Use default variant keys if not provided
|
||||
const defaultTitleKey = `errorBoundary.${variant}.title`
|
||||
const defaultMessageKey = `errorBoundary.${variant}.message`
|
||||
|
||||
return (
|
||||
<ErrorFallback
|
||||
variant={variant}
|
||||
error={error || null}
|
||||
title={t(titleKey || defaultTitleKey)}
|
||||
message={t(messageKey || defaultMessageKey)}
|
||||
showReset={showReset}
|
||||
resetButtonText={t(resetButtonKey)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level Error Boundary with i18n support.
|
||||
* Used for top-level application error handling.
|
||||
*/
|
||||
export function PageErrorBoundary({ children }: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<ErrorBoundaryWithI18n
|
||||
variant="page"
|
||||
errorTitleKey="errorBoundary.page.title"
|
||||
errorMessageKey="errorBoundary.page.message"
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundaryWithI18n>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-level Error Boundary with i18n support.
|
||||
* Used for major page sections like Dashboard, Tasks, Projects.
|
||||
*/
|
||||
export function SectionErrorBoundary({
|
||||
children,
|
||||
sectionName,
|
||||
}: {
|
||||
children: ReactNode
|
||||
sectionName?: string
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
variant="section"
|
||||
errorTitle={t('errorBoundary.section.title')}
|
||||
errorMessage={
|
||||
sectionName
|
||||
? t('errorBoundary.section.messageWithName', { section: sectionName })
|
||||
: t('errorBoundary.section.message')
|
||||
}
|
||||
resetButtonText={t('errorBoundary.retry')}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget-level Error Boundary with i18n support.
|
||||
* Used for individual widgets within a page.
|
||||
*/
|
||||
export function WidgetErrorBoundary({
|
||||
children,
|
||||
widgetName,
|
||||
}: {
|
||||
children: ReactNode
|
||||
widgetName?: string
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
variant="widget"
|
||||
errorTitle={widgetName ? `${widgetName} ${t('errorBoundary.widget.errorSuffix')}` : t('errorBoundary.widget.title')}
|
||||
errorMessage={t('errorBoundary.widget.message')}
|
||||
resetButtonText={t('errorBoundary.retry')}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorBoundaryWithI18n
|
||||
@@ -1,9 +1,18 @@
|
||||
import axios from 'axios'
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
// API base URL - using legacy routes until v1 migration is complete
|
||||
// TODO: Switch to /api/v1 when all routes are migrated
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// CSRF token management
|
||||
// Store in memory for security (not localStorage to prevent XSS access)
|
||||
let csrfToken: string | null = null
|
||||
let csrfTokenExpiry: number | null = null
|
||||
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
|
||||
const CSRF_PROTECTED_METHODS = ['DELETE', 'PUT', 'PATCH']
|
||||
// Token expires in 1 hour, refresh 5 minutes before expiry
|
||||
const CSRF_TOKEN_LIFETIME_MS = 55 * 60 * 1000
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
@@ -11,11 +20,77 @@ const api = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Add token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
/**
|
||||
* Fetch a new CSRF token from the server.
|
||||
* Called automatically before protected requests if token is missing or expired.
|
||||
*/
|
||||
async function fetchCsrfToken(): Promise<string | null> {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await axios.get<{ csrf_token: string }>(
|
||||
`${API_BASE_URL}/auth/csrf-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
csrfToken = response.data.csrf_token
|
||||
csrfTokenExpiry = Date.now() + CSRF_TOKEN_LIFETIME_MS
|
||||
return csrfToken
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch CSRF token:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid CSRF token, fetching a new one if needed.
|
||||
*/
|
||||
async function getValidCsrfToken(): Promise<string | null> {
|
||||
// Check if we have a valid token
|
||||
if (csrfToken && csrfTokenExpiry && Date.now() < csrfTokenExpiry) {
|
||||
return csrfToken
|
||||
}
|
||||
|
||||
// Fetch a new token
|
||||
return fetchCsrfToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the CSRF token (call on logout).
|
||||
*/
|
||||
export function clearCsrfToken(): void {
|
||||
csrfToken = null
|
||||
csrfTokenExpiry = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch CSRF token (call after login).
|
||||
*/
|
||||
export async function prefetchCsrfToken(): Promise<void> {
|
||||
await fetchCsrfToken()
|
||||
}
|
||||
|
||||
// Add token to requests and CSRF token for protected methods
|
||||
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
// Add CSRF token for protected methods
|
||||
const method = config.method?.toUpperCase()
|
||||
if (method && CSRF_PROTECTED_METHODS.includes(method)) {
|
||||
const csrf = await getValidCsrfToken()
|
||||
if (csrf) {
|
||||
config.headers[CSRF_TOKEN_HEADER] = csrf
|
||||
}
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
@@ -27,6 +102,7 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
clearCsrfToken()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
@@ -56,11 +132,14 @@ export interface LoginResponse {
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>('/auth/login', data)
|
||||
// Pre-fetch CSRF token after successful login
|
||||
prefetchCsrfToken()
|
||||
return response.data
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await api.post('/auth/logout')
|
||||
clearCsrfToken()
|
||||
},
|
||||
|
||||
me: async (): Promise<User> => {
|
||||
|
||||
Reference in New Issue
Block a user