feat(users): add user permissions management and enhance theme switcher

This commit is contained in:
Tran Anh Tuan
2026-01-22 13:22:55 +07:00
parent 32a69cb190
commit 0bac8d0f25
22 changed files with 881 additions and 124 deletions

View File

@@ -0,0 +1,67 @@
import { ROUTE_MANAGER_USERS } from '@/constants/routes';
import { apiQueryUserById } from '@/services/master/UserController';
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams } from '@umijs/max';
import { useEffect, useState } from 'react';
import AssignGroup from './components/AssignGroup';
import ShareThing from './components/ShareThing';
enum AssignTabsKey {
group = 'group',
device = 'device',
}
const AssignUserPage = () => {
const { userId } = useParams<{ userId: string }>();
const [userProfile, setUserProfile] =
useState<MasterModel.ProfileResponse | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
AssignTabsKey.group,
);
useEffect(() => {
const fetchUserProfile = async () => {
try {
setLoading(true);
const profile = await apiQueryUserById(userId || '');
setUserProfile(profile);
} catch (error) {
console.error('Failed to fetch user profile:', error);
} finally {
setLoading(false);
}
};
fetchUserProfile();
}, [userId]);
return (
<PageContainer
title={userProfile?.email}
header={{
onBack: () => history.push(ROUTE_MANAGER_USERS),
}}
loading={loading}
tabList={[
{
tab: 'Phân quyền đơn vị',
key: AssignTabsKey.group,
},
{
tab: 'Chia sẻ thiết bị',
key: AssignTabsKey.device,
},
]}
onTabChange={(key) => {
setTabSelected(key as AssignTabsKey);
}}
>
{tabSelected === AssignTabsKey.group && (
<AssignGroup user={userProfile} />
)}
{tabSelected === AssignTabsKey.device && (
<ShareThing user={userProfile} />
)}
</PageContainer>
);
};
export default AssignUserPage;

View File

