feat(users): add reset password functionality for users and implement forgot password page

This commit is contained in:
Tran Anh Tuan
2026-02-03 17:33:47 +07:00
parent 9bc15192ec
commit dca363275e
35 changed files with 1592 additions and 321 deletions

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