diff --git a/src/pages/authentication/auth-forms/AuthLogin.js b/src/pages/authentication/auth-forms/AuthLogin.js index 55d45a5..2876d1c 100644 --- a/src/pages/authentication/auth-forms/AuthLogin.js +++ b/src/pages/authentication/auth-forms/AuthLogin.js @@ -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 - }; + 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 ( <> { User Name { id="password-login" type={showPassword ? 'text' : 'password'} value={userPassword} - name="password" onBlur={handleBlur} onChange={onPasswordChange} InputProps={{ endAdornment: ( { )} - {/* 2FA Modal */} setTwoFAModalOpen(false)} maxWidth="sm" fullWidth> {twoFAMode === 'setup' ? 'Set Up Two-Factor Authentication' : 'Two-Factor Authentication Required'} @@ -275,15 +264,15 @@ const AuthLogin = () => { {twoFAMode === 'setup' && qrUrl && ( - + Scan this QR code with Microsoft Authenticator: - - - + + + After scanning, enter a code from the app to verify. - + - )} + )} {twoFAMode === 'login' && ( @@ -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 }} diff --git a/src/pages/lionerUserDetailPage/UserInformationCard.js b/src/pages/lionerUserDetailPage/UserInformationCard.js index 7c629b3..960d616 100644 --- a/src/pages/lionerUserDetailPage/UserInformationCard.js +++ b/src/pages/lionerUserDetailPage/UserInformationCard.js @@ -45,6 +45,7 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData, const [onReady, setOnReady] = useState(false); const {register, getValues, setValue} = useForm() const [errors, setErrors] = useState({}); + const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); const handleClickShowPassword = () => setShowPassword((show) => !show); const handleMouseDownPassword = () => setShowPassword(!showPassword); @@ -78,14 +79,15 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData, }, [userData]); useEffect(() => { - //if state data are ready and assign to different field if (Object.keys(currentUserData).length > 0 && currentUserData !== undefined) { setLocked(currentUserData.locked); + setTwoFactorEnabled(currentUserData.twoFactorEnabled ?? false); // ← Add this setUserGroup(USER_GROUP_COMBO.find(item => item.id == userData.groupId)); setOnReady(true); } else if(isNewRecord){ setLocked(false); + setTwoFactorEnabled(false); setOnReady(true); } }, [currentUserData]); @@ -151,6 +153,7 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData, const objectData ={ ...values, locked: locked, + twoFactorEnabled: twoFactorEnabled, } updateUserObject(objectData); } @@ -429,6 +432,27 @@ const UserInformationCard = ({isCollectData, updateUserObject,userData, + {!isNewRecord && ( + + + + + Two-Factor Authentication: + + + + + setTwoFactorEnabled(!twoFactorEnabled)} + disabled={!twoFactorEnabled} + /> + + + + )} + {!isNewRecord && ( diff --git a/src/pages/lionerUserDetailPage/index.js b/src/pages/lionerUserDetailPage/index.js index 11f8769..2579b50 100644 --- a/src/pages/lionerUserDetailPage/index.js +++ b/src/pages/lionerUserDetailPage/index.js @@ -155,6 +155,7 @@ const UserMaintainPage = () => { "email": editedUserData.email === null ? "": editedUserData.email.trim(), "userGroupId": editedUserData.userGroupId ?? null, "password": editedUserData.password ?? null, + "twoFactorEnabled": editedUserData.twoFactorEnabled ?? false, // "addGroupIds": userGroupData, // "removeGroupIds": deletedUserGroup, // "addAuthIds": userAuthData, diff --git a/src/pages/lionerUserSearchPage/UserSearchForm.js b/src/pages/lionerUserSearchPage/UserSearchForm.js index 80d881d..5a9ee19 100644 --- a/src/pages/lionerUserSearchPage/UserSearchForm.js +++ b/src/pages/lionerUserSearchPage/UserSearchForm.js @@ -49,6 +49,7 @@ const UserSearchForm = ({applySearch}) => { email: data.email, phone: data.phone, locked: locked, + twoFactorEnabled: twoFactorEnabled, }; applySearch(temp); }; diff --git a/src/pages/lionerUserSearchPage/UserTable.js b/src/pages/lionerUserSearchPage/UserTable.js index 4fc6af7..667513d 100644 --- a/src/pages/lionerUserSearchPage/UserTable.js +++ b/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 ( + + ); + }, + }, { id: 'locked', field: 'locked', diff --git a/src/pages/profile/profile.js b/src/pages/profile/profile.js index c426c01..3dddb64 100644 --- a/src/pages/profile/profile.js +++ b/src/pages/profile/profile.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; 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 { Box, @@ -20,10 +20,25 @@ const Profile = () => { const [loading, setLoading] = useState(false); const [message, setMessage] = 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(() => { - // Fetch current user or check Redux store for twoFactorEnabled - // Hide "Enable" button if already true + fetch2FAStatus(); }, []); const start2FASetup = async () => { @@ -32,11 +47,11 @@ const Profile = () => { setError(''); try { 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); } catch (err) { - setError('Failed to start 2FA setup. Please try again.'); + setError('Failed to generate QR code. Please try again.'); } finally { setLoading(false); } @@ -50,18 +65,49 @@ const Profile = () => { await axios.post(`${apiPath}/2fa/verify-setup`, { code }, { 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(''); setCode(''); + await fetch2FAStatus(); // Refresh status from backend } catch (err) { - setError('Invalid or expired code. Try again.'); + setError('Invalid or expired code. Please try again.'); } finally { 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 ( - + Profile & Security @@ -72,19 +118,55 @@ const Profile = () => { Two-Factor Authentication (2FA) + {loading && ( + + + + )} + {!qrUrl ? ( <> - - Add an extra layer of security to your account with 2FA using Microsoft Authenticator. - - + {is2FAEnabled ? ( + + + 2FA is enabled and bound to Microsoft Authenticator. + + + If you have a new phone or lost access, you can re-bind or disable 2FA below. + + + + + ) : ( + + + Add an extra layer of security with 2FA using Microsoft Authenticator. + + + + )} ) : ( @@ -101,25 +183,40 @@ const Profile = () => { onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} inputProps={{ maxLength: 6 }} sx={{ width: 200 }} + autoFocus /> - + + )} - {message && {message}} - {error && {error}} + {message && ( + + {message} + + )} + {error && ( + + {error} + + )} - - {/* Add other profile fields here later */} ); };