@@ -0,0 +1,318 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { HTTPSTATUS } from '@/constants';
import {
apiAssignToGroup,
apiQueryMembers,
apiUnassignToGroup,
} from '@/services/master/GroupController';
import { apiChangeRoleUser } from '@/services/master/UserController';
import { DeleteOutlined } from '@ant-design/icons';
import { ActionType, ProList, ProListMetas } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import {
Button,
Flex,
message,
Modal,
Popconfirm,
Segmented,
Space,
Tag,
Tooltip,
Typography,
} from 'antd';
import { useRef, useState } from 'react';
const { Text } = Typography;
type AssignGroupProps = {
user: MasterModel.ProfileResponse | null;
};
const AssignGroup = ({ user }: AssignGroupProps) => {
const groupActionRef = useRef<ActionType>();
const intl = useIntl();
const [assignedGroups, setAssignedGroups] = useState<MasterModel.GroupNode[]>(
[],
);
const [messageApi, contextHolder] = message.useMessage();
const [userRole, setUserRole] = useState<string>(
user?.metadata?.user_type || 'users',
);
const [open, setOpen] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [groupSelected, setGroupSelected] = useState<string | string[] | null>(
null,
);
const columns: ProListMetas<MasterModel.GroupNode> = {
title: {
dataIndex: 'name',
render: (_, item: MasterModel.GroupNode) => (
<Typography.Paragraph
style={{
marginBottom: 0,
marginLeft: 8,
verticalAlign: 'middle',
display: 'inline-block',
}}
copyable
>
{item?.name}
</Typography.Paragraph>
),
},
subTitle: {
render: (_, entity) => {
return (
<Space>
<Tooltip
title={intl.formatMessage({
id: 'master.groups.code',
defaultMessage: 'Mã đơn vị',
})}
>
<Tag color="blue">{entity?.metadata?.code || '-'}</Tag>
</Tooltip>
<Tooltip
title={intl.formatMessage({
id: 'master.groups.short_name',
defaultMessage: 'Tên viết tắt',
})}
>
<Tag color="green">{entity?.metadata?.short_name || '-'}</Tag>
</Tooltip>
</Space>
);
},
},
actions: {
render: (_, item: MasterModel.GroupNode) => {
return (
<Popconfirm
title={`${intl.formatMessage({
id: 'master.users.unassign.content',
defaultMessage: 'Are you sure unassign this group',
})} ${item?.name}?`}
onConfirm={async () => {
try {
const body: MasterModel.AssignMemberRequest = {
group_id: item.id,
type: 'users',
members: [user?.id || ''],
};
const success = await apiUnassignToGroup(body);
if (success.status === HTTPSTATUS.HTTP_NOCONTENT) {
messageApi.success(
intl.formatMessage({
id: 'master.users.unassign.success',
defaultMessage: 'Unassign group successfully',
}),
);
groupActionRef.current?.reload();
} else {
throw new Error('Unassign group failed');
}
} catch (error) {
console.error('Error when unassign group: ', error);
messageApi.error(
intl.formatMessage({
id: 'master.users.unassign.fail',
defaultMessage: 'Unassign group failed',
}),
);
}
}}
>
<Button type="primary" danger icon={<DeleteOutlined />} />
</Popconfirm>
);
},
},
};
const showModal = () => {
setOpen(true);
};
const handleAssignUserToGroup = async () => {
setConfirmLoading(true);
try {
const body: MasterModel.AssignMemberRequest = {
group_id: groupSelected as string,
type: userRole === 'admin' ? 'admin' : 'users',
members: [user?.id || ''],
};
const resp = await apiAssignToGroup(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
messageApi.success(
intl.formatMessage({
id: 'master.users.assign.success',
defaultMessage: 'Assign group successfully',
}),
);
groupActionRef.current?.reload();
setOpen(false);
} else {
throw new Error('Assign group failed');
}
} catch (error) {
console.error('Error when assign group: ', error);
messageApi.error(
intl.formatMessage({
id: 'master.users.assign.fail',
defaultMessage: 'Assign group failed',
}),
);
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
setOpen(false);
};
return (
<>
{contextHolder}
<Modal
title={intl.formatMessage({
id: 'master.users.group_assign.button.title',
defaultMessage: 'Assign Groups',
})}
open={open}
onOk={handleAssignUserToGroup}
confirmLoading={confirmLoading}
onCancel={handleCancel}
>
<Flex vertical gap={5}>
<Text>
{intl.formatMessage({
id: 'master.users.group_assign.select.title',
defaultMessage: 'Select Group',
})}
:
</Text>
<TreeSelectedGroup
disabled
groupIds={assignedGroups.map((group) => group.id)}
onSelected={(value) => {
setGroupSelected(value);
}}
/>
</Flex>
</Modal>
<ProList<MasterModel.GroupNode>
headerTitle={intl.formatMessage({
id: 'master.users.group_assign.title',
defaultMessage: 'Assigned list',
})}
actionRef={groupActionRef}
metas={columns}
toolBarRender={() => [
user?.metadata?.user_type !== 'enduser' && (
<Segmented
key="role"
value={userRole}
onChange={(value) => {
Modal.confirm({
title: intl.formatMessage({
id: 'master.users.change_role.confirm.title',
defaultMessage: 'Xác nhận thay đổi vai trò',
}),
content:
userRole === 'admin'
? intl.formatMessage({
id: 'master.users.change_role.user.content',
defaultMessage:
'Bạn có chắc muốn thay đổi vai trò thành giám sát đơn vị không?',
})
: intl.formatMessage({
id: 'master.users.change_role.admin.content',
defaultMessage:
'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?',
}),
onOk: async () => {
try {
const resp = await apiChangeRoleUser(
user?.id || '',
value as 'users' | 'admin',
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
messageApi.success(
intl.formatMessage({
id: 'master.users.change_role.user.success',
defaultMessage: 'Role change successful',
}),
);
groupActionRef.current?.reload();
setUserRole(value as string);
} else {
throw new Error('Change role failed');
}
} catch (error) {
console.error('Error when change role user: ', error);
messageApi.error(
intl.formatMessage({
id: 'master.users.change_role.user.fail',
defaultMessage: 'Role change failed',
}),
);
}
},
});
}}
options={[
{
label: intl.formatMessage({
id: 'master.users.role.user',
defaultMessage: 'Giám sát',
}),
value: 'users',
},
{
label: intl.formatMessage({
id: 'master.users.role.admin',
defaultMessage: 'Quản lý',
}),
value: 'admin',
},
]}
/>
),
<Button
key="assign"
type="primary"
onClick={() => {
showModal();
}}
>
{intl.formatMessage({
id: 'master.users.group_assign.button.title',
defaultMessage: 'Assign',
})}
</Button>,
]}
request={async () => {
if (user?.id) {
const resp = await apiQueryMembers(user.id);
if (resp?.groups) {
setAssignedGroups(resp.groups);
return Promise.resolve({
success: true,
data: resp?.groups,
total: resp?.groups?.length || 0,
});
}
}
return Promise.resolve({
success: false,
data: [],
total: 0,
});
}}
rowKey="id"
search={false}
/>
</>
);
};
export default AssignGroup;

View File

@@ -0,0 +1,100 @@
import { ActionType, ProList } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { message } from 'antd';
import { useRef } from 'react';
type ShareThingProps = {
user: MasterModel.ProfileResponse | null;
};
const ShareThing = ({ user }: ShareThingProps) => {
const listActionRef = useRef<ActionType>();
const intl = useIntl();
const [messageAPI, contextHolder] = message.useMessage();
return (
<>
{contextHolder}
<ProList
headerTitle={intl.formatMessage({
id: 'pages.users.things.list',
defaultMessage: 'List things',
})}
actionRef={listActionRef}
toolBarRender={() => [
// <Button
// type="primary"
// key="primary"
// onClick={() => {
// handleShareModalVisible(true);
// }}
// >
// <PlusOutlined />{" "}
// <FormattedMessage
// id="pages.things.share.text"
// defaultMessage="Share"
// />
// </Button>,
]}
metas={columns}
request={async () => {
const query = {
type: 'sub',
id: user?.id || '',
};
if (user?.id) {
const resp = (await apiQueryThingsByPolicy(
query,
)) as PolicyResponse;
const { relations } = resp;
if (relations) {
const queries = relations.map(async (rel: PolicyRelation) => {
const thg = await apiQueryThing(rel.id);
return {
...thg,
relations: rel?.actions,
};
});
const policies = await Promise.all(queries);
return Promise.resolve({
success: true,
data: policies,
total: policies.length,
});
}
}
return Promise.resolve({
success: false,
data: [],
total: 0,
});
}}
rowKey="id"
search={false}
// rowSelection={{
// selectedRowKeys: selectedRowsState.map((row) => row.id).filter((id): id is string => id !== undefined),
// onChange: (_: React.Key[], selectedRows: API.Thing[]) => {
// setSelectedRows(selectedRows);
// },
// }}
/>
{/* <FormShareVms
visible={shareModalVisibale}
onVisibleChange={handleShareModalVisible}
user={user}
onSubmit={async (values: ShareFormValues) => {
console.log(values);
const success = await handleShare(values);
if (success) {
handleShareModalVisible(false);
onReload();
if (actionRef.current) {
//await delay(1000);
actionRef.current.reload();
}
}
}}
/> */}
</>
);
};
export default ShareThing;

View File

@@ -232,24 +232,28 @@ const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
{
validator: async (rule, value) => {
if (value) {
const query: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 1,
order: 'email',
email: value,
dir: 'asc',
};
const resp = await apiQueryUsers(query);
const { total } = resp;
if (total && total > 0) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.email.exists',
defaultMessage: 'The email is exists',
}),
),
);
try {
const query: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 1,
order: 'email',
email: value,
dir: 'asc',
};
const resp = await apiQueryUsers(query);
const { total } = resp;
if (total && total > 0) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.email.exists',
defaultMessage: 'The email is exists',
}),
),
);
}
} catch (error) {
return Promise.resolve();
}
}
return Promise.resolve();

