vluk@2fi-solutions.com.hk 11 часов назад
Родитель
Сommit
33246e03bc
6 измененных файлов: 250 добавлений и 120 удалений
  1. +85
    -93
      src/pages/authentication/auth-forms/AuthLogin.js
  2. +25
    -1
      src/pages/lionerUserDetailPage/UserInformationCard.js
  3. +1
    -0
      src/pages/lionerUserDetailPage/index.js
  4. +1
    -0
      src/pages/lionerUserSearchPage/UserSearchForm.js
  5. +15
    -0
      src/pages/lionerUserSearchPage/UserTable.js
  6. +123
    -26
      src/pages/profile/profile.js

+ 85
- 93
src/pages/authentication/auth-forms/AuthLogin.js Просмотреть файл

@@ -5,7 +5,6 @@ import { useDispatch } from 'react-redux';
// material-ui // material-ui
import { import {
Button, Button,
FormHelperText,
Grid, Grid,
InputLabel, InputLabel,
Stack, Stack,
@@ -50,142 +49,136 @@ const AuthLogin = () => {


// 2FA States // 2FA States
const [twoFAModalOpen, setTwoFAModalOpen] = useState(false); const [twoFAModalOpen, setTwoFAModalOpen] = useState(false);
const [twoFAMode, setTwoFAMode] = useState('login'); // 'login' or 'setup'
const [twoFAMode, setTwoFAMode] = useState('login');
const [qrUrl, setQrUrl] = useState(''); const [qrUrl, setQrUrl] = useState('');
const [twoFACode, setTwoFACode] = useState(''); const [twoFACode, setTwoFACode] = useState('');
const [twoFAError, setTwoFAError] = useState(''); const [twoFAError, setTwoFAError] = useState('');
const [partialToken, setPartialToken] = useState(''); // If backend uses partial session


const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};

const handleMouseDownPassword = (event) => {
event.preventDefault();
};
const handleClickShowPassword = () => setShowPassword(!showPassword);
const handleMouseDownPassword = (event) => event.preventDefault();


const onUserNameChange = (e) => setUserName(e.target.value); const onUserNameChange = (e) => setUserName(e.target.value);
const onPasswordChange = (e) => setUserPassword(e.target.value); const onPasswordChange = (e) => setUserPassword(e.target.value);


// === CENTRALIZED LOGIN & REDIRECT FUNCTION ===
const performLoginAndRedirect = async (loginPayload, is2FA = false) => {
await dispatch(handleLogin(loginPayload));

// Determine target path using abilities from payload
const abilities = loginPayload.abilities || [];
let targetPath = '/client';

const hasMaintainClient = abilities.some(a => a.startsWith('MAINTAIN') && a.includes('CLIENT'));
const hasViewUser = abilities.some(a => a.startsWith('VIEW') && a.includes('USER'));

if (!is2FA && loginPayload.twoFactorEnabled === false) {
targetPath = '/profile';
} else if (hasMaintainClient) {
targetPath = '/client';
} else if (hasViewUser) {
targetPath = '/userSearchview';
}

const lastPath = localStorage.getItem('lastVisitedPath');
if (lastPath && targetPath === '/client') {
targetPath = lastPath;
}
localStorage.removeItem('lastVisitedPath');

// Full page redirect to force AbilityProvider to rebuild
window.location.href = targetPath;
};

const tryLogin = () => { const tryLogin = () => {
const formErrors = {}; const formErrors = {};

if (!userName) formErrors.loginError = 'Username is required.'; if (!userName) formErrors.loginError = 'Username is required.';
if (!userPassword) formErrors.passwordError = 'Password is required.'; if (!userPassword) formErrors.passwordError = 'Password is required.';

setErrors(formErrors); setErrors(formErrors);


if (Object.keys(formErrors).length === 0) { if (Object.keys(formErrors).length === 0) {
axios axios
.post(`${apiPath}${LOGIN_PATH}`, {
username: userName,
password: userPassword
})
.post(`${apiPath}${LOGIN_PATH}`, { username: userName, password: userPassword })
.then(async (response) => { .then(async (response) => {
const data = response.data; const data = response.data;


// Case 1: 2FA is required
if (data.requires2FA) { if (data.requires2FA) {
setPartialToken(data.partialToken || ''); // optional, depending on backend
setTwoFAMode('login'); setTwoFAMode('login');
setTwoFAModalOpen(true); setTwoFAModalOpen(true);
return; return;
} }


// Case 2: Login successful (no 2FA or already verified)
if (data.accessToken) { if (data.accessToken) {
const userData = {
const loginPayload = {
id: data.id, id: data.id,
fullName: data.name, fullName: data.name,
email: data.email, email: data.email,
role: data.role, role: data.role,
abilities: data.abilities,
abilities: data.abilities || [],
subDivisionId: data.subDivisionId, subDivisionId: data.subDivisionId,
lotusNotesUser: data.lotusNotesUser
lotusNotesUser: data.lotusNotesUser,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
twoFactorEnabled: data.twoFactorEnabled ?? false
}; };


await dispatch(handleLogin({ ...userData, accessToken: data.accessToken, refreshToken: data.refreshToken }));

const lastPath = localStorage.getItem('lastVisitedPath');
navigate(lastPath || '/client');
window.location.reload();
localStorage.removeItem('lastVisitedPath');

// Optional: Prompt to setup 2FA if not enabled
if (!data.twoFactorEnabled) {
setTimeout(() => {
if (window.confirm('For extra security, would you like to enable 2FA with Microsoft Authenticator?')) {
start2FASetup();
}
}, 1000);
}
await performLoginAndRedirect(loginPayload);
} }
}) })
.catch((error) => { .catch((error) => {
const formErrors = {};
formErrors.passwordError = error.response?.data?.message || 'Invalid credentials';
setErrors(formErrors);
setErrors({ passwordError: error.response?.data?.message || 'Invalid credentials' });
}); });
} }
}; };


const start2FASetup = () => {
axios
.post(`${apiPath}/2fa/setup`) // Requires user to be authenticated
.then((res) => {
setQrUrl(res.data.otpauthUrl);
setTwoFAMode('setup');
setTwoFAModalOpen(true);
setTwoFAError('');
})
.catch((err) => {
alert('Failed to start 2FA setup: ' + (err.response?.data?.message || err.message));
});
};

const handle2FASubmit = () => { const handle2FASubmit = () => {
if (twoFACode.length !== 6 || !/^\d+$/.test(twoFACode)) { if (twoFACode.length !== 6 || !/^\d+$/.test(twoFACode)) {
setTwoFAError('Please enter a valid 6-digit code');
return;
setTwoFAError('Please enter a valid 6-digit code');
return;
} }


const endpoint = twoFAMode === 'setup' ? '/2fa/verify-setup' : '/2fa/verify-login'; const endpoint = twoFAMode === 'setup' ? '/2fa/verify-setup' : '/2fa/verify-login';

// Use the actual username from login form, NOT hard-coded '2fi'
const payload = {
code: twoFACode,
username: userName // ← This is your state from <TextField onChange={onUserNameChange} />
};
const payload = { code: twoFACode, username: userName };


axios axios
.post(`${apiPath}${endpoint}`, payload)
.then(async (res) => {
.post(`${apiPath}${endpoint}`, payload)
.then(async (res) => {
const data = res.data;

if (twoFAMode === 'setup') { if (twoFAMode === 'setup') {
alert('2FA enabled!');
setTwoFAModalOpen(false);
} else {
// Full login - use the exact same structure as normal login
await dispatch(handleLogin(res.data)); // res.data should be JwtResponse
setTwoFAModalOpen(false);
const lastPath = localStorage.getItem('lastVisitedPath');
navigate(lastPath || '/client');
window.location.reload();
alert('2FA enabled!');
setTwoFAModalOpen(false);
return;
} }

const loginPayload = {
id: data.id,
fullName: data.name,
email: data.email,
role: data.role,
abilities: data.abilities || [],
subDivisionId: data.subDivisionId || null,
lotusNotesUser: data.lotusNotesUser || null,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
twoFactorEnabled: true
};

await performLoginAndRedirect(loginPayload, true);

setTwoFAModalOpen(false);
setTwoFACode(''); setTwoFACode('');
})
.catch((err) => {
setTwoFAError(err.response?.data?.message || 'Invalid or expired code');
});
};
setTwoFAError('');
})
.catch((err) => {
console.error('2FA verification failed:', err);
setTwoFAError(err.response?.data?.message || 'Invalid or expired code');
});
};


return ( return (
<> <>
<Formik <Formik
initialValues={{
userName: '',
password: '',
submit: null
}}
initialValues={{ userName: '', password: '', submit: null }}
validationSchema={Yup.object().shape({ validationSchema={Yup.object().shape({
userName: Yup.string().required('Username is required'), userName: Yup.string().required('Username is required'),
password: Yup.string().required('Password is required') password: Yup.string().required('Password is required')
@@ -200,7 +193,6 @@ const AuthLogin = () => {
<InputLabel htmlFor="username">User Name</InputLabel> <InputLabel htmlFor="username">User Name</InputLabel>
<TextField <TextField
id="username" id="username"
name="username"
value={userName} value={userName}
onBlur={handleBlur} onBlur={handleBlur}
onChange={onUserNameChange} onChange={onUserNameChange}
@@ -224,14 +216,12 @@ const AuthLogin = () => {
id="password-login" id="password-login"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={userPassword} value={userPassword}
name="password"
onBlur={handleBlur} onBlur={handleBlur}
onChange={onPasswordChange} onChange={onPasswordChange}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton <IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword} onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword} onMouseDown={handleMouseDownPassword}
edge="end" edge="end"
@@ -267,7 +257,6 @@ const AuthLogin = () => {
)} )}
</Formik> </Formik>


{/* 2FA Modal */}
<Dialog open={twoFAModalOpen} onClose={() => setTwoFAModalOpen(false)} maxWidth="sm" fullWidth> <Dialog open={twoFAModalOpen} onClose={() => setTwoFAModalOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle> <DialogTitle>
{twoFAMode === 'setup' ? 'Set Up Two-Factor Authentication' : 'Two-Factor Authentication Required'} {twoFAMode === 'setup' ? 'Set Up Two-Factor Authentication' : 'Two-Factor Authentication Required'}
@@ -275,15 +264,15 @@ const AuthLogin = () => {
<DialogContent> <DialogContent>
{twoFAMode === 'setup' && qrUrl && ( {twoFAMode === 'setup' && qrUrl && (
<Box sx={{ textAlign: 'center', my: 3 }}> <Box sx={{ textAlign: 'center', my: 3 }}>
<Typography variant="body1" gutterBottom>
<Typography variant="body1" gutterBottom>
Scan this QR code with <strong>Microsoft Authenticator</strong>: Scan this QR code with <strong>Microsoft Authenticator</strong>:
</Typography>
<QRCodeSVG value={qrUrl} size={220} level="M" />
<Typography variant="caption" display="block" sx={{ mt: 2 }}>
</Typography>
<QRCodeSVG value={qrUrl} size={220} level="M" />
<Typography variant="caption" display="block" sx={{ mt: 2 }}>
After scanning, enter a code from the app to verify. After scanning, enter a code from the app to verify.
</Typography>
</Typography>
</Box> </Box>
)}
)}


{twoFAMode === 'login' && ( {twoFAMode === 'login' && (
<Typography variant="body1" sx={{ mb: 2 }}> <Typography variant="body1" sx={{ mb: 2 }}>
@@ -297,6 +286,9 @@ const AuthLogin = () => {
label="6-Digit Code" label="6-Digit Code"
value={twoFACode} value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => {
if (e.key === 'Enter' && twoFACode.length === 6) handle2FASubmit();
}}
error={!!twoFAError} error={!!twoFAError}
helperText={twoFAError} helperText={twoFAError}
inputProps={{ maxLength: 6 }} inputProps={{ maxLength: 6 }}


+ 25
- 1
src/pages/lionerUserDetailPage/UserInformationCard.js Просмотреть файл

@@ -45,6 +45,7 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData,
const [onReady, setOnReady] = useState(false); const [onReady, setOnReady] = useState(false);
const {register, getValues, setValue} = useForm() const {register, getValues, setValue} = useForm()
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);


const handleClickShowPassword = () => setShowPassword((show) => !show); const handleClickShowPassword = () => setShowPassword((show) => !show);
const handleMouseDownPassword = () => setShowPassword(!showPassword); const handleMouseDownPassword = () => setShowPassword(!showPassword);
@@ -78,14 +79,15 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData,
}, [userData]); }, [userData]);


useEffect(() => { useEffect(() => {
//if state data are ready and assign to different field
if (Object.keys(currentUserData).length > 0 && currentUserData !== undefined) { if (Object.keys(currentUserData).length > 0 && currentUserData !== undefined) {
setLocked(currentUserData.locked); setLocked(currentUserData.locked);
setTwoFactorEnabled(currentUserData.twoFactorEnabled ?? false); // ← Add this
setUserGroup(USER_GROUP_COMBO.find(item => item.id == userData.groupId)); setUserGroup(USER_GROUP_COMBO.find(item => item.id == userData.groupId));
setOnReady(true); setOnReady(true);
} }
else if(isNewRecord){ else if(isNewRecord){
setLocked(false); setLocked(false);
setTwoFactorEnabled(false);
setOnReady(true); setOnReady(true);
} }
}, [currentUserData]); }, [currentUserData]);
@@ -151,6 +153,7 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData,
const objectData ={ const objectData ={
...values, ...values,
locked: locked, locked: locked,
twoFactorEnabled: twoFactorEnabled,
} }
updateUserObject(objectData); updateUserObject(objectData);
} }
@@ -429,6 +432,27 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData,
</Grid> </Grid>
</Grid> </Grid>


{!isNewRecord && (
<Grid item xs={12} s={12} md={12} lg={12} sx={{ml: 3, mr: 3, mb: 1}}>
<Grid container alignItems={"center"}>
<Grid item xs={4} s={4} md={4} lg={4}
sx={{ml: 3, mr: 3, display: 'flex', alignItems: 'center'}}>
<Typography variant="lionerSize" component="span">
Two-Factor Authentication:
</Typography>
</Grid>

<Grid item xs={7} s={7} md={7} lg={6}>
<Switch
checked={twoFactorEnabled}
onChange={() => setTwoFactorEnabled(!twoFactorEnabled)}
disabled={!twoFactorEnabled}
/>
</Grid>
</Grid>
</Grid>
)}

<Grid item xs={12} s={12} md={12} lg={12} sx={{ml: 3, mr: 3, mb: 1}}> <Grid item xs={12} s={12} md={12} lg={12} sx={{ml: 3, mr: 3, mb: 1}}>
</Grid> </Grid>
{!isNewRecord && ( {!isNewRecord && (


+ 1
- 0
src/pages/lionerUserDetailPage/index.js Просмотреть файл

@@ -155,6 +155,7 @@ const UserMaintainPage = () => {
"email": editedUserData.email === null ? "": editedUserData.email.trim(), "email": editedUserData.email === null ? "": editedUserData.email.trim(),
"userGroupId": editedUserData.userGroupId ?? null, "userGroupId": editedUserData.userGroupId ?? null,
"password": editedUserData.password ?? null, "password": editedUserData.password ?? null,
"twoFactorEnabled": editedUserData.twoFactorEnabled ?? false,
// "addGroupIds": userGroupData, // "addGroupIds": userGroupData,
// "removeGroupIds": deletedUserGroup, // "removeGroupIds": deletedUserGroup,
// "addAuthIds": userAuthData, // "addAuthIds": userAuthData,


+ 1
- 0
src/pages/lionerUserSearchPage/UserSearchForm.js Просмотреть файл

@@ -49,6 +49,7 @@ const UserSearchForm = ({applySearch}) => {
email: data.email, email: data.email,
phone: data.phone, phone: data.phone,
locked: locked, locked: locked,
twoFactorEnabled: twoFactorEnabled,
}; };
applySearch(temp); applySearch(temp);
}; };


+ 15
- 0
src/pages/lionerUserSearchPage/UserTable.js Просмотреть файл

@@ -98,6 +98,21 @@ export default function UserTable({recordList}) {
); );
} }
}, },
{
id: 'twoFactorEnabled',
field: 'twoFactorEnabled',
headerName: '2FA Enabled',
flex: 0.5,
renderCell: (params) => {
return (
<input
type="checkbox"
checked={!!params.row.twoFactorEnabled}
disabled
/>
);
},
},
{ {
id: 'locked', id: 'locked',
field: 'locked', field: 'locked',


+ 123
- 26
src/pages/profile/profile.js Просмотреть файл

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { QRCodeSVG } from 'qrcode.react'; // Use SVG for sharp rendering
import {apiPath} from "../../auth/utils";
import { QRCodeSVG } from 'qrcode.react';
import { apiPath } from "../../auth/utils";


import { import {
Box, Box,
@@ -20,10 +20,25 @@ const Profile = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [is2FAEnabled, setIs2FAEnabled] = useState(false);

// Fetch real 2FA status from backend on mount and after actions
const fetch2FAStatus = async () => {
try {
const response = await axios.get(`${apiPath}/2fa/status`, {
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
});
setIs2FAEnabled(response.data.enabled);
} catch (err) {
console.error('Failed to fetch 2FA status', err);
// Fallback to localStorage if API fails
const userData = JSON.parse(localStorage.getItem('userData') || '{}');
setIs2FAEnabled(!!userData.twoFactorEnabled);
}
};


useEffect(() => { useEffect(() => {
// Fetch current user or check Redux store for twoFactorEnabled
// Hide "Enable" button if already true
fetch2FAStatus();
}, []); }, []);


const start2FASetup = async () => { const start2FASetup = async () => {
@@ -32,11 +47,11 @@ const Profile = () => {
setError(''); setError('');
try { try {
const response = await axios.post(`${apiPath}/2fa/setup`, {}, { const response = await axios.post(`${apiPath}/2fa/setup`, {}, {
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` } // Adjust to your auth method
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
}); });
setQrUrl(response.data.otpauthUrl); setQrUrl(response.data.otpauthUrl);
} catch (err) { } catch (err) {
setError('Failed to start 2FA setup. Please try again.');
setError('Failed to generate QR code. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -50,18 +65,49 @@ const Profile = () => {
await axios.post(`${apiPath}/2fa/verify-setup`, { code }, { await axios.post(`${apiPath}/2fa/verify-setup`, { code }, {
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` } headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
}); });
setMessage('2FA enabled successfully! You will now be prompted for a code on login.');
setMessage(is2FAEnabled
? '2FA re-bound successfully to your new device!'
: '2FA enabled successfully!'
);
setQrUrl(''); setQrUrl('');
setCode(''); setCode('');
await fetch2FAStatus(); // Refresh status from backend
} catch (err) { } catch (err) {
setError('Invalid or expired code. Try again.');
setError('Invalid or expired code. Please try again.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };


const disable2FA = async () => {
if (!window.confirm('Are you sure you want to disable 2FA? This will reduce account security.')) {
return;
}

setLoading(true);
setMessage('');
setError('');
try {
await axios.post(`${apiPath}/2fa/disable`, {}, {
headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
});
setMessage('2FA disabled successfully.');
await fetch2FAStatus(); // Refresh status
} catch (err) {
setError('Failed to disable 2FA.');
} finally {
setLoading(false);
}
};

const cancelSetup = () => {
setQrUrl('');
setCode('');
setError('');
};

return ( return (
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4 }}>
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4, p: 2 }}>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Profile & Security Profile & Security
</Typography> </Typography>
@@ -72,19 +118,55 @@ const Profile = () => {
Two-Factor Authentication (2FA) Two-Factor Authentication (2FA)
</Typography> </Typography>


{loading && (
<Box sx={{ textAlign: 'center', my: 3 }}>
<CircularProgress />
</Box>
)}

{!qrUrl ? ( {!qrUrl ? (
<> <>
<Typography variant="body1" paragraph>
Add an extra layer of security to your account with 2FA using Microsoft Authenticator.
</Typography>
<Button
variant="contained"
color="primary"
onClick={start2FASetup}
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Enable 2FA'}
</Button>
{is2FAEnabled ? (
<Box>
<Alert severity="success" sx={{ mb: 2 }}>
2FA is enabled and bound to Microsoft Authenticator.
</Alert>
<Typography variant="body2" sx={{ mb: 3 }}>
If you have a new phone or lost access, you can re-bind or disable 2FA below.
</Typography>
<Button
variant="contained"
color="primary"
onClick={start2FASetup}
disabled={loading}
sx={{ mr: 2 }}
>
Re-bind 2FA (New Phone)
</Button>
<Button
variant="outlined"
color="error"
onClick={disable2FA}
disabled={loading}
>
Disable 2FA
</Button>
</Box>
) : (
<Box>
<Typography variant="body1" paragraph>
Add an extra layer of security with 2FA using Microsoft Authenticator.
</Typography>
<Button
variant="contained"
color="primary"
onClick={start2FASetup}
disabled={loading}
>
Enable 2FA
</Button>
</Box>
)}
</> </>
) : ( ) : (
<Box sx={{ textAlign: 'center', my: 4 }}> <Box sx={{ textAlign: 'center', my: 4 }}>
@@ -101,25 +183,40 @@ const Profile = () => {
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
inputProps={{ maxLength: 6 }} inputProps={{ maxLength: 6 }}
sx={{ width: 200 }} sx={{ width: 200 }}
autoFocus
/> />
<Box sx={{ mt: 2 }}>
<Box sx={{ mt: 3 }}>
<Button <Button
variant="contained" variant="contained"
color="primary"
onClick={verifyCode} onClick={verifyCode}
disabled={loading || code.length !== 6} disabled={loading || code.length !== 6}
> >
{loading ? <CircularProgress size={24} /> : 'Verify & Enable'}
{loading ? <CircularProgress size={24} /> : 'Verify'}
</Button>
<Button
variant="text"
onClick={cancelSetup}
sx={{ ml: 2 }}
>
Cancel
</Button> </Button>
</Box> </Box>
</Box> </Box>
)} )}


{message && <Alert severity="success" sx={{ mt: 2 }}>{message}</Alert>}
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
{message && (
<Alert severity="success" sx={{ mt: 3 }}>
{message}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mt: 3 }}>
{error}
</Alert>
)}
</CardContent> </CardContent>
</Card> </Card>

{/* Add other profile fields here later */}
</Box> </Box>
); );
}; };


Загрузка…
Отмена
Сохранить