From 977cd70d6774d29a95b5e193c27c6acfab85ffa1 Mon Sep 17 00:00:00 2001 From: Douglas Fabris <devfabris@gmail.com> Date: Thu, 14 Apr 2022 16:30:04 -0300 Subject: [PATCH] [IMPROVE] Rewrite Admin Permissions to Typescript Co-authored-by: gabriellsh <gabriel.henriques@rocket.chat> --- apps/meteor/app/api/server/v1/roles.ts | 2 + .../components/GenericTable/HeaderCell.tsx | 1 + .../GenericTable/hooks/usePagination.ts | 24 ++-- .../{EditRolePage.js => EditRolePage.tsx} | 71 +++++++---- .../permissions/EditRolePageContainer.js | 19 --- .../permissions/EditRolePageWithData.tsx | 22 ++++ .../views/admin/permissions/NewRolePage.js | 62 --------- .../views/admin/permissions/PermissionRow.js | 71 ----------- ...ontextBar.js => PermissionsContextBar.tsx} | 18 ++- ...issionsRouter.js => PermissionsRouter.tsx} | 8 +- .../admin/permissions/PermissionsTable.js | 117 ----------------- .../PermissionsTable/PermissionRow.tsx | 63 ++++++++++ .../PermissionsTable/PermissionsTable.tsx | 119 ++++++++++++++++++ .../PermissionsTableFilter.tsx} | 9 +- .../RoleCell.tsx} | 23 +++- .../RoleHeader.tsx} | 22 +++- .../permissions/PermissionsTable/index.ts | 1 + .../views/admin/permissions/RoleForm.js | 55 -------- .../views/admin/permissions/RoleForm.tsx | 71 +++++++++++ .../client/views/admin/permissions/UserRow.js | 45 ------- .../UsersInRolePage.tsx} | 56 ++++----- .../UsersInRole/UsersInRolePageWithData.tsx | 18 +++ .../UsersInRoleTable/UsersInRoleTable.tsx | 91 ++++++++++++++ .../UsersInRoleTable/UsersInRoleTableRow.tsx | 52 ++++++++ .../UsersInRoleTableWithData.tsx | 66 ++++++++++ .../UsersInRole/UsersInRoleTable/index.ts | 1 + .../admin/permissions/UsersInRole/index.ts | 1 + .../permissions/UsersInRolePageContainer.js | 19 --- .../admin/permissions/UsersInRoleTable.js | 59 --------- .../permissions/UsersInRoleTableContainer.js | 43 ------- .../admin/permissions/hooks/useChangeRole.ts | 30 +++++ .../hooks/usePermissionsAndRoles.ts | 15 ++- .../views/admin/permissions/hooks/useRole.ts | 7 ++ .../client/views/admin/permissions/useRole.js | 6 - packages/core-typings/src/IUser.ts | 5 + packages/rest-typings/src/v1/roles.ts | 10 +- 36 files changed, 693 insertions(+), 609 deletions(-) rename apps/meteor/client/views/admin/permissions/{EditRolePage.js => EditRolePage.tsx} (58%) delete mode 100644 apps/meteor/client/views/admin/permissions/EditRolePageContainer.js create mode 100644 apps/meteor/client/views/admin/permissions/EditRolePageWithData.tsx delete mode 100644 apps/meteor/client/views/admin/permissions/NewRolePage.js delete mode 100644 apps/meteor/client/views/admin/permissions/PermissionRow.js rename apps/meteor/client/views/admin/permissions/{PermissionsContextBar.js => PermissionsContextBar.tsx} (57%) rename apps/meteor/client/views/admin/permissions/{PermissionsRouter.js => PermissionsRouter.tsx} (75%) delete mode 100644 apps/meteor/client/views/admin/permissions/PermissionsTable.js create mode 100644 apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionRow.tsx create mode 100644 apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx rename apps/meteor/client/views/admin/permissions/{FilterComponent.js => PermissionsTable/PermissionsTableFilter.tsx} (65%) rename apps/meteor/client/views/admin/permissions/{RoleCell.js => PermissionsTable/RoleCell.tsx} (55%) rename apps/meteor/client/views/admin/permissions/{RoleHeader.js => PermissionsTable/RoleHeader.tsx} (51%) create mode 100644 apps/meteor/client/views/admin/permissions/PermissionsTable/index.ts delete mode 100644 apps/meteor/client/views/admin/permissions/RoleForm.js create mode 100644 apps/meteor/client/views/admin/permissions/RoleForm.tsx delete mode 100644 apps/meteor/client/views/admin/permissions/UserRow.js rename apps/meteor/client/views/admin/permissions/{UsersInRolePage.js => UsersInRole/UsersInRolePage.tsx} (61%) create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePageWithData.tsx create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTable.tsx create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableWithData.tsx create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/index.ts create mode 100644 apps/meteor/client/views/admin/permissions/UsersInRole/index.ts delete mode 100644 apps/meteor/client/views/admin/permissions/UsersInRolePageContainer.js delete mode 100644 apps/meteor/client/views/admin/permissions/UsersInRoleTable.js delete mode 100644 apps/meteor/client/views/admin/permissions/UsersInRoleTableContainer.js create mode 100644 apps/meteor/client/views/admin/permissions/hooks/useChangeRole.ts create mode 100644 apps/meteor/client/views/admin/permissions/hooks/useRole.ts delete mode 100644 apps/meteor/client/views/admin/permissions/useRole.js diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index 6ebf23aab8c..9785741326d 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -150,6 +150,8 @@ API.v1.addRoute( username: 1, emails: 1, avatarETag: 1, + createdAt: 1, + _updatedAt: 1, }; if (!role) { diff --git a/apps/meteor/client/components/GenericTable/HeaderCell.tsx b/apps/meteor/client/components/GenericTable/HeaderCell.tsx index 414dc1fa1be..b7161614c56 100644 --- a/apps/meteor/client/components/GenericTable/HeaderCell.tsx +++ b/apps/meteor/client/components/GenericTable/HeaderCell.tsx @@ -7,6 +7,7 @@ type HeaderCellProps = { active?: boolean; direction?: 'asc' | 'desc'; sort?: string; + clickable?: boolean; onClick?: (sort: string) => void; } & ComponentProps<typeof Box>; diff --git a/apps/meteor/client/components/GenericTable/hooks/usePagination.ts b/apps/meteor/client/components/GenericTable/hooks/usePagination.ts index 3f0558f4ac9..31276fd57ce 100644 --- a/apps/meteor/client/components/GenericTable/hooks/usePagination.ts +++ b/apps/meteor/client/components/GenericTable/hooks/usePagination.ts @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; + import { useCurrent } from './useCurrent'; import { useItemsPerPage } from './useItemsPerPage'; import { useItemsPerPageLabel } from './useItemsPerPageLabel'; @@ -12,19 +14,19 @@ export const usePagination = (): { showingResultsLabel: ReturnType<typeof useShowingResultsLabel>; } => { const [itemsPerPage, setItemsPerPage] = useItemsPerPage(); - const [current, setCurrent] = useCurrent(); - const itemsPerPageLabel = useItemsPerPageLabel(); - const showingResultsLabel = useShowingResultsLabel(); - return { - itemsPerPage, - setItemsPerPage, - current, - setCurrent, - itemsPerPageLabel, - showingResultsLabel, - }; + return useMemo( + () => ({ + itemsPerPage, + setItemsPerPage, + current, + setCurrent, + itemsPerPageLabel, + showingResultsLabel, + }), + [itemsPerPage, setItemsPerPage, current, setCurrent, itemsPerPageLabel, showingResultsLabel], + ); }; diff --git a/apps/meteor/client/views/admin/permissions/EditRolePage.js b/apps/meteor/client/views/admin/permissions/EditRolePage.tsx similarity index 58% rename from apps/meteor/client/views/admin/permissions/EditRolePage.js rename to apps/meteor/client/views/admin/permissions/EditRolePage.tsx index 512f6157da1..73966022002 100644 --- a/apps/meteor/client/views/admin/permissions/EditRolePage.js +++ b/apps/meteor/client/views/admin/permissions/EditRolePage.tsx @@ -1,6 +1,8 @@ +import { IRole } from '@rocket.chat/core-typings'; import { Box, ButtonGroup, Button, Margins } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React from 'react'; +import React, { ReactElement } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; import GenericModal from '../../../components/GenericModal'; import VerticalBar from '../../../components/VerticalBar'; @@ -9,37 +11,47 @@ import { useRoute } from '../../../contexts/RouterContext'; import { useEndpoint } from '../../../contexts/ServerContext'; import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; import { useTranslation } from '../../../contexts/TranslationContext'; -import { useForm } from '../../../hooks/useForm'; import RoleForm from './RoleForm'; -const EditRolePage = ({ data }) => { +const EditRolePage = ({ role }: { role?: IRole }): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); const usersInRoleRouter = useRoute('admin-permissions'); const router = useRoute('admin-permissions'); - const { values, handlers, hasUnsavedChanges } = useForm({ - roleId: data._id, - name: data.name, - description: data.description || '', - scope: data.scope || 'Users', - mandatory2fa: !!data.mandatory2fa, - }); - - const saveRole = useEndpoint('POST', 'roles.update'); + const createRole = useEndpoint('POST', 'roles.create'); + const updateRole = useEndpoint('POST', 'roles.update'); const deleteRole = useEndpoint('POST', 'roles.delete'); + const methods = useForm({ + defaultValues: { + roleId: role?._id, + name: role?.name, + description: role?.description, + scope: role?.scope || 'Users', + mandatory2fa: !!role?.mandatory2fa, + }, + }); + const handleManageUsers = useMutableCallback(() => { - usersInRoleRouter.push({ - context: 'users-in-role', - _id: data._id, - }); + if (role?._id) { + usersInRoleRouter.push({ + context: 'users-in-role', + _id: role._id, + }); + } }); - const handleSave = useMutableCallback(async () => { + const handleSave = useMutableCallback(async (data) => { try { - await saveRole(values); + if (data.roleId) { + await updateRole(data); + dispatchToastMessage({ type: 'success', message: t('Saved') }); + return router.push({}); + } + + await createRole(data); dispatchToastMessage({ type: 'success', message: t('Saved') }); router.push({}); } catch (error) { @@ -48,11 +60,16 @@ const EditRolePage = ({ data }) => { }); const handleDelete = useMutableCallback(async () => { - const deleteRoleAction = async () => { + if (!role?._id) { + return; + } + + const deleteRoleAction = async (): Promise<void> => { try { - await deleteRole({ roleId: data._id }); + await deleteRole({ roleId: role._id }); dispatchToastMessage({ type: 'success', message: t('Role_removed') }); setModal(); + router.push({}); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); @@ -64,8 +81,8 @@ const EditRolePage = ({ data }) => { <GenericModal variant='danger' onConfirm={deleteRoleAction} - onClose={() => setModal()} - onCancel={() => setModal()} + onClose={(): void => setModal()} + onCancel={(): void => setModal()} confirmText={t('Delete')} > {t('Delete_Role_Warning')} @@ -78,21 +95,23 @@ const EditRolePage = ({ data }) => { <VerticalBar.ScrollableContent> <Box w='full' alignSelf='center' mb='neg-x8'> <Margins block='x8'> - <RoleForm values={values} handlers={handlers} editing isProtected={data.protected} /> + <FormProvider {...methods}> + <RoleForm editing={Boolean(role?._id)} isProtected={role?.protected} /> + </FormProvider> </Margins> </Box> </VerticalBar.ScrollableContent> <VerticalBar.Footer> <ButtonGroup vertical stretch> - <Button primary disabled={!hasUnsavedChanges} onClick={handleSave}> + <Button primary disabled={!methods.formState.isDirty} onClick={methods.handleSubmit(handleSave)}> {t('Save')} </Button> - {!data.protected && ( + {!role?.protected && role?._id && ( <Button danger onClick={handleDelete}> {t('Delete')} </Button> )} - <Button onClick={handleManageUsers}>{t('Users_in_role')}</Button> + {role?._id && <Button onClick={handleManageUsers}>{t('Users_in_role')}</Button>} </ButtonGroup> </VerticalBar.Footer> </> diff --git a/apps/meteor/client/views/admin/permissions/EditRolePageContainer.js b/apps/meteor/client/views/admin/permissions/EditRolePageContainer.js deleted file mode 100644 index d98cfd881aa..00000000000 --- a/apps/meteor/client/views/admin/permissions/EditRolePageContainer.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Callout } from '@rocket.chat/fuselage'; -import React from 'react'; - -import { useTranslation } from '../../../contexts/TranslationContext'; -import EditRolePage from './EditRolePage'; -import { useRole } from './useRole'; - -const EditRolePageContainer = ({ _id }) => { - const t = useTranslation(); - const role = useRole(_id); - - if (!role) { - return <Callout type='danger'>{t('error-invalid-role')}</Callout>; - } - - return <EditRolePage key={_id} data={role} />; -}; - -export default EditRolePageContainer; diff --git a/apps/meteor/client/views/admin/permissions/EditRolePageWithData.tsx b/apps/meteor/client/views/admin/permissions/EditRolePageWithData.tsx new file mode 100644 index 00000000000..d4ca593553a --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/EditRolePageWithData.tsx @@ -0,0 +1,22 @@ +import { IRole } from '@rocket.chat/core-typings'; +import { Callout } from '@rocket.chat/fuselage'; +import React, { ReactElement } from 'react'; + +import { useRouteParameter } from '../../../contexts/RouterContext'; +import { useTranslation } from '../../../contexts/TranslationContext'; +import EditRolePage from './EditRolePage'; +import { useRole } from './hooks/useRole'; + +const EditRolePageWithData = ({ roleId }: { roleId?: IRole['_id'] }): ReactElement => { + const t = useTranslation(); + const role = useRole(roleId); + const context = useRouteParameter('context'); + + if (!role && context === 'edit') { + return <Callout type='danger'>{t('error-invalid-role')}</Callout>; + } + + return <EditRolePage key={roleId} role={role} />; +}; + +export default EditRolePageWithData; diff --git a/apps/meteor/client/views/admin/permissions/NewRolePage.js b/apps/meteor/client/views/admin/permissions/NewRolePage.js deleted file mode 100644 index 4ce60a700d2..00000000000 --- a/apps/meteor/client/views/admin/permissions/NewRolePage.js +++ /dev/null @@ -1,62 +0,0 @@ -import { Box, ButtonGroup, Button, Margins } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useState } from 'react'; - -import VerticalBar from '../../../components/VerticalBar'; -import { useRoute } from '../../../contexts/RouterContext'; -import { useEndpoint } from '../../../contexts/ServerContext'; -import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import { useForm } from '../../../hooks/useForm'; -import RoleForm from './RoleForm'; - -const NewRolePage = () => { - const t = useTranslation(); - const router = useRoute('admin-permissions'); - const dispatchToastMessage = useToastMessageDispatch(); - const [errors, setErrors] = useState(); - - const { values, handlers } = useForm({ - name: '', - description: '', - scope: 'Users', - mandatory2fa: false, - }); - - const saveRole = useEndpoint('POST', 'roles.create'); - - const handleSave = useMutableCallback(async () => { - if (values.name === '') { - return setErrors({ name: t('error-the-field-is-required', { field: t('Role') }) }); - } - - try { - await saveRole(values); - dispatchToastMessage({ type: 'success', message: t('Saved') }); - router.push({}); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - - return ( - <> - <VerticalBar.ScrollableContent> - <Box w='full' alignSelf='center' mb='neg-x8'> - <Margins block='x8'> - <RoleForm values={values} handlers={handlers} errors={errors} /> - </Margins> - </Box> - </VerticalBar.ScrollableContent> - <VerticalBar.Footer> - <ButtonGroup stretch w='full'> - <Button primary onClick={handleSave}> - {t('Save')} - </Button> - </ButtonGroup> - </VerticalBar.Footer> - </> - ); -}; - -export default NewRolePage; diff --git a/apps/meteor/client/views/admin/permissions/PermissionRow.js b/apps/meteor/client/views/admin/permissions/PermissionRow.js deleted file mode 100644 index d5de63fa7a2..00000000000 --- a/apps/meteor/client/views/admin/permissions/PermissionRow.js +++ /dev/null @@ -1,71 +0,0 @@ -import { Table } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useState, memo } from 'react'; - -import { CONSTANTS } from '../../../../app/authorization/lib'; -import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; -import RoleCell from './RoleCell'; - -const useChangeRole = ({ onGrant, onRemove, permissionId }) => { - const dispatchToastMessage = useToastMessageDispatch(); - return useMutableCallback(async (roleId, granted) => { - try { - if (granted) { - await onRemove(permissionId, roleId); - } else { - await onGrant(permissionId, roleId); - } - return !granted; - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - return granted; - }); -}; - -const getName = (t, permission) => { - if (permission.level === CONSTANTS.SETTINGS_LEVEL) { - let path = ''; - if (permission.group) { - path = `${t(permission.group)} > `; - } - if (permission.section) { - path = `${path}${t(permission.section)} > `; - } - return `${path}${t(permission.settingId)}`; - } - - return t(permission._id); -}; - -const PermissionRow = ({ permission, t, roleList, onGrant, onRemove, ...props }) => { - const { _id, roles } = permission; - - const [hovered, setHovered] = useState(false); - - const onMouseEnter = useMutableCallback(() => setHovered(true)); - const onMouseLeave = useMutableCallback(() => setHovered(false)); - - const changeRole = useChangeRole({ onGrant, onRemove, permissionId: _id }); - return ( - <Table.Row key={_id} role='link' action tabIndex={0} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} {...props}> - <Table.Cell maxWidth='x300' withTruncatedText title={t(`${_id}_description`)}> - {getName(t, permission)} - </Table.Cell> - {roleList.map(({ _id, name, description }) => ( - <RoleCell - key={_id} - _id={_id} - name={name} - description={description} - grantedRoles={roles} - onChange={changeRole} - lineHovered={hovered} - permissionId={_id} - /> - ))} - </Table.Row> - ); -}; - -export default memo(PermissionRow); diff --git a/apps/meteor/client/views/admin/permissions/PermissionsContextBar.js b/apps/meteor/client/views/admin/permissions/PermissionsContextBar.tsx similarity index 57% rename from apps/meteor/client/views/admin/permissions/PermissionsContextBar.js rename to apps/meteor/client/views/admin/permissions/PermissionsContextBar.tsx index 992a903173c..8eb9de3434e 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsContextBar.js +++ b/apps/meteor/client/views/admin/permissions/PermissionsContextBar.tsx @@ -1,20 +1,18 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React from 'react'; +import React, { ReactElement } from 'react'; import VerticalBar from '../../../components/VerticalBar'; import { useRouteParameter, useRoute } from '../../../contexts/RouterContext'; import { useTranslation } from '../../../contexts/TranslationContext'; -import EditRolePage from './EditRolePageContainer'; -import NewRolePage from './NewRolePage'; +import EditRolePageWithData from './EditRolePageWithData'; -const PermissionsContextBar = () => { +const PermissionsContextBar = (): ReactElement | null => { const t = useTranslation(); const _id = useRouteParameter('_id'); const context = useRouteParameter('context'); - const router = useRoute('admin-permissions'); - const handleVerticalBarCloseButton = useMutableCallback(() => { + const handleCloseVerticalBar = useMutableCallback(() => { router.push({}); }); @@ -22,12 +20,10 @@ const PermissionsContextBar = () => { (context && ( <VerticalBar> <VerticalBar.Header> - {context === 'new' && t('New_role')} - {context === 'edit' && t('Role_Editing')} - <VerticalBar.Close onClick={handleVerticalBarCloseButton} /> + {context === 'edit' ? t('Role_Editing') : t('New_role')} + <VerticalBar.Close onClick={handleCloseVerticalBar} /> </VerticalBar.Header> - {context === 'new' && <NewRolePage />} - {context === 'edit' && <EditRolePage _id={_id} />} + <EditRolePageWithData roleId={_id} /> </VerticalBar> )) || null diff --git a/apps/meteor/client/views/admin/permissions/PermissionsRouter.js b/apps/meteor/client/views/admin/permissions/PermissionsRouter.tsx similarity index 75% rename from apps/meteor/client/views/admin/permissions/PermissionsRouter.js rename to apps/meteor/client/views/admin/permissions/PermissionsRouter.tsx index 6903c9dc18f..8203b92f4fd 100644 --- a/apps/meteor/client/views/admin/permissions/PermissionsRouter.js +++ b/apps/meteor/client/views/admin/permissions/PermissionsRouter.tsx @@ -1,12 +1,12 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import { usePermission } from '../../../contexts/AuthorizationContext'; import { useRouteParameter } from '../../../contexts/RouterContext'; import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; -import PermissionsTable from './PermissionsTable'; -import UsersInRole from './UsersInRolePageContainer'; +import PermissionsTable from './PermissionsTable/PermissionsTable'; +import UsersInRole from './UsersInRole'; -const PermissionsRouter = () => { +const PermissionsRouter = (): ReactElement => { const canViewPermission = usePermission('access-permissions'); const canViewSettingPermission = usePermission('access-setting-permissions'); const context = useRouteParameter('context'); diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable.js b/apps/meteor/client/views/admin/permissions/PermissionsTable.js deleted file mode 100644 index f5f39f3e395..00000000000 --- a/apps/meteor/client/views/admin/permissions/PermissionsTable.js +++ /dev/null @@ -1,117 +0,0 @@ -import { Margins, Icon, Tabs, Button } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useState, useCallback } from 'react'; - -import GenericTable from '../../../components/GenericTable'; -import Page from '../../../components/Page'; -import { usePermission } from '../../../contexts/AuthorizationContext'; -import { useRoute } from '../../../contexts/RouterContext'; -import { useMethod } from '../../../contexts/ServerContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import FilterComponent from './FilterComponent'; -import PermissionRow from './PermissionRow'; -import PermissionsContextBar from './PermissionsContextBar'; -import RoleHeader from './RoleHeader'; -import { usePermissionsAndRoles } from './hooks/usePermissionsAndRoles'; - -const PermissionsTable = () => { - const t = useTranslation(); - const [filter, setFilter] = useState(''); - const canViewPermission = usePermission('access-permissions'); - const canViewSettingPermission = usePermission('access-setting-permissions'); - const defaultType = canViewPermission ? 'permissions' : 'settings'; - const [type, setType] = useState(defaultType); - const [params, setParams] = useState({ limit: 25, skip: 0 }); - - const router = useRoute('admin-permissions'); - - const grantRole = useMethod('authorization:addPermissionToRole'); - const removeRole = useMethod('authorization:removeRoleFromPermission'); - - const permissionsData = usePermissionsAndRoles(type, filter, params.limit, params.skip); - - const [permissions, total, roleList] = permissionsData; - - const handleParams = useMutableCallback(({ current, itemsPerPage }) => { - setParams({ skip: current, limit: itemsPerPage }); - }); - - const handlePermissionsTab = useMutableCallback(() => { - if (type === 'permissions') { - return; - } - setType('permissions'); - }); - - const handleSettingsTab = useMutableCallback(() => { - if (type === 'settings') { - return; - } - setType('settings'); - }); - - const handleAdd = useMutableCallback(() => { - router.push({ - context: 'new', - }); - }); - - return ( - <Page flexDirection='row'> - <Page> - <Page.Header title={t('Permissions')}> - <Button primary onClick={handleAdd} aria-label={t('New')}> - <Icon name='plus' /> {t('New_role')} - </Button> - </Page.Header> - <Margins blockEnd='x16'> - <Tabs> - <Tabs.Item selected={type === 'permissions'} onClick={handlePermissionsTab} disabled={!canViewPermission}> - {t('Permissions')} - </Tabs.Item> - <Tabs.Item selected={type === 'settings'} onClick={handleSettingsTab} disabled={!canViewSettingPermission}> - {t('Settings')} - </Tabs.Item> - </Tabs> - </Margins> - <Page.Content mb='neg-x8'> - <Margins block='x8'> - <FilterComponent onChange={setFilter} /> - <GenericTable - header={ - <> - <GenericTable.HeaderCell width='x120'>{t('Name')}</GenericTable.HeaderCell> - {roleList.map(({ _id, name, description }) => ( - <RoleHeader key={_id} _id={_id} name={name} description={description} router={router} /> - ))} - </> - } - total={total} - results={permissions} - params={params} - setParams={handleParams} - fixed={false} - > - {useCallback( - (permission) => ( - <PermissionRow - key={permission._id} - permission={permission} - t={t} - roleList={roleList} - onGrant={grantRole} - onRemove={removeRole} - /> - ), - [grantRole, removeRole, roleList, t], - )} - </GenericTable> - </Margins> - </Page.Content> - </Page> - <PermissionsContextBar /> - </Page> - ); -}; - -export default PermissionsTable; diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionRow.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionRow.tsx new file mode 100644 index 00000000000..40444a54445 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionRow.tsx @@ -0,0 +1,63 @@ +import { IRole, IPermission } from '@rocket.chat/core-typings'; +import { TableRow, TableCell } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { useState, memo, ReactElement } from 'react'; + +import { CONSTANTS } from '../../../../../app/authorization/lib'; +import { useTranslation, TranslationKey } from '../../../../contexts/TranslationContext'; +import { useChangeRole } from '../hooks/useChangeRole'; +import RoleCell from './RoleCell'; + +const getName = (t: ReturnType<typeof useTranslation>, permission: IPermission): string => { + if (permission.level === CONSTANTS.SETTINGS_LEVEL) { + let path = ''; + if (permission.group) { + path = `${t(permission.group as TranslationKey)} > `; + } + if (permission.section) { + path = `${path}${t(permission.section as TranslationKey)} > `; + } + return `${path}${t(permission.settingId as TranslationKey)}`; + } + + return t(permission._id as TranslationKey); +}; + +type PermissionRowProps = { + permission: IPermission; + roleList: IRole[]; + onGrant: (permissionId: IPermission['_id'], roleId: IRole['_id']) => Promise<void>; + onRemove: (permissionId: IPermission['_id'], roleId: IRole['_id']) => Promise<void>; +}; + +const PermissionRow = ({ permission, roleList, onGrant, onRemove }: PermissionRowProps): ReactElement => { + const t = useTranslation(); + const { _id, roles } = permission; + const [hovered, setHovered] = useState(false); + const changeRole = useChangeRole({ onGrant, onRemove, permissionId: _id }); + + const onMouseEnter = useMutableCallback(() => setHovered(true)); + const onMouseLeave = useMutableCallback(() => setHovered(false)); + + return ( + <TableRow key={_id} role='link' action tabIndex={0} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> + <TableCell maxWidth='x300' withTruncatedText title={t(`${_id}_description` as TranslationKey)}> + {getName(t, permission)} + </TableCell> + {roleList.map(({ _id, name, description }) => ( + <RoleCell + key={_id} + _id={_id} + name={name} + description={description} + grantedRoles={roles} + onChange={changeRole} + lineHovered={hovered} + permissionId={_id} + /> + ))} + </TableRow> + ); +}; + +export default memo(PermissionRow); diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx new file mode 100644 index 00000000000..bb29255e569 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx @@ -0,0 +1,119 @@ +import { Margins, Icon, Tabs, Button, Pagination, Tile } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { useState, ReactElement } from 'react'; + +import { GenericTable, GenericTableHeader, GenericTableHeaderCell, GenericTableBody } from '../../../../components/GenericTable'; +import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; +import Page from '../../../../components/Page'; +import { usePermission } from '../../../../contexts/AuthorizationContext'; +import { useRoute } from '../../../../contexts/RouterContext'; +import { useMethod } from '../../../../contexts/ServerContext'; +import { useTranslation } from '../../../../contexts/TranslationContext'; +import PermissionsContextBar from '../PermissionsContextBar'; +import { usePermissionsAndRoles } from '../hooks/usePermissionsAndRoles'; +import PermissionRow from './PermissionRow'; +import PermissionsTableFilter from './PermissionsTableFilter'; +import RoleHeader from './RoleHeader'; + +const PermissionsTable = (): ReactElement => { + const t = useTranslation(); + const [filter, setFilter] = useState(''); + const canViewPermission = usePermission('access-permissions'); + const canViewSettingPermission = usePermission('access-setting-permissions'); + const defaultType = canViewPermission ? 'permissions' : 'settings'; + const [type, setType] = useState(defaultType); + const router = useRoute('admin-permissions'); + + const grantRole = useMethod('authorization:addPermissionToRole'); + const removeRole = useMethod('authorization:removeRoleFromPermission'); + + const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); + const { permissions, total, roleList } = usePermissionsAndRoles(type, filter, itemsPerPage, current); + + const handlePermissionsTab = useMutableCallback(() => { + if (type === 'permissions') { + return; + } + setType('permissions'); + }); + + const handleSettingsTab = useMutableCallback(() => { + if (type === 'settings') { + return; + } + setType('settings'); + }); + + const handleAdd = useMutableCallback(() => { + router.push({ + context: 'new', + }); + }); + + return ( + <Page flexDirection='row'> + <Page> + <Page.Header title={t('Permissions')}> + <Button primary onClick={handleAdd} aria-label={t('New')}> + <Icon name='plus' /> {t('New_role')} + </Button> + </Page.Header> + <Margins blockEnd='x16'> + <Tabs> + <Tabs.Item selected={type === 'permissions'} onClick={handlePermissionsTab} disabled={!canViewPermission}> + {t('Permissions')} + </Tabs.Item> + <Tabs.Item selected={type === 'settings'} onClick={handleSettingsTab} disabled={!canViewSettingPermission}> + {t('Settings')} + </Tabs.Item> + </Tabs> + </Margins> + <Page.Content mb='neg-x8'> + <Margins block='x8'> + <PermissionsTableFilter onChange={setFilter} /> + {permissions?.length === 0 && ( + <Tile fontScale='p2' elevation='0' color='info' textAlign='center'> + {t('No_data_found')} + </Tile> + )} + {permissions?.length > 0 && ( + <> + <GenericTable fixed={false}> + <GenericTableHeader> + <GenericTableHeaderCell width='x120'>{t('Name')}</GenericTableHeaderCell> + {roleList?.map(({ _id, name, description }) => ( + <RoleHeader key={_id} _id={_id} name={name} description={description} /> + ))} + </GenericTableHeader> + <GenericTableBody> + {permissions?.map((permission) => ( + <PermissionRow + key={permission._id} + permission={permission} + roleList={roleList} + onGrant={grantRole} + onRemove={removeRole} + /> + ))} + </GenericTableBody> + </GenericTable> + <Pagination + divider + current={current} + itemsPerPage={itemsPerPage} + count={total} + onSetItemsPerPage={onSetItemsPerPage} + onSetCurrent={onSetCurrent} + {...paginationProps} + /> + </> + )} + </Margins> + </Page.Content> + </Page> + <PermissionsContextBar /> + </Page> + ); +}; + +export default PermissionsTable; diff --git a/apps/meteor/client/views/admin/permissions/FilterComponent.js b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx similarity index 65% rename from apps/meteor/client/views/admin/permissions/FilterComponent.js rename to apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx index 4987f4e9c42..cc6122cb80e 100644 --- a/apps/meteor/client/views/admin/permissions/FilterComponent.js +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTableFilter.tsx @@ -1,13 +1,12 @@ import { TextInput } from '@rocket.chat/fuselage'; import { useMutableCallback, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, ReactElement } from 'react'; -import { useTranslation } from '../../../contexts/TranslationContext'; +import { useTranslation } from '../../../../contexts/TranslationContext'; -const FilterComponent = ({ onChange }) => { +const PermissionsTableFilter = ({ onChange }: { onChange: (debouncedFilter: string) => void }): ReactElement => { const t = useTranslation(); const [filter, setFilter] = useState(''); - const debouncedFilter = useDebouncedValue(filter, 500); useEffect(() => { @@ -21,4 +20,4 @@ const FilterComponent = ({ onChange }) => { return <TextInput value={filter} onChange={handleFilter} placeholder={t('Search')} flexGrow={0} />; }; -export default FilterComponent; +export default PermissionsTableFilter; diff --git a/apps/meteor/client/views/admin/permissions/RoleCell.js b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleCell.tsx similarity index 55% rename from apps/meteor/client/views/admin/permissions/RoleCell.js rename to apps/meteor/client/views/admin/permissions/PermissionsTable/RoleCell.tsx index d8c41cf3039..275879c560d 100644 --- a/apps/meteor/client/views/admin/permissions/RoleCell.js +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleCell.tsx @@ -1,10 +1,21 @@ -import { Table, Margins, Box, CheckBox, Throbber } from '@rocket.chat/fuselage'; +import { IRole } from '@rocket.chat/core-typings'; +import { TableCell, Margins, Box, CheckBox, Throbber } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useState, memo } from 'react'; +import React, { useState, memo, ReactElement } from 'react'; -import { AuthorizationUtils } from '../../../../app/authorization/lib'; +import { AuthorizationUtils } from '../../../../../app/authorization/lib'; -const RoleCell = ({ grantedRoles = [], _id, name, description, onChange, lineHovered, permissionId }) => { +type RoleCellProps = { + _id: IRole['_id']; + name: IRole['name']; + description: IRole['description']; + onChange: (roleId: IRole['_id'], granted: boolean) => Promise<boolean>; + lineHovered: boolean; + permissionId: string; + grantedRoles: IRole['_id'][]; +}; + +const RoleCell = ({ _id, name, description, onChange, lineHovered, permissionId, grantedRoles = [] }: RoleCellProps): ReactElement => { const [granted, setGranted] = useState(() => !!grantedRoles.includes(_id)); const [loading, setLoading] = useState(false); @@ -20,7 +31,7 @@ const RoleCell = ({ grantedRoles = [], _id, name, description, onChange, lineHov const isDisabled = !!loading || !!isRestrictedForRole; return ( - <Table.Cell withTruncatedText> + <TableCell withTruncatedText> <Margins inline='x2'> <CheckBox checked={granted} onChange={handleChange} disabled={isDisabled} /> {!loading && ( @@ -30,7 +41,7 @@ const RoleCell = ({ grantedRoles = [], _id, name, description, onChange, lineHov )} {loading && <Throbber size='x12' display='inline-block' />} </Margins> - </Table.Cell> + </TableCell> ); }; diff --git a/apps/meteor/client/views/admin/permissions/RoleHeader.js b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx similarity index 51% rename from apps/meteor/client/views/admin/permissions/RoleHeader.js rename to apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx index ddaa47fe2f2..a95671baa7d 100644 --- a/apps/meteor/client/views/admin/permissions/RoleHeader.js +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/RoleHeader.tsx @@ -1,12 +1,22 @@ +import { IRole } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Margins, Box, Icon } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; -import GenericTable from '../../../components/GenericTable'; +import GenericTable from '../../../../components/GenericTable'; +import { useRoute } from '../../../../contexts/RouterContext'; -const RoleHeader = ({ router, _id, name, description, ...props }) => { - const onClick = useMutableCallback(() => { +type RoleHeaderProps = { + _id: IRole['_id']; + name: IRole['name']; + description: IRole['description']; +}; + +const RoleHeader = ({ _id, name, description }: RoleHeaderProps): ReactElement => { + const router = useRoute('admin-permissions'); + + const handleEditRole = useMutableCallback(() => { router.push({ context: 'edit', _id, @@ -14,7 +24,7 @@ const RoleHeader = ({ router, _id, name, description, ...props }) => { }); return ( - <GenericTable.HeaderCell clickable pi='x4' p='x8' {...props}> + <GenericTable.HeaderCell clickable pi='x4' p='x8'> <Box className={css` white-space: nowrap; @@ -26,7 +36,7 @@ const RoleHeader = ({ router, _id, name, description, ...props }) => { borderWidth='x2' borderRadius='x2' borderColor='neutral-300' - onClick={onClick} + onClick={handleEditRole} > <Margins inline='x2'> <span>{description || name}</span> diff --git a/apps/meteor/client/views/admin/permissions/PermissionsTable/index.ts b/apps/meteor/client/views/admin/permissions/PermissionsTable/index.ts new file mode 100644 index 00000000000..1dc0ba4dfcc --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/PermissionsTable/index.ts @@ -0,0 +1 @@ +export { default } from './PermissionsTable'; diff --git a/apps/meteor/client/views/admin/permissions/RoleForm.js b/apps/meteor/client/views/admin/permissions/RoleForm.js deleted file mode 100644 index ba718c5e2cf..00000000000 --- a/apps/meteor/client/views/admin/permissions/RoleForm.js +++ /dev/null @@ -1,55 +0,0 @@ -import { Box, Field, TextInput, Select, ToggleSwitch } from '@rocket.chat/fuselage'; -import React, { useMemo } from 'react'; - -import { useTranslation } from '../../../contexts/TranslationContext'; - -const RoleForm = ({ values, handlers, className, errors, editing = false, isProtected = false }) => { - const t = useTranslation(); - - const { name, description, scope, mandatory2fa } = values; - - const { handleName, handleDescription, handleScope, handleMandatory2fa } = handlers; - - const options = useMemo( - () => [ - ['Users', t('Global')], - ['Subscriptions', t('Rooms')], - ], - [t], - ); - - return ( - <> - <Field className={className}> - <Field.Label>{t('Role')}</Field.Label> - <Field.Row> - <TextInput disabled={editing} value={name} onChange={handleName} placeholder={t('Role')} /> - </Field.Row> - <Field.Error>{errors?.name}</Field.Error> - </Field> - <Field className={className}> - <Field.Label>{t('Description')}</Field.Label> - <Field.Row> - <TextInput value={description} onChange={handleDescription} placeholder={t('Description')} /> - </Field.Row> - <Field.Hint>{'Leave the description field blank if you dont want to show the role'}</Field.Hint> - </Field> - <Field className={className}> - <Field.Label>{t('Scope')}</Field.Label> - <Field.Row> - <Select disabled={isProtected} options={options} value={scope} onChange={handleScope} placeholder={t('Scope')} /> - </Field.Row> - </Field> - <Field className={className}> - <Box display='flex' flexDirection='row'> - <Field.Label>{t('Users must use Two Factor Authentication')}</Field.Label> - <Field.Row> - <ToggleSwitch checked={mandatory2fa} onChange={handleMandatory2fa} /> - </Field.Row> - </Box> - </Field> - </> - ); -}; - -export default RoleForm; diff --git a/apps/meteor/client/views/admin/permissions/RoleForm.tsx b/apps/meteor/client/views/admin/permissions/RoleForm.tsx new file mode 100644 index 00000000000..011a84e6c17 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/RoleForm.tsx @@ -0,0 +1,71 @@ +import { Box, Field, TextInput, Select, ToggleSwitch, SelectOption } from '@rocket.chat/fuselage'; +import React, { useMemo, ReactElement } from 'react'; +import { useFormContext, Controller } from 'react-hook-form'; + +import { useTranslation } from '../../../contexts/TranslationContext'; + +type RoleFormProps = { + className?: string; + editing?: boolean; + isProtected?: boolean; +}; + +const RoleForm = ({ className, editing = false, isProtected = false }: RoleFormProps): ReactElement => { + const t = useTranslation(); + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + const options: SelectOption[] = useMemo( + () => [ + ['Users', t('Global')], + ['Subscriptions', t('Rooms')], + ], + [t], + ); + + return ( + <> + <Field className={className}> + <Field.Label>{t('Role')}</Field.Label> + <Field.Row> + <TextInput disabled={editing} placeholder={t('Role')} {...register('name', { required: true })} /> + </Field.Row> + {errors?.name && <Field.Error>{t('error-the-field-is-required', { field: t('Role') })}</Field.Error>} + </Field> + <Field className={className}> + <Field.Label>{t('Description')}</Field.Label> + <Field.Row> + <TextInput placeholder={t('Description')} {...register('description')} /> + </Field.Row> + <Field.Hint>{'Leave the description field blank if you dont want to show the role'}</Field.Hint> + </Field> + <Field className={className}> + <Field.Label>{t('Scope')}</Field.Label> + <Field.Row> + <Controller + name='scope' + control={control} + render={({ field }): ReactElement => <Select {...field} options={options} disabled={isProtected} placeholder={t('Scope')} />} + /> + </Field.Row> + </Field> + <Field className={className}> + <Box display='flex' flexDirection='row'> + <Field.Label>{t('Users must use Two Factor Authentication')}</Field.Label> + <Field.Row> + <Controller + name='mandatory2fa' + control={control} + render={({ field }): ReactElement => <ToggleSwitch {...field} checked={field.value} />} + /> + </Field.Row> + </Box> + </Field> + </> + ); +}; + +export default RoleForm; diff --git a/apps/meteor/client/views/admin/permissions/UserRow.js b/apps/meteor/client/views/admin/permissions/UserRow.js deleted file mode 100644 index 7b89a85d37b..00000000000 --- a/apps/meteor/client/views/admin/permissions/UserRow.js +++ /dev/null @@ -1,45 +0,0 @@ -import { Box, Table, Button, Icon } from '@rocket.chat/fuselage'; -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { memo } from 'react'; - -import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; -import UserAvatar from '../../../components/avatar/UserAvatar'; - -const UserRow = ({ _id, username, name, avatarETag, emails, onRemove }) => { - const email = getUserEmailAddress({ emails }); - - const handleRemove = useMutableCallback(() => { - onRemove(username); - }); - - return ( - <Table.Row key={_id} tabIndex={0} role='link'> - <Table.Cell withTruncatedText> - <Box display='flex' alignItems='center'> - <UserAvatar size='x40' title={username} username={username} etag={avatarETag} /> - <Box display='flex' withTruncatedText mi='x8'> - <Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText> - <Box fontScale='p2m' withTruncatedText color='default'> - {name || username} - </Box> - {name && ( - <Box fontScale='p2' color='hint' withTruncatedText> - {' '} - {`@${username}`}{' '} - </Box> - )} - </Box> - </Box> - </Box> - </Table.Cell> - <Table.Cell withTruncatedText>{email}</Table.Cell> - <Table.Cell withTruncatedText> - <Button small square danger onClick={handleRemove}> - <Icon name='trash' size='x20' /> - </Button> - </Table.Cell> - </Table.Row> - ); -}; - -export default memo(UserRow); diff --git a/apps/meteor/client/views/admin/permissions/UsersInRolePage.js b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx similarity index 61% rename from apps/meteor/client/views/admin/permissions/UsersInRolePage.js rename to apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx index 74843b6c0c8..1dc75dd9bdc 100644 --- a/apps/meteor/client/views/admin/permissions/UsersInRolePage.js +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePage.tsx @@ -1,30 +1,27 @@ +import { IRole } from '@rocket.chat/core-typings'; import { Box, Field, Margins, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, ReactElement } from 'react'; -import Page from '../../../components/Page'; -import RoomAutoComplete from '../../../components/RoomAutoComplete'; -import UserAutoComplete from '../../../components/UserAutoComplete'; -import { useRoute } from '../../../contexts/RouterContext'; -import { useEndpoint } from '../../../contexts/ServerContext'; -import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import UsersInRoleTableContainer from './UsersInRoleTableContainer'; +import Page from '../../../../components/Page'; +import RoomAutoComplete from '../../../../components/RoomAutoComplete'; +import UserAutoComplete from '../../../../components/UserAutoComplete'; +import { useRoute } from '../../../../contexts/RouterContext'; +import { useEndpoint } from '../../../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../contexts/TranslationContext'; +import UsersInRoleTable from './UsersInRoleTable'; -const UsersInRolePage = ({ data }) => { +const UsersInRolePage = ({ role }: { role: IRole }): ReactElement => { const t = useTranslation(); + const reload = useRef<() => void>(() => undefined); + const [user, setUser] = useState<string | undefined>(''); + const [rid, setRid] = useState<string>(); + const [userError, setUserError] = useState<string>(); const dispatchToastMessage = useToastMessageDispatch(); - const reload = useRef(); - - const [user, setUser] = useState(''); - const [rid, setRid] = useState(); - const [userError, setUserError] = useState(); - - const { _id, name, description } = data; - + const { _id, name, description } = role; const router = useRoute('admin-permissions'); - const addUser = useEndpoint('POST', 'roles.addUserToRole'); const handleReturn = useMutableCallback(() => { @@ -42,8 +39,8 @@ const UsersInRolePage = ({ data }) => { try { await addUser({ roleId: _id, username: user, roomId: rid }); dispatchToastMessage({ type: 'success', message: t('User_added') }); - setUser(); - reload.current(); + setUser(undefined); + reload.current?.(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } @@ -51,7 +48,7 @@ const UsersInRolePage = ({ data }) => { const handleUserChange = useMutableCallback((user) => { if (user !== '') { - setUserError(); + setUserError(undefined); } return setUser(user); @@ -67,7 +64,7 @@ const UsersInRolePage = ({ data }) => { <Page.Content> <Box display='flex' flexDirection='column' w='full' mi='neg-x4'> <Margins inline='x4'> - {data.scope !== 'Users' && ( + {role.scope !== 'Users' && ( <Field mbe='x4'> <Field.Label>{t('Choose_a_room')}</Field.Label> <Field.Row> @@ -91,17 +88,10 @@ const UsersInRolePage = ({ data }) => { </Margins> </Box> <Margins blockStart='x8'> - {(data.scope === 'Users' || rid) && ( - <UsersInRoleTableContainer - reloadRef={reload} - scope={data.scope} - rid={rid} - roleId={_id} - roleName={name} - description={description} - /> + {(role.scope === 'Users' || rid) && ( + <UsersInRoleTable reloadRef={reload} rid={rid} roleId={_id} roleName={name} description={description} /> )} - {data.scope !== 'Users' && !rid && <Callout type='info'>{t('Select_a_room')}</Callout>} + {role.scope !== 'Users' && !rid && <Callout type='info'>{t('Select_a_room')}</Callout>} </Margins> </Page.Content> </Page> diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePageWithData.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePageWithData.tsx new file mode 100644 index 00000000000..9db04e6d5dc --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRolePageWithData.tsx @@ -0,0 +1,18 @@ +import React, { ReactElement } from 'react'; + +import { useRouteParameter } from '../../../../contexts/RouterContext'; +import { useRole } from '../hooks/useRole'; +import UsersInRolePage from './UsersInRolePage'; + +const UsersInRolePageWithData = (): ReactElement | null => { + const _id = useRouteParameter('_id'); + const role = useRole(_id); + + if (!role) { + return null; + } + + return <UsersInRolePage role={role} />; +}; + +export default UsersInRolePageWithData; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTable.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTable.tsx new file mode 100644 index 00000000000..4936091951f --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTable.tsx @@ -0,0 +1,91 @@ +import { IRole, IRoom, IUserInRole } from '@rocket.chat/core-typings'; +import { Tile, Pagination } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { ReactElement } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { GenericTable, GenericTableHeader, GenericTableHeaderCell, GenericTableBody } from '../../../../../components/GenericTable'; +import { usePagination } from '../../../../../components/GenericTable/hooks/usePagination'; +import { useSetModal } from '../../../../../contexts/ModalContext'; +import { useEndpoint } from '../../../../../contexts/ServerContext'; +import { useToastMessageDispatch } from '../../../../../contexts/ToastMessagesContext'; +import { useTranslation } from '../../../../../contexts/TranslationContext'; +import UsersInRoleTableRow from './UsersInRoleTableRow'; + +type UsersInRoleTable = { + users: IUserInRole[]; + reload: () => void; + roleName: IRole['name']; + roleId: IRole['_id']; + description: IRole['description']; + total: number; + rid?: IRoom['_id']; + paginationData: ReturnType<typeof usePagination>; +}; + +const UsersInRoleTable = ({ users, reload, roleName, roleId, description, total, rid, paginationData }: UsersInRoleTable): ReactElement => { + const t = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const removeUser = useEndpoint('POST', 'roles.removeUserFromRole'); + const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = paginationData; + + const closeModal = (): void => setModal(); + + const handleRemove = useMutableCallback((username) => { + const remove = async (): Promise<void> => { + try { + await removeUser({ roleId, username, scope: rid }); + dispatchToastMessage({ type: 'success', message: t('User_removed') }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + closeModal(); + reload(); + } + }; + + setModal( + <GenericModal variant='danger' onConfirm={remove} onClose={closeModal} onCancel={closeModal} confirmText={t('Delete')}> + {t('The_user_s_will_be_removed_from_role_s', username, description || roleName)} + </GenericModal>, + ); + }); + + return ( + <> + {users.length === 0 && ( + <Tile fontScale='p2' elevation='0' color='info' textAlign='center'> + {t('No_data_found')} + </Tile> + )} + {users.length > 0 && ( + <> + <GenericTable> + <GenericTableHeader> + <GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell> + <GenericTableHeaderCell>{t('Email')}</GenericTableHeaderCell> + <GenericTableHeaderCell w='x80'></GenericTableHeaderCell> + </GenericTableHeader> + <GenericTableBody> + {users.map((user) => ( + <UsersInRoleTableRow onRemove={handleRemove} key={user?._id} user={user} /> + ))} + </GenericTableBody> + </GenericTable> + <Pagination + divider + current={current} + itemsPerPage={itemsPerPage} + count={total} + onSetItemsPerPage={onSetItemsPerPage} + onSetCurrent={onSetCurrent} + {...paginationProps} + /> + </> + )} + </> + ); +}; + +export default UsersInRoleTable; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx new file mode 100644 index 00000000000..553b7aaed17 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableRow.tsx @@ -0,0 +1,52 @@ +import { IUserInRole } from '@rocket.chat/core-typings'; +import { Box, TableRow, TableCell, Button, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import React, { memo, ReactElement } from 'react'; + +import { getUserEmailAddress } from '../../../../../../lib/getUserEmailAddress'; +import UserAvatar from '../../../../../components/avatar/UserAvatar'; + +type UsersInRoleTableRowProps = { + user: IUserInRole; + onRemove: (username: IUserInRole['username']) => void; +}; + +const UsersInRoleTableRow = ({ user, onRemove }: UsersInRoleTableRowProps): ReactElement => { + const { _id, name, username, avatarETag } = user; + const email = getUserEmailAddress(user); + + const handleRemove = useMutableCallback(() => { + onRemove(username); + }); + + return ( + <TableRow key={_id} tabIndex={0} role='link'> + <TableCell withTruncatedText> + <Box display='flex' alignItems='center'> + <UserAvatar size='x40' username={username ?? ''} etag={avatarETag} /> + <Box display='flex' withTruncatedText mi='x8'> + <Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText> + <Box fontScale='p2m' withTruncatedText color='default'> + {name || username} + </Box> + {name && ( + <Box fontScale='p2' color='hint' withTruncatedText> + {' '} + {`@${username}`}{' '} + </Box> + )} + </Box> + </Box> + </Box> + </TableCell> + <TableCell withTruncatedText>{email}</TableCell> + <TableCell withTruncatedText> + <Button small square danger onClick={handleRemove}> + <Icon name='trash' size='x20' /> + </Button> + </TableCell> + </TableRow> + ); +}; + +export default memo(UsersInRoleTableRow); diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableWithData.tsx b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableWithData.tsx new file mode 100644 index 00000000000..91dedabe172 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/UsersInRoleTableWithData.tsx @@ -0,0 +1,66 @@ +import { IRole, IRoom, IUserInRole } from '@rocket.chat/core-typings'; +import React, { useEffect, useMemo, ReactElement, MutableRefObject } from 'react'; + +import { usePagination } from '../../../../../components/GenericTable/hooks/usePagination'; +import { useEndpointData } from '../../../../../hooks/useEndpointData'; +import { AsyncStatePhase } from '../../../../../lib/asyncState'; +import UsersInRoleTable from './UsersInRoleTable'; + +type UsersInRoleTableWithDataProps = { + rid?: IRoom['_id']; + roleId: IRole['_id']; + roleName: IRole['name']; + description: IRole['description']; + reloadRef: MutableRefObject<() => void>; +}; + +const UsersInRoleTableWithData = ({ + rid, + roleId, + roleName, + description, + reloadRef, +}: UsersInRoleTableWithDataProps): ReactElement | null => { + const { itemsPerPage, current, ...paginationData } = usePagination(); + + const query = useMemo( + () => ({ + role: roleId, + ...(rid && { roomId: rid }), + ...(itemsPerPage && { count: itemsPerPage }), + ...(current && { offset: current }), + }), + [itemsPerPage, current, rid, roleId], + ); + + const { reload, ...result } = useEndpointData('roles.getUsersInRole', query); + + useEffect(() => { + reloadRef.current = reload; + }, [reload, reloadRef]); + + if (result.phase === AsyncStatePhase.LOADING || result.phase === AsyncStatePhase.REJECTED) { + return null; + } + + const users: IUserInRole[] = result.value?.users.map((user) => ({ + ...user, + createdAt: new Date(user.createdAt), + _updatedAt: new Date(user._updatedAt), + })); + + return ( + <UsersInRoleTable + users={users} + total={result.value.total} + reload={reload} + roleName={roleName} + roleId={roleId} + description={description} + rid={rid} + paginationData={{ itemsPerPage, current, ...paginationData }} + /> + ); +}; + +export default UsersInRoleTableWithData; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/index.ts b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/index.ts new file mode 100644 index 00000000000..e7195afe4fd --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/UsersInRoleTable/index.ts @@ -0,0 +1 @@ +export { default } from './UsersInRoleTableWithData'; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRole/index.ts b/apps/meteor/client/views/admin/permissions/UsersInRole/index.ts new file mode 100644 index 00000000000..70d786d00a9 --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/UsersInRole/index.ts @@ -0,0 +1 @@ +export { default } from './UsersInRolePageWithData'; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRolePageContainer.js b/apps/meteor/client/views/admin/permissions/UsersInRolePageContainer.js deleted file mode 100644 index d921f53ebef..00000000000 --- a/apps/meteor/client/views/admin/permissions/UsersInRolePageContainer.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { useRouteParameter } from '../../../contexts/RouterContext'; -import UsersInRolePage from './UsersInRolePage'; -import { useRole } from './useRole'; - -const UsersInRolePageContainer = () => { - const _id = useRouteParameter('_id'); - - const role = useRole(_id); - - if (!role) { - return null; - } - - return <UsersInRolePage data={role} />; -}; - -export default UsersInRolePageContainer; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRoleTable.js b/apps/meteor/client/views/admin/permissions/UsersInRoleTable.js deleted file mode 100644 index 91fc71bab55..00000000000 --- a/apps/meteor/client/views/admin/permissions/UsersInRoleTable.js +++ /dev/null @@ -1,59 +0,0 @@ -import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import React from 'react'; - -import GenericModal from '../../../components/GenericModal'; -import GenericTable from '../../../components/GenericTable'; -import { useSetModal } from '../../../contexts/ModalContext'; -import { useEndpoint } from '../../../contexts/ServerContext'; -import { useToastMessageDispatch } from '../../../contexts/ToastMessagesContext'; -import { useTranslation } from '../../../contexts/TranslationContext'; -import UserRow from './UserRow'; - -function UsersInRoleTable({ data, reload, roleName, roleId, description, total, params, setParams, rid }) { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - - const setModal = useSetModal(); - const closeModal = () => setModal(); - - const removeUser = useEndpoint('POST', 'roles.removeUserFromRole'); - - const onRemove = useMutableCallback((username) => { - const remove = async () => { - try { - await removeUser({ roleId, username, scope: rid }); - dispatchToastMessage({ type: 'success', message: t('User_removed') }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - closeModal(); - reload(); - }; - - setModal( - <GenericModal variant='danger' onConfirm={remove} onCancel={closeModal} confirmText={t('Delete')}> - {t('The_user_s_will_be_removed_from_role_s', username, description || roleName || roleId)} - </GenericModal>, - ); - }); - - return ( - <GenericTable - header={ - <> - <GenericTable.HeaderCell>{t('Name')}</GenericTable.HeaderCell> - <GenericTable.HeaderCell>{t('Email')}</GenericTable.HeaderCell> - <GenericTable.HeaderCell w='x80'></GenericTable.HeaderCell> - </> - } - results={data} - params={params} - setParams={setParams} - total={total} - > - {(props) => <UserRow onRemove={onRemove} key={props._id} {...props} />} - </GenericTable> - ); -} - -export default UsersInRoleTable; diff --git a/apps/meteor/client/views/admin/permissions/UsersInRoleTableContainer.js b/apps/meteor/client/views/admin/permissions/UsersInRoleTableContainer.js deleted file mode 100644 index daea43d37b9..00000000000 --- a/apps/meteor/client/views/admin/permissions/UsersInRoleTableContainer.js +++ /dev/null @@ -1,43 +0,0 @@ -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { useState, useMemo } from 'react'; - -import { useEndpointData } from '../../../hooks/useEndpointData'; -import UsersInRoleTable from './UsersInRoleTable'; - -const UsersInRoleTableContainer = ({ rid, roleId, roleName, description, reloadRef }) => { - const [params, setParams] = useState({ current: 0, itemsPerPage: 25 }); - - const debouncedParams = useDebouncedValue(params, 500); - - const query = useMemo( - () => ({ - roomId: rid, - role: roleId, - ...(debouncedParams.itemsPerPage && { count: debouncedParams.itemsPerPage }), - ...(debouncedParams.current && { offset: debouncedParams.current }), - }), - [debouncedParams, rid, roleId], - ); - - const { value: data = {}, reload } = useEndpointData('roles.getUsersInRole', query); - - reloadRef.current = reload; - - const tableData = data?.users || []; - - return ( - <UsersInRoleTable - data={tableData} - total={data?.total} - reload={reload} - params={params} - setParams={setParams} - roleName={roleName} - roleId={roleId} - description={description} - rid={rid} - /> - ); -}; - -export default UsersInRoleTableContainer; diff --git a/apps/meteor/client/views/admin/permissions/hooks/useChangeRole.ts b/apps/meteor/client/views/admin/permissions/hooks/useChangeRole.ts new file mode 100644 index 00000000000..7ae624fd3aa --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/hooks/useChangeRole.ts @@ -0,0 +1,30 @@ +import type { IRole, IPermission } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; + +import { useToastMessageDispatch } from '../../../../contexts/ToastMessagesContext'; + +export const useChangeRole = ({ + onGrant, + onRemove, + permissionId, +}: { + onGrant: (permissionId: IPermission['_id'], roleId: IRole['_id']) => Promise<void>; + onRemove: (permissionId: IPermission['_id'], roleId: IRole['_id']) => Promise<void>; + permissionId: IPermission['_id']; +}): ((roleId: IRole['_id'], granted: boolean) => Promise<boolean>) => { + const dispatchToastMessage = useToastMessageDispatch(); + + return useMutableCallback(async (roleId, granted) => { + try { + if (granted) { + await onRemove(permissionId, roleId); + } else { + await onGrant(permissionId, roleId); + } + return !granted; + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + return granted; + }); +}; diff --git a/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts b/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts index a77bb7bdb35..e021674962f 100644 --- a/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts +++ b/apps/meteor/client/views/admin/permissions/hooks/usePermissionsAndRoles.ts @@ -1,3 +1,4 @@ +import type { IRole, IPermission } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useCallback } from 'react'; @@ -6,7 +7,12 @@ import { CONSTANTS } from '../../../../../app/authorization/lib'; import { Roles } from '../../../../../app/models/client'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; -export const usePermissionsAndRoles = (type = 'permissions', filter = '', limit = 25, skip = 0): Array<any> => { +export const usePermissionsAndRoles = ( + type = 'permissions', + filter = '', + limit = 25, + skip = 0, +): { permissions: IPermission[]; total: number; roleList: IRole[]; reload: () => void } => { const getPermissions = useCallback(() => { const filterRegExp = new RegExp(filter, 'i'); @@ -25,12 +31,9 @@ export const usePermissionsAndRoles = (type = 'permissions', filter = '', limit ); }, [filter, limit, skip, type]); - const getRoles = useMutableCallback(() => Roles.find().fetch()); - const permissions = useReactiveValue(getPermissions); + const getRoles = useMutableCallback(() => Roles.find().fetch()); const roles = useReactiveValue(getRoles); - const reloadRoles = getRoles(); - - return [permissions.fetch(), permissions.count(false), roles, reloadRoles]; + return { permissions: permissions.fetch(), total: permissions.count(false), roleList: roles, reload: getRoles }; }; diff --git a/apps/meteor/client/views/admin/permissions/hooks/useRole.ts b/apps/meteor/client/views/admin/permissions/hooks/useRole.ts new file mode 100644 index 00000000000..1b96c4ed43f --- /dev/null +++ b/apps/meteor/client/views/admin/permissions/hooks/useRole.ts @@ -0,0 +1,7 @@ +import type { IRole } from '@rocket.chat/core-typings'; +import { useCallback } from 'react'; + +import { Roles } from '../../../../../app/models/client'; +import { useReactiveValue } from '../../../../hooks/useReactiveValue'; + +export const useRole = (_id?: IRole['_id']): IRole => useReactiveValue(useCallback(() => Roles.findOne({ _id }), [_id])); diff --git a/apps/meteor/client/views/admin/permissions/useRole.js b/apps/meteor/client/views/admin/permissions/useRole.js deleted file mode 100644 index 539b45b06d1..00000000000 --- a/apps/meteor/client/views/admin/permissions/useRole.js +++ /dev/null @@ -1,6 +0,0 @@ -import { useCallback } from 'react'; - -import { Roles } from '../../../../app/models/client'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; - -export const useRole = (_id) => useReactiveValue(useCallback(() => Roles.findOne({ _id }), [_id])); diff --git a/packages/core-typings/src/IUser.ts b/packages/core-typings/src/IUser.ts index bfde7eb9bb0..3cd4cdf08c5 100644 --- a/packages/core-typings/src/IUser.ts +++ b/packages/core-typings/src/IUser.ts @@ -171,3 +171,8 @@ export type IUserDataEvent = { unset: Record<keyof IUser, boolean | 0 | 1>; } ); + +export type IUserInRole = Pick< + IUser, + '_id' | 'name' | 'username' | 'emails' | 'avatarETag' | 'createdAt' | 'roles' | 'type' | 'active' | '_updatedAt' +>; diff --git a/packages/rest-typings/src/v1/roles.ts b/packages/rest-typings/src/v1/roles.ts index 307cff1d830..b6709c996a1 100644 --- a/packages/rest-typings/src/v1/roles.ts +++ b/packages/rest-typings/src/v1/roles.ts @@ -1,7 +1,7 @@ import Ajv, { JSONSchemaType } from "ajv"; import type { RocketChatRecordDeleted } from "@rocket.chat/core-typings"; -import type { IRole, IUser } from "@rocket.chat/core-typings"; +import type { IRole, IUser, IUserInRole } from "@rocket.chat/core-typings"; const ajv = new Ajv(); @@ -191,12 +191,12 @@ export type RolesEndpoints = { "roles.getUsersInRole": { GET: (params: { - roomId: string; + roomId?: string; role: string; - offset: number; - count: number; + offset?: number; + count?: number; }) => { - users: IUser[]; + users: IUserInRole[]; total: number; }; }; -- GitLab