From c45b22da0912d3d5e74cad31f33fd90348a64865 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Thu, 1 Jan 2026 03:14:02 +0800 Subject: [PATCH] no message --- package.json | 5 +- src/auth/index.js | 102 ++-- src/components/TwoFAModal.js | 36 ++ .../Header/HeaderContent/Profile/index.js | 4 +- src/menu-items/index.js | 4 +- src/menu-items/misc.js | 8 + .../authentication/auth-forms/AuthLogin.js | 502 ++++++++++-------- src/pages/profile/profile.js | 127 +++++ src/routes/ClientRoutes.js | 2 +- src/routes/SettingRoutes.js | 14 +- 10 files changed, 508 insertions(+), 296 deletions(-) create mode 100644 src/components/TwoFAModal.js create mode 100644 src/pages/profile/profile.js diff --git a/package.json b/package.json index 5c21250..4d06317 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@mantine/core": "^7.0.2", - "@mui/icons-material": "^5.14.1", + "@mui/icons-material": "^5.18.0", "@mui/lab": "^5.0.0-alpha.139", - "@mui/material": "^5.14.11", + "@mui/material": "^5.18.0", "@mui/system": "^5.14.11", "@mui/x-data-grid": "^6.11.1", "@reduxjs/toolkit": "^1.8.5", @@ -50,6 +50,7 @@ "mui-image": "^1.0.7", "prop-types": "^15.8.1", "pspdfkit": "^2024.8.2", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-apexcharts": "^1.4.0", "react-copy-to-clipboard": "^5.1.0", diff --git a/src/auth/index.js b/src/auth/index.js index 6acd1c7..f870c92 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -121,66 +121,58 @@ export const SetupAxiosInterceptors = (dispatch, navigate) => { ); axios.interceptors.response.use( - (response) => { - //updateLastRequestTime(Date.now()); - return response; - }, + (response) => response, async (error) => { - // ** const { config, response: { status } } = error - //const {response} = error - // if (error.response.status === 401) { - // dispatch(handleLogoutFunction()); - // navigate('/login'); - // } - // - // // ** if (status === 401) { - // if (response.status === 401) { - // dispatch(handleLogoutFunction()); - // navigate('/login'); - // } - // - // if (response && response.status === 401) { - // dispatch(handleLogoutFunction()); - // navigate('/login'); - // } - - if (error.response.status === 401 && error.config.url !== apiPath + REFRESH_TOKEN) { - // Make a request to refresh the access token - const refreshToken = localStorage.getItem('refreshToken'); - if (isRefreshToken) { - return; + const originalRequest = error.config; + + if (error.response) { + // === NEW: Handle 2FA required response === + if (error.response.data && error.response.data.requires2FA && !originalRequest._retry2FA) { + originalRequest._retry2FA = true; + // This is caught in AuthLogin.js tryLogin — do NOT reject here + return Promise.reject(error); } - isRefreshToken = true; - return axios - .post(`${apiPath}${REFRESH_TOKEN}`, { - refreshToken: refreshToken // Replace with your refresh token - }) - .then((response) => { - if (response.status === 200) { - const newAccessToken = response.data.accessToken; - const newRefreshToken = response.data.refreshToken; - localStorage.setItem('accessToken', newAccessToken); - localStorage.setItem('refreshToken', newRefreshToken); - isRefreshToken = false; + // === END NEW === + + if (error.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + let isRefreshToken = localStorage.getItem('isRefreshToken'); + if (isRefreshToken) { + return Promise.reject(error); + } + localStorage.setItem('isRefreshToken', 'true'); + return axios + .post(`${apiPath}${REFRESH_TOKEN}`, { refreshToken }) + .then((response) => { + if (response.status === 200) { + const newAccessToken = response.data.accessToken; + const newRefreshToken = response.data.refreshToken; + localStorage.setItem('accessToken', newAccessToken); + localStorage.setItem('refreshToken', newRefreshToken); + localStorage.setItem('axiosToken', 'Bearer ' + newAccessToken); + originalRequest.headers['Authorization'] = 'Bearer ' + newAccessToken; + localStorage.removeItem('isRefreshToken'); + return axios(originalRequest); + } + }) + .catch((refreshError) => { + dispatch(handleLogoutFunction()); + navigate('/login'); + localStorage.removeItem('isRefreshToken'); window.location.reload(); - } - }) - .catch((refreshError) => { - dispatch(handleLogoutFunction()); - navigate('/login'); - isRefreshToken = false; - window.location.reload(); - throw refreshError; - }); - } else { - if (error.response.status === 401) { - await dispatch(handleLogoutFunction()); - await navigate('/login'); - await window.location.reload(); + return Promise.reject(refreshError); + }); + } else { + dispatch(handleLogoutFunction()); + navigate('/login'); + window.location.reload(); + } } - if (error.response.status === 500){ - //setIsUploading(false); + if (error.response.status === 500) { + // handle 500 if needed } } diff --git a/src/components/TwoFAModal.js b/src/components/TwoFAModal.js new file mode 100644 index 0000000..59d7a07 --- /dev/null +++ b/src/components/TwoFAModal.js @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; +import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography, Box } from '@mui/material'; +import QRCode from 'qrcode.react'; + +const TwoFAModal = ({ open, mode, qrUrl, onSubmit, onClose, error }) => { + const [code, setCode] = useState(''); + + return ( + + {mode === 'setup' ? 'Setup 2FA' : 'Enter 2FA Code'} + + {mode === 'setup' && qrUrl && ( + + Scan this QR code with Microsoft Authenticator: + + Then enter a code to verify. + + )} + setCode(e.target.value)} + fullWidth + error={!!error} + helperText={error} + /> + + + + + + + ); +}; + +export default TwoFAModal; \ No newline at end of file diff --git a/src/layout/MainLayout/Header/HeaderContent/Profile/index.js b/src/layout/MainLayout/Header/HeaderContent/Profile/index.js index e9e8c68..27cb77d 100644 --- a/src/layout/MainLayout/Header/HeaderContent/Profile/index.js +++ b/src/layout/MainLayout/Header/HeaderContent/Profile/index.js @@ -136,7 +136,9 @@ const Profile = () => { {/**/} - {userData == null ? "" : userData.fullName} + + {userData ? (userData.fullname || userData.name || userData.username || '') : ''} + {/**/} {/* UI/UX Designer*/} {/**/} diff --git a/src/menu-items/index.js b/src/menu-items/index.js index 3428155..3a37046 100644 --- a/src/menu-items/index.js +++ b/src/menu-items/index.js @@ -2,7 +2,7 @@ //import pages from './pages'; import dashboard from './dashboard'; import setting from "./setting"; -//import misc from "./misc"; +import misc from "./misc"; import award from "./award"; import client from "./client"; import appreciation from "./appreciation"; @@ -12,7 +12,7 @@ import appreciation from "./appreciation"; // ==============================|| MENU ITEMS ||============================== // const menuItems = { - items: [client, setting] + items: [client, setting, misc] // items: [dashboard, client, setting] }; // pages, utilities, support, misc diff --git a/src/menu-items/misc.js b/src/menu-items/misc.js index c180897..09c63c6 100644 --- a/src/menu-items/misc.js +++ b/src/menu-items/misc.js @@ -15,6 +15,14 @@ const misc = { title: 'Miscellaneous', type: 'group', children: [ + { + id: 'profile', + title: 'Profile & 2FA', + type: 'item', + url: '/profile', + icon: icons.ProfileOutlined, + breadcrumbs: false + }, { id: 'logout', title: 'Logout', diff --git a/src/pages/authentication/auth-forms/AuthLogin.js b/src/pages/authentication/auth-forms/AuthLogin.js index d438883..55d45a5 100644 --- a/src/pages/authentication/auth-forms/AuthLogin.js +++ b/src/pages/authentication/auth-forms/AuthLogin.js @@ -1,283 +1,317 @@ -import React, {useState} from 'react'; -import {useNavigate} from 'react-router-dom'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; // material-ui import { - Button, - //Checkbox, - //Divider, - //FormControlLabel, - FormHelperText, - Grid, - //Link, - IconButton, - InputAdornment, - InputLabel, - Stack, TextField, - //Typography + Button, + FormHelperText, + Grid, + InputLabel, + Stack, + TextField, + IconButton, + InputAdornment, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + Box } from '@mui/material'; // third party import * as Yup from 'yup'; import { Formik } from 'formik'; -// project import -//import FirebaseSocial from './FirebaseSocial'; +// project imports import AnimateButton from 'components/@extended/AnimateButton'; +import { handleLogin } from 'auth/index'; +import axios from 'axios'; +import { apiPath } from '../../../auth/utils'; +import { LOGIN_PATH } from '../../../utils/ApiPathConst'; +import { LIONER_LOGIN_THEME } from '../../../themes/themeConst'; +import { ThemeProvider } from '@emotion/react'; + // assets import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; -//import axios from "axios"; -import {useDispatch} from "react-redux"; -import {handleLogin} from "auth/index"; -//import useJwt from "../../../auth/jwt/useJwt"; -import axios from "axios"; -import {apiPath} from "../../../auth/utils"; -import {LOGIN_PATH} from "../../../utils/ApiPathConst"; -import {LIONER_LOGIN_THEME} from "../../../themes/themeConst"; -import {ThemeProvider} from "@emotion/react"; -//import {AbilityContext} from "utils/context/Can" -// ============================|| FIREBASE - LOGIN ||============================ // +import { QRCodeSVG } from 'qrcode.react'; + +// ============================|| AUTH LOGIN WITH 2FA ||============================ // const AuthLogin = () => { - //const ability = useContext(AbilityContext) - const dispatch = useDispatch() - const navigate = useNavigate() - //const [checked, setChecked] = useState(false); + const dispatch = useDispatch(); + const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); + const [userName, setUserName] = useState(''); + const [userPassword, setUserPassword] = useState(''); const [errors, setErrors] = useState({}); + + // 2FA States + const [twoFAModalOpen, setTwoFAModalOpen] = useState(false); + const [twoFAMode, setTwoFAMode] = useState('login'); // 'login' or 'setup' + 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 [userName, setUserName] = useState(""); - const [userPassword, setUserPassword] = useState(""); - const handleMouseDownPassword = (event) => { event.preventDefault(); }; + const onUserNameChange = (e) => setUserName(e.target.value); + const onPasswordChange = (e) => setUserPassword(e.target.value); + 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 + }) + .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 = { + id: data.id, + fullName: data.name, + email: data.email, + role: data.role, + abilities: data.abilities, + subDivisionId: data.subDivisionId, + lotusNotesUser: data.lotusNotesUser + }; + + 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); + } + } + }) + .catch((error) => { + const formErrors = {}; + formErrors.passwordError = error.response?.data?.message || 'Invalid credentials'; + setErrors(formErrors); + }); + } + }; + + 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; + } + + 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 + }; + + axios + .post(`${apiPath}${endpoint}`, payload) + .then(async (res) => { + 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(); + } + setTwoFACode(''); + }) + .catch((err) => { + setTwoFAError(err.response?.data?.message || 'Invalid or expired code'); + }); + }; - const formErrors = {}; - - if (userName.length===0) { - formErrors.loginError = 'Username are required.'; - } - - if (userPassword.length===0){ - formErrors.passwordError = 'Password are required.'; - } - - setErrors(formErrors); - - if (Object.keys(formErrors).length === 0) { - axios.post(`${apiPath}${LOGIN_PATH}`, - {username: userName, password: userPassword}, - ) - .then(async (response) => { - const userData = { - id: response.data.id, - fullName: response.data.name, - email: response.data.email, - role: response.data.role, - abilities: response.data.abilities, - subDivisionId: response.data.subDivisionId, - lotusNotesUser: response.data.lotusNotesUser - //avatar: require('src/assets/images/users/avatar-3.png').default, - } - const data = { - ...userData, - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken - } - await dispatch(handleLogin(data)) - //const abilities = response.data.abilities - //ability.update(abilities) - const lastPath = localStorage.getItem('lastVisitedPath'); - - await navigate(lastPath === null ? '/client' : lastPath); - // await navigate(lastPath === null ? '/lionerDashboard' : lastPath); - await window.location.reload(); - await localStorage.removeItem('lastVisitedPath'); - - }) - .catch(error => { - const formErrors = {}; - formErrors.loginError = " "; - formErrors.passwordError = error.response.data.message; - setErrors(formErrors); - - return false; - }); - - /*useJwt - .login({username: userName, password: userPassword}) - .then((response) => { - const userData = { - id: response.data.id, - fullName: response.data.name, - email: response.data.email, - role: response.data.role, - abilities: response.data.abilities, - //avatar: require('src/assets/images/users/avatar-3.png').default, - } - const data = {...userData, accessToken: response.data.accessToken, refreshToken: response.data.refreshToken} - dispatch(handleLogin(data)) - //const abilities = response.data.abilities - //ability.update(abilities) - navigate('/lionerDashboard'); - }) - .catch(error => { - console.log(error); - return Promise.reject(error); - });*/ - } - - } - - const onUserNameChange = (event) => { - setUserName(event.target.value); - } - - const onPasswordChange = (event) => { - setUserPassword(event.target.value); - } return ( <> { - try { - setStatus({ success: false }); - setSubmitting(false); - } catch (err) { - setStatus({ success: false }); - setErrors({ submit: err.message }); - setSubmitting(false); - } - }} > - {({ handleBlur, handleSubmit, isSubmitting /*touched*/ }) => ( -
- + {({ handleBlur }) => ( + + - - - User Name - - {/*{touched.email && errors.email && (*/} - {/* */} - {/* {errors.email}*/} - {/* */} - {/*)}*/} - - - - - Password - + + User Name + + + + + + + Password + - - {showPassword ? : } - - - ), - }} - placeholder="Enter password" - /> - {/*{touched.password && errors.password && (*/} - {/* */} - {/* {errors.password}*/} - {/* */} - {/*)}*/} - - + + + {showPassword ? : } + + + ) + }} + placeholder="Enter password" + /> + + - - - {/* setChecked(event.target.checked)}*/} - {/* name="checked"*/} - {/* color="primary"*/} - {/* size="small"*/} - {/* />*/} - {/* }*/} - {/* label={Keep me sign in}*/} - {/*/>*/} - {/**/} - {/* Forgot Password?*/} - {/**/} - - - {errors.submit && ( - {errors.submit} + + + - )} - - - - - {/**/} - {/* */} - {/* Login with*/} - {/* */} - {/**/} - {/**/} - {/* */} - {/**/} - - +
)}
+ + {/* 2FA Modal */} + setTwoFAModalOpen(false)} maxWidth="sm" fullWidth> + + {twoFAMode === 'setup' ? 'Set Up Two-Factor Authentication' : 'Two-Factor Authentication Required'} + + + {twoFAMode === 'setup' && qrUrl && ( + + + Scan this QR code with Microsoft Authenticator: + + + + After scanning, enter a code from the app to verify. + + + )} + + {twoFAMode === 'login' && ( + + Open Microsoft Authenticator and enter the 6-digit code for your account. + + )} + + setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))} + error={!!twoFAError} + helperText={twoFAError} + inputProps={{ maxLength: 6 }} + sx={{ mt: 2 }} + /> + + + + + + ); }; -export default AuthLogin; +export default AuthLogin; \ No newline at end of file diff --git a/src/pages/profile/profile.js b/src/pages/profile/profile.js new file mode 100644 index 0000000..c426c01 --- /dev/null +++ b/src/pages/profile/profile.js @@ -0,0 +1,127 @@ +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 { + Box, + Button, + Typography, + TextField, + Card, + CardContent, + Alert, + CircularProgress +} from '@mui/material'; + +const Profile = () => { + const [qrUrl, setQrUrl] = useState(''); + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + // Fetch current user or check Redux store for twoFactorEnabled + // Hide "Enable" button if already true + }, []); + + const start2FASetup = async () => { + setLoading(true); + setMessage(''); + setError(''); + try { + const response = await axios.post(`${apiPath}/2fa/setup`, {}, { + headers: { Authorization: `Bearer ${localStorage.getItem('accessToken')}` } // Adjust to your auth method + }); + setQrUrl(response.data.otpauthUrl); + } catch (err) { + setError('Failed to start 2FA setup. Please try again.'); + } finally { + setLoading(false); + } + }; + + const verifyCode = async () => { + setLoading(true); + setMessage(''); + setError(''); + try { + 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.'); + setQrUrl(''); + setCode(''); + } catch (err) { + setError('Invalid or expired code. Try again.'); + } finally { + setLoading(false); + } + }; + + return ( + + + Profile & Security + + + + + + Two-Factor Authentication (2FA) + + + {!qrUrl ? ( + <> + + Add an extra layer of security to your account with 2FA using Microsoft Authenticator. + + + + ) : ( + + + Scan this QR code with Microsoft Authenticator: + + + + After scanning, enter the 6-digit code from the app: + + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + inputProps={{ maxLength: 6 }} + sx={{ width: 200 }} + /> + + + + + )} + + {message && {message}} + {error && {error}} + + + + {/* Add other profile fields here later */} + + ); +}; + +export default Profile; \ No newline at end of file diff --git a/src/routes/ClientRoutes.js b/src/routes/ClientRoutes.js index 393a3b1..e8efc48 100644 --- a/src/routes/ClientRoutes.js +++ b/src/routes/ClientRoutes.js @@ -32,7 +32,7 @@ const ClientRoutes =() => { handleRouteAbility( ability.can('VIEW', 'CLIENT'), , - + ) ), }, diff --git a/src/routes/SettingRoutes.js b/src/routes/SettingRoutes.js index 90a7c10..c47179b 100644 --- a/src/routes/SettingRoutes.js +++ b/src/routes/SettingRoutes.js @@ -28,11 +28,13 @@ const UserActionLogPage = Loadable(lazy(() => import('pages/lionerUserActionLog' const EmailConfigPage = Loadable(lazy(() => import('pages/lionerEmailConfig'))); const GenerateReminderPage = Loadable(lazy(() => import('pages/lionerManualButtonPage'))); const ClientDepartmentPage = Loadable(lazy(() => import('pages/lionerClientDepartmentPage'))); +const ProfilePage = Loadable(lazy(() => import('pages/profile/profile'))); + // ==============================|| AUTH ROUTING ||============================== // const SettingRoutes = () => { const ability = useContext(AbilityContext); - + return { path: '/', element: , @@ -208,6 +210,16 @@ const SettingRoutes = () => { ) ), }, + { + path: 'profile', + element: ( + handleRouteAbility( + true, // Or use a specific ability like ability.can('VIEW', 'PROFILE') + , + + ) + ), + }, { path: 'emailConfig', element: (