Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Fixed two critical issues in user settings:
1. Settings save 400 error - Removed invalid notifications boolean
that didn't match backend DTO structure
2. Push notifications toggle not working - Properly structured
notifications preferences as nested object with pushEnabled/emailEnabled
Changes:
- Updated state to use pushEnabled instead of simple notifications boolean
- Load pushEnabled from user.preferences.notifications.pushEnabled
- Send notifications as nested object to match UpdateProfileDto
- Both push and email notification toggles now work independently
Backend DTO expects:
{
preferences: {
notifications: {
pushEnabled: boolean,
emailEnabled: boolean
}
}
}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { Box, Typography, Card, CardContent, TextField, Button, Divider, Switch, FormControlLabel, Alert, CircularProgress, Snackbar } from '@mui/material';
|
|
import { Save, Logout } from '@mui/icons-material';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { useState, useEffect } from 'react';
|
|
import { AppShell } from '@/components/layouts/AppShell/AppShell';
|
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
|
import { usersApi } from '@/lib/api/users';
|
|
import { MFASettings } from '@/components/settings/MFASettings';
|
|
import { SessionsManagement } from '@/components/settings/SessionsManagement';
|
|
import { DeviceTrustManagement } from '@/components/settings/DeviceTrustManagement';
|
|
import { BiometricSettings } from '@/components/settings/BiometricSettings';
|
|
import { DataExport } from '@/components/settings/DataExport';
|
|
import { AccountDeletion } from '@/components/settings/AccountDeletion';
|
|
import { LanguageSelector } from '@/components/settings/LanguageSelector';
|
|
import { MeasurementUnitSelector } from '@/components/settings/MeasurementUnitSelector';
|
|
import { TimeZoneSelector } from '@/components/settings/TimeZoneSelector';
|
|
import { TimeFormatSelector } from '@/components/settings/TimeFormatSelector';
|
|
import { PhotoUpload } from '@/components/common/PhotoUpload';
|
|
import { motion } from 'framer-motion';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useThemeContext } from '@/contexts/ThemeContext';
|
|
|
|
export default function SettingsPage() {
|
|
const { t } = useTranslation('settings');
|
|
const { user, logout, refreshUser } = useAuth();
|
|
const { themeMode, setThemeMode } = useThemeContext();
|
|
const [name, setName] = useState(user?.name || '');
|
|
const [photoUrl, setPhotoUrl] = useState(user?.photoUrl || '');
|
|
const [timezone, setTimezone] = useState(user?.timezone || 'UTC');
|
|
const [timeFormat, setTimeFormat] = useState<'12h' | '24h'>(user?.preferences?.timeFormat || '12h');
|
|
const [settings, setSettings] = useState({
|
|
pushEnabled: true,
|
|
emailUpdates: false,
|
|
darkMode: false,
|
|
measurementUnit: 'metric' as 'metric' | 'imperial',
|
|
});
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
const [nameError, setNameError] = useState<string | null>(null);
|
|
|
|
// Load preferences from user object when it changes
|
|
useEffect(() => {
|
|
if (user?.preferences) {
|
|
setSettings({
|
|
pushEnabled: user.preferences.notifications?.pushEnabled ?? true,
|
|
emailUpdates: user.preferences.emailUpdates ?? false,
|
|
darkMode: user.preferences.darkMode ?? false,
|
|
measurementUnit: (user.preferences.measurementUnit as 'metric' | 'imperial') || 'metric',
|
|
});
|
|
setTimeFormat(user.preferences.timeFormat || '12h');
|
|
}
|
|
}, [user?.preferences]);
|
|
|
|
// Sync name, photo, and timezone state when user data changes
|
|
useEffect(() => {
|
|
if (user?.name) {
|
|
setName(user.name);
|
|
}
|
|
if (user?.photoUrl) {
|
|
setPhotoUrl(user.photoUrl);
|
|
}
|
|
if (user?.timezone) {
|
|
setTimezone(user.timezone);
|
|
}
|
|
}, [user]);
|
|
|
|
const handleSaveAll = async () => {
|
|
// Validate name
|
|
if (!name || name.trim() === '') {
|
|
setNameError(t('profile.nameRequired'));
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setNameError(null);
|
|
|
|
try {
|
|
const response = await usersApi.updateProfile({
|
|
name: name.trim(),
|
|
photoUrl: photoUrl || undefined,
|
|
timezone,
|
|
preferences: {
|
|
notifications: {
|
|
pushEnabled: settings.pushEnabled,
|
|
emailEnabled: settings.emailUpdates,
|
|
},
|
|
emailUpdates: settings.emailUpdates,
|
|
darkMode: settings.darkMode,
|
|
measurementUnit: settings.measurementUnit,
|
|
timeFormat,
|
|
}
|
|
});
|
|
console.log('✅ All settings saved successfully:', response);
|
|
|
|
// Refresh user to get latest data from server
|
|
await refreshUser();
|
|
|
|
setSuccessMessage(t('saved'));
|
|
} catch (err: any) {
|
|
console.error('❌ Failed to save settings:', err);
|
|
console.error('Error response:', err.response);
|
|
setError(err.response?.data?.message || err.message || 'Failed to save settings. Please try again.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
};
|
|
|
|
return (
|
|
<ProtectedRoute>
|
|
<AppShell>
|
|
<Box sx={{ maxWidth: 'md', mx: 'auto' }}>
|
|
<Typography variant="h4" component="h1" fontWeight="600" gutterBottom>
|
|
{t('title')}
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
|
{t('profile.title')}
|
|
</Typography>
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
|
{error}
|
|
</Alert>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Profile Settings */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}
|
|
>
|
|
<Card sx={{ mb: 3, minHeight: '120px' }}>
|
|
<CardContent>
|
|
<Typography variant="h6" component="h2" fontWeight="600" gutterBottom>
|
|
{t('profile.title')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 2 }}>
|
|
<PhotoUpload
|
|
label="Profile Photo"
|
|
value={photoUrl}
|
|
onChange={setPhotoUrl}
|
|
disabled={isLoading}
|
|
size={100}
|
|
/>
|
|
<TextField
|
|
label={t('profile.name')}
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value);
|
|
if (nameError) setNameError(null);
|
|
}}
|
|
fullWidth
|
|
error={!!nameError}
|
|
helperText={nameError}
|
|
disabled={isLoading}
|
|
/>
|
|
<TextField
|
|
label={t('profile.email')}
|
|
value={user?.email || ''}
|
|
fullWidth
|
|
disabled
|
|
helperText={t('profile.emailNotEditable')}
|
|
/>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Preferences (Language, Measurement Units, Timezone, Time Format) */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.05 }}
|
|
>
|
|
<Card sx={{ mb: 3, minHeight: '120px' }}>
|
|
<CardContent>
|
|
<Typography variant="h6" component="h2" fontWeight="600" gutterBottom>
|
|
{t('preferences.title')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
|
|
<LanguageSelector />
|
|
<Divider />
|
|
<MeasurementUnitSelector
|
|
value={settings.measurementUnit}
|
|
onChange={(value) => setSettings(prev => ({ ...prev, measurementUnit: value }))}
|
|
/>
|
|
<Divider />
|
|
<TimeZoneSelector value={timezone} onChange={setTimezone} />
|
|
<Divider />
|
|
<TimeFormatSelector value={timeFormat} onChange={setTimeFormat} />
|
|
<Divider />
|
|
<Box>
|
|
<Typography variant="subtitle1" fontWeight="600" gutterBottom>
|
|
Theme Mode
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
High Contrast mode uses larger text and stronger colors for better readability
|
|
</Typography>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={themeMode === 'highContrast'}
|
|
onChange={(e) => setThemeMode(e.target.checked ? 'highContrast' : 'standard')}
|
|
disabled={isLoading}
|
|
/>
|
|
}
|
|
label={themeMode === 'highContrast' ? 'High Contrast Mode' : 'Standard Mode'}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Notification Settings */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.1 }}
|
|
>
|
|
<Card sx={{ mb: 3, minHeight: '120px' }}>
|
|
<CardContent>
|
|
<Typography variant="h6" component="h2" fontWeight="600" gutterBottom>
|
|
{t('notifications.title')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 2 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.pushEnabled}
|
|
onChange={(e) => setSettings({ ...settings, pushEnabled: e.target.checked })}
|
|
disabled={isLoading}
|
|
/>
|
|
}
|
|
label={t('notifications.push')}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.emailUpdates}
|
|
onChange={(e) => setSettings({ ...settings, emailUpdates: e.target.checked })}
|
|
disabled={isLoading}
|
|
/>
|
|
}
|
|
label={t('notifications.email')}
|
|
/>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Appearance Settings */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.2 }}
|
|
>
|
|
<Card sx={{ mb: 3, minHeight: '120px' }}>
|
|
<CardContent>
|
|
<Typography variant="h6" component="h2" fontWeight="600" gutterBottom>
|
|
{t('appearance.title')}
|
|
</Typography>
|
|
<Box sx={{ mt: 2 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={settings.darkMode}
|
|
onChange={(e) => setSettings({ ...settings, darkMode: e.target.checked })}
|
|
/>
|
|
}
|
|
label={t('appearance.darkMode')}
|
|
disabled
|
|
/>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Security Settings - MFA */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.25 }}
|
|
>
|
|
<Box sx={{ mb: 3 }}>
|
|
<MFASettings />
|
|
</Box>
|
|
</motion.div>
|
|
|
|
{/* Sessions Management */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.3 }}
|
|
style={{ marginBottom: '24px' }}
|
|
>
|
|
<SessionsManagement />
|
|
</motion.div>
|
|
|
|
{/* Device Trust Management */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.33 }}
|
|
style={{ marginBottom: '24px' }}
|
|
>
|
|
<DeviceTrustManagement />
|
|
</motion.div>
|
|
|
|
{/* Biometric Authentication */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.36 }}
|
|
>
|
|
<Box sx={{ mb: 3 }}>
|
|
<BiometricSettings />
|
|
</Box>
|
|
</motion.div>
|
|
|
|
{/* Privacy & Compliance - Data Export */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.39 }}
|
|
>
|
|
<Box sx={{ mb: 3 }}>
|
|
<DataExport />
|
|
</Box>
|
|
</motion.div>
|
|
|
|
{/* Privacy & Compliance - Account Deletion */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.42 }}
|
|
>
|
|
<Box sx={{ mb: 3 }}>
|
|
<AccountDeletion />
|
|
</Box>
|
|
</motion.div>
|
|
|
|
{/* Global Save Button */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.44 }}
|
|
>
|
|
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'center' }}>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
startIcon={isLoading ? <CircularProgress size={20} color="inherit" /> : <Save />}
|
|
onClick={handleSaveAll}
|
|
disabled={isLoading}
|
|
sx={{ minWidth: 200 }}
|
|
>
|
|
{isLoading ? t('saving') : t('save')}
|
|
</Button>
|
|
</Box>
|
|
</motion.div>
|
|
|
|
{/* Account Actions */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.45 }}
|
|
>
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="h6" component="h2" fontWeight="600" gutterBottom>
|
|
{t('accountActions.title')}
|
|
</Typography>
|
|
<Divider sx={{ my: 2 }} />
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
startIcon={<Logout />}
|
|
onClick={handleLogout}
|
|
fullWidth
|
|
>
|
|
{t('accountActions.logout')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{/* Success Snackbar */}
|
|
<Snackbar
|
|
open={!!successMessage}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSuccessMessage(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
>
|
|
<Alert onClose={() => setSuccessMessage(null)} severity="success" sx={{ width: '100%' }}>
|
|
{successMessage}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
</AppShell>
|
|
</ProtectedRoute>
|
|
);
|
|
}
|