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

@@ -1,3 +1,6 @@
module.exports = { module.exports = {
extends: require.resolve('@umijs/max/eslint'), extends: require.resolve('@umijs/max/eslint'),
rules: {
'@typescript-eslint/no-unused-vars': 'off',
},
}; };

1
README.md Normal file
View File

@@ -0,0 +1 @@
Source web của các dự án SMATEC

View File

@@ -61,6 +61,10 @@ export const commonManagerRoutes = [
path: '/manager/users', path: '/manager/users',
component: './Manager/User', component: './Manager/User',
}, },
{
path: '/manager/users/:userId/permissions',
component: './Manager/User/Permission/Assign',
},
], ],
}, },
{ {

View File

@@ -1,6 +1,10 @@
import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes'; import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes';
import { clearAllData, clearSessionData, removeToken } from '@/utils/storage'; import { clearAllData, clearSessionData, removeToken } from '@/utils/storage';
import { LogoutOutlined, ProfileOutlined } from '@ant-design/icons'; import {
LogoutOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons';
import { history, useIntl } from '@umijs/max'; import { history, useIntl } from '@umijs/max';
import { Dropdown } from 'antd'; import { Dropdown } from 'antd';
@@ -18,7 +22,7 @@ export const AvatarDropdown = ({
items: [ items: [
{ {
key: ROUTE_PROFILE, key: ROUTE_PROFILE,
icon: <ProfileOutlined />, icon: <SettingOutlined />,
label: intl.formatMessage({ id: 'menu.profile' }), label: intl.formatMessage({ id: 'menu.profile' }),
onClick: () => { onClick: () => {
history.push(ROUTE_PROFILE); history.push(ROUTE_PROFILE);
@@ -42,17 +46,8 @@ export const AvatarDropdown = ({
}} }}
> >
<div className="flex gap-2"> <div className="flex gap-2">
<img <UserOutlined />
src="/avatar.svg" <span className="font-sans">
alt="avatar"
style={{
width: 32,
height: 32,
borderRadius: '50%',
cursor: 'pointer',
}}
/>
<span className="font-bold">
{currentUserProfile?.metadata?.full_name || ''} {currentUserProfile?.metadata?.full_name || ''}
</span> </span>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { THEME_KEY } from '@/constants'; import { THEME_KEY } from '@/constants';
import { MoonOutlined, SunOutlined } from '@ant-design/icons'; import { MoonOutlined, SunOutlined } from '@ant-design/icons';
import { useIntl, useModel } from '@umijs/max'; import { useModel } from '@umijs/max';
import { Button, Dropdown } from 'antd'; import { Segmented } from 'antd';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
export interface ThemeSwitcherProps { export interface ThemeSwitcherProps {
@@ -10,7 +10,6 @@ export interface ThemeSwitcherProps {
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => { const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
const { initialState, setInitialState } = useModel('@@initialState'); const { initialState, setInitialState } = useModel('@@initialState');
const intl = useIntl();
const [isDark, setIsDark] = useState( const [isDark, setIsDark] = useState(
(initialState?.theme as 'light' | 'dark') === 'dark', (initialState?.theme as 'light' | 'dark') === 'dark',
); );
@@ -30,46 +29,80 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
}, []); }, []);
const handleThemeChange = (newTheme: 'light' | 'dark') => { const handleThemeChange = (newTheme: 'light' | 'dark') => {
// Check if browser supports View Transitions API
const supportsViewTransition = 'startViewTransition' in document;
if (!supportsViewTransition) {
// Fallback: just change theme without animation
localStorage.setItem(THEME_KEY, newTheme); localStorage.setItem(THEME_KEY, newTheme);
// Update global state để trigger layout re-render setIsDark(newTheme === 'dark');
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
);
setInitialState({ setInitialState({
...initialState, ...initialState,
theme: newTheme, theme: newTheme,
} as any).then(() => { } as any);
// Dispatch event để notify ThemeProvider return;
}
// Set origin to top-right corner
const x = window.innerWidth;
const y = 0;
// Calculate end radius to cover the entire screen
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
// Start the view transition
const transition = (document as any).startViewTransition(() => {
localStorage.setItem(THEME_KEY, newTheme);
setIsDark(newTheme === 'dark');
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }), new CustomEvent('theme-change', { detail: { theme: newTheme } }),
); );
}); });
// Animate the ripple effect
transition.ready.then(() => {
requestAnimationFrame(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 800,
easing: 'ease-in-out',
pseudoElement: '::view-transition-new(root)',
},
);
});
});
// Update initialState after transition
setInitialState({
...initialState,
theme: newTheme,
} as any);
}; };
const items = [
{
key: 'light',
label: intl.formatMessage({
id: 'common.theme.light',
defaultMessage: 'Light Theme',
}),
icon: <SunOutlined />,
onClick: () => handleThemeChange('light'),
},
{
key: 'dark',
label: intl.formatMessage({
id: 'common.theme.dark',
defaultMessage: 'Dark Theme',
}),
icon: <MoonOutlined />,
onClick: () => handleThemeChange('dark'),
},
];
return ( return (
<Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}> <Segmented
<Button type="text" className={className}> className={className}
{isDark ? <MoonOutlined /> : <SunOutlined />} size="small"
</Button> shape="round"
</Dropdown> value={isDark ? 'dark' : 'light'}
onChange={(value) => handleThemeChange(value as 'light' | 'dark')}
options={[
{ value: 'light', icon: <SunOutlined /> },
{ value: 'dark', icon: <MoonOutlined /> },
]}
/>
); );
}; };

