feat: Implement Two-Factor Authentication (2FA) with new OTP and login forms, updated API endpoints, and manager functionality to disable 2FA.

This commit is contained in:
2026-02-07 08:24:13 +07:00
parent a011405d92
commit 155101491b
22 changed files with 803 additions and 144 deletions

View File

@@ -159,17 +159,21 @@ export const handleRequestConfig: RequestConfig = {
); );
} }
// Rebuild request options from config
const originalConfig = response.config;
const newOptions = { const newOptions = {
...options, method: originalConfig.method,
headers: { headers: {
...(options.headers || {}), ...(originalConfig.headers || {}),
Authorization: `${newToken}`, Authorization: `${newToken}`,
}, },
data: originalConfig.data,
params: originalConfig.params,
skipAuthRefresh: true, skipAuthRefresh: true,
}; };
// Gọi lại request gốc với accessToken mới // Gọi lại request gốc với accessToken mới
return request(response.url, newOptions); return request(originalConfig.url, newOptions);
} }
if ( if (

View File

@@ -149,17 +149,21 @@ export const handleRequestConfig: RequestConfig = {
); );
} }
// Rebuild request options from config
const originalConfig = response.config;
const newOptions = { const newOptions = {
...options, method: originalConfig.method,
headers: { headers: {
...(options.headers || {}), ...(originalConfig.headers || {}),
Authorization: `${newToken}`, Authorization: `${newToken}`,
}, },
data: originalConfig.data,
params: originalConfig.params,
skipAuthRefresh: true, skipAuthRefresh: true,
}; };
// Gọi lại request gốc với accessToken mới // Gọi lại request gốc với accessToken mới
return request(response.url, newOptions); return request(originalConfig.url, newOptions);
} }
if ( if (

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "smatec-frontend", "name": "SMATEC-FRONTEND",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,9 +1,16 @@
// Auth API Paths // Auth API Paths
export const API_PATH_LOGIN = '/api/tokens'; export const API_PATH_LOGIN = '/api/login';
export const API_PATH_LOGIN_2FA = '/api/login/2fa';
export const API_PATH_LOGOUT = '/api/logout';
export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh'; export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh';
export const API_PATH_GET_PROFILE = '/api/users/profile'; export const API_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password'; export const API_CHANGE_PASSWORD = '/api/password';
export const API_FORGOT_PASSWORD = '/api/password/reset-request'; export const API_FORGOT_PASSWORD = '/api/password/reset-request';
export const API_ENABLE_2FA = '/api/users/2fa/enable';
export const API_ENABLE_2FA_VERIFY = '/api/users/2fa/verify';
export const API_DISABLE_2FA = '/api/users/2fa/disable';
export const API_ADMIN_DISABLE_2FA = '/api/2fa/disable/';
// Alarm API Constants // Alarm API Constants
export const API_ALARMS = '/api/alarms'; export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm'; export const API_ALARMS_CONFIRM = '/api/alarms/confirm';

View File

@@ -5,10 +5,6 @@ export default {
'master.auth.validation.email': 'Email is required', 'master.auth.validation.email': 'Email is required',
'master.auth.password': 'Password', 'master.auth.password': 'Password',
'master.auth.validation.password': 'Password is required', 'master.auth.validation.password': 'Password is required',
'master.auth.login.subtitle': 'Ship Monitoring System',
'master.auth.login.description': 'Login to continue monitoring vessels',
'master.auth.login.invalid': 'Invalid username or password',
'master.auth.login.success': 'Login successful',
'master.auth.logout.title': 'Logout', 'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?', 'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful', 'master.auth.logout.success': 'Logout successful',
@@ -18,6 +14,11 @@ export default {
'master.auth.forgot.message.success': 'master.auth.forgot.message.success':
'Request sent successfully, please check your email!', 'Request sent successfully, please check your email!',
'master.auth.forgot.message.fail': 'Request failed, please try again later!', 'master.auth.forgot.message.fail': 'Request failed, please try again later!',
'master.auth.otp.error': 'Invalid OTP code. Please try again.',
'master.auth.otp.button.title': 'Verify OTP',
'master.auth.otp.placeholder': 'Enter OTP code',
'master.auth.otp.required': 'OTP code is required',
'master.auth.otp.length': 'OTP must be 6 digits',
'master.auth.reset.success': 'Password reset successful', 'master.auth.reset.success': 'Password reset successful',
'master.auth.reset.error': 'An error occurred, please try again later!', 'master.auth.reset.error': 'An error occurred, please try again later!',
'master.auth.reset.invalid': 'master.auth.reset.invalid':

View File

@@ -17,4 +17,28 @@ export default {
'master.profile.change-password.fail': 'Change password failed', 'master.profile.change-password.fail': 'Change password failed',
'master.profile.change-password.password.strong': 'master.profile.change-password.password.strong':
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !', 'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
'master.profile.2fa.status': '2FA Status',
'master.profile.2fa.description':
'Enable two-step verification to enhance the security of your account',
'master.profile.2fa.enabled': 'Enabled',
'master.profile.2fa.disabled': 'Disabled',
'master.profile.2fa.setup.title': 'Set up two-step verification',
'master.profile.2fa.verify': 'Confirm',
'master.profile.2fa.cancel': 'Cancel',
'master.profile.2fa.scan.instruction':
'Scan the QR code with an authentication app (Google Authenticator, Authy, ...)',
'master.profile.2fa.otp.instruction':
'Enter the 6-digit code from the authentication app:',
'master.profile.2fa.enable.error': 'Unable to enable 2FA. Please try again.',
'master.profile.2fa.otp.invalid': 'Please enter a 6-digit OTP code',
'master.profile.2fa.enable.success': '2FA enabled successfully!',
'master.profile.2fa.verify.error': 'Invalid OTP code. Please try again.',
'master.profile.2fa.disable.confirm.title': 'Confirm disable 2FA',
'master.profile.2fa.disable.confirm.content':
'Are you sure you want to disable two-step verification? This will reduce the security of your account.',
'master.profile.2fa.disable.confirm.ok': 'Disable 2FA',
'master.profile.2fa.disable.confirm.cancel': 'Cancel',
'master.profile.2fa.disable.success': '2FA disabled successfully!',
'master.profile.2fa.disable.error':
'Unable to disable 2FA. Please try again.',
}; };

View File

@@ -74,4 +74,12 @@ export default {
'master.users.resetPassword.modal.title': 'Reset Password For User', 'master.users.resetPassword.modal.title': 'Reset Password For User',
'master.users.resetPassword.success': 'Password reset successful', 'master.users.resetPassword.success': 'Password reset successful',
'master.users.resetPassword.error': 'Password reset failed', 'master.users.resetPassword.error': 'Password reset failed',
'master.users.disable2fa.title': 'Disable 2FA',
'master.users.disable2fa.success': '2FA has been disabled successfully',
'master.users.disable2fa.error': 'Failed to disable 2FA',
'master.users.disable2fa.modal.title': 'Disable Two-Factor Authentication',
'master.users.disable2fa.modal.warning':
'Are you sure you want to disable 2FA for this user?',
'master.users.disable2fa.modal.caution':
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
}; };

View File

@@ -3,9 +3,6 @@ export default {
'master.auth.login.email': 'Email', 'master.auth.login.email': 'Email',
'master.auth.login.title': 'Đăng nhập', 'master.auth.login.title': 'Đăng nhập',
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá', 'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
'master.auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
'master.auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
'master.auth.login.success': 'Đăng nhập thành công',
'master.auth.logout.title': 'Đăng xuất', 'master.auth.logout.title': 'Đăng xuất',
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?', 'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
'master.auth.logout.success': 'Đăng xuất thành công', 'master.auth.logout.success': 'Đăng xuất thành công',
@@ -19,6 +16,11 @@ export default {
'Gửi yêu cầu thành công, vui lòng kiểm tra email của bạn!', 'Gửi yêu cầu thành công, vui lòng kiểm tra email của bạn!',
'master.auth.forgot.message.fail': 'master.auth.forgot.message.fail':
'Gửi yêu cầu thất bại, vui lòng thử lại sau!', 'Gửi yêu cầu thất bại, vui lòng thử lại sau!',
'master.auth.otp.error': 'Mã OTP không hợp lệ. Vui lòng thử lại.',
'master.auth.otp.button.title': 'Xác thực OTP',
'master.auth.otp.placeholder': 'Nhập mã OTP',
'master.auth.otp.required': 'Vui lòng nhập mã OTP',
'master.auth.otp.length': 'Mã OTP phải có 6 chữ số',
'master.auth.reset.success': 'Đặt lại mật khẩu thành công', 'master.auth.reset.success': 'Đặt lại mật khẩu thành công',
'master.auth.reset.error': 'Có lỗi xảy ra, vui lòng thử lại sau!', 'master.auth.reset.error': 'Có lỗi xảy ra, vui lòng thử lại sau!',
'master.auth.reset.invalid': 'master.auth.reset.invalid':

View File

@@ -17,4 +17,26 @@ export default {
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại', 'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
'master.profile.change-password.success': 'Đổi mật khẩu thành công', 'master.profile.change-password.success': 'Đổi mật khẩu thành công',
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại', 'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
'master.profile.2fa.status': 'Trạng thái 2FA',
'master.profile.2fa.description':
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
'master.profile.2fa.enabled': 'Bật',
'master.profile.2fa.disabled': 'Tắt',
'master.profile.2fa.setup.title': 'Thiết lập xác thực 2 bước',
'master.profile.2fa.verify': 'Xác nhận',
'master.profile.2fa.cancel': 'Hủy',
'master.profile.2fa.scan.instruction':
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
'master.profile.2fa.otp.instruction': 'Nhập mã 6 số từ ứng dụng xác thực:',
'master.profile.2fa.enable.error': 'Không thể bật 2FA. Vui lòng thử lại.',
'master.profile.2fa.otp.invalid': 'Vui lòng nhập mã OTP 6 số',
'master.profile.2fa.enable.success': 'Bật 2FA thành công!',
'master.profile.2fa.verify.error': 'Mã OTP không đúng. Vui lòng thử lại.',
'master.profile.2fa.disable.confirm.title': 'Xác nhận tắt 2FA',
'master.profile.2fa.disable.confirm.content':
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
'master.profile.2fa.disable.confirm.ok': 'Tắt 2FA',
'master.profile.2fa.disable.confirm.cancel': 'Hủy',
'master.profile.2fa.disable.success': 'Đã tắt 2FA thành công!',
'master.profile.2fa.disable.error': 'Không thể tắt 2FA. Vui lòng thử lại.',
}; };

View File

@@ -73,4 +73,12 @@ export default {
'master.users.resetPassword.modal.title': 'Đặt lại mật khẩu cho người dùng', 'master.users.resetPassword.modal.title': 'Đặt lại mật khẩu cho người dùng',
'master.users.resetPassword.success': 'Đặt lại mật khẩu thành công', 'master.users.resetPassword.success': 'Đặt lại mật khẩu thành công',
'master.users.resetPassword.error': 'Đặt lại mật khẩu thất bại', 'master.users.resetPassword.error': 'Đặt lại mật khẩu thất bại',
'master.users.disable2fa.title': 'Tắt 2FA',
'master.users.disable2fa.success': 'Đã tắt 2FA thành công',
'master.users.disable2fa.error': 'Tắt 2FA thất bại',
'master.users.disable2fa.modal.title': 'Tắt xác thực hai yếu tố',
'master.users.disable2fa.modal.warning':
'Bạn có chắc chắn muốn tắt 2FA cho người dùng này không?',
'master.users.disable2fa.modal.caution':
'Cảnh báo: Việc tắt 2FA sẽ làm giảm bảo mật tài khoản. Người dùng sẽ cần thiết lập lại 2FA từ cài đặt hồ sơ của họ.',
}; };

View File

@@ -0,0 +1,47 @@
import { UserOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const ForgotPasswordForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.validation.email"
defaultMessage="Email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
</>
);
};
export default ForgotPasswordForm;

View File

@@ -0,0 +1,68 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const LoginForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.validation.email"
defaultMessage="Email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
);
};
export default LoginForm;

