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 = {
|
||||
...options,
|
||||
method: originalConfig.method,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...(originalConfig.headers || {}),
|
||||
Authorization: `${newToken}`,
|
||||
},
|
||||
data: originalConfig.data,
|
||||
params: originalConfig.params,
|
||||
skipAuthRefresh: true,
|
||||
};
|
||||
|
||||
// Gọi lại request gốc với accessToken mới
|
||||
return request(response.url, newOptions);
|
||||
return request(originalConfig.url, newOptions);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -149,17 +149,21 @@ export const handleRequestConfig: RequestConfig = {
|
||||
);
|
||||
}
|
||||
|
||||
// Rebuild request options from config
|
||||
const originalConfig = response.config;
|
||||
const newOptions = {
|
||||
...options,
|
||||
method: originalConfig.method,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...(originalConfig.headers || {}),
|
||||
Authorization: `${newToken}`,
|
||||
},
|
||||
data: originalConfig.data,
|
||||
params: originalConfig.params,
|
||||
skipAuthRefresh: true,
|
||||
};
|
||||
|
||||
// Gọi lại request gốc với accessToken mới
|
||||
return request(response.url, newOptions);
|
||||
return request(originalConfig.url, newOptions);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "smatec-frontend",
|
||||
"name": "SMATEC-FRONTEND",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// 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_GET_PROFILE = '/api/users/profile';
|
||||
export const API_CHANGE_PASSWORD = '/api/password';
|
||||
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
|
||||
export const API_ALARMS = '/api/alarms';
|
||||
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
|
||||
|
||||
@@ -5,10 +5,6 @@ export default {
|
||||
'master.auth.validation.email': 'Email is required',
|
||||
'master.auth.password': 'Password',
|
||||
'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.confirm': 'Are you sure you want to logout?',
|
||||
'master.auth.logout.success': 'Logout successful',
|
||||
@@ -18,6 +14,11 @@ export default {
|
||||
'master.auth.forgot.message.success':
|
||||
'Request sent successfully, please check your email!',
|
||||
'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.error': 'An error occurred, please try again later!',
|
||||
'master.auth.reset.invalid':
|
||||
|
||||
@@ -17,4 +17,28 @@ export default {
|
||||
'master.profile.change-password.fail': 'Change password failed',
|
||||
'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 #, ?, !',
|
||||
'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.success': 'Password reset successful',
|
||||
'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.title': 'Đăng nhập',
|
||||
'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.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
|
||||
'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!',
|
||||
'master.auth.forgot.message.fail':
|
||||
'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.error': 'Có lỗi xảy ra, vui lòng thử lại sau!',
|
||||
'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-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.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.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.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 {
|
||||
apiForgotPassword,
|
||||
apiLogin,
|
||||
apiLogin2FA,
|
||||
apiQueryProfile,
|
||||
} from '@/services/master/AuthController';
|
||||
import { checkRefreshTokenExpired } from '@/utils/jwt';
|
||||
@@ -18,29 +19,32 @@ import {
|
||||
setAccessToken,
|
||||
setRefreshToken,
|
||||
} from '@/utils/storage';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
||||
import { LoginFormPage } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
|
||||
import { CSSProperties, useEffect, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
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
|
||||
const FormWrapper = ({
|
||||
children,
|
||||
key,
|
||||
animationKey,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
key: string;
|
||||
animationKey: string;
|
||||
}) => {
|
||||
const style: CSSProperties = {
|
||||
animation: 'fadeInSlide 0.4s ease-out forwards',
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<div key={animationKey} style={style}>
|
||||
<style>{`
|
||||
@keyframes fadeInSlide {
|
||||
from {
|
||||
@@ -69,6 +73,7 @@ const LoginPage = () => {
|
||||
const intl = useIntl();
|
||||
const { setInitialState } = useModel('@@initialState');
|
||||
const [loginType, setLoginType] = useState<LoginType>('login');
|
||||
const [pending2faToken, setPending2faToken] = useState<string>('');
|
||||
|
||||
// Listen for theme changes from ThemeSwitcherAuth
|
||||
useEffect(() => {
|
||||
@@ -85,6 +90,7 @@ const LoginPage = () => {
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkLogin = async () => {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
@@ -117,7 +123,7 @@ const LoginPage = () => {
|
||||
checkLogin();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
|
||||
const handleLogin = async (values: any) => {
|
||||
const { email, password } = values;
|
||||
if (loginType === 'login') {
|
||||
try {
|
||||
@@ -126,9 +132,16 @@ const LoginPage = () => {
|
||||
email,
|
||||
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) {
|
||||
setAccessToken(resp.token);
|
||||
setRefreshToken(resp.refresh_token);
|
||||
setRefreshToken(resp.refresh_token || '');
|
||||
const userInfo = await apiQueryProfile();
|
||||
if (userInfo) {
|
||||
flushSync(() => {
|
||||
@@ -147,6 +160,39 @@ const LoginPage = () => {
|
||||
} catch (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 {
|
||||
try {
|
||||
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 (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@@ -208,125 +278,15 @@ const LoginPage = () => {
|
||||
subTitle={<Image preview={false} src={mobifontLogo} />}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText:
|
||||
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',
|
||||
}),
|
||||
submitText: getSubmitText(),
|
||||
},
|
||||
}}
|
||||
onFinish={async (values: MasterModel.LoginRequestBody) =>
|
||||
handleLogin(values)
|
||||
}
|
||||
onFinish={async (values: any) => handleLogin(values)}
|
||||
>
|
||||
<FormWrapper key={loginType}>
|
||||
{loginType === 'login' && (
|
||||
<>
|
||||
<ProFormText
|
||||
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 animationKey={loginType}>
|
||||
{loginType === 'login' && <LoginForm />}
|
||||
{loginType === 'forgot' && <ForgotPasswordForm />}
|
||||
{loginType === 'otp' && <OtpForm />}
|
||||
</FormWrapper>
|
||||
<Flex
|
||||
justify="flex-end"
|
||||
@@ -340,7 +300,7 @@ const LoginPage = () => {
|
||||
if (loginType === 'login') {
|
||||
setLoginType('forgot');
|
||||
} else {
|
||||
setLoginType('login');
|
||||
handleBackToLogin();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -377,4 +337,5 @@ const LoginPage = () => {
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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 { useRef, useState } from 'react';
|
||||
import CreateUser from './components/CreateUser';
|
||||
import Disable2FA from './components/Disable2FA';
|
||||
import ResetPassword from './components/ResetPassword';
|
||||
type ResetUserPaswordProps = {
|
||||
user: MasterModel.UserResponse | null;
|
||||
@@ -158,6 +159,13 @@ const ManagerUserPage = () => {
|
||||
})}
|
||||
onClick={() => handleClickResetPassword(user)}
|
||||
/>
|
||||
<Disable2FA
|
||||
user={user}
|
||||
message={messageApi}
|
||||
onSuccess={(isSuccess) => {
|
||||
if (isSuccess) actionRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
</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 = () => {
|
||||
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;
|
||||
|
||||
@@ -30,7 +30,7 @@ const ProfilePage = () => {
|
||||
label: intl.formatMessage({
|
||||
id: 'master.profile.two-factor-authentication',
|
||||
}),
|
||||
disabled: true,
|
||||
// disabled: true,
|
||||
},
|
||||
];
|
||||
const handleMenuSelect = (e: { key: string }) => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
API_ADMIN_DISABLE_2FA,
|
||||
API_CHANGE_PASSWORD,
|
||||
API_DISABLE_2FA,
|
||||
API_ENABLE_2FA,
|
||||
API_ENABLE_2FA_VERIFY,
|
||||
API_FORGOT_PASSWORD,
|
||||
API_PATH_GET_PROFILE,
|
||||
API_PATH_LOGIN,
|
||||
API_PATH_LOGIN_2FA,
|
||||
API_PATH_REFRESH_TOKEN,
|
||||
API_USERS,
|
||||
} from '@/constants/api';
|
||||
@@ -59,3 +64,46 @@ export async function apiForgotPassword(
|
||||
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 {
|
||||
token?: string;
|
||||
refresh_token: string;
|
||||
enabled_2fa: boolean;
|
||||
refresh_token?: string;
|
||||
enabled2fa?: boolean;
|
||||
}
|
||||
interface RefreshTokenRequestBody {
|
||||
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 {
|
||||
id?: string;
|
||||
email?: string;
|
||||
enable_2fa?: boolean;
|
||||
metadata?: UserMetadata;
|
||||
}
|
||||
|
||||
@@ -46,4 +47,14 @@ declare namespace MasterModel {
|
||||
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