Browse Source

no message

master
parent
commit
33246e03bc
6 changed files with 250 additions and 120 deletions
  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 View File

@@ -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 }}


+ 25
- 1
src/pages/lionerUserDetailPage/UserInformationCard.js View File

@@ -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,
</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>
{!isNewRecord && (


+ 1
- 0
src/pages/lionerUserDetailPage/index.js View File

@@ -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,


+ 1
- 0
src/pages/lionerUserSearchPage/UserSearchForm.js View File

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


+ 15
- 0
src/pages/lionerUserSearchPage/UserTable.js View File

@@ -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',
field: 'locked',


+ 123
- 26
src/pages/profile/profile.js View File

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

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

{!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 }}>
@@ -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
/>
<Box sx={{ mt: 2 }}>
<Box sx={{ mt: 3 }}>
<Button
variant="contained"
color="primary"
onClick={verifyCode}
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>
</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>
</Card>

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


Loading…
Cancel
Save