All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m42s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Backend: - Add login event logging to getUserStatus() controller method - Create POST /auth/track-logout endpoint for logout tracking Frontend: - Create useLogout hook that wraps Auth0 logout with audit tracking - Update all logout locations to use the new hook (SettingsPage, Layout, MobileSettingsScreen, useDeletion) Login events are logged when the frontend calls /auth/user-status after Auth0 callback. Logout events are logged via fire-and-forget call to /auth/track-logout before Auth0 logout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
641 lines
22 KiB
TypeScript
641 lines
22 KiB
TypeScript
/**
|
|
* @ai-summary Settings page component for desktop application
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import { useAuth0 } from '@auth0/auth0-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useLogout } from '../core/auth/useLogout';
|
|
import { useUnits } from '../core/units/UnitsContext';
|
|
import { useAdminAccess } from '../core/auth/useAdminAccess';
|
|
import { useProfile, useUpdateProfile } from '../features/settings/hooks/useProfile';
|
|
import { useExportUserData } from '../features/settings/hooks/useExportUserData';
|
|
import { useVehicles } from '../features/vehicles/hooks/useVehicles';
|
|
import { useTheme } from '../shared-minimal/theme/ThemeContext';
|
|
import { DeleteAccountDialog } from '../features/settings/components/DeleteAccountDialog';
|
|
import { PendingDeletionBanner } from '../features/settings/components/PendingDeletionBanner';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Switch,
|
|
Divider,
|
|
Avatar,
|
|
List,
|
|
ListItem,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
ListItemSecondaryAction,
|
|
Button as MuiButton,
|
|
Select,
|
|
MenuItem,
|
|
FormControl,
|
|
TextField,
|
|
CircularProgress
|
|
} from '@mui/material';
|
|
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
import PaletteIcon from '@mui/icons-material/Palette';
|
|
import SecurityIcon from '@mui/icons-material/Security';
|
|
import StorageIcon from '@mui/icons-material/Storage';
|
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
|
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import SaveIcon from '@mui/icons-material/Save';
|
|
import CancelIcon from '@mui/icons-material/Cancel';
|
|
import { Card } from '../shared-minimal/components/Card';
|
|
|
|
export const SettingsPage: React.FC = () => {
|
|
const { user } = useAuth0();
|
|
const { logout } = useLogout();
|
|
const navigate = useNavigate();
|
|
const { unitSystem, setUnitSystem } = useUnits();
|
|
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
|
const { isDarkMode, setDarkMode } = useTheme();
|
|
const [notifications, setNotifications] = useState(true);
|
|
const [emailUpdates, setEmailUpdates] = useState(false);
|
|
|
|
// Profile state
|
|
const { data: profile, isLoading: profileLoading } = useProfile();
|
|
const updateProfileMutation = useUpdateProfile();
|
|
|
|
// Vehicles state (for My Vehicles section)
|
|
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
|
|
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
|
const [editedDisplayName, setEditedDisplayName] = useState('');
|
|
const [editedNotificationEmail, setEditedNotificationEmail] = useState('');
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const exportMutation = useExportUserData();
|
|
|
|
// Initialize edit form when profile loads or edit mode starts
|
|
React.useEffect(() => {
|
|
if (profile && isEditingProfile) {
|
|
setEditedDisplayName(profile.displayName || '');
|
|
setEditedNotificationEmail(profile.notificationEmail || '');
|
|
}
|
|
}, [profile, isEditingProfile]);
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
};
|
|
|
|
const handleEditProfile = () => {
|
|
setIsEditingProfile(true);
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setIsEditingProfile(false);
|
|
setEditedDisplayName(profile?.displayName || '');
|
|
setEditedNotificationEmail(profile?.notificationEmail || '');
|
|
};
|
|
|
|
const handleSaveProfile = async () => {
|
|
const updates: { displayName?: string; notificationEmail?: string } = {};
|
|
|
|
if (editedDisplayName !== (profile?.displayName || '')) {
|
|
updates.displayName = editedDisplayName;
|
|
}
|
|
|
|
if (editedNotificationEmail !== (profile?.notificationEmail || '')) {
|
|
updates.notificationEmail = editedNotificationEmail || undefined;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
setIsEditingProfile(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await updateProfileMutation.mutateAsync(updates);
|
|
setIsEditingProfile(false);
|
|
} catch (error) {
|
|
// Error is handled by the mutation hook
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ py: 2 }}>
|
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary', mb: 4 }}>
|
|
Settings
|
|
</Typography>
|
|
|
|
<PendingDeletionBanner />
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{/* Profile Section */}
|
|
<Card>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
Profile
|
|
</Typography>
|
|
{!isEditingProfile && !profileLoading && (
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
startIcon={<EditIcon />}
|
|
onClick={handleEditProfile}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Edit
|
|
</MuiButton>
|
|
)}
|
|
</Box>
|
|
|
|
{profileLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : (
|
|
<>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
|
<Avatar
|
|
sx={{
|
|
width: 64,
|
|
height: 64,
|
|
bgcolor: 'primary.main',
|
|
fontSize: '1.5rem',
|
|
fontWeight: 600,
|
|
mr: 3
|
|
}}
|
|
>
|
|
{profile?.displayName?.charAt(0) || user?.name?.charAt(0) || user?.email?.charAt(0)}
|
|
</Avatar>
|
|
<Box>
|
|
<Typography variant="h6" sx={{ fontWeight: 500 }}>
|
|
{profile?.displayName || user?.name || 'User'}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{profile?.email || user?.email}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Verified account
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{isEditingProfile ? (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<TextField
|
|
label="Email"
|
|
value={profile?.email || ''}
|
|
disabled
|
|
fullWidth
|
|
helperText="Email is managed by your account provider and cannot be changed here"
|
|
variant="outlined"
|
|
/>
|
|
<TextField
|
|
label="Display Name"
|
|
value={editedDisplayName}
|
|
onChange={(e) => setEditedDisplayName(e.target.value)}
|
|
fullWidth
|
|
placeholder="Enter your display name"
|
|
variant="outlined"
|
|
/>
|
|
<TextField
|
|
label="Notification Email"
|
|
value={editedNotificationEmail}
|
|
onChange={(e) => setEditedNotificationEmail(e.target.value)}
|
|
fullWidth
|
|
placeholder="Leave blank to use your primary email"
|
|
helperText="Optional: Use a different email address for notifications"
|
|
variant="outlined"
|
|
type="email"
|
|
/>
|
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
|
<MuiButton
|
|
variant="outlined"
|
|
startIcon={<CancelIcon />}
|
|
onClick={handleCancelEdit}
|
|
disabled={updateProfileMutation.isPending}
|
|
>
|
|
Cancel
|
|
</MuiButton>
|
|
<MuiButton
|
|
variant="contained"
|
|
startIcon={updateProfileMutation.isPending ? <CircularProgress size={20} /> : <SaveIcon />}
|
|
onClick={handleSaveProfile}
|
|
disabled={updateProfileMutation.isPending}
|
|
>
|
|
Save
|
|
</MuiButton>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<List disablePadding>
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<AccountCircleIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="Email"
|
|
secondary={profile?.email || user?.email || 'Not available'}
|
|
/>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Display Name"
|
|
secondary={profile?.displayName || 'Not set'}
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Notification Email"
|
|
secondary={profile?.notificationEmail || 'Using primary email'}
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<SecurityIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="Security & Privacy"
|
|
secondary="Password, two-factor authentication"
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/security')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</List>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
{/* My Vehicles Section */}
|
|
<Card>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<DirectionsCarIcon color="primary" />
|
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
My Vehicles
|
|
</Typography>
|
|
{!vehiclesLoading && vehicles && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
({vehicles.length})
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/vehicles')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</Box>
|
|
|
|
{vehiclesLoading ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
|
<CircularProgress size={24} />
|
|
</Box>
|
|
) : !vehicles?.length ? (
|
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
|
|
No vehicles registered. Add your first vehicle to get started.
|
|
</Typography>
|
|
) : (
|
|
<List disablePadding>
|
|
{vehicles.map((vehicle, index) => (
|
|
<React.Fragment key={vehicle.id}>
|
|
{index > 0 && <Divider />}
|
|
<ListItem sx={{ py: 1.5 }}>
|
|
<ListItemText
|
|
primary={`${vehicle.year} ${vehicle.make} ${vehicle.model}`}
|
|
secondary={vehicle.nickname || undefined}
|
|
primaryTypographyProps={{ fontWeight: 500 }}
|
|
/>
|
|
</ListItem>
|
|
</React.Fragment>
|
|
))}
|
|
</List>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Notifications Section */}
|
|
<Card>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
|
Notifications
|
|
</Typography>
|
|
|
|
<List disablePadding>
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<NotificationsIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="Push Notifications"
|
|
secondary="Receive notifications about your vehicles"
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<Switch
|
|
checked={notifications}
|
|
onChange={(e) => setNotifications(e.target.checked)}
|
|
color="primary"
|
|
/>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Email Updates"
|
|
secondary="Receive maintenance reminders and updates"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<Switch
|
|
checked={emailUpdates}
|
|
onChange={(e) => setEmailUpdates(e.target.checked)}
|
|
color="primary"
|
|
/>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</List>
|
|
</Card>
|
|
|
|
{/* Appearance & Units Section */}
|
|
<Card>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
|
Appearance & Units
|
|
</Typography>
|
|
|
|
<List disablePadding>
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<PaletteIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="Dark Mode"
|
|
secondary="Use dark theme for better night viewing"
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<Switch
|
|
checked={isDarkMode}
|
|
onChange={(e) => setDarkMode(e.target.checked)}
|
|
color="primary"
|
|
/>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Units for distance and capacity"
|
|
secondary="Imperial: miles, gallons, MPG, USD | Metric: km, liters, L/100km, EUR"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<FormControl size="small" sx={{ minWidth: 120 }}>
|
|
<Select
|
|
value={unitSystem}
|
|
onChange={(e) => setUnitSystem(e.target.value as 'imperial' | 'metric')}
|
|
displayEmpty
|
|
sx={{
|
|
fontSize: '0.875rem',
|
|
'& .MuiSelect-select': {
|
|
py: 1
|
|
}
|
|
}}
|
|
>
|
|
<MenuItem value="imperial">Imperial</MenuItem>
|
|
<MenuItem value="metric">Metric</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</List>
|
|
</Card>
|
|
|
|
{/* Data & Storage Section */}
|
|
<Card>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
|
Data & Storage
|
|
</Typography>
|
|
|
|
<List disablePadding>
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<StorageIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="Export Data"
|
|
secondary="Download your vehicle and fuel log data"
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
disabled={exportMutation.isPending}
|
|
onClick={() => exportMutation.mutate()}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
{exportMutation.isPending ? 'Exporting...' : 'Export'}
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</List>
|
|
</Card>
|
|
|
|
{/* Admin Console Section */}
|
|
{!adminLoading && isAdmin && (
|
|
<Card>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'primary.main' }}>
|
|
Admin Console
|
|
</Typography>
|
|
|
|
<List disablePadding>
|
|
<ListItem>
|
|
<ListItemIcon>
|
|
<AdminPanelSettingsIcon />
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary="User Management"
|
|
secondary="Manage admin users and permissions"
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/admin/users')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Vehicle Catalog"
|
|
secondary="Manage makes, models, years, trims, and engines"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/admin/catalog')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Email Templates"
|
|
secondary="Manage notification email templates"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/admin/email-templates')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Backup & Restore"
|
|
secondary="Create backups and restore data"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/admin/backup')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
Manage
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
<Divider />
|
|
<ListItem>
|
|
<ListItemText
|
|
primary="Audit Logs"
|
|
secondary="View system activity and audit logs"
|
|
sx={{ pl: 7 }}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<MuiButton
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => navigate('/garage/settings/admin/logs')}
|
|
sx={{
|
|
backgroundColor: 'primary.main',
|
|
color: 'primary.contrastText',
|
|
'&:hover': {
|
|
backgroundColor: 'primary.dark'
|
|
}
|
|
}}
|
|
>
|
|
View
|
|
</MuiButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</List>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Account Actions */}
|
|
<Card>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, color: 'primary.main' }}>
|
|
Account Actions
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
|
<MuiButton
|
|
variant="contained"
|
|
onClick={handleLogout}
|
|
sx={{
|
|
borderRadius: '999px',
|
|
backgroundColor: 'grey.600',
|
|
color: 'common.white',
|
|
'&:hover': {
|
|
backgroundColor: 'grey.700'
|
|
}
|
|
}}
|
|
>
|
|
Sign Out
|
|
</MuiButton>
|
|
<MuiButton
|
|
variant="outlined"
|
|
color="error"
|
|
onClick={() => setDeleteDialogOpen(true)}
|
|
sx={{ borderRadius: '999px' }}
|
|
>
|
|
Delete Account
|
|
</MuiButton>
|
|
</Box>
|
|
</Card>
|
|
</Box>
|
|
|
|
<DeleteAccountDialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} />
|
|
</Box>
|
|
);
|
|
}; |