Update AccountSettings layout

This commit is contained in:
2024-11-06 21:51:45 +01:00
parent e56378f1f0
commit 48f75b3839

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useReducer, useRef } from 'react';
import { import {
Modal, Modal,
Badge, Badge,
@@ -8,187 +8,342 @@ import {
Stack, Stack,
Accordion, Accordion,
TextInput, TextInput,
Text,
PasswordInput, PasswordInput,
Box, Box,
LoadingOverlay, Text,
} from '@mantine/core'; } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useProfileSettings } from '../hooks/useProfileSettings'; import { useProfileSettings } from '../hooks/useProfileSettings';
// Reducer for managing settings state
const initialState = {
localSettings: {},
initialSettings: {},
hasUnsavedChanges: false,
};
function settingsReducer(state, action) {
switch (action.type) {
case 'INIT_SETTINGS':
return {
...state,
localSettings: action.payload,
initialSettings: action.payload,
hasUnsavedChanges: false,
};
case 'UPDATE_LOCAL_SETTINGS':
const newLocalSettings = { ...state.localSettings, ...action.payload };
const hasChanges =
JSON.stringify(newLocalSettings) !==
JSON.stringify(state.initialSettings);
return {
...state,
localSettings: newLocalSettings,
hasUnsavedChanges: hasChanges,
};
case 'MARK_SAVED':
return {
...state,
initialSettings: state.localSettings,
hasUnsavedChanges: false,
};
default:
return state;
}
}
// Password confirmation modal for email changes
const EmailPasswordModal = ({ opened, onClose, onConfirm, email }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Confirm Password"
centered
size="sm"
>
<Stack>
<Text size="sm">
Please enter your password to confirm changing your email to: {email}
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
);
};
// Delete account confirmation modal
const DeleteAccountModal = ({ opened, onClose, onConfirm }) => {
const [password, setPassword] = useState('');
return (
<Modal
opened={opened}
onClose={onClose}
title="Delete Account"
centered
size="sm"
>
<Stack>
<Text c="red" fw={500}>
Warning: This action cannot be undone
</Text>
<Text size="sm">
Please enter your password to confirm account deletion.
</Text>
<PasswordInput
label="Current Password"
placeholder="Enter your current password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="red"
onClick={() => {
onConfirm(password);
setPassword('');
}}
>
Delete Account
</Button>
</Group>
</Stack>
</Modal>
);
};
const AccordionControl = ({ children }) => ( const AccordionControl = ({ children }) => (
<Accordion.Control> <Accordion.Control>
<Title order={4}>{children}</Title> <Title order={4}>{children}</Title>
</Accordion.Control> </Accordion.Control>
); );
const ProfileSettings = ({ displayName, email, onUpdate, loading }) => { const ProfileSettings = ({ settings, onInputChange }) => (
const [newDisplayName, setNewDisplayName] = useState(displayName || ''); <Box>
const [newEmail, setNewEmail] = useState(email);
const [currentPassword, setCurrentPassword] = useState('');
const hasEmailChanges = newEmail !== email;
const hasDisplayNameChanges = newDisplayName !== displayName;
const hasChanges = hasEmailChanges || hasDisplayNameChanges;
const handleSave = () => {
const updates = {};
if (hasDisplayNameChanges) updates.displayName = newDisplayName;
if (hasEmailChanges) {
updates.email = newEmail;
updates.currentPassword = currentPassword;
}
onUpdate(updates);
};
return (
<Stack spacing="md"> <Stack spacing="md">
<TextInput <TextInput
label="Display Name" label="Display Name"
value={newDisplayName} value={settings.displayName || ''}
onChange={(e) => setNewDisplayName(e.currentTarget.value)} onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
placeholder="Enter display name" placeholder="Enter display name"
/> />
<TextInput <TextInput
label="Email" label="Email"
value={newEmail} value={settings.email || ''}
onChange={(e) => setNewEmail(e.currentTarget.value)} onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email" placeholder="Enter email"
/> />
{hasEmailChanges && (
<PasswordInput
label="Current Password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
placeholder="Required to change email"
required
/>
)}
{hasChanges && (
<Button onClick={handleSave} loading={loading}>
Save Changes
</Button>
)}
</Stack> </Stack>
</Box>
); );
};
const SecuritySettings = ({ onUpdate, loading }) => { const SecuritySettings = ({ settings, onInputChange }) => {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const handlePasswordChange = () => { const handlePasswordChange = (field, value) => {
if (newPassword !== confirmPassword) { if (field === 'confirmNewPassword') {
setConfirmPassword(value);
// Check if passwords match when either password field changes
if (value !== settings.newPassword) {
setError('Passwords do not match'); setError('Passwords do not match');
return; } else {
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
setError(''); setError('');
onUpdate({ currentPassword, newPassword }); }
} else {
onInputChange(field, value);
// Check if passwords match when either password field changes
if (field === 'newPassword' && value !== confirmPassword) {
setError('Passwords do not match');
} else if (value === confirmPassword) {
setError('');
}
}
}; };
const hasChanges = currentPassword && newPassword && confirmPassword;
return ( return (
<Box>
<Stack spacing="md"> <Stack spacing="md">
<PasswordInput <PasswordInput
label="Current Password" label="Current Password"
value={currentPassword} value={settings.currentPassword || ''}
onChange={(e) => setCurrentPassword(e.currentTarget.value)} onChange={(e) =>
handlePasswordChange('currentPassword', e.currentTarget.value)
}
placeholder="Enter current password" placeholder="Enter current password"
/> />
<PasswordInput <PasswordInput
label="New Password" label="New Password"
value={newPassword} value={settings.newPassword || ''}
onChange={(e) => setNewPassword(e.currentTarget.value)} onChange={(e) =>
handlePasswordChange('newPassword', e.currentTarget.value)
}
placeholder="Enter new password" placeholder="Enter new password"
/> />
<PasswordInput <PasswordInput
label="Confirm New Password" label="Confirm New Password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)} onChange={(e) =>
handlePasswordChange('confirmNewPassword', e.currentTarget.value)
}
placeholder="Confirm new password" placeholder="Confirm new password"
error={error}
/> />
{error && (
<Text color="red" size="sm">
{error}
</Text>
)}
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Password must be at least 8 characters long Password must be at least 8 characters long. Leave password fields
empty if you don't want to change it.
</Text> </Text>
{hasChanges && (
<Button onClick={handlePasswordChange} loading={loading}>
Change Password
</Button>
)}
</Stack> </Stack>
</Box>
); );
}; };
const DangerZone = ({ onDelete, loading }) => { const DangerZone = ({ onDeleteClick }) => (
const [password, setPassword] = useState(''); <Box>
const [confirmDelete, setConfirmDelete] = useState(false); <Button color="red" variant="light" onClick={onDeleteClick} fullWidth>
Delete Account
const handleDelete = () => {
if (confirmDelete && password) {
onDelete(password);
} else {
setConfirmDelete(true);
}
};
return (
<Stack spacing="md">
{confirmDelete && (
<PasswordInput
label="Current Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Enter password to confirm"
required
/>
)}
<Box mb="md">
<Button
color="red"
variant="light"
onClick={handleDelete}
fullWidth
loading={loading}
>
{confirmDelete ? 'Confirm Delete Account' : 'Delete Account'}
</Button> </Button>
</Box> </Box>
</Stack>
); );
};
const AccountSettings = ({ opened, onClose }) => { const AccountSettings = ({ opened, onClose }) => {
const { user, logout, refreshUser } = useAuth(); const { user, logout, refreshUser } = useAuth();
const { loading, updateProfile, deleteAccount } = useProfileSettings(); const { loading, updateProfile, deleteAccount } = useProfileSettings();
const [activeSection, setActiveSection] = useState(['profile']); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef(true);
const [deleteModalOpened, setDeleteModalOpened] = useState(false);
const [emailModalOpened, setEmailModalOpened] = useState(false);
// Initialize settings on mount
React.useEffect(() => {
if (isInitialMount.current && user) {
isInitialMount.current = false;
const settings = {
displayName: user.displayName,
email: user.email,
currentPassword: '',
newPassword: '',
};
dispatch({ type: 'INIT_SETTINGS', payload: settings });
}
}, [user]);
const handleInputChange = (key, value) => {
dispatch({ type: 'UPDATE_LOCAL_SETTINGS', payload: { [key]: value } });
};
const handleSubmit = async () => {
const updates = {};
const needsPasswordConfirmation =
state.localSettings.email !== state.initialSettings.email;
// Add display name if changed
if (state.localSettings.displayName !== state.initialSettings.displayName) {
updates.displayName = state.localSettings.displayName;
}
// Handle password change
if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) {
notifications.show({
title: 'Error',
message: 'Current password is required to change password',
color: 'red',
});
return;
}
updates.newPassword = state.localSettings.newPassword;
updates.currentPassword = state.localSettings.currentPassword;
}
// If we're only changing display name or have password already provided, proceed directly
if (!needsPasswordConfirmation || state.localSettings.currentPassword) {
if (needsPasswordConfirmation) {
updates.email = state.localSettings.email;
// If we don't have a password change, we still need to include the current password for email change
if (!updates.currentPassword) {
updates.currentPassword = state.localSettings.currentPassword;
}
}
const handleProfileUpdate = async (updates) => {
const result = await updateProfile(updates); const result = await updateProfile(updates);
if (result.success) { if (result.success) {
await refreshUser(); await refreshUser();
dispatch({ type: 'MARK_SAVED' });
onClose();
}
} else {
// Only show the email confirmation modal if we don't already have the password
setEmailModalOpened(true);
}
};
const handleEmailConfirm = async (password) => {
const updates = {
...state.localSettings,
currentPassword: password,
};
// Remove any undefined/empty values
Object.keys(updates).forEach((key) => {
if (updates[key] === undefined || updates[key] === '') {
delete updates[key];
}
});
// Remove keys that haven't changed
if (updates.displayName === state.initialSettings.displayName) {
delete updates.displayName;
}
if (updates.email === state.initialSettings.email) {
delete updates.email;
}
const result = await updateProfile(updates);
if (result.success) {
await refreshUser();
dispatch({ type: 'MARK_SAVED' });
setEmailModalOpened(false);
onClose();
} }
}; };
const handleDelete = async (password) => { const handleDelete = async (password) => {
const result = await deleteAccount(password); const result = await deleteAccount(password);
if (result.success) { if (result.success) {
setDeleteModalOpened(false);
onClose(); onClose();
logout(); logout();
} }
}; };
return ( return (
<>
<Modal <Modal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
@@ -196,11 +351,15 @@ const AccountSettings = ({ opened, onClose }) => {
centered centered
size="lg" size="lg"
> >
<LoadingOverlay visible={loading} />
<Stack spacing="xl"> <Stack spacing="xl">
{state.hasUnsavedChanges && (
<Badge color="yellow" variant="light">
Unsaved Changes
</Badge>
)}
<Accordion <Accordion
value={activeSection} defaultValue={['profile', 'security', 'danger']}
onChange={setActiveSection}
multiple multiple
styles={(theme) => ({ styles={(theme) => ({
control: { control: {
@@ -226,10 +385,8 @@ const AccountSettings = ({ opened, onClose }) => {
<AccordionControl>Profile</AccordionControl> <AccordionControl>Profile</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<ProfileSettings <ProfileSettings
displayName={user.displayName} settings={state.localSettings}
email={user.email} onInputChange={handleInputChange}
onUpdate={handleProfileUpdate}
loading={loading}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
@@ -238,8 +395,8 @@ const AccountSettings = ({ opened, onClose }) => {
<AccordionControl>Security</AccordionControl> <AccordionControl>Security</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<SecuritySettings <SecuritySettings
onUpdate={handleProfileUpdate} settings={state.localSettings}
loading={loading} onInputChange={handleInputChange}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
@@ -247,18 +404,39 @@ const AccountSettings = ({ opened, onClose }) => {
<Accordion.Item value="danger"> <Accordion.Item value="danger">
<AccordionControl>Danger Zone</AccordionControl> <AccordionControl>Danger Zone</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<DangerZone onDelete={handleDelete} loading={loading} /> <DangerZone onDeleteClick={() => setDeleteModalOpened(true)} />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={onClose}> <Button variant="default" onClick={onClose}>
Close Cancel
</Button>
<Button
onClick={handleSubmit}
loading={loading}
disabled={!state.hasUnsavedChanges}
>
Save Changes
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>
<EmailPasswordModal
opened={emailModalOpened}
onClose={() => setEmailModalOpened(false)}
onConfirm={handleEmailConfirm}
email={state.localSettings.email}
/>
<DeleteAccountModal
opened={deleteModalOpened}
onClose={() => setDeleteModalOpened(false)}
onConfirm={handleDelete}
/>
</>
); );
}; };