1 Commits

Author SHA1 Message Date
Tran Anh Tuan
dca363275e feat(users): add reset password functionality for users and implement forgot password page 2026-02-03 17:33:47 +07:00
35 changed files with 1592 additions and 321 deletions

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerCameraRoute,
managerRouteBase,
@@ -16,6 +17,7 @@ export default defineConfig({
},
routes: [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
profileRoute,
{

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -15,6 +16,7 @@ export default defineConfig({
},
routes: [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
profileRoute,
{

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -16,6 +17,7 @@ export default defineConfig({
routes: [
loginRoute,
alarmsRoute,
forgotPasswordRoute,
profileRoute,
{
name: 'spole.monitoring',

View File

@@ -4,6 +4,7 @@ import proxyProd from './config/proxy_prod';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -16,6 +17,7 @@ const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
const routes = [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
{
...managerRouteBase,

View File

@@ -1,43 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
// Error handling scheme: Error types
// enum ErrorShowType {
// SILENT = 0,
// WARN_MESSAGE = 1,
// ERROR_MESSAGE = 2,
// NOTIFICATION = 3,
// REDIRECT = 9,
// }
// Response data structure agreed with the backend
// interface ResponseStructure<T = any> {
// success: boolean;
// data: T;
// errorCode?: number;
// errorMessage?: string;
// showType?: ErrorShowType;
// }
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
// const codeMessage = {
// 200: 'The server successfully returned the requested data。',
// 201: 'New or modified data succeeded。',
// 202: 'A request has been queued in the background (asynchronous task)。',
// 204: 'Data deleted successfully。',
// 400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
// 401: 'The user does not have permission (token, username, password is wrong) 。',
// 403: 'User is authorized, but access is prohibited。',
// 404: 'The request issued was for a non-existent record, the server did not operate。',
// 406: 'The requested format is not available。',
// 410: 'The requested resource is permanently deleted and will no longer be available。',
// 422: 'When creating an object, a validation error occurred。',
// 500: 'Server error, please check the server。',
// 502: 'Gateway error。',
// 503: 'Service unavailable, server temporarily overloaded or maintained。',
// 504: 'Gateway timeout。',
// };
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -45,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -76,8 +83,8 @@ export const handleRequestConfig: RequestConfig = {
// 'Unknown error';
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
removeToken();
history.push(ROUTE_LOGIN);
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
@@ -93,7 +100,7 @@ export const handleRequestConfig: RequestConfig = {
// Request interceptors
requestInterceptors: [
(url: string, options: any) => {
const token = getToken();
const token = getAccessToken();
// console.log('Token: ', token);
return {
@@ -113,14 +120,67 @@ export const handleRequestConfig: RequestConfig = {
// Unwrap data from backend response
responseInterceptors: [
(response) => {
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
console.log('Access Token hết hạn, đang refresh...');
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
console.log('Access Token hết hạn và không thể refresh');
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
console.log(
'Request dev: Chuyển về trang login và có pathname ',
pathname,
);
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
console.log(
'Request dev: Chuyển về trang login và không có pathname',
);
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
return {
status: response.status,
statusText: response.statusText,
@@ -129,5 +189,5 @@ export const handleRequestConfig: RequestConfig = {
data: response.data,
};
},
],
] as any,
};

View File

@@ -1,26 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
const codeMessage = {
200: 'The server successfully returned the requested data。',
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。',
204: 'Data deleted successfully。',
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
401: 'The user does not have permission (token, username, password is wrong) 。',
403: 'User is authorized, but access is prohibited。',
404: 'The request issued was for a non-existent record, the server did not operate。',
406: 'The requested format is not available。',
410: 'The requested resource is permanently deleted and will no longer be available。',
422: 'When creating an object, a validation error occurred。',
500: 'Server error, please check the server。',
502: 'Gateway error。',
503: 'Service unavailable, server temporarily overloaded or maintained。',
504: 'Gateway timeout。',
};
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -28,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -48,24 +72,25 @@ export const handleRequestConfig: RequestConfig = {
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status, statusText, data } = error.response;
const { status } = error.response;
// Ưu tiên: codeMessage → backend message → statusText
const errMsg =
codeMessage[status as keyof typeof codeMessage] ||
data?.message ||
statusText ||
'Unknown error';
// const errMsg =
// codeMessage[status as keyof typeof codeMessage] ||
// data?.message ||
// statusText ||
// 'Unknown error';
message.error(`${status}: ${errMsg}`);
if (status === 401) {
removeToken();
history.push(ROUTE_LOGIN);
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
message.error(`${status}: ${error.message || 'Error'}`);
}
} else if (error.request) {
message.error('🚨 No response from server!');
} else if (status) {
// message.error('💥 Internal server error!');
} else {
message.error(`⚠️ Request setup error: ${error.message}`);
}
@@ -79,7 +104,7 @@ export const handleRequestConfig: RequestConfig = {
// Chỉ cần thêm token, proxy sẽ xử lý việc redirect đến đúng port
// URL sẽ bắt đầu với /api và proxy sẽ chuyển đến hostname:81/api
const token = getToken();
const token = getAccessToken();
return {
url: url,
options: {
@@ -96,14 +121,65 @@ export const handleRequestConfig: RequestConfig = {
],
// Unwrap data from backend response
// responseInterceptors: [
// (response) => {
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
// return response.data;
// },
// ],
responseInterceptors: [
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: response.config,
data: response.data,
};
},
] as any,
};

View File

@@ -5,6 +5,14 @@ export const loginRoute = {
layout: false,
};
export const forgotPasswordRoute = {
title: 'Forgot Password',
path: '/password/reset',
component: './Auth/ForgotPassword',
layout: false,
access: 'canAll',
};
export const profileRoute = {
name: 'profile',
icon: 'icon-user',

2
package-lock.json generated
View File

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

BIN
public/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -11,17 +11,20 @@ import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider';
import ThemeSwitcher from './components/Theme/ThemeSwitcher';
import { THEME_KEY } from './constants';
import { ROUTE_LOGIN } from './constants/routes';
import { ROUTE_FORGOT_PASSWORD, ROUTE_LOGIN } from './constants/routes';
import NotFoundPage from './pages/Exception/NotFound';
import UnAccessPage from './pages/Exception/UnAccess';
import { apiQueryProfile } from './services/master/AuthController';
import { checkTokenExpired } from './utils/jwt';
import {
apiQueryProfile,
apiRefreshToken,
} from './services/master/AuthController';
import { checkRefreshTokenExpired } from './utils/jwt';
import { getLogoImage } from './utils/logo';
import {
clearAllData,
clearSessionData,
getToken,
removeToken,
getAccessToken,
getRefreshToken,
setAccessToken,
} from './utils/storage';
const isProdBuild = process.env.NODE_ENV === 'production';
export type InitialStateResponse = {
@@ -30,27 +33,62 @@ export type InitialStateResponse = {
theme?: 'light' | 'dark';
};
const publicRoutes = [ROUTE_LOGIN, ROUTE_FORGOT_PASSWORD];
const handleBackToLogin = () => {
const { pathname } = history.location;
clearAllData();
// Tránh reload liên tục nếu đã ở trang login hoặc public routes
if (publicRoutes.includes(pathname)) return;
window.location.href = `${ROUTE_LOGIN}?redirect=${pathname}`;
};
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<InitialStateResponse> {
const userToken: string = getToken();
const refreshToken: string = getRefreshToken();
const { pathname } = history.location;
// Public routes that don't require authentication
if (publicRoutes.includes(pathname)) {
const currentTheme =
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
return {
theme: currentTheme,
};
}
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
if (!userToken) {
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
if (!refreshToken) {
handleBackToLogin();
return {};
}
const isTokenExpried = checkTokenExpired(userToken);
const isTokenExpried = checkRefreshTokenExpired(refreshToken);
if (isTokenExpried) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
handleBackToLogin();
return {};
}
const ensureAccessToken = async () => {
const existing = getAccessToken();
if (existing) return existing;
try {
const resp = await apiRefreshToken({ refresh_token: refreshToken });
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
} catch (error) {
console.error('Cannot refresh access token: ', error);
}
return null;
};
const accessToken = await ensureAccessToken();
if (!accessToken) {
handleBackToLogin();
return {};
}
@@ -59,10 +97,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
const resp = await apiQueryProfile();
return resp;
} catch (error) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
console.error('Cannot get Profile: ', error);
}
};
const resp = await getUserProfile();
@@ -86,7 +121,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light',
splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_qeg2471go4g.js',
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
contentStyle: {
padding: 0,
margin: 0,
@@ -144,6 +179,10 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
},
pageContainer: {
paddingInlinePageContainerContent: 8,
paddingBlockPageContainerContent: 8,
},
},
unAccessible: <UnAccessPage />,
noFound: <NotFoundPage />,

View File

@@ -1,5 +1,9 @@
import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes';
import { clearAllData, clearSessionData, removeToken } from '@/utils/storage';
import {
clearAllData,
clearSessionData,
removeAccessToken,
} from '@/utils/storage';
import {
LogoutOutlined,
SettingOutlined,
@@ -36,7 +40,7 @@ export const AvatarDropdown = ({
icon: <LogoutOutlined />,
label: intl.formatMessage({ id: 'common.logout' }),
onClick: () => {
removeToken();
removeAccessToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;

View File

@@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_qeg2471go4g.js',
scriptUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
});
export default IconFont;

View File

@@ -73,7 +73,6 @@ const DeviceAlarmList = ({ thingId }: DeviceAlarmListProps) => {
icon={<CheckOutlined />}
type="dashed"
className="green-btn"
style={{ color: 'green', borderColor: 'green' }}
size="small"
onClick={() => setAlarmConfirmed(entity)}
></Button>

View File

@@ -1,5 +1,5 @@
import type { ButtonProps } from 'antd';
import { Button, Tooltip } from 'antd';
import { Button, theme, Tooltip } from 'antd';
import React from 'react';
import IconFont from '../IconFont';
@@ -17,6 +17,7 @@ const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
onClick,
...buttonProps
}) => {
const { token } = theme.useToken();
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
/[^a-zA-Z0-9]/g,
'-',
@@ -24,7 +25,7 @@ const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
const icon = (
<IconFont
type={iconFontName}
style={{ color: color || 'black' }}
style={{ color: color || token.colorText }}
className={wrapperClassName}
/>
);

View File

@@ -1,7 +1,9 @@
// Auth API Paths
export const API_PATH_LOGIN = '/api/tokens';
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';
// Alarm API Constants
export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
@@ -21,4 +23,5 @@ export const API_READER = '/api/reader/channels';
// User API Constants
export const API_USERS = '/api/users';
export const API_USER_RESET_PASSWORD = '/api/password/reset';
export const API_USERS_BY_GROUP = '/api/users/groups';

View File

@@ -19,7 +19,8 @@ export const COLOR_WARNING = '#d48806';
export const COLOR_DANGEROUS = '#d9363e';
export const COLOR_SOS = '#ff0000';
export const TOKEN = 'token';
export const ACCESS_TOKEN = 'access_token';
export const REFRESH_TOKEN = 'refresh_token';
export const THEME_KEY = 'theme';
// Global Constants
export const LIMIT_TREE_LEVEL = 5;

View File

@@ -1,4 +1,5 @@
export const ROUTE_LOGIN = '/login';
export const ROUTE_FORGOT_PASSWORD = '/password/reset';
export const ROUTER_HOME = '/';
export const ROUTE_PROFILE = '/profile';
export const ROUTE_MANAGER_USERS = '/manager/users';

View File

@@ -12,4 +12,17 @@ export default {
'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful',
'master.auth.forgot.title': 'Forgot Password?',
'master.auth.backToLogin.title': 'Back to Login',
'master.auth.forgot.button.title': 'Send Reset Link',
'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.reset.success': 'Password reset successful',
'master.auth.reset.error': 'An error occurred, please try again later!',
'master.auth.reset.invalid':
'The password reset link is invalid or has expired!',
'master.auth.reset.successTitle': 'Password reset successful!',
'master.auth.reset.successMessage': 'Redirecting to login page...',
'master.auth.reset.submit': 'Reset password',
};

View File

@@ -18,7 +18,10 @@ export default {
'master.users.full_name.placeholder': 'Enter full name',
'master.users.full_name.required': 'Please enter full name',
'master.users.password.placeholder': 'Password',
'master.users.confirmpassword.required': 'Confirm password is required',
'master.users.confirmPassword': 'Confirm Password',
'master.users.confirmPassword.placeholder': 'Enter Confirm Password',
'master.users.confirmPassword.required': 'Confirm password is required',
'master.users.confirmPassword.mismatch': 'Passwords do not match',
'master.users.email.placeholder': 'Email',
'master.users.phone_number': 'Phone number',
'master.users.phone_number.tip': 'The phone number is the unique key',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully',
'master.users.change_role.title': 'Set Permissions',
'master.users.change_role.confirm.title': 'Confirm role change',
'master.users.change_role.admin.content':
'Are you sure you want to change the role to Unit Manager?',
@@ -66,4 +70,8 @@ export default {
'master.users.things.sharing': 'Sharing devices...',
'master.users.thing.share.success': 'Device sharing successful',
'master.users.thing.share.fail': 'Device sharing failed',
'master.users.resetPassword.title': 'Reset Password',
'master.users.resetPassword.modal.title': 'Reset Password For User',
'master.users.resetPassword.success': 'Password reset successful',
'master.users.resetPassword.error': 'Password reset failed',
};

View File

@@ -12,4 +12,21 @@ export default {
'master.auth.validation.email': 'Email không được để trống!',
'master.auth.password': 'Mật khẩu',
'master.auth.validation.password': 'Mật khẩu không được để trống!',
'master.auth.forgot.title': 'Quên mật khẩu?',
'master.auth.backToLogin.title': 'Quay lại đăng nhập',
'master.auth.forgot.button.title': 'Gửi yêu cầu đặt lại',
'master.auth.forgot.message.success':
'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.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':
'Liên kết đặt lại mật khẩu không hợp lệ hoặc đã hết hạn!',
'master.auth.reset.successTitle': 'Đặt lại mật khẩu thành công!',
'master.auth.reset.successMessage':
'Đang chuyển hướng đến trang đăng nhập...',
'master.auth.reset.submit': 'Đặt lại mật khẩu',
'master.auth.reset.newPassword.placeholder': 'Nhập mật khẩu mới',
'master.auth.reset.confirmPassword.placeholder': 'Xác nhận mật khẩu mới',
};

View File

@@ -15,7 +15,10 @@ export default {
'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự',
'master.users.password': 'Mật khẩu',
'master.users.password.placeholder': 'Nhập mật khẩu',
'master.users.confirmpassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.confirmPassword': 'Xác nhận mật khẩu',
'master.users.confirmPassword.placeholder': 'Nhập lại mật khẩu',
'master.users.confirmPassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.confirmPassword.mismatch': 'Mật khẩu không khớp',
'master.users.full_name': 'Tên đầy đủ',
'master.users.full_name.placeholder': 'Nhập tên đầy đủ',
'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi',
'master.users.create.success': 'Tạo người dùng thành công',
'master.users.change_role.title': 'Cài đặt quyền',
'master.users.change_role.confirm.title': 'Xác nhận thay đổi vai trò',
'master.users.change_role.admin.content':
'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?',
@@ -65,4 +69,8 @@ export default {
'master.users.things.sharing': 'Đang chia sẻ thiết bị...',
'master.users.thing.share.success': 'Chia sẻ thiết bị thành công',
'master.users.thing.share.fail': 'Chia sẻ thiết bị thất bại',
'master.users.resetPassword.title': 'Đặt lại mật khẩu',
'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',
};

View File

@@ -0,0 +1,421 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTE_LOGIN } from '@/constants/routes';
import { apiUserResetPassword } from '@/services/master/UserController';
import { parseAccessToken } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import { ProForm, ProFormText } from '@ant-design/pro-components';
import {
FormattedMessage,
history,
useIntl,
useSearchParams,
} from '@umijs/max';
import { Button, ConfigProvider, Image, message, Result, theme } from 'antd';
import { useEffect, useState } from 'react';
import backgroundImg from '../../../../public/background.png';
import mobifontLogo from '../../../../public/mobifont-logo.png';
type ResetPasswordFormValues = {
password: string;
confirmPassword: string;
};
const ResetPassword = () => {
const [searchParams] = useSearchParams();
const resetPasswordToken = searchParams.get('token');
const [tokenValid, setTokenValid] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();
// Listen for theme changes from ThemeSwitcherAuth
useEffect(() => {
const handleThemeChange = (e: Event) => {
const customEvent = e as CustomEvent<{ theme: 'light' | 'dark' }>;
setIsDark(customEvent.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
useEffect(() => {
const tokenParsed = resetPasswordToken
? parseAccessToken(resetPasswordToken)
: null;
setTokenValid(
tokenParsed?.expriresAt
? tokenParsed.expriresAt * 1000 > Date.now()
: true,
);
}, [resetPasswordToken]);
const handleSubmit = async (values: ResetPasswordFormValues) => {
setLoading(true);
try {
const resp = await apiUserResetPassword({
token: resetPasswordToken!,
password: values.password,
confirm_password: values.confirmPassword,
});
messageApi.success(
intl.formatMessage({
id: 'master.auth.reset.success',
defaultMessage: 'Đặt lại mật khẩu thành công!',
}),
);
setSuccess(true);
setTimeout(() => {
history.push(ROUTE_LOGIN);
}, 2000);
} catch (error) {
console.error('Reset password error:', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.reset.error',
defaultMessage: 'Có lỗi xảy ra, vui lòng thử lại!',
}),
);
} finally {
setLoading(false);
}
};
// Token invalid state
if (!tokenValid) {
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundColor: isDark ? '#000' : token.colorBgContainer,
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
backgroundColor: token.colorBgSolidActive,
backdropFilter: 'blur(4px)',
padding: '48px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '500px',
width: '100%',
}}
>
<Result
icon={<Image src={getLogoImage()} preview={false} width={80} />}
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
subTitle={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: 'master.auth.reset.invalid',
defaultMessage:
'Liên kết đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.',
})}
</span>
}
extra={
<Button
type="primary"
onClick={() => history.push(ROUTE_LOGIN)}
>
{intl.formatMessage({
id: 'master.auth.backToLogin.title',
defaultMessage: 'Quay về trang Đăng nhập',
})}
</Button>
}
/>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
</div>
</ConfigProvider>
);
}
// Success state
if (success) {
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundImage: `url(${backgroundImg})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
backgroundColor: token.colorBgLayout,
padding: '48px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '500px',
width: '100%',
}}
>
<Result
status="success"
title={
<span style={{ color: token.colorText }}>
{intl.formatMessage({
id: 'master.auth.reset.successTitle',
defaultMessage: 'Mật khẩu đã được đặt lại!',
})}
</span>
}
subTitle={
<span style={{ color: token.colorTextDescription }}>
{intl.formatMessage({
id: 'master.auth.reset.successMessage',
defaultMessage: 'Đang chuyển hướng đến trang đăng nhập...',
})}
</span>
}
/>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
</div>
</ConfigProvider>
);
}
// Reset password form
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundColor: 'transparent',
height: '100vh',
backgroundImage: `url(${backgroundImg})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{contextHolder}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
position: 'relative',
}}
>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.65)',
backdropFilter: 'blur(4px)',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
width: '100%',
maxWidth: '400px',
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<Image
src={getLogoImage()}
preview={false}
width={60}
style={{ marginBottom: '16px' }}
/>
<h2
style={{
color: token.colorBgContainer,
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
}}
>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</h2>
<Image preview={false} src={mobifontLogo} />
</div>
<ProForm
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'master.auth.reset.submit',
defaultMessage: 'Đặt lại mật khẩu',
}),
},
submitButtonProps: {
loading,
size: 'large',
block: true,
},
resetButtonProps: {
style: { display: 'none' },
},
}}
onFinish={handleSubmit}
>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
placeholder: intl.formatMessage({
id: 'master.auth.reset.newPassword.placeholder',
defaultMessage: 'Nhập mật khẩu mới',
}),
autoComplete: 'password',
}}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password.required"
defaultMessage="Password is required"
/>
),
},
{
pattern:
/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
/>
<ProFormText.Password
name="confirmPassword"
fieldProps={{
size: 'large',
placeholder: intl.formatMessage({
id: 'master.users.confirmPassword',
defaultMessage: 'Xác nhận mật khẩu',
}),
autoComplete: 'confirmPassword',
}}
dependencies={['password']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.confirmPassword.required',
defaultMessage: 'Vui lòng xác nhận mật khẩu!',
}),
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.confirmPassword.mismatch',
defaultMessage: 'Mật khẩu xác nhận không khớp!',
}),
),
);
},
}),
]}
/>
</ProForm>
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<Button
type="link"
// style={{ color: token.colorBgContainer }}
onClick={() => history.push(ROUTE_LOGIN)}
>
{intl.formatMessage({
id: 'master.auth.backToLogin.title',
defaultMessage: 'Quay về trang Đăng nhập',
})}
</Button>
</div>
</div>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div>
</ConfigProvider>
);
};
export default ResetPassword;

