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:
beabigegg
2026-01-11 18:41:19 +08:00
parent 2cb591ef23
commit 679b89ae4c
41 changed files with 3673 additions and 153 deletions

View File

@@ -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"
}
}
}

View File

@@ -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": "發生錯誤"
}
}
}

View File

@@ -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>
)
}

View 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()
})
})

View 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

View 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

View File

@@ -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> => {