View File

@@ -0,0 +1,48 @@
import { LockOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const OtpForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="otp"
fieldProps={{
autoComplete: 'one-time-code',
autoFocus: true,
size: 'large',
maxLength: 6,
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.otp.placeholder',
defaultMessage: 'Enter OTP code',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.otp.required"
defaultMessage="OTP code is required"
/>
),
},
{
len: 6,
message: (
<FormattedMessage
id="master.auth.otp.length"
defaultMessage="OTP must be 6 digits"
/>
),
},
]}
/>
</>
);
};
export default OtpForm;

View File

@@ -6,6 +6,7 @@ import { ROUTER_HOME } from '@/constants/routes';
import { import {
apiForgotPassword, apiForgotPassword,
apiLogin, apiLogin,
apiLogin2FA,
apiQueryProfile, apiQueryProfile,
} from '@/services/master/AuthController'; } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt'; import { checkRefreshTokenExpired } from '@/utils/jwt';
@@ -18,29 +19,32 @@ import {
setAccessToken, setAccessToken,
setRefreshToken, setRefreshToken,
} from '@/utils/storage'; } from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { LoginFormPage } from '@ant-design/pro-components';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max'; import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd'; import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
import { CSSProperties, useEffect, useState } from 'react'; import { CSSProperties, useEffect, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png'; import mobifontLogo from '../../../public/mobifont-logo.png';
type LoginType = 'login' | 'forgot'; import ForgotPasswordForm from './components/ForgotPasswordForm';
import LoginForm from './components/LoginForm';
import OtpForm from './components/OtpForm';
type LoginType = 'login' | 'forgot' | 'otp';
// Form wrapper with animation // Form wrapper with animation
const FormWrapper = ({ const FormWrapper = ({
children, children,
key, animationKey,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
key: string; animationKey: string;
}) => { }) => {
const style: CSSProperties = { const style: CSSProperties = {
animation: 'fadeInSlide 0.4s ease-out forwards', animation: 'fadeInSlide 0.4s ease-out forwards',
}; };
return ( return (
<div key={key} style={style}> <div key={animationKey} style={style}>
<style>{` <style>{`
@keyframes fadeInSlide { @keyframes fadeInSlide {
from { from {
@@ -69,6 +73,7 @@ const LoginPage = () => {
const intl = useIntl(); const intl = useIntl();
const { setInitialState } = useModel('@@initialState'); const { setInitialState } = useModel('@@initialState');
const [loginType, setLoginType] = useState<LoginType>('login'); const [loginType, setLoginType] = useState<LoginType>('login');
const [pending2faToken, setPending2faToken] = useState<string>('');
// Listen for theme changes from ThemeSwitcherAuth // Listen for theme changes from ThemeSwitcherAuth
useEffect(() => { useEffect(() => {
@@ -85,6 +90,7 @@ const LoginPage = () => {
); );
}; };
}, []); }, []);
const checkLogin = async () => { const checkLogin = async () => {
const refreshToken = getRefreshToken(); const refreshToken = getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
@@ -117,7 +123,7 @@ const LoginPage = () => {
checkLogin(); checkLogin();
}, []); }, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => { const handleLogin = async (values: any) => {
const { email, password } = values; const { email, password } = values;
if (loginType === 'login') { if (loginType === 'login') {
try { try {
@@ -126,9 +132,16 @@ const LoginPage = () => {
email, email,
password, password,
}); });
// Check if 2FA is enabled
if (resp?.enabled2fa && resp?.token) {
// Save pending 2FA token and switch to OTP form
setPending2faToken(resp.token);
setLoginType('otp');
return;
}
if (resp?.token) { if (resp?.token) {
setAccessToken(resp.token); setAccessToken(resp.token);
setRefreshToken(resp.refresh_token); setRefreshToken(resp.refresh_token || '');
const userInfo = await apiQueryProfile(); const userInfo = await apiQueryProfile();
if (userInfo) { if (userInfo) {
flushSync(() => { flushSync(() => {
@@ -147,6 +160,39 @@ const LoginPage = () => {
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
} }
} else if (loginType === 'otp') {
// Handle OTP verification
try {
const resp = await apiLogin2FA(pending2faToken, {
otp: values.otp || '',
});
if (resp?.token) {
setAccessToken(resp.token);
setRefreshToken(resp.refresh_token || '');
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
}
}
} catch (error) {
console.error('2FA verification error:', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.otp.error',
defaultMessage: 'Invalid OTP code. Please try again.',
}),
);
}
} else { } else {
try { try {
const host = window.location.origin; const host = window.location.origin;
@@ -176,6 +222,30 @@ const LoginPage = () => {
} }
}; };
const getSubmitText = () => {
if (loginType === 'login') {
return intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
});
}
if (loginType === 'otp') {
return intl.formatMessage({
id: 'master.auth.otp.button.title',
defaultMessage: 'Verify OTP',
});
}
return intl.formatMessage({
id: 'master.auth.forgot.button.title',
defaultMessage: 'Send Reset Link',
});
};
const handleBackToLogin = () => {
setPending2faToken('');
setLoginType('login');
};
return ( return (
<ConfigProvider <ConfigProvider
theme={{ theme={{
@@ -208,125 +278,15 @@ const LoginPage = () => {
subTitle={<Image preview={false} src={mobifontLogo} />} subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{ submitter={{
searchConfig: { searchConfig: {
submitText: submitText: getSubmitText(),
loginType === 'login'
? intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
})
: intl.formatMessage({
id: 'master.auth.forgot.button.title',
defaultMessage: 'Đăng nhập',
}),
}, },
}} }}
onFinish={async (values: MasterModel.LoginRequestBody) => onFinish={async (values: any) => handleLogin(values)}
handleLogin(values)
}
> >
<FormWrapper key={loginType}> <FormWrapper animationKey={loginType}>
{loginType === 'login' && ( {loginType === 'login' && <LoginForm />}
<> {loginType === 'forgot' && <ForgotPasswordForm />}
<ProFormText {loginType === 'otp' && <OtpForm />}
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: (
<UserOutlined
// style={{
// color: token.colorText,
// }}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
)}
{loginType === 'forgot' && (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
</>
)}
</FormWrapper> </FormWrapper>
<Flex <Flex
justify="flex-end" justify="flex-end"
@@ -340,7 +300,7 @@ const LoginPage = () => {
if (loginType === 'login') { if (loginType === 'login') {
setLoginType('forgot'); setLoginType('forgot');
} else { } else {
setLoginType('login'); handleBackToLogin();
} }
}} }}
> >
@@ -377,4 +337,5 @@ const LoginPage = () => {
</ConfigProvider> </ConfigProvider>
); );
}; };
export default LoginPage; export default LoginPage;

View File

@@ -0,0 +1,136 @@
import IconFont from '@/components/IconFont';
import { apiAdminDisable2FA } from '@/services/master/AuthController';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Modal, Tooltip } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useState } from 'react';
interface Disable2FAProps {
user: MasterModel.UserResponse;
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
}
const Disable2FA = ({ user, message, onSuccess }: Disable2FAProps) => {
const intl = useIntl();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Only show if user has 2FA enabled
if (!user.enable_2fa) {
return null;
}
const handleConfirmDisable = async () => {
if (!user.id) return;
setIsLoading(true);
try {
await apiAdminDisable2FA(user.id);
message.success(
intl.formatMessage({
id: 'master.users.disable2fa.success',
defaultMessage: '2FA has been disabled successfully',
}),
);
setIsModalOpen(false);
onSuccess?.(true);
} catch (error) {
console.error('Disable 2FA error:', error);
message.error(
intl.formatMessage({
id: 'master.users.disable2fa.error',
defaultMessage: 'Failed to disable 2FA',
}),
);
onSuccess?.(false);
} finally {
setIsLoading(false);
}
};
return (
<>
<Tooltip
title={intl.formatMessage({
id: 'master.users.disable2fa.title',
defaultMessage: 'Disable 2FA',
})}
>
<Button
shape="default"
size="small"
danger
icon={<IconFont type="icon-security" />}
onClick={() => setIsModalOpen(true)}
style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
/>
</Tooltip>
<Modal
title={
<span style={{ color: '#ff4d4f' }}>
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
{intl.formatMessage({
id: 'master.users.disable2fa.modal.title',
defaultMessage: 'Disable Two-Factor Authentication',
})}
</span>
}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={[
<Button key="cancel" onClick={() => setIsModalOpen(false)}>
{intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Cancel',
})}
</Button>,
<Button
key="confirm"
type="primary"
danger
loading={isLoading}
onClick={handleConfirmDisable}
>
{intl.formatMessage({
id: 'common.confirm',
defaultMessage: 'Confirm',
})}
</Button>,
]}
>
<div style={{ marginBottom: 16 }}>
<p>
{intl.formatMessage({
id: 'master.users.disable2fa.modal.warning',
defaultMessage:
'Are you sure you want to disable 2FA for this user?',
})}
</p>
<p style={{ fontWeight: 'bold' }}>{user.email}</p>
</div>
<div
style={{
backgroundColor: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 6,
padding: 12,
}}
>
<p style={{ color: '#ff4d4f', margin: 0 }}>
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
{intl.formatMessage({
id: 'master.users.disable2fa.modal.caution',
defaultMessage:
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
})}
</p>
</div>
</Modal>
</>
);
};
export default Disable2FA;

View File

@@ -24,6 +24,7 @@ import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph'; import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser'; import CreateUser from './components/CreateUser';
import Disable2FA from './components/Disable2FA';
import ResetPassword from './components/ResetPassword'; import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = { type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null; user: MasterModel.UserResponse | null;
@@ -158,6 +159,13 @@ const ManagerUserPage = () => {
})} })}
onClick={() => handleClickResetPassword(user)} onClick={() => handleClickResetPassword(user)}
/> />
<Disable2FA
user={user}
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) actionRef.current?.reload();
}}
/>
</Space> </Space>
); );
}, },

