Added onboarding progress tracking & landing page
This commit is contained in:
145
frontend/src/components/shared/ComponentErrorBoundary.tsx
Normal file
145
frontend/src/components/shared/ComponentErrorBoundary.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Box, Typography, Button, Alert, Stack } from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface ComponentErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
componentName: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface ComponentErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight Error Boundary for Individual Components
|
||||
*
|
||||
* Use this to wrap specific components that might fail without crashing the entire app.
|
||||
* Shows a minimal error UI that doesn't take over the whole page.
|
||||
*
|
||||
* Usage:
|
||||
* <ComponentErrorBoundary componentName="API Key Carousel">
|
||||
* <ApiKeyCarousel />
|
||||
* </ComponentErrorBoundary>
|
||||
*/
|
||||
class ComponentErrorBoundary extends Component<
|
||||
ComponentErrorBoundaryProps,
|
||||
ComponentErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ComponentErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ComponentErrorBoundaryState> {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error(`Error in ${this.props.componentName}:`, error, errorInfo);
|
||||
|
||||
// Log to backend or error tracking service
|
||||
this.logError(error, errorInfo);
|
||||
}
|
||||
|
||||
logError(error: Error, errorInfo: ErrorInfo) {
|
||||
try {
|
||||
// Import error reporting utility
|
||||
import('../../utils/errorReporting').then(({ reportError }) => {
|
||||
reportError({
|
||||
error,
|
||||
context: `Component: ${this.props.componentName}`,
|
||||
metadata: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
componentError: true,
|
||||
},
|
||||
severity: 'medium', // Component errors are medium severity
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}).catch(console.error);
|
||||
|
||||
console.group(`🔴 Component Error: ${this.props.componentName}`);
|
||||
console.error('Error:', error.message);
|
||||
console.error('Stack:', error.stack);
|
||||
console.error('Component Stack:', errorInfo.componentStack);
|
||||
console.groupEnd();
|
||||
} catch (e) {
|
||||
console.error('Failed to log component error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (this.props.onReset) {
|
||||
this.props.onReset();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
my: 2,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={this.handleReset}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{this.props.componentName} Error
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{this.state.error?.message || 'An unexpected error occurred in this component.'}
|
||||
</Typography>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error?.stack && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
mt: 1,
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
fontSize: '0.7rem',
|
||||
maxHeight: 100,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{this.state.error.stack}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ComponentErrorBoundary;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Chip, Button, CircularProgress, Tooltip } from '@mui/material';
|
||||
import { PlayArrow, Pause, Stop } from '@mui/icons-material';
|
||||
import { ShimmerHeader } from './styled';
|
||||
import UserBadge from './UserBadge';
|
||||
import { DashboardHeaderProps } from './types';
|
||||
|
||||
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
@@ -402,6 +403,7 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{rightContent}
|
||||
<UserBadge colorMode="dark" />
|
||||
</Box>
|
||||
</Box>
|
||||
</ShimmerHeader>
|
||||
|
||||
392
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
392
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Container,
|
||||
Stack,
|
||||
Alert,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ErrorOutline as ErrorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Home as HomeIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
BugReport as BugReportIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
showDetails?: boolean;
|
||||
context?: string; // Context for better error messages (e.g., "Onboarding Wizard")
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
showDetails: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorBoundary Component
|
||||
*
|
||||
* Catches JavaScript errors anywhere in the child component tree,
|
||||
* logs those errors, and displays a fallback UI instead of blank screen.
|
||||
*
|
||||
* Usage:
|
||||
* <ErrorBoundary context="Dashboard">
|
||||
* <YourComponent />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Log error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// Update state with error info
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Send to error tracking service (Sentry, LogRocket, etc.)
|
||||
this.logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
logErrorToService(error: Error, errorInfo: ErrorInfo) {
|
||||
try {
|
||||
// Import error reporting utility
|
||||
import('../../utils/errorReporting').then(({ reportError }) => {
|
||||
reportError({
|
||||
error,
|
||||
context: this.props.context || 'ErrorBoundary',
|
||||
metadata: {
|
||||
componentStack: errorInfo.componentStack,
|
||||
errorBoundary: true,
|
||||
},
|
||||
severity: 'high', // Rendering errors are high severity
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}).catch(console.error);
|
||||
|
||||
// Log to console with detailed info
|
||||
console.group('🚨 Error Boundary - Error Details');
|
||||
console.error('Error:', error);
|
||||
console.error('Error Info:', errorInfo);
|
||||
console.error('Component Stack:', errorInfo.componentStack);
|
||||
console.error('Context:', this.props.context || 'Global');
|
||||
console.error('Timestamp:', new Date().toISOString());
|
||||
console.groupEnd();
|
||||
} catch (loggingError) {
|
||||
console.error('Failed to log error:', loggingError);
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
});
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
toggleDetails = () => {
|
||||
this.setState((prevState) => ({
|
||||
showDetails: !prevState.showDetails,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Custom fallback UI provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default fallback UI
|
||||
const { error, errorInfo, showDetails } = this.state;
|
||||
const { context, showDetails: showDetailsDefault } = this.props;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
p: { xs: 2, md: 4 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Paper
|
||||
elevation={24}
|
||||
sx={{
|
||||
p: { xs: 3, md: 5 },
|
||||
borderRadius: 4,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
{/* Error Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(45deg, #f44336 30%, #e91e63 90%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 32px rgba(244, 67, 54, 0.3)',
|
||||
}}
|
||||
>
|
||||
<ErrorIcon sx={{ fontSize: 48, color: 'white' }} />
|
||||
</Box>
|
||||
|
||||
{/* Error Title */}
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="h1"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Oops! Something went wrong
|
||||
</Typography>
|
||||
|
||||
{/* Context Message */}
|
||||
{context && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center' }}
|
||||
>
|
||||
An error occurred in: <strong>{context}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* User-friendly message */}
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', maxWidth: 600 }}
|
||||
>
|
||||
We're sorry for the inconvenience. The error has been logged and our team will investigate.
|
||||
In the meantime, you can try refreshing the page or returning to the home page.
|
||||
</Typography>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, width: '100%', maxWidth: 500 }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(45deg, #667eea 30%, #764ba2 90%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(45deg, #5568d3 30%, #6a3f8f 90%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<HomeIcon />}
|
||||
onClick={this.handleGoHome}
|
||||
sx={{
|
||||
flex: 1,
|
||||
py: 1.5,
|
||||
borderColor: '#667eea',
|
||||
color: '#667eea',
|
||||
'&:hover': {
|
||||
borderColor: '#5568d3',
|
||||
background: 'rgba(102, 126, 234, 0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{/* Error Details Toggle (for developers/debugging) */}
|
||||
{(showDetailsDefault || process.env.NODE_ENV === 'development') && (
|
||||
<>
|
||||
<Divider sx={{ width: '100%', my: 2 }} />
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<BugReportIcon />}
|
||||
endIcon={
|
||||
<ExpandMoreIcon
|
||||
sx={{
|
||||
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={this.toggleDetails}
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} Technical Details
|
||||
</Button>
|
||||
|
||||
<Collapse in={showDetails} sx={{ width: '100%' }}>
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<BugReportIcon />}
|
||||
sx={{
|
||||
textAlign: 'left',
|
||||
'& .MuiAlert-message': {
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
Error Message:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
mb: 2,
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{error?.toString()}
|
||||
</Typography>
|
||||
|
||||
{error?.stack && (
|
||||
<>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
Stack Trace:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{error.stack}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorInfo?.componentStack && (
|
||||
<>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ mt: 2 }}>
|
||||
Component Stack:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
p: 1,
|
||||
bgcolor: 'rgba(0,0,0,0.05)',
|
||||
borderRadius: 1,
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{errorInfo.componentStack}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Collapse>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mt: 2 }}
|
||||
>
|
||||
Error ID: {Date.now().toString(36)} • Timestamp: {new Date().toLocaleString()}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// No error, render children normally
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
203
frontend/src/components/shared/ErrorBoundaryTest.tsx
Normal file
203
frontend/src/components/shared/ErrorBoundaryTest.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Typography, Stack, Alert, Paper } from '@mui/material';
|
||||
import { BugReport as BugReportIcon } from '@mui/icons-material';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ComponentErrorBoundary from './ComponentErrorBoundary';
|
||||
|
||||
/**
|
||||
* Error Boundary Test Component
|
||||
*
|
||||
* Use this component to test that error boundaries are working correctly.
|
||||
* Access via: http://localhost:3000/error-test (add route in App.tsx)
|
||||
*
|
||||
* This should ONLY be used in development!
|
||||
*/
|
||||
|
||||
// Component that intentionally crashes
|
||||
const CrashingComponent: React.FC<{ shouldCrash: boolean }> = ({ shouldCrash }) => {
|
||||
if (shouldCrash) {
|
||||
throw new Error('Intentional error for testing ErrorBoundary');
|
||||
}
|
||||
return <Typography>Component is working normally</Typography>;
|
||||
};
|
||||
|
||||
// Component that crashes after a delay
|
||||
const DelayedCrashComponent: React.FC<{ shouldCrash: boolean }> = ({ shouldCrash }) => {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
if (count > 3 && shouldCrash) {
|
||||
throw new Error('Delayed crash after 3 clicks');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography>Click count: {count}</Typography>
|
||||
<Button onClick={() => setCount(count + 1)} variant="outlined" size="small">
|
||||
Increment (crashes after 3)
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorBoundaryTest: React.FC = () => {
|
||||
const [globalCrash, setGlobalCrash] = useState(false);
|
||||
const [componentCrash, setComponentCrash] = useState(false);
|
||||
const [delayedCrash, setDelayedCrash] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 1200, mx: 'auto' }}>
|
||||
<Paper sx={{ p: 4, mb: 4, bgcolor: 'warning.light' }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<BugReportIcon sx={{ fontSize: 40 }} />
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
Error Boundary Testing
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Alert severity="warning">
|
||||
<strong>Development Only:</strong> This page is for testing error boundaries.
|
||||
Remove this route before deploying to production!
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack spacing={4}>
|
||||
{/* Test 1: Global Error Boundary */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 1: Global Error Boundary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This will crash the entire component tree. The global ErrorBoundary should catch it
|
||||
and show a full-page error screen with reload options.
|
||||
</Typography>
|
||||
|
||||
<ErrorBoundary context="Global Error Test">
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<CrashingComponent shouldCrash={globalCrash} />
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => setGlobalCrash(true)}
|
||||
disabled={globalCrash}
|
||||
>
|
||||
Trigger Global Crash
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Test 2: Component-Level Error Boundary */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 2: Component Error Boundary
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This will crash only a specific component. The ComponentErrorBoundary should show
|
||||
a minimal error message inline without affecting the rest of the page.
|
||||
</Typography>
|
||||
|
||||
<ComponentErrorBoundary
|
||||
componentName="Test Component"
|
||||
onReset={() => setComponentCrash(false)}
|
||||
>
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<CrashingComponent shouldCrash={componentCrash} />
|
||||
</Box>
|
||||
</ComponentErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
onClick={() => setComponentCrash(true)}
|
||||
disabled={componentCrash}
|
||||
>
|
||||
Trigger Component Crash
|
||||
</Button>
|
||||
|
||||
{componentCrash && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
Notice: Only the component crashed, not the entire page!
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Test 3: Delayed Crash */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 3: Delayed Error (Simulates User Interaction)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
This component crashes after user interaction (3 clicks). Tests that error boundaries
|
||||
work for runtime errors, not just initial render errors.
|
||||
</Typography>
|
||||
|
||||
<ComponentErrorBoundary
|
||||
componentName="Delayed Crash Component"
|
||||
onReset={() => setDelayedCrash(false)}
|
||||
>
|
||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1, mb: 2 }}>
|
||||
<DelayedCrashComponent shouldCrash={delayedCrash} />
|
||||
</Box>
|
||||
</ComponentErrorBoundary>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="info"
|
||||
onClick={() => setDelayedCrash(true)}
|
||||
disabled={delayedCrash}
|
||||
>
|
||||
Enable Delayed Crash
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{/* Test 4: API Error Simulation */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Test 4: Verify Error Boundary Doesn't Catch API Errors
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Error boundaries only catch rendering errors, not async errors.
|
||||
This is expected behavior - API errors should be handled with try/catch.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info">
|
||||
Error boundaries do NOT catch:
|
||||
<ul>
|
||||
<li>Event handlers (onClick, onChange, etc.)</li>
|
||||
<li>Asynchronous code (setTimeout, fetch, promises)</li>
|
||||
<li>Server-side rendering errors</li>
|
||||
<li>Errors in the error boundary itself</li>
|
||||
</ul>
|
||||
These should be handled with try/catch blocks.
|
||||
</Alert>
|
||||
</Paper>
|
||||
|
||||
{/* Instructions */}
|
||||
<Paper sx={{ p: 3, bgcolor: 'success.light' }}>
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Testing Instructions
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2">
|
||||
1. <strong>Global Crash:</strong> Should show full-page error with "Reload Page" and "Go Home" buttons
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
2. <strong>Component Crash:</strong> Should show inline error alert with "Retry" button
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
3. <strong>Delayed Crash:</strong> Click increment 4 times to trigger error
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
4. <strong>Check Console:</strong> All errors should be logged with detailed stack traces
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorBoundaryTest;
|
||||
|
||||
@@ -1,57 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface OnboardingStatus {
|
||||
is_completed: boolean;
|
||||
current_step: number;
|
||||
completion_percentage: number;
|
||||
next_step?: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
can_proceed_to_final: boolean;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onboardingComplete, setOnboardingComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
console.log('ProtectedRoute: Checking onboarding status...');
|
||||
const response = await apiClient.get('/api/onboarding/status');
|
||||
const status: OnboardingStatus = response.data;
|
||||
|
||||
console.log('ProtectedRoute: Onboarding status:', status);
|
||||
|
||||
if (status.is_completed) {
|
||||
console.log('ProtectedRoute: Onboarding is complete, allowing access');
|
||||
setOnboardingComplete(true);
|
||||
} else {
|
||||
console.log('ProtectedRoute: Onboarding not complete, redirecting to onboarding');
|
||||
setOnboardingComplete(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ProtectedRoute: Error checking onboarding status:', err);
|
||||
setError('Failed to check onboarding status');
|
||||
// On error, assume onboarding is not complete for security
|
||||
setOnboardingComplete(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
const { isSignedIn } = useAuth();
|
||||
|
||||
// Use onboarding context instead of making API calls
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
isOnboardingComplete,
|
||||
refresh,
|
||||
clearError
|
||||
} = useOnboarding();
|
||||
|
||||
// Loading state - show spinner
|
||||
if (loading) {
|
||||
console.log('ProtectedRoute: Loading onboarding state from context...');
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -69,7 +41,9 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Error state - show error with retry
|
||||
if (error) {
|
||||
console.error('ProtectedRoute: Error from context:', error);
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -83,24 +57,46 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Access Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ maxWidth: 500, mb: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
clearError();
|
||||
refresh();
|
||||
}}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography variant="body2" color="textSecondary" textAlign="center">
|
||||
Please complete the setup process first.
|
||||
Please try refreshing or complete the setup process first.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!onboardingComplete) {
|
||||
console.log('ProtectedRoute: Redirecting to onboarding');
|
||||
// Not signed in - redirect to landing
|
||||
if (!isSignedIn) {
|
||||
console.log('ProtectedRoute: Not signed in, redirecting to landing');
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete - redirect to onboarding
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('ProtectedRoute: Onboarding not complete (from context), redirecting');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// If onboarding is complete, render the protected component
|
||||
console.log('ProtectedRoute: Rendering protected component');
|
||||
// All checks passed - render protected component
|
||||
console.log('ProtectedRoute: Access granted (from context), rendering component');
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
70
frontend/src/components/shared/UserBadge.tsx
Normal file
70
frontend/src/components/shared/UserBadge.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Avatar, Box, Button, Menu, MenuItem, Typography, Tooltip } from '@mui/material';
|
||||
import { useUser, useClerk } from '@clerk/clerk-react';
|
||||
|
||||
interface UserBadgeProps {
|
||||
colorMode?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
const UserBadge: React.FC<UserBadgeProps> = ({ colorMode = 'light' }) => {
|
||||
const { user, isSignedIn } = useUser();
|
||||
const { signOut } = useClerk();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const initials = React.useMemo(() => {
|
||||
const first = user?.firstName?.[0] || '';
|
||||
const last = user?.lastName?.[0] || '';
|
||||
return (first + last || user?.username?.[0] || user?.primaryEmailAddress?.emailAddress?.[0] || '?').toUpperCase();
|
||||
}, [user]);
|
||||
|
||||
if (!isSignedIn) return null;
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
|
||||
const handleClose = () => setAnchorEl(null);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
window.location.assign('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Tooltip title={`${user?.fullName || user?.username || user?.primaryEmailAddress?.emailAddress || 'User'}`}>
|
||||
<Avatar
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
bgcolor: colorMode === 'dark' ? 'rgba(255,255,255,0.2)' : 'primary.main',
|
||||
color: colorMode === 'dark' ? 'white' : 'white',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
src={user?.imageUrl || undefined}
|
||||
>
|
||||
{initials}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'right' }}>
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{user?.fullName || user?.username || 'User'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</Typography>
|
||||
</Box>
|
||||
<MenuItem onClick={handleClose}>Signed in</MenuItem>
|
||||
<MenuItem onClick={handleSignOut}>Sign out</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBadge;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user