View File

@@ -1,3 +1,12 @@
/* View Transitions API - Disable default animation for custom ripple effect */
/* stylelint-disable selector-pseudo-element-no-unknown */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/* stylelint-enable selector-pseudo-element-no-unknown */
/* From Uiverse.io by Galahhad */ /* From Uiverse.io by Galahhad */
.theme-switch { .theme-switch {
--toggle-size: 20px; --toggle-size: 20px;

View File

@@ -8,6 +8,7 @@ export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants // Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search'; export const API_THINGS_SEARCH = '/api/things/search';
export const API_THINGS_POLICY = '/api/things/policy';
// Group API Constants // Group API Constants
export const API_GROUPS = '/api/groups'; export const API_GROUPS = '/api/groups';

View File

@@ -1,3 +1,5 @@
export const ROUTE_LOGIN = '/login'; export const ROUTE_LOGIN = '/login';
export const ROUTER_HOME = '/'; export const ROUTER_HOME = '/';
export const ROUTE_PROFILE = '/profile'; export const ROUTE_PROFILE = '/profile';
export const ROUTE_MANAGER_USERS = '/manager/users';
export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions';

View File

@@ -7,6 +7,7 @@ import masterMenuProfileEn from './master-profile-en';
import masterThingEn from './master-thing-en'; import masterThingEn from './master-thing-en';
import masterUserEn from './master-user-en'; import masterUserEn from './master-user-en';
export default { export default {
'master.footer.chosen': 'Chosen',
...masterAuthEn, ...masterAuthEn,
...masterMenuEn, ...masterMenuEn,
...masterMenuProfileEn, ...masterMenuProfileEn,

View File

@@ -32,4 +32,23 @@ export default {
'master.users.role.sgw.end_user': 'Ship Owner', 'master.users.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed', 'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully', 'master.users.create.success': 'User created successfully',
'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?',
'master.users.change_role.user.content':
'Are you sure you want to change the role to Unit Supervisor?',
'master.users.change_role.user.success': 'Role change successful',
'master.users.change_role.user.fail': 'Role change failed',
'master.users.group_assign.title': 'Assigned Groups',
'master.users.group_assign.button.title': 'Assign Groups',
'master.users.group_assign.select.title': 'Select Group',
'master.users.unassign.content':
'Are you sure you want to unassign this group?',
'master.users.unassign.success': 'Unassign successful',
'master.users.unassign.fail': 'Unassign failed',
'master.users.assign.success': 'Assign group successful',
'master.users.assign.fail': 'Assign group failed',
'master.users.deletion.title': 'Are you sure to delete this selected items?',
'master.users.delete.success': 'User deleted successfully',
'master.users.delete.fail': 'User deletion failed',
}; };

View File

@@ -4,7 +4,7 @@ export default {
'Không thể tạo đơn vị con khi gốc đã có thiết bị', 'Không thể tạo đơn vị con khi gốc đã có thiết bị',
'master.groups.add': 'Tạo đơn vị cấp dưới', 'master.groups.add': 'Tạo đơn vị cấp dưới',
'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?', 'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?',
'master.groups.code': 'Mã', 'master.groups.code': 'Mã đỡ vị',
'master.groups.code.exists': 'Mã đã tồn tại', 'master.groups.code.exists': 'Mã đã tồn tại',
'master.groups.short_name': 'Tên viết tắt', 'master.groups.short_name': 'Tên viết tắt',
'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại', 'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại',

View File

@@ -32,4 +32,22 @@ export default {
'master.users.role.sgw.end_user': 'Chủ tàu', 'master.users.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi', '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.create.success': 'Tạo người dùng thành công',
'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?',
'master.users.change_role.user.content':
'Bạn có chắc muốn thay đổi vai trò thành giám sát đơn vị không?',
'master.users.change_role.user.success': 'Thay đổi vai trò thành công',
'master.users.change_role.user.fail': 'Thay đổi vai trò thất bại',
'master.users.group_assign.title': 'Đơn vị được phân quyền',
'master.users.group_assign.button.title': 'Phân quyền đơn vị',
'master.users.group_assign.select.title': 'Chọn đơn vị',
'master.users.unassign.content': 'Bạn chắc chắn ngừng phân quyền khỏi đơn vị',
'master.users.unassign.success': 'Ngừng phân quyền thành công',
'master.users.unassign.fail': 'Ngừng phân quyền thất bại',
'master.users.assign.success': 'Phân quyền đơn vị thành công',
'master.users.assign.fail': 'Phân quyền đơn vị thất bại',
'master.users.deletion.title': 'Chắc chắn xoá các tài khoản đã chọn?',
'master.users.delete.success': 'Xoá người dùng thành công',
'master.users.delete.fail': 'Xoá người dùng thất bại',
}; };

View File

@@ -7,6 +7,7 @@ import masterProfileVi from './master-profile-vi';
import masterThingVi from './master-thing-vi'; import masterThingVi from './master-thing-vi';
import masterUserVi from './master-user-vi'; import masterUserVi from './master-user-vi';
export default { export default {
'master.footer.chosen': 'Đã chọn',
...masterAuthVi, ...masterAuthVi,
...masterMenuVi, ...masterMenuVi,
...masterProfileVi, ...masterProfileVi,

View File

@@ -38,7 +38,7 @@ const AlarmFormConfirm = ({
formRef={formRef} formRef={formRef}
title={ title={
<Flex align="center" justify="center"> <Flex align="center" justify="center">
<FormattedMessage id="alarms.confirm.title" /> <FormattedMessage id="master.alarms.confirm.title" />
</Flex> </Flex>
} }
width="40%" width="40%"
@@ -66,7 +66,7 @@ const AlarmFormConfirm = ({
if (resp.status === HTTPSTATUS.HTTP_ACCEPTED) { if (resp.status === HTTPSTATUS.HTTP_ACCEPTED) {
message.success({ message.success({
content: intl.formatMessage({ content: intl.formatMessage({
id: 'alarms.confirm.success', id: 'master.alarms.confirm.success',
defaultMessage: 'Confirm alarm successfully', defaultMessage: 'Confirm alarm successfully',
}), }),
}); });
@@ -74,7 +74,7 @@ const AlarmFormConfirm = ({
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) { } else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
message.warning({ message.warning({
content: intl.formatMessage({ content: intl.formatMessage({
id: 'alarms.not_found', id: 'master.alarms.not_found',
defaultMessage: 'Alarm has expired or does not exist', defaultMessage: 'Alarm has expired or does not exist',
}), }),
}); });
@@ -86,7 +86,7 @@ const AlarmFormConfirm = ({
console.error('Error when confirm alarm: ', error); console.error('Error when confirm alarm: ', error);
message.error({ message.error({
content: intl.formatMessage({ content: intl.formatMessage({
id: 'alarms.confirm.fail', id: 'master.alarms.confirm.fail',
defaultMessage: 'Confirm alarm failed', defaultMessage: 'Confirm alarm failed',
}), }),
}); });
@@ -120,7 +120,7 @@ const AlarmFormConfirm = ({
<ProFormText <ProFormText
name="time" name="time"
label={intl.formatMessage({ label={intl.formatMessage({
id: 'alarms.occurred_at', id: 'master.alarms.occurred_at',
defaultMessage: 'When', defaultMessage: 'When',
})} })}
readonly={true} readonly={true}
@@ -139,7 +139,7 @@ const AlarmFormConfirm = ({
{ {
required: true, required: true,
message: intl.formatMessage({ message: intl.formatMessage({
id: 'alarms.confirm.description.required', id: 'master.alarms.confirm.description.required',
defaultMessage: 'The description is required', defaultMessage: 'The description is required',
}), }),
}, },

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,6 +232,7 @@ const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
{ {
validator: async (rule, value) => { validator: async (rule, value) => {
if (value) { if (value) {
try {
const query: MasterModel.SearchUserPaginationBody = { const query: MasterModel.SearchUserPaginationBody = {
offset: 0, offset: 0,
limit: 1, limit: 1,
@@ -251,6 +252,9 @@ const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
), ),
); );
} }
} catch (error) {
return Promise.resolve();
}
} }
return Promise.resolve(); return Promise.resolve();
}, },

View File

@@ -2,17 +2,24 @@ import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup'; import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { import {
ROUTE_MANAGER_USERS,
ROUTE_MANAGER_USERS_PERMISSIONS,
} from '@/constants/routes';
import {
apiDeleteUser,
apiQueryUsers, apiQueryUsers,
apiQueryUsersByGroup, apiQueryUsersByGroup,
} from '@/services/master/UserController'; } from '@/services/master/UserController';
import { DeleteOutlined } from '@ant-design/icons';
import { import {
ActionType, ActionType,
FooterToolbar,
ProCard, ProCard,
ProColumns, ProColumns,
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max'; import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Grid } from 'antd'; import { Button, Grid, Popconfirm, theme } from 'antd';
import message from 'antd/es/message'; import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph'; import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
@@ -22,18 +29,21 @@ const ManagerUserPage = () => {
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const intl = useIntl(); const intl = useIntl();
const screens = useBreakpoint(); const screens = useBreakpoint();
const { token } = theme.useToken();
const actionRef = useRef<ActionType | null>(null); const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState< const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.ProfileResponse[] MasterModel.ProfileResponse[]
>([]); >([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState< const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null string | string[] | null
>(null); >(null);
const handleClickAssign = (user: MasterModel.ProfileResponse) => { 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>[] = [ const columns: ProColumns<MasterModel.ProfileResponse>[] = [
@@ -53,6 +63,7 @@ const ManagerUserPage = () => {
marginBottom: 0, marginBottom: 0,
verticalAlign: 'middle', verticalAlign: 'middle',
display: 'inline-block', display: 'inline-block',
color: token.colorText,
}} }}
copyable copyable
> >
@@ -82,6 +93,7 @@ const ManagerUserPage = () => {
marginBottom: 0, marginBottom: 0,
verticalAlign: 'middle', verticalAlign: 'middle',
display: 'inline-block', display: 'inline-block',
color: token.colorText,
}} }}
copyable 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 ( return (
<> <>
{contextHolder} {contextHolder}
@@ -182,6 +236,7 @@ const ManagerUserPage = () => {
const offset = current === 1 ? 0 : (current - 1) * size; const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true); setIsLoading(true);
// If groups are checked, use queryUsersByGroup // If groups are checked, use queryUsersByGroup
try {
if (groupCheckedKeys && groupCheckedKeys.length > 0) { if (groupCheckedKeys && groupCheckedKeys.length > 0) {
// Ensure groupCheckedKeys is an array // Ensure groupCheckedKeys is an array
const groupIdsArray = Array.isArray(groupCheckedKeys) const groupIdsArray = Array.isArray(groupCheckedKeys)
@@ -224,7 +279,8 @@ const ManagerUserPage = () => {
dir: 'asc', dir: 'asc',
}; };
if (email) query.email = email; if (email) query.email = email;
if (Object.keys(metadata).length > 0) query.metadata = metadata; if (Object.keys(metadata).length > 0)
query.metadata = metadata;
const response = await apiQueryUsers(query); const response = await apiQueryUsers(query);
setIsLoading(false); setIsLoading(false);
@@ -234,6 +290,14 @@ const ManagerUserPage = () => {
total: response.total, total: response.total,
}; };
} }
} catch (error) {
setIsLoading(false);
return {
data: [],
success: false,
total: 0,
};
}
}} }}
options={{ options={{
search: false, search: false,
@@ -255,6 +319,53 @@ const ManagerUserPage = () => {
/> />
</ProCard> </ProCard>
</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>
)}
</> </>
); );
}; };

View File

@@ -55,3 +55,20 @@ export async function apiDeleteGroup(group_id: string) {
}, },
); );
} }
export async function apiUnassignToGroup(
body: MasterModel.AssignMemberRequest,
) {
return request(`${API_GROUPS}/${body.group_id}/members`, {
method: 'DELETE',
data: body,
getResponse: true,
});
}
export async function apiAssignToGroup(body: MasterModel.AssignMemberRequest) {
return request(`${API_GROUPS}/${body.group_id}/members`, {
method: 'POST',
data: body,
getResponse: true,
});
}

View File

@@ -1,4 +1,4 @@
import { API_USERS, API_USERS_BY_GROUP } from '@/constants/api'; import { API_GROUPS, API_USERS, API_USERS_BY_GROUP } from '@/constants/api';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
export async function apiQueryUsers( export async function apiQueryUsers(
@@ -9,6 +9,10 @@ export async function apiQueryUsers(
}); });
} }
export async function apiQueryUserById(userId: string) {
return request<MasterModel.ProfileResponse>(`${API_USERS}/${userId}`);
}
export async function apiQueryUsersByGroup( export async function apiQueryUsersByGroup(
group_id: string, group_id: string,
): Promise<MasterModel.UserResponse> { ): Promise<MasterModel.UserResponse> {
@@ -22,3 +26,19 @@ export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
getResponse: true, getResponse: true,
}); });
} }
export async function apiChangeRoleUser(
userId: string,
role: 'users' | 'admin',
) {
return request(`${API_GROUPS}/${userId}/${role}`, {
method: 'PUT',
getResponse: true,
});
}
export async function apiDeleteUser(userId: string) {
return request(`${API_USERS}/${userId}`, {
method: 'DELETE',
getResponse: true,
});
}