View File

@@ -1,5 +1,257 @@
import {
apiDisable2FA,
apiEnable2FA,
apiVerify2FA,
} from '@/services/master/AuthController';
import { useIntl, useModel } from '@umijs/max';
import {
Card,
Input,
message,
Modal,
QRCode,
Space,
Switch,
Typography,
} from 'antd';
import { useState } from 'react';
const { Text, Title } = Typography;
const TwoFactorAuthentication = () => { const TwoFactorAuthentication = () => {
return <div>TwoFactorAuthentication</div>; const intl = useIntl();
const { initialState, refresh } = useModel('@@initialState');
const [modal, contextHolder] = Modal.useModal();
const profile = initialState?.currentUserProfile;
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [qrData, setQrData] = useState<MasterModel.Enable2FAResponse | null>(
null,
);
const [otpCode, setOtpCode] = useState('');
const [verifying, setVerifying] = useState(false);
const handleEnable2FA = async () => {
setLoading(true);
try {
const response = await apiEnable2FA();
if (response?.data) {
setQrData(response.data);
setModalVisible(true);
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.enable.error',
defaultMessage: 'Không thể bật 2FA. Vui lòng thử lại.',
}),
);
} finally {
setLoading(false);
}
};
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length !== 6) {
message.warning(
intl.formatMessage({
id: 'master.profile.2fa.otp.invalid',
defaultMessage: 'Vui lòng nhập mã OTP 6 số',
}),
);
return;
}
setVerifying(true);
try {
const response = await apiVerify2FA({ otp: otpCode });
if (response?.status === 200 || response?.status === 201) {
message.success(
intl.formatMessage({
id: 'master.profile.2fa.enable.success',
defaultMessage: 'Bật 2FA thành công!',
}),
);
setModalVisible(false);
setOtpCode('');
setQrData(null);
refresh();
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.verify.error',
defaultMessage: 'Mã OTP không đúng. Vui lòng thử lại.',
}),
);
} finally {
setVerifying(false);
}
};
const handleDisable2FA = async () => {
modal.confirm({
title: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.title',
defaultMessage: 'Xác nhận tắt 2FA',
}),
content: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.content',
defaultMessage:
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
}),
okText: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.ok',
defaultMessage: 'Tắt 2FA',
}),
cancelText: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.cancel',
defaultMessage: 'Hủy',
}),
okButtonProps: { danger: true },
onOk: async () => {
try {
const response = await apiDisable2FA();
if (response?.status === 200 || response?.status === 204) {
message.success(
intl.formatMessage({
id: 'master.profile.2fa.disable.success',
defaultMessage: 'Đã tắt 2FA thành công!',
}),
);
refresh();
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.disable.error',
defaultMessage: 'Không thể tắt 2FA. Vui lòng thử lại.',
}),
);
}
},
});
};
const handleToggle2FA = (checked: boolean) => {
if (checked) {
handleEnable2FA();
} else {
handleDisable2FA();
}
};
const handleModalCancel = () => {
setModalVisible(false);
setOtpCode('');
setQrData(null);
};
return (
<>
{contextHolder}
<Card>
<Title level={4}>
{intl.formatMessage({
id: 'master.profile.two-factor-authentication',
})}
</Title>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 16,
}}
>
<div>
<Text strong>
{intl.formatMessage({
id: 'master.profile.2fa.status',
defaultMessage: 'Trạng thái 2FA',
})}
</Text>
<br />
<Text type="secondary">
{intl.formatMessage({
id: 'master.profile.2fa.description',
defaultMessage:
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
})}
</Text>
</div>
<Switch
checked={profile?.enable_2fa}
onChange={handleToggle2FA}
loading={loading}
checkedChildren={intl.formatMessage({
id: 'master.profile.2fa.enabled',
defaultMessage: 'Bật',
})}
unCheckedChildren={intl.formatMessage({
id: 'master.profile.2fa.disabled',
defaultMessage: 'Tắt',
})}
/>
</div>
</Card>
<Modal
title={intl.formatMessage({
id: 'master.profile.2fa.setup.title',
defaultMessage: 'Thiết lập xác thực 2 bước',
})}
open={modalVisible}
onOk={handleVerifyOTP}
onCancel={handleModalCancel}
confirmLoading={verifying}
okText={intl.formatMessage({
id: 'master.profile.2fa.verify',
defaultMessage: 'Xác nhận',
})}
cancelText={intl.formatMessage({
id: 'master.profile.2fa.cancel',
defaultMessage: 'Hủy',
})}
>
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Text>
{intl.formatMessage({
id: 'master.profile.2fa.scan.instruction',
defaultMessage:
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
})}
</Text>
{qrData?.qr_code_base64 ? (
<img
src={`data:image/png;base64,${qrData.qr_code_base64}`}
alt="QR Code"
style={{ width: 200, height: 200 }}
/>
) : qrData?.otp_auth_url ? (
<QRCode value={qrData.otp_auth_url} size={200} />
) : null}
<Text type="secondary" style={{ marginTop: 16 }}>
{intl.formatMessage({
id: 'master.profile.2fa.otp.instruction',
defaultMessage: 'Nhập mã 6 số từ ứng dụng xác thực:',
})}
</Text>
<Input.OTP
length={6}
value={otpCode}
onChange={setOtpCode}
style={{ marginTop: 8 }}
/>
</Space>
</Modal>
</>
);
}; };
export default TwoFactorAuthentication; export default TwoFactorAuthentication;

