From facb802b2dedcff2c4f4e11995135df33fa0b739 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 14 Oct 2025 15:52:41 -0300 Subject: [PATCH 1/5] feat: ABAC Rooms attributes tab --- apps/meteor/client/lib/queryKeys.ts | 9 + .../client/views/admin/ABAC/AdminABACPage.tsx | 37 ++- .../admin/ABAC/AdminABACRoomAttributeMenu.tsx | 28 ++ .../admin/ABAC/AdminABACRoomAttributes.tsx | 110 ++++++++ .../ABAC/AdminABACRoomAttributesForm.spec.tsx | 8 +- .../ABAC/AdminABACRoomAttributesForm.tsx | 108 ++++---- .../client/views/admin/ABAC/AdminABACTabs.tsx | 3 + .../ABAC/RoomAttributesContextualBar.tsx | 102 +++++++ .../RoomAttributesContextualBarWithData.tsx | 28 ++ .../AdminABACRoomAttributesForm.spec.tsx.snap | 254 ++++++++--------- .../admin/ABAC/hooks/useIsABACAvailable.tsx | 11 + .../ABAC/useRoomAttributeOptions.spec.tsx | 262 ++++++++++++++++++ .../admin/ABAC/useRoomAttributeOptions.tsx | 103 +++++++ .../meteor/client/views/admin/sidebarItems.ts | 3 +- apps/meteor/ee/server/api/abac/schemas.ts | 2 +- packages/i18n/src/locales/en.i18n.json | 14 + 16 files changed, 896 insertions(+), 186 deletions(-) create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBarWithData.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 904fc4b2f90e3..31b7930719a46 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -117,3 +117,12 @@ export const teamsQueryKeys = { [...teamsQueryKeys.team(teamId), 'rooms-of-user', userId, options] as const, listUserTeams: (userId: IUser['_id']) => [...teamsQueryKeys.all, 'listUserTeams', userId] as const, }; + +export const ABACQueryKeys = { + all: ['abac'] as const, + roomAttributes: { + all: () => [...ABACQueryKeys.all, 'room-attributes'] as const, + roomAttributesList: (query?: PaginatedRequest) => [...ABACQueryKeys.roomAttributes.all(), 'room-attributes-list', query] as const, + attribute: (attributeId: string) => [...ABACQueryKeys.roomAttributes.all(), 'attribute', attributeId] as const, + }, +}; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx index d8352181e4df9..25fe1c059f679 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -1,9 +1,15 @@ import { Box, Button, Callout } from '@rocket.chat/fuselage'; -import { useRouteParameter } from '@rocket.chat/ui-contexts'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; import { Trans, useTranslation } from 'react-i18next'; +import AdminABACRoomAttributes from './AdminABACRoomAttributes'; import AdminABACSettings from './AdminABACSettings'; import AdminABACTabs from './AdminABACTabs'; +import RoomAttributesContextualBar from './RoomAttributesContextualBar'; +import RoomAttributesContextualBarWithData from './RoomAttributesContextualBarWithData'; +import useIsABACAvailable from './hooks/useIsABACAvailable'; +import { ContextualbarDialog } from '../../../components/Contextualbar'; import { Page, PageContent, PageHeader } from '../../../components/Page'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { links } from '../../../lib/links'; @@ -14,8 +20,26 @@ type AdminABACPageProps = { const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { const { t } = useTranslation(); + const router = useRouter(); const tab = useRouteParameter('tab'); + const _id = useRouteParameter('id'); + const context = useRouteParameter('context'); const learnMore = useExternalLink(); + const isABACAvailable = useIsABACAvailable(); + + const handleCloseContextualbar = useEffectEvent((): void => { + if (!context) { + return; + } + + router.navigate( + { + name: 'admin-ABAC', + params: { ...router.getRouteParameters(), context: '', id: '' }, + }, + { replace: true }, + ); + }); return ( @@ -38,8 +62,17 @@ const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { )} - {tab === 'settings' && } + + {tab === 'settings' && } + {tab === 'room-attributes' && } + + {tab === 'room-attributes' && context !== undefined && isABACAvailable && ( + handleCloseContextualbar()}> + {context === 'new' && handleCloseContextualbar()} />} + {context === 'edit' && _id && handleCloseContextualbar()} />} + + )} ); }; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx new file mode 100644 index 0000000000000..ac6d4eaf120ee --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx @@ -0,0 +1,28 @@ +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import useRoomAttributeItems from './useRoomAttributeOptions'; + +type AdminABACRoomAttributeMenuProps = { + attribute: { _id: string; key: string }; +}; + +const AdminABACRoomAttributeMenu = ({ attribute }: AdminABACRoomAttributeMenuProps) => { + const { t } = useTranslation(); + + const items = useRoomAttributeItems(attribute); + + return ( + + ); +}; + +export default AdminABACRoomAttributeMenu; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx new file mode 100644 index 0000000000000..b013efcf7d350 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx @@ -0,0 +1,110 @@ +import { Box, Button, Icon, Margins, Pagination, TextInput } from '@rocket.chat/fuselage'; +import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AdminABACRoomAttributeMenu from './AdminABACRoomAttributeMenu'; +import useIsABACAvailable from './hooks/useIsABACAvailable'; +import GenericNoResults from '../../../components/GenericNoResults'; +import { + GenericTable, + GenericTableBody, + GenericTableCell, + GenericTableHeader, + GenericTableHeaderCell, + GenericTableRow, +} from '../../../components/GenericTable'; +import { usePagination } from '../../../components/GenericTable/hooks/usePagination'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +const AdminABACRoomAttributes = () => { + const { t } = useTranslation(); + + const [text, setText] = useState(''); + const debouncedText = useDebouncedValue(text, 200); + const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); + const getAttributes = useEndpoint('GET', '/v1/abac/attributes'); + const isABACAvailable = useIsABACAvailable(); + + const router = useRouter(); + const handleNewAttribute = useEffectEvent(() => { + router.navigate({ + name: 'admin-ABAC', + params: { + tab: 'room-attributes', + context: 'new', + }, + }); + }); + + const query = useMemo( + () => ({ + ...(debouncedText ? { key: debouncedText, values: debouncedText } : {}), + offset: current, + count: itemsPerPage, + }), + [debouncedText, current, itemsPerPage], + ); + + const { data, isLoading } = useQuery({ + queryKey: ABACQueryKeys.roomAttributes.roomAttributesList(query), + queryFn: () => getAttributes(query), + }); + + return ( + <> + + + } + placeholder={t('ABAC_Search_attributes')} + value={text} + onChange={(e) => setText((e.target as HTMLInputElement).value)} + /> + + + + {(!data || data.attributes.length === 0) && !isLoading ? ( + + + + ) : ( + <> + + + {t('Name')} + {t('Value')} + + + + {data?.attributes.map((attribute) => ( + + {attribute.key} + {attribute.values.join(', ')} + + + + + ))} + + + + + )} + + ); +}; + +export default AdminABACRoomAttributes; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx index 7ffa9ca665d9c..bfde0331c6bae 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.spec.tsx @@ -135,7 +135,7 @@ describe('AdminABACRoomAttributesForm', () => { { wrapper: appRoot }, ); - const trashButtons = screen.getAllByRole('button', { name: 'Remove' }); + const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' }); expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument(); expect(screen.getByDisplayValue('Value 2')).toBeInTheDocument(); @@ -158,7 +158,7 @@ describe('AdminABACRoomAttributesForm', () => { { wrapper: appRoot }, ); - const trashButtons = screen.queryAllByRole('button', { name: 'Remove' }); + const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' }); expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument(); expect(screen.getByDisplayValue('Locked Value 2')).toBeInTheDocument(); @@ -211,7 +211,7 @@ describe('AdminABACRoomAttributesForm', () => { { wrapper: appRoot }, ); - const trashButtons = screen.queryAllByRole('button', { name: 'Remove' }); + const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' }); await userEvent.click(trashButtons[0]); const saveButton = screen.getByRole('button', { name: 'Save' }); @@ -253,7 +253,7 @@ describe('AdminABACRoomAttributesForm', () => { expect(screen.getByDisplayValue('Locked Value')).toBeDisabled(); expect(screen.getByDisplayValue('Regular Value')).not.toBeDisabled(); - const trashButtons = screen.getAllByRole('button', { name: 'Remove' }); + const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' }); expect(trashButtons).toHaveLength(1); }); }); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx index fa30bf880a5d9..664050e5f3d02 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx @@ -23,7 +23,7 @@ export type AdminABACRoomAttributesFormFormData = { }; type AdminABACRoomAttributesFormProps = { - onSave: (data: unknown) => void; + onSave: (data: AdminABACRoomAttributesFormFormData) => void; onCancel: () => void; description: string; }; @@ -68,68 +68,74 @@ const AdminABACRoomAttributesForm = ({ onSave, onCancel, description }: AdminABA }, [errors.attributeValues, errors.lockedAttributes]); return ( - + <> - {description} - - - {t('Name')} - - - - - {errors.name?.message || ''} - - - - {t('Values')} - - {lockedAttributesFields.map((field, index) => ( - + + {description} + + + {t('Name')} + + - {index !== 0 && removeLockedAttribute(index)} />} - ))} - {fields.map((field, index) => ( - - - {(index !== 0 || lockedAttributesFields.length > 0) && ( - remove(index)} /> - )} - - ))} - {getAttributeValuesError()} - - + {errors.name?.message || ''} + + + + {t('Values')} + + {lockedAttributesFields.map((field, index) => ( + + + {index !== 0 && removeLockedAttribute(index)} />} + + ))} + {fields.map((field, index) => ( + + + {(index !== 0 || lockedAttributesFields.length > 0) && ( + remove(index)} /> + )} + + ))} + {getAttributeValuesError()} + + + - - + ); }; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx index 4fc73a491f44e..e4733b8be5114 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -17,6 +17,9 @@ const AdminABACTabs = () => { handleTabClick('settings')}> {t('Settings')} + handleTabClick('room-attributes')}> + {t('ABAC_Room_Attributes')} + ); }; diff --git a/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx new file mode 100644 index 0000000000000..015f38d30bc40 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx @@ -0,0 +1,102 @@ +import { ContextualbarTitle } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { AdminABACRoomAttributesFormFormData } from './AdminABACRoomAttributesForm'; +import AdminABACRoomAttributesForm from './AdminABACRoomAttributesForm'; +import { ContextualbarClose, ContextualbarHeader } from '../../../components/Contextualbar'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type RoomAttributesContextualBarProps = { + attributeId?: string; + attributeData?: { + key: string; + values: string[]; + usage: Record; + }; + onClose: () => void; +}; + +const RoomAttributesContextualBar = ({ attributeData, onClose }: RoomAttributesContextualBarProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const methods = useForm<{ + name: string; + attributeValues: { value: string }[]; + lockedAttributes: { value: string }[]; + }>({ + defaultValues: attributeData + ? { + name: attributeData.key, + attributeValues: [{ value: '' }], + lockedAttributes: attributeData.values.map((value) => ({ value })), + } + : { + name: '', + attributeValues: [{ value: '' }], + lockedAttributes: [], + }, + }); + + const { getValues } = methods; + + const attributeId = useRouteParameter('id'); + const createAttribute = useEndpoint('POST', '/v1/abac/attributes'); + const updateAttribute = useEndpoint('PUT', '/v1/abac/attributes/:_id', { + _id: attributeId ?? '', + }); + + const onSave = useEffectEvent(async (data: AdminABACRoomAttributesFormFormData) => { + const payload = { + key: data.name, + values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)], + }; + if (attributeId) { + await updateAttribute(payload); + } else { + await createAttribute(payload); + } + }); + + const dispatchToastMessage = useToastMessageDispatch(); + + const saveMutation = useMutation({ + mutationFn: onSave, + onSuccess: () => { + if (attributeId) { + dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) }); + } else { + dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_created', { attributeName: getValues('name') }) }); + } + onClose(); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.all() }); + }, + }); + + return ( + <> + + {t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')} + + + + saveMutation.mutateAsync(values)} + onCancel={onClose} + description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')} + /> + + + ); +}; + +export default RoomAttributesContextualBar; diff --git a/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBarWithData.tsx b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBarWithData.tsx new file mode 100644 index 0000000000000..8b334fdcadceb --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBarWithData.tsx @@ -0,0 +1,28 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +import RoomAttributesContextualBar from './RoomAttributesContextualBar'; +import { ContextualbarSkeletonBody } from '../../../components/Contextualbar'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type RoomAttributesContextualBarWithDataProps = { + id: string; + onClose: () => void; +}; + +const RoomAttributesContextualBarWithData = ({ id, onClose }: RoomAttributesContextualBarWithDataProps) => { + const getAttributes = useEndpoint('GET', '/v1/abac/attributes/:_id', { _id: id }); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ABACQueryKeys.roomAttributes.attribute(id), + queryFn: () => getAttributes(), + staleTime: 0, + }); + + if (isLoading || isFetching) { + return ; + } + + return ; +}; + +export default RoomAttributesContextualBarWithData; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap index ab8d65693d3bb..83c3c4434d048 100644 --- a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACRoomAttributesForm.spec.tsx.snap @@ -6,40 +6,40 @@ exports[`AdminABACRoomAttributesForm renders NewAttribute without crashing 1`] =
-
-
-
+ class="os-size-observer-listener" + /> +
+
-
Create an attribute that can later be assigned to rooms.
-
+
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
- - + -
+ Save + +
- +
@@ -178,40 +179,40 @@ exports[`AdminABACRoomAttributesForm renders WithLockedAttributes without crashi
-
-
-
+ class="os-size-observer-listener" + /> +
+
-
Attribute values cannot be edited, but can be added or deleted.
-
+
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
-
+ class="os-scrollbar-handle" + />
+
+
-
- - + -
+ Save + +
- +
diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx new file mode 100644 index 0000000000000..b5bfa1051dc4a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx @@ -0,0 +1,11 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; + +import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; + +const useIsABACAvailable = () => { + const hasABAC = useHasLicenseModule('abac'); + const isABACSettingEnabled = useSetting('ABAC_Enabled'); + return Boolean(hasABAC && isABACSettingEnabled); +}; + +export default useIsABACAvailable; diff --git a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx new file mode 100644 index 0000000000000..def4f28115505 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx @@ -0,0 +1,262 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor } from '@testing-library/react'; + +import useRoomAttributeItems from './useRoomAttributeOptions'; +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; + +const mockNavigate = jest.fn(); +const mockSetModal = jest.fn(); +const mockDispatchToastMessage = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => { + const originalModule = jest.requireActual('@rocket.chat/ui-contexts'); + return { + ...originalModule, + useRouter: () => ({ + navigate: mockNavigate, + }), + useSetModal: () => mockSetModal, + useToastMessageDispatch: () => mockDispatchToastMessage, + }; +}); + +const mockAttribute = { + _id: 'attribute-1', + key: 'Room Type', +}; + +const baseAppRoot = mockAppRoot() + .withTranslations('en', 'core', { + Edit: 'Edit', + Delete: 'Delete', + ABAC_Attribute_deleted: 'Attribute {{attributeName}} deleted', + ABAC_Cannot_delete_attribute: 'Cannot delete attribute', + ABAC_Cannot_delete_attribute_content: + 'The attribute {{attributeName}} is currently in use and cannot be deleted. Please remove it from all rooms before deleting.', + ABAC_Delete_room_attribute: 'Delete room attribute', + ABAC_Delete_room_attribute_content: + 'Are you sure you want to delete the attribute {{attributeName}}? This action cannot be undone.', + View_rooms: 'View rooms', + Cancel: 'Cancel', + }) + .withSetting('ABAC_Enabled', true, { + packageValue: false, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', + }) + .withEndpoint('GET', '/v1/licenses.info', async () => ({ + license: createFakeLicenseInfo({ activeModules: ['abac'] }), + })); + +describe('useRoomAttributeItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNavigate.mockClear(); + mockSetModal.mockClear(); + mockDispatchToastMessage.mockClear(); + }); + + it('should return menu items with correct structure', () => { + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + expect(result.current).toHaveLength(2); + expect(result.current[0]).toMatchObject({ + id: 'edit', + icon: 'edit', + content: 'Edit', + }); + expect(result.current[1]).toMatchObject({ + id: 'delete', + icon: 'trash', + iconColor: 'danger', + }); + }); + + it('should enable edit when ABAC is available', () => { + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + expect(result.current[0].disabled).toBe(false); + }); + + it('should navigate to edit page when edit action is clicked', async () => { + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + const editAction = result.current[0].onClick; + if (editAction) { + editAction(); + } + + expect(mockNavigate).toHaveBeenCalledWith( + { + name: 'admin-ABAC', + params: { + tab: 'room-attributes', + context: 'edit', + id: mockAttribute._id, + }, + }, + { replace: true }, + ); + }); + + it('should disable edit when ABAC is not available', () => { + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withSetting('ABAC_Enabled', false, { + packageValue: false, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', + }) + .withEndpoint('GET', '/v1/licenses.info', async () => ({ + license: createFakeLicenseInfo({ activeModules: [] }), + })) + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + expect(result.current[0].disabled).toBe(true); + }); + + it('should show warning modal when delete is clicked and attribute is in use', async () => { + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true })) + .build(), + }); + + const deleteAction = result.current[1].onClick; + if (deleteAction) { + deleteAction(); + } + + await waitFor(() => { + expect(mockSetModal).toHaveBeenCalled(); + }); + + const modalCall = mockSetModal.mock.calls[0][0]; + expect(modalCall.props.variant).toBe('warning'); + expect(modalCall.props.title).toBe('Cannot delete attribute'); + }); + + it('should show delete confirmation modal when delete is clicked and attribute is not in use', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + const deleteAction = result.current[1].onClick; + if (deleteAction) { + deleteAction(); + } + + await waitFor(() => { + expect(mockSetModal).toHaveBeenCalled(); + }); + + const modalCall = mockSetModal.mock.calls[0][0]; + expect(modalCall.props.variant).toBe('danger'); + expect(modalCall.props.title).toBe('Delete room attribute'); + expect(modalCall.props.confirmText).toBe('Delete'); + }); + + it('should call delete endpoint when delete is confirmed', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + + let confirmHandler: (() => void) | undefined; + + mockSetModal.mockImplementation((modal) => { + if (modal?.props?.onConfirm) { + confirmHandler = modal.props.onConfirm; + } + }); + + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + const deleteAction = result.current[1].onClick; + if (deleteAction) { + deleteAction(); + } + + await waitFor(() => { + expect(mockSetModal).toHaveBeenCalled(); + }); + + if (confirmHandler) { + confirmHandler(); + } + + await waitFor(() => { + expect(deleteEndpointMock).toHaveBeenCalled(); + }); + }); + + it('should show success toast when delete succeeds', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + + let confirmHandler: (() => void) | undefined; + + mockSetModal.mockImplementation((modal) => { + if (modal?.props?.onConfirm) { + confirmHandler = modal.props.onConfirm; + } + }); + + const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { + wrapper: baseAppRoot + .withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock) + .withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false })) + .build(), + }); + + const deleteAction = result.current[1].onClick; + if (deleteAction) { + deleteAction(); + } + + await waitFor(() => { + expect(mockSetModal).toHaveBeenCalled(); + }); + + if (confirmHandler) { + confirmHandler(); + } + + await waitFor(() => { + expect(mockDispatchToastMessage).toHaveBeenCalledWith({ + type: 'success', + message: 'Attribute Room Type deleted', + }); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx new file mode 100644 index 0000000000000..3c67e5598d4ee --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx @@ -0,0 +1,103 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import GenericModal from '@rocket.chat/ui-client/dist/components/Modal/GenericModal'; +import { useRouter, useSetModal, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Trans, useTranslation } from 'react-i18next'; + +import useIsABACAvailable from './hooks/useIsABACAvailable'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +const useRoomAttributeItems = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const router = useRouter(); + const setModal = useSetModal(); + const queryClient = useQueryClient(); + const deleteAttribute = useEndpoint('DELETE', '/v1/abac/attributes/:_id', { _id: attribute._id }); + const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: attribute.key }); + const dispatchToastMessage = useToastMessageDispatch(); + const isABACAvailable = useIsABACAvailable(); + + const editAction = useEffectEvent(() => { + return router.navigate( + { + name: 'admin-ABAC', + params: { + tab: 'room-attributes', + context: 'edit', + id: attribute._id, + }, + }, + { replace: true }, + ); + }); + + const deleteMutation = useMutation({ + mutationFn: deleteAttribute, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_deleted', { attributeName: attribute.key }) }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.all() }); + setModal(null); + }, + }); + + const deleteAction = useEffectEvent(async () => { + const isUsed = await isAttributeUsed(); + if (isUsed.inUse) { + return setModal( + setModal(null)} + onCancel={() => setModal(null)} + > + }} + /> + , + ); + } + setModal( + { + deleteMutation.mutateAsync(undefined); + }} + onCancel={() => setModal(null)} + > + }} + /> + , + ); + }); + + return [ + { id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: !isABACAvailable }, + { + id: 'delete', + iconColor: 'danger', + icon: 'trash' as const, + content: {t('Delete')}, + onClick: () => deleteAction(), + }, + ]; +}; + +export default useRoomAttributeItems; diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index 7b907afec75e4..ae82ea6584fc7 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -68,8 +68,7 @@ export const { href: '/admin/ABAC', i18nLabel: 'ABAC', icon: 'team-lock', - // TODO: Check what permission is needed to view the ABAC page - permissionGranted: (): boolean => !hasPermission('abac-management'), + permissionGranted: (): boolean => hasPermission('abac-management'), }, { href: '/admin/device-management', diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 552f66df2f5d4..894cb95a6a5f6 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -64,7 +64,7 @@ const GetAbacAttributesQuery = { additionalProperties: false, }; -export const GETAbacAttributesQuerySchema = ajv.compile<{ key: string; values: string; offset: number; count: number }>( +export const GETAbacAttributesQuerySchema = ajv.compile<{ key?: string; values?: string; offset: number; count: number }>( GetAbacAttributesQuery, ); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2570053dbdce3..09b1070a5a589 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -25,6 +25,20 @@ "ABAC_Warning_Modal_Content": "You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access control, it must be removed from ABAC management in <1>ABAC > Rooms.", "ABAC_ShowAttributesInRooms": "Show ABAC attributes in rooms", "ABAC_ShowAttributesInRooms_Description": "Display the ABAC attributes assigned to the room in the contextual bar", + "ABAC_Room_Attributes": "Room Attributes", + "ABAC_Cannot_delete_attribute": "Cannot delete attribute assigned to rooms", + "ABAC_Cannot_delete_attribute_content": "Unassign {{attributeName}} from all rooms before attempting to delete it.", + "ABAC_Delete_room_attribute": "Delete attribute", + "ABAC_Delete_room_attribute_content": "Are you sure you want to delete {{attributeName}} ?
Existing rooms will not be affected as it is not currently assigned to any.", + "ABAC_Attribute_created": "{{attributeName}} attribute created", + "ABAC_Attribute_updated": "{{attributeName}} attribute updated", + "ABAC_Attribute_deleted": "{{attributeName}} attribute deleted", + "ABAC_New_attribute": "New attribute", + "ABAC_Edit_attribute": "Edit attribute", + "ABAC_Edit_attribute_description": "Attribute values cannot be edited, but can be added or deleted.", + "ABAC_New_attribute_description": "Create an attribute that can later be assigned to rooms.", + "ABAC_Search_attributes": "Search attributes", + "ABAC_Remove_attribute": "Delete attribute value", "abac-management": "Manage ABAC configuration", "abac_removed_user_from_the_room": "was removed by ABAC", "AI_Actions": "AI actions", From b547cbda24edb11e90ce987fb863f9ffcfe14309 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Thu, 13 Nov 2025 13:25:48 -0300 Subject: [PATCH 2/5] review: improve useIsABACAvailable --- .../client/views/admin/ABAC/hooks/useIsABACAvailable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx index b5bfa1051dc4a..24b3a516aedff 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx +++ b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx @@ -3,9 +3,9 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; const useIsABACAvailable = () => { - const hasABAC = useHasLicenseModule('abac'); - const isABACSettingEnabled = useSetting('ABAC_Enabled'); - return Boolean(hasABAC && isABACSettingEnabled); + const hasABAC = useHasLicenseModule('abac') === true; + const isABACSettingEnabled = useSetting('ABAC_Enabled', false); + return hasABAC && isABACSettingEnabled; }; export default useIsABACAvailable; From d39426f79aa0026771a04b7cf8584ce4824b9179 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Thu, 13 Nov 2025 15:38:37 -0300 Subject: [PATCH 3/5] review: handle weird hook return --- apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx | 11 +++++++---- .../views/admin/ABAC/AdminABACRoomAttributes.tsx | 2 +- .../views/admin/ABAC/hooks/useIsABACAvailable.tsx | 4 ++-- .../views/admin/ABAC/useRoomAttributeOptions.spec.tsx | 11 +++++++++-- .../views/admin/ABAC/useRoomAttributeOptions.tsx | 2 +- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx index 25fe1c059f679..808eadae71b18 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -9,7 +9,7 @@ import AdminABACTabs from './AdminABACTabs'; import RoomAttributesContextualBar from './RoomAttributesContextualBar'; import RoomAttributesContextualBarWithData from './RoomAttributesContextualBarWithData'; import useIsABACAvailable from './hooks/useIsABACAvailable'; -import { ContextualbarDialog } from '../../../components/Contextualbar'; +import { ContextualbarDialog, ContextualbarSkeletonBody } from '../../../components/Contextualbar'; import { Page, PageContent, PageHeader } from '../../../components/Page'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { links } from '../../../lib/links'; @@ -67,10 +67,13 @@ const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { {tab === 'room-attributes' && } - {tab === 'room-attributes' && context !== undefined && isABACAvailable && ( + {tab === 'room-attributes' && context !== undefined && ( handleCloseContextualbar()}> - {context === 'new' && handleCloseContextualbar()} />} - {context === 'edit' && _id && handleCloseContextualbar()} />} + {isABACAvailable === 'loading' && } + {context === 'new' && isABACAvailable === true && handleCloseContextualbar()} />} + {context === 'edit' && _id && isABACAvailable === true && ( + handleCloseContextualbar()} /> + )} )} diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx index b013efcf7d350..99fe877326bef 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx @@ -63,7 +63,7 @@ const AdminABACRoomAttributes = () => { value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} /> - diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx index 24b3a516aedff..9cb553406aaf5 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx +++ b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx @@ -3,9 +3,9 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; const useIsABACAvailable = () => { - const hasABAC = useHasLicenseModule('abac') === true; + const hasABAC = useHasLicenseModule('abac'); const isABACSettingEnabled = useSetting('ABAC_Enabled', false); - return hasABAC && isABACSettingEnabled; + return hasABAC === 'loading' ? 'loading' : hasABAC && isABACSettingEnabled; }; export default useIsABACAvailable; diff --git a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx index def4f28115505..9565a3f34d986 100644 --- a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx @@ -8,6 +8,10 @@ const mockNavigate = jest.fn(); const mockSetModal = jest.fn(); const mockDispatchToastMessage = jest.fn(); +let ABACAvailable = true; + +jest.mock('./hooks/useIsABACAvailable', () => jest.fn(() => ABACAvailable)); + jest.mock('@rocket.chat/ui-contexts', () => { const originalModule = jest.requireActual('@rocket.chat/ui-contexts'); return { @@ -80,7 +84,7 @@ describe('useRoomAttributeItems', () => { }); }); - it('should enable edit when ABAC is available', () => { + it('should enable edit when ABAC is available', async () => { const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { wrapper: baseAppRoot .withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null) @@ -88,7 +92,9 @@ describe('useRoomAttributeItems', () => { .build(), }); - expect(result.current[0].disabled).toBe(false); + await waitFor(() => { + expect(result.current[0].disabled).toBe(false); + }); }); it('should navigate to edit page when edit action is clicked', async () => { @@ -118,6 +124,7 @@ describe('useRoomAttributeItems', () => { }); it('should disable edit when ABAC is not available', () => { + ABACAvailable = false; const { result } = renderHook(() => useRoomAttributeItems(mockAttribute), { wrapper: baseAppRoot .withSetting('ABAC_Enabled', false, { diff --git a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx index 3c67e5598d4ee..a8c8169eecc66 100644 --- a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx @@ -89,7 +89,7 @@ const useRoomAttributeItems = (attribute: { _id: string; key: string }): Generic }); return [ - { id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: !isABACAvailable }, + { id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: isABACAvailable !== true }, { id: 'delete', iconColor: 'danger', From d1e3bde0cb97edaec344c739ff14f84c2a6e7ae4 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 14 Nov 2025 14:48:41 -0300 Subject: [PATCH 4/5] review: rabbit thing --- apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx index 99fe877326bef..6c4dcdd97347c 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx @@ -63,7 +63,7 @@ const AdminABACRoomAttributes = () => { value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} /> - From e6eb60c4b7fca1bf147b1865b9b18ca16899d511 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 14 Nov 2025 14:51:51 -0300 Subject: [PATCH 5/5] review: :tasso: --- .../ABAC/RoomAttributesContextualBar.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx index 015f38d30bc40..ace7a9ea0c022 100644 --- a/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx +++ b/apps/meteor/client/views/admin/ABAC/RoomAttributesContextualBar.tsx @@ -1,5 +1,4 @@ import { ContextualbarTitle } from '@rocket.chat/fuselage'; -import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { FormProvider, useForm } from 'react-hook-form'; @@ -50,22 +49,20 @@ const RoomAttributesContextualBar = ({ attributeData, onClose }: RoomAttributesC _id: attributeId ?? '', }); - const onSave = useEffectEvent(async (data: AdminABACRoomAttributesFormFormData) => { - const payload = { - key: data.name, - values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)], - }; - if (attributeId) { - await updateAttribute(payload); - } else { - await createAttribute(payload); - } - }); - const dispatchToastMessage = useToastMessageDispatch(); const saveMutation = useMutation({ - mutationFn: onSave, + mutationFn: async (data: AdminABACRoomAttributesFormFormData) => { + const payload = { + key: data.name, + values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)], + }; + if (attributeId) { + await updateAttribute(payload); + } else { + await createAttribute(payload); + } + }, onSuccess: () => { if (attributeId) { dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) });