View File

@@ -36,6 +36,7 @@ declare namespace MasterModel {
subtopic?: string; subtopic?: string;
} }
// Auth
interface LoginRequestBody { interface LoginRequestBody {
guid: string; guid: string;
email: string; email: string;
@@ -66,6 +67,7 @@ declare namespace MasterModel {
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users'; user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
} }
// User
interface CreateUserMetadata extends ProfileMetadata { interface CreateUserMetadata extends ProfileMetadata {
group_id?: string; group_id?: string;
} }
@@ -96,7 +98,7 @@ declare namespace MasterModel {
thing_id?: string; thing_id?: string;
time?: number; time?: number;
} }
// Alarm
interface Alarm { interface Alarm {
name?: string; name?: string;
time?: number; time?: number;
@@ -110,6 +112,8 @@ declare namespace MasterModel {
thing_name?: string; thing_name?: string;
thing_type?: string; thing_type?: string;
} }
// Thing
interface ThingMetadata { interface ThingMetadata {
address?: string; address?: string;
alarm_list?: string; alarm_list?: string;
@@ -155,6 +159,25 @@ declare namespace MasterModel {
metadata?: T; metadata?: T;
} }
// Thing Policy
interface ThingPolicy {
next_page_token?: string;
relations?: Relation[];
}
interface Relation {
id?: string;
actions?: Action[];
}
enum Action {
Delete = 'delete',
Read = 'read',
Write = 'write',
}
// Group
interface GroupBodyRequest { interface GroupBodyRequest {
id?: string; id?: string;
name: string; name: string;
@@ -200,6 +223,8 @@ declare namespace MasterModel {
[key: string]: any; [key: string]: any;
} }
// Log
type LogTypeRequest = 'user_logs' | undefined; type LogTypeRequest = 'user_logs' | undefined;
interface LogResponse { interface LogResponse {
@@ -222,4 +247,12 @@ declare namespace MasterModel {
time?: number; time?: number;
string_value?: string; string_value?: string;
} }
// User
interface AssignMemberRequest {
group_id: string;
type: 'users' | 'admin' | 'things' | undefined;
members: string[];
}
} }