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:
@@ -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 (
|
||||||
|
|||||||
@@ -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
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "smatec-frontend",
|
"name": "SMATEC-FRONTEND",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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ọ.',
|
||||||
};
|
};
|
||||||
|
|||||||
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal file
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal 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;
|
||||||
68
src/pages/Auth/components/LoginForm.tsx
Normal file
68
src/pages/Auth/components/LoginForm.tsx
Normal 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;
|
||||||
48
src/pages/Auth/components/OtpForm.tsx
Normal file
48
src/pages/Auth/components/OtpForm.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal file
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
4
src/services/master/typings/auth.d.ts
vendored
4
src/services/master/typings/auth.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
11
src/services/master/typings/user.d.ts
vendored
11
src/services/master/typings/user.d.ts
vendored
@@ -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
0
update-iconfont.sh
Normal file → Executable file
Reference in New Issue
Block a user