View File

@@ -2,17 +2,24 @@ import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
ROUTE_MANAGER_USERS,
ROUTE_MANAGER_USERS_PERMISSIONS,
} from '@/constants/routes';
import {
apiDeleteUser,
apiQueryUsers,
apiQueryUsersByGroup,
} from '@/services/master/UserController';
import { DeleteOutlined } from '@ant-design/icons';
import {
ActionType,
FooterToolbar,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Grid } from 'antd';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Grid, Popconfirm, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
@@ -22,18 +29,21 @@ const ManagerUserPage = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const { token } = theme.useToken();
const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.ProfileResponse[]
>([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null
>(null);
const handleClickAssign = (user: MasterModel.ProfileResponse) => {
console.log('User ', user);
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
history.push(path);
};
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
@@ -53,6 +63,7 @@ const ManagerUserPage = () => {
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
@@ -82,6 +93,7 @@ const ManagerUserPage = () => {
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
@@ -124,6 +136,48 @@ const ManagerUserPage = () => {
},
];
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => {
const key = 'remove_user';
if (!selectedRows) return true;
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'common.deleting',
defaultMessage: 'deleting...',
}),
duration: 0,
key,
});
const allDelete = selectedRows.map(
async (row: MasterModel.ProfileResponse) => {
await apiDeleteUser(row?.id || '');
},
);
await Promise.all(allDelete);
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.users.delete.success',
defaultMessage: 'Deleted successfully and will refresh soon',
}),
key,
});
return true;
} catch (error) {
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.users.delete.fail',
defaultMessage: 'Delete failed, please try again!',
}),
key,
});
return false;
}
};
return (
<>
{contextHolder}
@@ -182,56 +236,66 @@ const ManagerUserPage = () => {
const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true);
// If groups are checked, use queryUsersByGroup
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
// Ensure groupCheckedKeys is an array
const groupIdsArray = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
try {
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
// Ensure groupCheckedKeys is an array
const groupIdsArray = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
const userByGroupResponses = await apiQueryUsersByGroup(
groupIdsArray,
);
let users = userByGroupResponses.users || [];
// Apply filters
if (email) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.email?.includes(email),
const userByGroupResponses = await apiQueryUsersByGroup(
groupIdsArray,
);
}
if (phone_number) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.metadata?.phone_number?.includes(phone_number),
);
}
let users = userByGroupResponses.users || [];
// Apply filters
if (email) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.email?.includes(email),
);
}
if (phone_number) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.metadata?.phone_number?.includes(phone_number),
);
}
const total = users.length;
const paginatedUsers = users.slice(offset, offset + size);
const total = users.length;
const paginatedUsers = users.slice(offset, offset + size);
setIsLoading(false);
return {
data: paginatedUsers,
success: true,
total: total,
};
} else {
// Use regular queryUsers API
const metadata: Partial<MasterModel.ProfileMetadata> = {};
if (phone_number) metadata.phone_number = phone_number;
const query: MasterModel.SearchUserPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (email) query.email = email;
if (Object.keys(metadata).length > 0)
query.metadata = metadata;
const response = await apiQueryUsers(query);
setIsLoading(false);
return {
data: response.users,
success: true,
total: response.total,
};
}
} catch (error) {
setIsLoading(false);
return {
data: paginatedUsers,
success: true,
total: total,
};
} else {
// Use regular queryUsers API
const metadata: Partial<MasterModel.ProfileMetadata> = {};
if (phone_number) metadata.phone_number = phone_number;
const query: MasterModel.SearchUserPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (email) query.email = email;
if (Object.keys(metadata).length > 0) query.metadata = metadata;
const response = await apiQueryUsers(query);
setIsLoading(false);
return {
data: response.users,
success: true,
total: response.total,
data: [],
success: false,
total: 0,
};
}
}}
@@ -255,6 +319,53 @@ const ManagerUserPage = () => {
/>
</ProCard>
</ProCard>
{selectedRowsState?.length > 0 && (
<FooterToolbar
extra={
<div>
<FormattedMessage
id="master.footer.chosen"
defaultMessage="Chosen"
/>{' '}
<a
style={{
fontWeight: 600,
}}
>
{selectedRowsState.length}
</a>{' '}
<FormattedMessage
id="master.users.table.pagination"
defaultMessage="users"
/>
</div>
}
>
<Popconfirm
title={intl.formatMessage({
id: 'master.users.deletion.title',
defaultMessage: 'Are you sure to delete this selected items',
})}
okText={intl.formatMessage({
id: 'common.sure',
defaultMessage: 'Sure',
})}
onConfirm={async () => {
const success = await handleRemove(selectedRowsState);
if (success) {
setSelectedRowsState([]);
if (actionRef.current) {
actionRef.current.reload();
}
}
}}
>
<Button icon={<DeleteOutlined />} type="primary" danger>
<FormattedMessage id="common.delete" defaultMessage="Delete" />
</Button>
</Popconfirm>
</FooterToolbar>
)}
</>
);
};