View File

@@ -30,7 +30,7 @@ const ProfilePage = () => {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'master.profile.two-factor-authentication', id: 'master.profile.two-factor-authentication',
}), }),
disabled: true, // disabled: true,
}, },
]; ];
const handleMenuSelect = (e: { key: string }) => { const handleMenuSelect = (e: { key: string }) => {

View File

@@ -1,8 +1,13 @@
import { import {
API_ADMIN_DISABLE_2FA,
API_CHANGE_PASSWORD, API_CHANGE_PASSWORD,
API_DISABLE_2FA,
API_ENABLE_2FA,
API_ENABLE_2FA_VERIFY,
API_FORGOT_PASSWORD, API_FORGOT_PASSWORD,
API_PATH_GET_PROFILE, API_PATH_GET_PROFILE,
API_PATH_LOGIN, API_PATH_LOGIN,
API_PATH_LOGIN_2FA,
API_PATH_REFRESH_TOKEN, API_PATH_REFRESH_TOKEN,
API_USERS, API_USERS,
} from '@/constants/api'; } from '@/constants/api';
@@ -59,3 +64,46 @@ export async function apiForgotPassword(
data: body, data: body,
}); });
} }
// 2FA
export async function apiLogin2FA(
token: string,
body: MasterModel.Verify2FARequestBody,
) {
return request<MasterModel.LoginResponse>(API_PATH_LOGIN_2FA, {
method: 'POST',
data: body,
headers: {
Authorization: token,
},
});
}
export async function apiEnable2FA() {
return request<MasterModel.Enable2FAResponse>(API_ENABLE_2FA, {
method: 'GET',
getResponse: true,
});
}
export async function apiVerify2FA(body: MasterModel.Verify2FARequestBody) {
return request(API_ENABLE_2FA_VERIFY, {
method: 'POST',
data: body,
getResponse: true,
});
}
export async function apiDisable2FA() {
return request(API_DISABLE_2FA, {
method: 'PUT',
getResponse: true,
});
}
export async function apiAdminDisable2FA(userId: string) {
return request(API_ADMIN_DISABLE_2FA + userId, {
method: 'PUT',
getResponse: true,
});
}

View File

@@ -7,8 +7,8 @@ declare namespace MasterModel {
interface LoginResponse { interface LoginResponse {
token?: string; token?: string;
refresh_token: string; refresh_token?: string;
enabled_2fa: boolean; enabled2fa?: boolean;
} }
interface RefreshTokenRequestBody { interface RefreshTokenRequestBody {
refresh_token: string; refresh_token: string;

View File

@@ -12,6 +12,7 @@ declare namespace MasterModel {
interface UserResponse { interface UserResponse {
id?: string; id?: string;
email?: string; email?: string;
enable_2fa?: boolean;
metadata?: UserMetadata; metadata?: UserMetadata;
} }
@@ -46,4 +47,14 @@ declare namespace MasterModel {
password: string; password: string;
confirm_password?: string; confirm_password?: string;
} }
// 2FA
interface Enable2FAResponse {
otp_auth_url?: string;
qr_code_base64?: string;
}
interface Verify2FARequestBody {
otp?: string;
}
} }

0
update-iconfont.sh Normal file → Executable file
View File