diff --git a/.umirc.gms.ts b/.umirc.gms.ts index 0f80da5..45f9122 100644 --- a/.umirc.gms.ts +++ b/.umirc.gms.ts @@ -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, { diff --git a/.umirc.sgw.ts b/.umirc.sgw.ts index 864ebb0..e89fb42 100644 --- a/.umirc.sgw.ts +++ b/.umirc.sgw.ts @@ -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, { diff --git a/.umirc.spole.ts b/.umirc.spole.ts index e5ec99b..8fdb6bf 100644 --- a/.umirc.spole.ts +++ b/.umirc.spole.ts @@ -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', diff --git a/.umirc.ts b/.umirc.ts index fd5df78..d20d53a 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -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, diff --git a/config/request_dev.ts b/config/request_dev.ts index 235b118..8d64a26 100644 --- a/config/request_dev.ts +++ b/config/request_dev.ts @@ -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 { -// 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 | 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 { + 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; - // 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, }; diff --git a/config/request_prod.ts b/config/request_prod.ts index 96485c6..bc15a3b 100644 --- a/config/request_prod.ts +++ b/config/request_prod.ts @@ -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 | null = null; + +async function getValidAccessToken(): Promise { + 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; - // 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, }; diff --git a/config/routes.ts b/config/routes.ts index 819f57e..14c6961 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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', diff --git a/package-lock.json b/package-lock.json index a61b400..6533cf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "SMATEC-FRONTEND", + "name": "smatec-frontend", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/public/background.png b/public/background.png new file mode 100644 index 0000000..3ea6fe4 Binary files /dev/null and b/public/background.png differ diff --git a/src/app.tsx b/src/app.tsx index df3b495..cf083c9 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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 { - 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 { 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: , noFound: , diff --git a/src/components/Avatar/AvatarDropdown.tsx b/src/components/Avatar/AvatarDropdown.tsx index bf2bdda..62fb613 100644 --- a/src/components/Avatar/AvatarDropdown.tsx +++ b/src/components/Avatar/AvatarDropdown.tsx @@ -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: , label: intl.formatMessage({ id: 'common.logout' }), onClick: () => { - removeToken(); + removeAccessToken(); clearAllData(); clearSessionData(); window.location.href = ROUTE_LOGIN; diff --git a/src/components/IconFont/index.tsx b/src/components/IconFont/index.tsx index 1176697..cb72c57 100644 --- a/src/components/IconFont/index.tsx +++ b/src/components/IconFont/index.tsx @@ -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; diff --git a/src/components/shared/DeviceAlarmList.tsx b/src/components/shared/DeviceAlarmList.tsx index c08be8b..e443e4c 100644 --- a/src/components/shared/DeviceAlarmList.tsx +++ b/src/components/shared/DeviceAlarmList.tsx @@ -73,7 +73,6 @@ const DeviceAlarmList = ({ thingId }: DeviceAlarmListProps) => { icon={} type="dashed" className="green-btn" - style={{ color: 'green', borderColor: 'green' }} size="small" onClick={() => setAlarmConfirmed(entity)} > diff --git a/src/components/shared/TooltipIconFontButton.tsx b/src/components/shared/TooltipIconFontButton.tsx index 2acb57e..65f44e1 100644 --- a/src/components/shared/TooltipIconFontButton.tsx +++ b/src/components/shared/TooltipIconFontButton.tsx @@ -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 = ({ 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 = ({ const icon = ( ); diff --git a/src/constants/api.ts b/src/constants/api.ts index 24475dc..f6fb18f 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -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'; diff --git a/src/constants/index.ts b/src/constants/index.ts index 5d2e16c..43d0f0d 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -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; diff --git a/src/constants/routes.ts b/src/constants/routes.ts index d701cb0..2a46866 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -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'; diff --git a/src/locales/en-US/master/master-auth-en.ts b/src/locales/en-US/master/master-auth-en.ts index a8cba3b..e65be53 100644 --- a/src/locales/en-US/master/master-auth-en.ts +++ b/src/locales/en-US/master/master-auth-en.ts @@ -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', }; diff --git a/src/locales/en-US/master/master-user-en.ts b/src/locales/en-US/master/master-user-en.ts index a767047..5ab6a85 100644 --- a/src/locales/en-US/master/master-user-en.ts +++ b/src/locales/en-US/master/master-user-en.ts @@ -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', }; diff --git a/src/locales/vi-VN/master/master-auth-vi.ts b/src/locales/vi-VN/master/master-auth-vi.ts index 0898bb2..77899be 100644 --- a/src/locales/vi-VN/master/master-auth-vi.ts +++ b/src/locales/vi-VN/master/master-auth-vi.ts @@ -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', }; diff --git a/src/locales/vi-VN/master/master-user-vi.ts b/src/locales/vi-VN/master/master-user-vi.ts index 868a78e..b6f40ab 100644 --- a/src/locales/vi-VN/master/master-user-vi.ts +++ b/src/locales/vi-VN/master/master-user-vi.ts @@ -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', }; diff --git a/src/pages/Auth/ForgotPassword/index.tsx b/src/pages/Auth/ForgotPassword/index.tsx new file mode 100644 index 0000000..2e6e44c --- /dev/null +++ b/src/pages/Auth/ForgotPassword/index.tsx @@ -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 ( + +
+
+ } + title={ + + {intl.formatMessage({ + id: getDomainTitle(), + defaultMessage: 'Smatec', + })} + + } + subTitle={ + + {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.', + })} + + } + extra={ + + } + /> +
+
+ + +
+
+
+ ); + } + + // Success state + if (success) { + return ( + +
+
+ + {intl.formatMessage({ + id: 'master.auth.reset.successTitle', + defaultMessage: 'Mật khẩu đã được đặt lại!', + })} + + } + subTitle={ + + {intl.formatMessage({ + id: 'master.auth.reset.successMessage', + defaultMessage: 'Đang chuyển hướng đến trang đăng nhập...', + })} + + } + /> +
+
+ + +
+
+
+ ); + } + + // Reset password form + return ( + +
+ {contextHolder} +
+
+
+ +

+ {intl.formatMessage({ + id: getDomainTitle(), + defaultMessage: 'Smatec', + })} +

+ +
+ + + + ), + }, + { + 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(); + }, + }, + ]} + /> + ({ + 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!', + }), + ), + ); + }, + }), + ]} + /> + + +
+ +
+
+
+ +
+ + +
+ +
+
+
+
+
+ ); +}; + +export default ResetPassword; diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index ae3eb29..fdec55c 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -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 ( +
+ + {children} +
+ ); +}; + 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('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 ( -
- - {intl.formatMessage({ - id: getDomainTitle(), - defaultMessage: 'Smatec', - })} - - } - containerStyle={{ - backgroundColor: 'rgba(0, 0, 0,0.65)', - backdropFilter: 'blur(4px)', - }} - subTitle={} - submitter={{ - searchConfig: { - submitText: intl.formatMessage({ - id: 'master.auth.login.title', - defaultMessage: 'Đăng nhập', - }), - }, - }} - onFinish={async (values: MasterModel.LoginRequestBody) => - handleLogin(values) - } - > - <> - - ), - }} - 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!', - }), - }, - ]} - /> - - ), - }} - 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!', - }), - }, - ]} - /> - - -
- - -
-
+ {contextHolder} + + {intl.formatMessage({ + id: getDomainTitle(), + defaultMessage: 'Smatec', + })} + + } + containerStyle={{ + backgroundColor: 'rgba(0, 0, 0,0.65)', + backdropFilter: 'blur(4px)', + }} + subTitle={} + 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) + } + > + + {loginType === 'login' && ( + <> + + ), + }} + placeholder={intl.formatMessage({ + id: 'master.auth.login.email', + defaultMessage: 'Email', + })} + rules={[ + { + required: true, + message: ( + + ), + }, + { + type: 'email', + message: ( + + ), + }, + ]} + /> + , + }} + 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' && ( + <> + , + }} + placeholder={intl.formatMessage({ + id: 'master.auth.login.email', + defaultMessage: 'Email', + })} + rules={[ + { + required: true, + message: ( + + ), + }, + { + type: 'email', + message: ( + + ), + }, + ]} + /> + + )} + + + + + +
+ + +
+
+
+
-
+ ); }; export default LoginPage; diff --git a/src/pages/Manager/Device/Detail/components/BinarySensors.tsx b/src/pages/Manager/Device/Detail/components/BinarySensors.tsx index a01e507..72ea96f 100644 --- a/src/pages/Manager/Device/Detail/components/BinarySensors.tsx +++ b/src/pages/Manager/Device/Detail/components/BinarySensors.tsx @@ -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 ( { + 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: ( - + +
+ +
+
), - value: entity.active === 1 ? 'Active' : 'Inactive', + title: ( + + {entity.name} + + ), + 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 ( - - - {binarySensors.map((entity) => StatisticCardItem(entity, token))} - + + Cảm biến + {binarySensors.map((entity) => ( +
+ {StatisticCardItem(entity, token)} +
+ ))}
); }; diff --git a/src/pages/Manager/Device/Detail/index.tsx b/src/pages/Manager/Device/Detail/index.tsx index 7183780..1318fc2 100644 --- a/src/pages/Manager/Device/Detail/index.tsx +++ b/src/pages/Manager/Device/Detail/index.tsx @@ -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 = () => { > - - + + + + Trạng thái + diff --git a/src/pages/Manager/User/components/ResetPassword.tsx b/src/pages/Manager/User/components/ResetPassword.tsx new file mode 100644 index 0000000..828b97d --- /dev/null +++ b/src/pages/Manager/User/components/ResetPassword.tsx @@ -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>(); + const intl = useIntl(); + + return ( + + 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; + } + }} + > + + ), + }, + { + 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: , + autoComplete: 'new-password', + }} + placeholder={intl.formatMessage({ + id: 'master.users.password.placeholder', + defaultMessage: '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: , + autoComplete: 'new-password', + }} + placeholder={intl.formatMessage({ + id: 'master.users.confirmPassword.placeholder', + defaultMessage: 'Confirm Password', + })} + /> + + ); +}; + +export default ResetPassword; diff --git a/src/pages/Manager/User/index.tsx b/src/pages/Manager/User/index.tsx index d882ecb..0fd4faa 100644 --- a/src/pages/Manager/User/index.tsx +++ b/src/pages/Manager/User/index.tsx @@ -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({ + 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[] = [ { key: 'email', @@ -123,14 +137,28 @@ const ManagerUserPage = () => { hideInSearch: true, render: (_, user) => { return ( - <> -