View File

@@ -1,48 +1,100 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTER_HOME } from '@/constants/routes';
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController';
import { parseJwt } from '@/utils/jwt';
import { getLogoImage } from '@/utils/logo';
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage';
import {
apiForgotPassword,
apiLogin,
apiQueryProfile,
} from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import {
getBrowserId,
getRefreshToken,
removeAccessToken,
removeRefreshToken,
setAccessToken,
setRefreshToken,
} from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history, useIntl, useModel } from '@umijs/max';
import { Image, theme } from 'antd';
import { useEffect } from 'react';
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';
// Form wrapper with animation
const FormWrapper = ({
children,
key,
}: {
children: React.ReactNode;
key: string;
}) => {
const style: CSSProperties = {
animation: 'fadeInSlide 0.4s ease-out forwards',
};
return (
<div key={key} style={style}>
<style>{`
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}</style>
{children}
</div>
);
};
const LoginPage = () => {
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect');
const intl = useIntl();
const { setInitialState } = useModel('@@initialState');
const getDomainTitle = () => {
switch (process.env.DOMAIN_ENV) {
case 'gms':
return 'gms.title';
case 'sgw':
return 'sgw.title';
case 'spole':
return 'spole.title';
default:
return 'Smatec Master';
}
};
const [loginType, setLoginType] = useState<LoginType>('login');
// Listen for theme changes from ThemeSwitcherAuth
useEffect(() => {
const handleThemeChange = (e: Event) => {
const customEvent = e as CustomEvent<{ theme: 'light' | 'dark' }>;
setIsDark(customEvent.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
const checkLogin = async () => {
const token = getToken();
if (!token) {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return;
}
const parsed = parseJwt(token);
const { exp } = parsed;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
if (exp - now < oneHour) {
removeToken();
const isRefreshTokenExpired = checkRefreshTokenExpired(refreshToken);
if (isRefreshTokenExpired) {
removeAccessToken();
removeRefreshToken();
return;
} else {
const userInfo = await apiQueryProfile();
if (userInfo) {
@@ -66,147 +118,263 @@ const LoginPage = () => {
}, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
try {
const { email, password } = values;
const resp = await apiLogin({
guid: getBrowserId(),
email,
password,
});
if (resp?.token) {
setToken(resp.token);
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
const { email, password } = values;
if (loginType === 'login') {
try {
const resp = await apiLogin({
guid: getBrowserId(),
email,
password,
});
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('Login error:', error);
}
} else {
try {
const host = window.location.origin;
const body: MasterModel.ForgotPasswordRequestBody = {
email: email,
host: host,
};
const resp = await apiForgotPassword(body);
if (!resp.error) {
messageApi.success(
intl.formatMessage({
id: 'master.auth.forgot.message.success',
defaultMessage:
'Request sent successfully, please check your email!',
}),
);
}
} catch (error) {
console.error('Error when send reset password: ', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.forgot.message.fail',
defaultMessage: 'Request failed, please try again later!',
}),
);
}
} catch (error) {
console.error('Login error:', error);
}
};
return (
<div
style={{
backgroundColor: 'white',
height: '100vh',
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<LoginFormPage
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
containerStyle={{
backgroundColor: 'rgba(0, 0, 0,0.65)',
backdropFilter: 'blur(4px)',
}}
subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
}),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
handleLogin(values)
}
>
<>
<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: intl.formatMessage({
id: 'master.auth.validation.email',
defaultMessage: 'Email không được để trống!',
}),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: (
<LockOutlined
style={{
color: token.colorText,
}}
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!',
}),
},
]}
/>
</>
</LoginFormPage>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
backgroundColor: isDark ? '#000' : 'white',
height: '100vh',
}}
>
<Footer />
{contextHolder}
<LoginFormPage
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
containerStyle={{
backgroundColor: 'rgba(0, 0, 0,0.65)',
backdropFilter: 'blur(4px)',
}}
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',
}),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
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>
<Flex
justify="flex-end"
align="flex-start"
style={{ marginBlockEnd: 16 }}
>
<Button
type="link"
size="small"
onClick={() => {
if (loginType === 'login') {
setLoginType('forgot');
} else {
setLoginType('login');
}
}}
>
{loginType === 'login' ? (
<FormattedMessage
id="master.auth.forgot.title"
defaultMessage="Quên mật khẩu?"
/>
) : (
<FormattedMessage
id="master.auth.backToLogin.title"
defaultMessage="Back to Login"
/>
)}
</Button>
</Flex>
</LoginFormPage>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div>
</div>
</ConfigProvider>
);
};
export default LoginPage;

View File

@@ -1,7 +1,15 @@
import IconFont from '@/components/IconFont';
import { StatisticCard } from '@ant-design/pro-components';
import { Flex, GlobalToken, Grid, theme } from 'antd';
import {
Divider,
Flex,
GlobalToken,
Grid,
theme,
Tooltip,
Typography,
} from 'antd';
const { Text } = Typography;
type BinarySensorsProps = {
nodeConfigs: MasterModel.NodeConfig[];
};
@@ -19,6 +27,7 @@ export const getBinaryEntities = (
interface IconTypeResult {
iconType: string;
color: string;
name?: string;
}
const getIconTypeByEntity = (
@@ -27,60 +36,103 @@ const getIconTypeByEntity = (
): IconTypeResult => {
if (!entity.config || entity.config.length === 0) {
return {
iconType: 'icon-device-setting',
iconType: 'icon-not-found',
color: token.colorPrimary,
};
}
switch (entity.config[0].subType) {
case 'smoke':
return {
iconType: 'icon-fire',
iconType: 'icon-smoke1',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
};
case 'heat':
return {
iconType: 'icon-fire',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
};
case 'motion':
return {
iconType: 'icon-motion',
color: entity.value === 0 ? token.colorTextBase : token.colorInfoActive,
name: entity.value === 0 ? 'Không' : 'Phát hiện',
};
case 'flood':
return {
iconType: 'icon-water-ingress',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Không' : 'Phát hiện',
};
case 'door':
return {
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open',
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open1',
color: entity.value === 0 ? token.colorText : token.colorWarning,
name: entity.value === 0 ? 'Đóng' : 'Mở',
};
case 'button':
return {
iconType: 'icon-alarm-button',
color: entity.value === 0 ? token.colorText : token.colorSuccess,
name: entity.value === 0 ? 'Tắt' : 'Bật',
};
default:
return {
iconType: 'icon-door',
iconType: 'icon-not-found',
color: token.colorPrimary,
};
}
};
const StatisticCardItem = (entity: MasterModel.Entity, token: GlobalToken) => {
const { iconType, color } = getIconTypeByEntity(entity, token);
const { iconType, color, name } = getIconTypeByEntity(entity, token);
return (
<StatisticCard
bordered={false}
key={entity.entityId}
style={{
borderRadius: 8,
background: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.boxShadow = `0 4px 12px ${token.colorPrimary}20`;
el.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.boxShadow = 'none';
el.style.transform = 'translateY(0)';
}}
statistic={{
title: entity.name,
icon: (
<IconFont type={iconType} style={{ color: color, fontSize: 24 }} />
<Tooltip title={entity.name}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 56,
marginBottom: 8,
}}
>
<IconFont type={iconType} style={{ color, fontSize: 32 }} />
</div>
</Tooltip>
),
value: entity.active === 1 ? 'Active' : 'Inactive',
title: (
<Text
ellipsis={{ tooltip: entity.name }}
style={{ fontSize: 13, fontWeight: 500 }}
>
{entity.name}
</Text>
),
value: name,
valueStyle: { fontSize: 12, color, fontWeight: 600, marginTop: 8 },
}}
/>
);
@@ -93,11 +145,20 @@ const BinarySensors = ({ nodeConfigs }: BinarySensorsProps) => {
const { token } = theme.useToken();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
return (
<Flex wrap="wrap">
<StatisticCard.Group direction={screens.sm ? 'row' : 'column'}>
{binarySensors.map((entity) => StatisticCardItem(entity, token))}
</StatisticCard.Group>
<Flex wrap="wrap" gap="middle">
<Divider orientation="left">Cảm biến</Divider>
{binarySensors.map((entity) => (
<div
key={entity.entityId}
style={{
width: 'fit-content',
}}
>
{StatisticCardItem(entity, token)}
</div>
))}
</Flex>
);
};

View File

@@ -5,7 +5,7 @@ import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { PageContainer, ProCard } from '@ant-design/pro-components';
import { history, useIntl, useModel, useParams } from '@umijs/max';
import { Grid } from 'antd';
import { Divider, Flex, Grid } from 'antd';
import { useEffect, useState } from 'react';
import BinarySensors from './components/BinarySensors';
import ThingTitle from './components/ThingTitle';
@@ -95,17 +95,23 @@ const DetailDevicePage = () => {
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard
bodyStyle={{
paddingInline: 12,
paddingBlock: 8,
}}
title={intl.formatMessage({
id: 'master.thing.detail.alarmList.title',
})}
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
bordered
>
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
</ProCard>
<ProCard>
<BinarySensors nodeConfigs={nodeConfigs} />
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
<Flex wrap gap="small">
<BinarySensors nodeConfigs={nodeConfigs} />
<Divider orientation="left">Trạng thái</Divider>
</Flex>
</ProCard>
</ProCard>
</PageContainer>

View File

@@ -0,0 +1,168 @@
import { apiResetUserPassword } from '@/services/master/UserController';
import { LockOutlined } from '@ant-design/icons';
import {
ModalForm,
ProFormInstance,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef } from 'react';
type ResetPasswordProps = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: MasterModel.UserResponse;
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type ResetPasswordFormValues = {
password: string;
confirmPassword: string;
};
const ResetPassword = ({
isOpen,
setIsOpen,
user,
message,
onSuccess,
}: ResetPasswordProps) => {
const formRef = useRef<ProFormInstance<ResetPasswordFormValues>>();
const intl = useIntl();
return (
<ModalForm<ResetPasswordFormValues>
title={`${intl.formatMessage({
id: 'master.users.resetPassword.modal.title',
defaultMessage: 'Reset Password',
})}: ${user.metadata?.full_name || user.email}`}
open={isOpen}
onOpenChange={setIsOpen}
formRef={formRef}
autoFocusFirstInput
modalProps={{
destroyOnHidden: true,
}}
onFinish={async (values: ResetPasswordFormValues) => {
try {
const resp = await apiResetUserPassword(
user.id || '',
values.password,
);
message.success(
intl.formatMessage({
id: 'master.users.resetPassword.success',
defaultMessage: 'Reset password successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.users.resetPassword.error',
defaultMessage: 'Failed to reset password',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText.Password
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password.required"
defaultMessage="Password is required"
/>
),
},
{
pattern: /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
name="password"
label={intl.formatMessage({
id: 'master.users.password',
defaultMessage: 'Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'new-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.password.placeholder',
defaultMessage: 'Password',
})}
/>
<ProFormText.Password
dependencies={['password']}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.confirmPassword.required"
defaultMessage="Please confirm your password"
/>
),
},
({ getFieldValue }) => ({
validator(_: unknown, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.confirmPassword.mismatch',
defaultMessage: 'The two passwords do not match',
}),
),
);
},
}),
]}
name="confirmPassword"
label={intl.formatMessage({
id: 'master.users.confirmPassword',
defaultMessage: 'Confirm Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'new-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.confirmPassword.placeholder',
defaultMessage: 'Confirm Password',
})}
/>
</ModalForm>
);
};
export default ResetPassword;

View File

@@ -1,4 +1,4 @@
import IconFont from '@/components/IconFont';
import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
@@ -19,12 +19,16 @@ import {
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Grid, Popconfirm, theme } from 'antd';
import { Button, Grid, Popconfirm, Space, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser';
import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null;
isOpen: boolean;
};
const ManagerUserPage = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
@@ -41,11 +45,21 @@ const ManagerUserPage = () => {
string | string[] | null
>(null);
const [resetPasswordUser, setResetPasswordUser] =
useState<ResetUserPaswordProps>({
user: null,
isOpen: false,
});
const handleClickAssign = (user: MasterModel.UserResponse) => {
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
history.push(path);
};
const handleClickResetPassword = (user: MasterModel.UserResponse) => {
setResetPasswordUser({ user: user, isOpen: true });
};
const columns: ProColumns<MasterModel.UserResponse>[] = [
{
key: 'email',
@@ -123,14 +137,28 @@ const ManagerUserPage = () => {
hideInSearch: true,
render: (_, user) => {
return (
<>
<Button
<Space>
<TooltipIconFontButton
shape="default"
size="small"
icon={<IconFont type="icon-assign" />}
iconFontName="icon-assign"
tooltip={intl.formatMessage({
id: 'master.users.change_role.title',
defaultMessage: 'Set Permissions',
})}
onClick={() => handleClickAssign(user)}
/>
</>
<TooltipIconFontButton
shape="default"
size="small"
iconFontName="icon-reset-password"
tooltip={intl.formatMessage({
id: 'master.users.resetPassword.title',
defaultMessage: 'Reset Password',
})}
onClick={() => handleClickResetPassword(user)}
/>
</Space>
);
},
},
@@ -181,6 +209,19 @@ const ManagerUserPage = () => {
return (
<>
{contextHolder}
{resetPasswordUser.user && (
<ResetPassword
message={messageApi}
isOpen={resetPasswordUser.isOpen}
user={resetPasswordUser.user}
setIsOpen={(isOpen) =>
setResetPasswordUser((prev) => ({ ...prev, isOpen }))
}
onSuccess={(isSuccess) => {
if (isSuccess) actionRef.current?.reload();
}}
/>
)}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
<TreeGroup

View File

@@ -1,7 +1,9 @@
import {
API_CHANGE_PASSWORD,
API_FORGOT_PASSWORD,
API_PATH_GET_PROFILE,
API_PATH_LOGIN,
API_PATH_REFRESH_TOKEN,
API_USERS,
} from '@/constants/api';
import { request } from '@umijs/max';
@@ -15,6 +17,15 @@ export async function apiLogin(body: MasterModel.LoginRequestBody) {
});
}
export async function apiRefreshToken(
body: MasterModel.RefreshTokenRequestBody,
) {
return request<MasterModel.RefreshTokenReponse>(API_PATH_REFRESH_TOKEN, {
method: 'POST',
data: body,
});
}
export async function apiQueryProfile() {
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
}
@@ -40,3 +51,11 @@ export async function apiChangePassword(
getResponse: true,
});
}
export async function apiForgotPassword(
body: MasterModel.ForgotPasswordRequestBody,
) {
return request<MasterModel.ForgotPasswordResponse>(API_FORGOT_PASSWORD, {
method: 'POST',
data: body,
});
}

View File

@@ -1,4 +1,9 @@
import { API_GROUPS, API_USERS, API_USERS_BY_GROUP } from '@/constants/api';
import {
API_GROUPS,
API_USER_RESET_PASSWORD,
API_USERS,
API_USERS_BY_GROUP,
} from '@/constants/api';
import { request } from '@umijs/max';
export async function apiQueryUsers(
@@ -44,3 +49,23 @@ export async function apiDeleteUser(userId: string) {
getResponse: true,
});
}
export async function apiResetUserPassword(
userId: string,
newPassword: string,
) {
return request(`${API_USERS}/${userId}/password`, {
method: 'PUT',
data: { new_password: newPassword },
getResponse: true,
});
}
export async function apiUserResetPassword(
body: MasterModel.UserResetPasswordRequest,
) {
return request(API_USER_RESET_PASSWORD, {
method: 'PUT',
data: body,
});
}

View File

@@ -7,5 +7,51 @@ declare namespace MasterModel {
interface LoginResponse {
token?: string;
refresh_token: string;
enabled_2fa: boolean;
}
interface RefreshTokenRequestBody {
refresh_token: string;
}
interface RefreshTokenReponse {
access_token: string;
}
interface TokenParsed {
exp: number;
iat: number;
iss: string;
sub: string;
issuer_id: string;
type: number;
purpose: 'access' | 'refresh';
}
interface RefreshTokenParsed extends TokenParsed {
jti: string;
}
interface TokenParsedTransformed {
expriresAt: number;
issuedAt: number;
issuer: string;
/** User Email */
subject: string;
issuerId: string;
type: number;
purpose: 'access' | 'refresh';
}
interface RefreshTokenParsedTransformed extends TokenParsedTransformed {
jwtID: string;
}
interface ForgotPasswordRequestBody {
email: string;
host: string;
}
interface ForgotPasswordResponse {
message: string;
error?: string;
}
}

View File

@@ -41,4 +41,9 @@ declare namespace MasterModel {
limit?: number;
users: UserResponse[];
}
interface UserResetPasswordRequest {
token: string;
password: string;
confirm_password?: string;
}
}

View File

@@ -1,7 +1,37 @@
export function parseJwt(token: string) {
export function parseAccessToken(
token: string,
): MasterModel.TokenParsedTransformed | null {
if (!token) return null;
const base64Url = token.split('.')[1];
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
const accessToken: MasterModel.TokenParsed = JSON.parse(jsonPayload);
return {
expriresAt: accessToken.exp,
issuedAt: accessToken.iat,
issuer: accessToken.iss,
subject: accessToken.sub,
issuerId: accessToken.issuer_id,
type: accessToken.type,
purpose: 'access',
};
} catch (error) {
return null;
}
}
export function parseRefreshToken(
refreshToken: string,
): MasterModel.RefreshTokenParsedTransformed | null {
if (!refreshToken) return null;
const base64Url = refreshToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
@@ -9,16 +39,27 @@ export function parseJwt(token: string) {
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
return JSON.parse(jsonPayload);
const refreshTokenParsed: MasterModel.RefreshTokenParsed =
JSON.parse(jsonPayload);
return {
expriresAt: refreshTokenParsed.exp,
issuedAt: refreshTokenParsed.iat,
issuer: refreshTokenParsed.iss,
subject: refreshTokenParsed.sub,
issuerId: refreshTokenParsed.issuer_id,
type: refreshTokenParsed.type,
purpose: 'refresh',
jwtID: refreshTokenParsed.jti,
};
}
export function checkTokenExpired(token: string): boolean {
const parsed = parseJwt(token);
const { exp } = parsed;
export function checkRefreshTokenExpired(token: string): boolean {
const parsed = parseRefreshToken(token);
if (!parsed) return true;
const { expriresAt } = parsed;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
if (exp - now < oneHour) {
const fiveMinutes = 60 * 5; // 5 minutes
if (expriresAt - now < fiveMinutes) {
return true;
}
return false;

View File

@@ -14,3 +14,15 @@ export const getLogoImage = () => {
return smatecLogo;
}
};
export const getDomainTitle = () => {
switch (process.env.DOMAIN_ENV) {
case 'gms':
return 'gms.title';
case 'sgw':
return 'sgw.title';
case 'spole':
return 'spole.title';
default:
return 'Smatec Master';
}
};

View File

@@ -1,15 +1,27 @@
import { TOKEN } from '@/constants';
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/constants';
export function getToken(): string {
return localStorage.getItem(TOKEN) || '';
export function getAccessToken(): string {
return localStorage.getItem(ACCESS_TOKEN) || '';
}
export function setToken(token: string) {
localStorage.setItem(TOKEN, token);
export function setAccessToken(accessToken: string) {
localStorage.setItem(ACCESS_TOKEN, accessToken);
}
export function removeToken() {
localStorage.removeItem(TOKEN);
export function removeAccessToken() {
localStorage.removeItem(ACCESS_TOKEN);
}
export function getRefreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN) || '';
}
export function setRefreshToken(refreshToken: string) {
localStorage.setItem(REFRESH_TOKEN, refreshToken);
}
export function removeRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN);
}
export function getBrowserId() {

View File

@@ -1,5 +1,5 @@
import ReconnectingWebSocket from 'reconnecting-websocket';
import { getToken } from './storage';
import { getAccessToken } from './storage';
type MessageHandler = (data: any) => void;
@@ -16,7 +16,7 @@ class WSClient {
if (this.ws) return;
let token = '';
if (isAuthenticated) {
token = getToken();
token = getAccessToken();
}
let wsUrl = url;
if (url.startsWith('/')) {