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 (
+
+ );
+};
+
+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*/ }) => (
-
+
-
-
- {/* 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 */}
+
>
);
};
-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: (