Files
motovaultpro/frontend/src/pages/SettingsPage.tsx
Eric Gullickson 566deae5af
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 2m39s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 37s
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
fix: match import button style to export button (refs #26)
Desktop changes:
- Replace ImportButton component with MUI Button matching Export style
- Use hidden file input with validation
- Dark red/maroon button with consistent styling

Mobile changes:
- Update both Import and Export buttons to use primary-500 style
- Consistent dark primary button appearance
- Maintains 44px touch target requirement

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 20:23:56 -06:00

716 lines
25 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 { ImportDialog } from '../features/settings/components/ImportDialog';
import toast from 'react-hot-toast';
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 [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
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
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file extension
if (!file.name.endsWith('.tar.gz')) {
toast.error('Please select a .tar.gz file');
return;
}
// Validate file size (max 500MB)
const maxSize = 500 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('File size exceeds 500MB limit');
return;
}
setSelectedFile(file);
setImportDialogOpen(true);
// Reset input so same file can be selected again
event.target.value = '';
};
const handleImportClose = () => {
setImportDialogOpen(false);
setSelectedFile(null);
};
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="Import Data"
secondary="Upload and restore your vehicle data from a backup"
/>
<ListItemSecondaryAction>
<input
ref={fileInputRef}
type="file"
accept=".tar.gz"
onChange={handleFileChange}
style={{ display: 'none' }}
aria-label="Select import file"
/>
<MuiButton
variant="contained"
size="small"
onClick={handleImportClick}
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.dark'
}
}}
>
Import
</MuiButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Export Data"
secondary="Download your vehicle and fuel log data"
sx={{ pl: 7 }}
/>
<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)} />
<ImportDialog
isOpen={importDialogOpen}
onClose={handleImportClose}
file={selectedFile}
/>
</Box>
);
};