|
|
|
@@ -5,7 +5,6 @@ import { useDispatch } from 'react-redux'; |
|
|
|
// material-ui |
|
|
|
import { |
|
|
|
Button, |
|
|
|
FormHelperText, |
|
|
|
Grid, |
|
|
|
InputLabel, |
|
|
|
Stack, |
|
|
|
@@ -50,142 +49,136 @@ const AuthLogin = () => { |
|
|
|
|
|
|
|
// 2FA States |
|
|
|
const [twoFAModalOpen, setTwoFAModalOpen] = useState(false); |
|
|
|
const [twoFAMode, setTwoFAMode] = useState('login'); // 'login' or 'setup' |
|
|
|
const [twoFAMode, setTwoFAMode] = useState('login'); |
|
|
|
const [qrUrl, setQrUrl] = useState(''); |
|
|
|
const [twoFACode, setTwoFACode] = 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 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 formErrors = {}; |
|
|
|
|
|
|
|
if (!userName) formErrors.loginError = 'Username is required.'; |
|
|
|
if (!userPassword) formErrors.passwordError = 'Password is required.'; |
|
|
|
|
|
|
|
setErrors(formErrors); |
|
|
|
|
|
|
|
if (Object.keys(formErrors).length === 0) { |
|
|
|
axios |
|
|
|
.post(`${apiPath}${LOGIN_PATH}`, { |
|
|
|
username: userName, |
|
|
|
password: userPassword |
|
|
|
}) |
|
|
|
.post(`${apiPath}${LOGIN_PATH}`, { username: userName, password: userPassword }) |
|
|
|
.then(async (response) => { |
|
|
|
const data = response.data; |
|
|
|
|
|
|
|
// Case 1: 2FA is required |
|
|
|
if (data.requires2FA) { |
|
|
|
setPartialToken(data.partialToken || ''); // optional, depending on backend |
|
|
|
setTwoFAMode('login'); |
|
|
|
setTwoFAModalOpen(true); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// Case 2: Login successful (no 2FA or already verified) |
|
|
|
if (data.accessToken) { |
|
|
|
const userData = { |
|
|
|
const loginPayload = { |
|
|
|
id: data.id, |
|
|
|
fullName: data.name, |
|
|
|
email: data.email, |
|
|
|
role: data.role, |
|
|
|
abilities: data.abilities, |
|
|
|
abilities: data.abilities || [], |
|
|
|
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) => { |
|
|
|
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 = () => { |
|
|
|
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'; |
|
|
|
|
|
|
|
// 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 |
|
|
|
.post(`${apiPath}${endpoint}`, payload) |
|
|
|
.then(async (res) => { |
|
|
|
.post(`${apiPath}${endpoint}`, payload) |
|
|
|
.then(async (res) => { |
|
|
|
const data = res.data; |
|
|
|
|
|
|
|
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(''); |
|
|
|
}) |
|
|
|
.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 ( |
|
|
|
<> |
|
|
|
<Formik |
|
|
|
initialValues={{ |
|
|
|
userName: '', |
|
|
|
password: '', |
|
|
|
submit: null |
|
|
|
}} |
|
|
|
initialValues={{ userName: '', password: '', submit: null }} |
|
|
|
validationSchema={Yup.object().shape({ |
|
|
|
userName: Yup.string().required('Username is required'), |
|
|
|
password: Yup.string().required('Password is required') |
|
|
|
@@ -200,7 +193,6 @@ const AuthLogin = () => { |
|
|
|
<InputLabel htmlFor="username">User Name</InputLabel> |
|
|
|
<TextField |
|
|
|
id="username" |
|
|
|
name="username" |
|
|
|
value={userName} |
|
|
|
onBlur={handleBlur} |
|
|
|
onChange={onUserNameChange} |
|
|
|
@@ -224,14 +216,12 @@ const AuthLogin = () => { |
|
|
|
id="password-login" |
|
|
|
type={showPassword ? 'text' : 'password'} |
|
|
|
value={userPassword} |
|
|
|
name="password" |
|
|
|
onBlur={handleBlur} |
|
|
|
onChange={onPasswordChange} |
|
|
|
InputProps={{ |
|
|
|
endAdornment: ( |
|
|
|
<InputAdornment position="end"> |
|
|
|
<IconButton |
|
|
|
aria-label="toggle password visibility" |
|
|
|
onClick={handleClickShowPassword} |
|
|
|
onMouseDown={handleMouseDownPassword} |
|
|
|
edge="end" |
|
|
|
@@ -267,7 +257,6 @@ const AuthLogin = () => { |
|
|
|
)} |
|
|
|
</Formik> |
|
|
|
|
|
|
|
{/* 2FA Modal */} |
|
|
|
<Dialog open={twoFAModalOpen} onClose={() => setTwoFAModalOpen(false)} maxWidth="sm" fullWidth> |
|
|
|
<DialogTitle> |
|
|
|
{twoFAMode === 'setup' ? 'Set Up Two-Factor Authentication' : 'Two-Factor Authentication Required'} |
|
|
|
@@ -275,15 +264,15 @@ const AuthLogin = () => { |
|
|
|
<DialogContent> |
|
|
|
{twoFAMode === 'setup' && qrUrl && ( |
|
|
|
<Box sx={{ textAlign: 'center', my: 3 }}> |
|
|
|
<Typography variant="body1" gutterBottom> |
|
|
|
<Typography variant="body1" gutterBottom> |
|
|
|
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. |
|
|
|
</Typography> |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
)} |
|
|
|
)} |
|
|
|
|
|
|
|
{twoFAMode === 'login' && ( |
|
|
|
<Typography variant="body1" sx={{ mb: 2 }}> |
|
|
|
@@ -297,6 +286,9 @@ const AuthLogin = () => { |
|
|
|
label="6-Digit Code" |
|
|
|
value={twoFACode} |
|
|
|
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))} |
|
|
|
onKeyDown={(e) => { |
|
|
|
if (e.key === 'Enter' && twoFACode.length === 6) handle2FASubmit(); |
|
|
|
}} |
|
|
|
error={!!twoFAError} |
|
|
|
helperText={twoFAError} |
|
|
|
inputProps={{ maxLength: 6 }} |
|
|
|
|