diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index e8d60dd4a4138..4bd3ff9baf1bd 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -126,7 +126,13 @@ export const ABACQueryKeys = { }, 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, + list: (query?: PaginatedRequest) => [...ABACQueryKeys.roomAttributes.all(), query] as const, + attribute: (attributeId: string) => [...ABACQueryKeys.roomAttributes.all(), attributeId] as const, + }, + rooms: { + all: () => [...ABACQueryKeys.all, 'rooms'] as const, + list: (query?: PaginatedRequest) => [...ABACQueryKeys.rooms.all(), query] as const, + autocomplete: (query?: PaginatedRequest) => [...ABACQueryKeys.rooms.all(), 'autocomplete', query] as const, + room: (roomId: string) => [...ABACQueryKeys.rooms.all(), roomId] as const, }, }; diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx new file mode 100644 index 0000000000000..fc9d2884ef65a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx @@ -0,0 +1,43 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; + +import * as stories from './ABACAttributeField.stories'; + +const mockAttribute1 = { + _id: 'attr1', + key: 'Department', + label: 'Department', + values: ['Engineering', 'Sales', 'Marketing'], +}; + +const mockAttribute2 = { + _id: 'attr2', + key: 'Security-Level', + label: 'Security Level', + values: ['Public', 'Internal', 'Confidential'], +}; + +const mockAttribute3 = { + _id: 'attr3', + key: 'Location', + label: 'Location', + values: ['US', 'EU', 'APAC'], +}; + +jest.mock('./hooks/useABACAttributeList', () => ({ + useABACAttributeList: jest.fn(() => ({ + data: [mockAttribute1, mockAttribute2, mockAttribute3], + fetchNextPage: jest.fn(), + isLoading: false, + })), +})); + +describe('ABACAttributeField', () => { + // TODO: Once the autocomplete components are a11y compliant, and testable, add more tests + const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributeField.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.stories.tsx new file mode 100644 index 0000000000000..d43f06801b3c8 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.stories.tsx @@ -0,0 +1,75 @@ +import { Field } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import ABACAttributeField from './ABACAttributeField'; +import type { AdminABACRoomFormData } from './AdminABACRoomForm'; + +const mockAttribute1 = { + _id: 'attr1', + _updatedAt: new Date().toISOString(), + key: 'Department', + values: ['Engineering', 'Sales', 'Marketing'], +}; + +const mockAttribute2 = { + _id: 'attr2', + _updatedAt: new Date().toISOString(), + key: 'Security-Level', + values: ['Public', 'Internal', 'Confidential'], +}; + +const mockAttribute3 = { + _id: 'attr3', + _updatedAt: new Date().toISOString(), + key: 'Location', + values: ['US', 'EU', 'APAC'], +}; + +const meta: Meta = { + component: ABACAttributeField, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .withEndpoint('GET', '/v1/abac/attributes', () => ({ + attributes: [mockAttribute1, mockAttribute2, mockAttribute3], + count: 3, + offset: 0, + total: 3, + })) + .build(); + + const methods = useForm({ + defaultValues: { + room: '', + attributes: [{ key: '', values: [] }], + }, + mode: 'onChange', + }); + + return ( + + + + + + + + ); + }, + ], + args: { + onRemove: action('onRemove'), + index: 0, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx new file mode 100644 index 0000000000000..da5eeee9dc34e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx @@ -0,0 +1,99 @@ +import { Box, Button, FieldError, FieldRow, InputBoxSkeleton, MultiSelect, PaginatedSelectFiltered } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useCallback, useMemo, useState } from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { AdminABACRoomFormData } from './AdminABACRoomForm'; +import { useABACAttributeList } from './hooks/useABACAttributeList'; + +type ABACAttributeAutocompleteProps = { + onRemove: () => void; + index: number; +}; + +const ABACAttributeField = ({ onRemove, index }: ABACAttributeAutocompleteProps) => { + const { t } = useTranslation(); + const [filter, setFilter] = useState(); + const filterDebounced = useDebouncedValue(filter, 300); + + const { control, getValues, resetField } = useFormContext(); + + const { data: options, fetchNextPage, isLoading } = useABACAttributeList(filterDebounced || undefined); + + const validateRepeatedAttributes = useCallback( + (value: string) => { + const attributes = getValues('attributes'); + // Only one instance of the same attribute is allowed to be in the form at a time + const repeatedAttributes = attributes.filter((attribute) => attribute.key === value).length > 1; + return repeatedAttributes ? t('ABAC_No_repeated_attributes') : undefined; + }, + [getValues, t], + ); + + const { field: keyField, fieldState: keyFieldState } = useController({ + name: `attributes.${index}.key`, + control, + rules: { + required: t('Required_field', { field: t('Attribute') }), + validate: validateRepeatedAttributes, + }, + }); + + const { field: valuesField, fieldState: valuesFieldState } = useController({ + name: `attributes.${index}.values`, + control, + rules: { required: t('Required_field', { field: t('Attribute_Values') }) }, + }); + + const valueOptions: [string, string][] = useMemo(() => { + if (!keyField.value) { + return []; + } + + const selectedAttributeData = options.find((option) => option.value === keyField.value); + + return selectedAttributeData?.attributeValues.map((value) => [value, value]) || []; + }, [keyField.value, options]); + + if (isLoading) { + return ; + } + return ( + + + { + resetField(`attributes.${index}.values`); + keyField.onChange(val); + }} + filter={filter} + setFilter={setFilter as (value: string | number | undefined) => void} + options={options} + endReached={() => fetchNextPage()} + placeholder={t('ABAC_Search_Attribute')} + mbe={4} + error={keyFieldState.error?.message} + /> + + {keyFieldState.error?.message || ''} + + + + + {valuesFieldState.error?.message || ''} + + + {t('Remove')} + + + ); +}; + +export default ABACAttributeField; diff --git a/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.spec.tsx new file mode 100644 index 0000000000000..518031b8fb2d7 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.spec.tsx @@ -0,0 +1,71 @@ +import { faker } from '@faker-js/faker'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ABACDeleteRoomModal from './ABACDeleteRoomModal'; + +const mockDispatchToastMessage = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useToastMessageDispatch: () => mockDispatchToastMessage, +})); + +const baseAppRoot = mockAppRoot().withTranslations('en', 'core', { + Edit: 'Edit', + Remove: 'Remove', + ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', + ABAC_Delete_room: 'Remove room from ABAC management', + ABAC_Delete_room_annotation: 'Proceed with caution', + ABAC_Delete_room_content: 'Removing {{roomName}} from ABAC management may result in unintended users gaining access.', + Cancel: 'Cancel', +}); + +describe('ABACDeleteRoomModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const rid = faker.database.mongodbObjectId(); + const roomName = 'Test Room'; + + it('should render without crashing', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot.build(), + }); + + expect(baseElement).toMatchSnapshot(); + }); + + it('should call delete endpoint when delete is confirmed', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + + render(, { + wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Remove' })); + + await waitFor(() => { + expect(deleteEndpointMock).toHaveBeenCalled(); + }); + }); + + it('should show success toast when delete succeeds', async () => { + const deleteEndpointMock = jest.fn().mockResolvedValue(null); + + render(, { + wrapper: baseAppRoot.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', deleteEndpointMock).build(), + }); + + await userEvent.click(screen.getByRole('button', { name: 'Remove' })); + + await waitFor(() => { + expect(mockDispatchToastMessage).toHaveBeenCalledWith({ + type: 'success', + message: 'Room Test Room removed from ABAC management', + }); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.tsx b/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.tsx new file mode 100644 index 0000000000000..81d999b3daee2 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.tsx @@ -0,0 +1,48 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQueryClient } from '@tanstack/react-query'; +import { Trans, useTranslation } from 'react-i18next'; + +import { useEndpointMutation } from '../../../hooks/useEndpointMutation'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type ABACDeleteRoomModalProps = { + rid: IRoom['_id']; + roomName: string; + onClose: () => void; +}; + +const ABACDeleteRoomModal = ({ rid, roomName, onClose }: ABACDeleteRoomModalProps) => { + const { t } = useTranslation(); + + const queryClient = useQueryClient(); + const dispatchToastMessage = useToastMessageDispatch(); + const deleteMutation = useEndpointMutation('DELETE', '/v1/abac/rooms/:rid/attributes', { + keys: { rid }, + onSuccess: () => { + dispatchToastMessage({ type: 'success', message: t('ABAC_Room_removed', { roomName }) }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.all() }); + onClose(); + }, + }); + + return ( + deleteMutation.mutate(undefined)} + onCancel={onClose} + > + }} /> + + ); +}; + +export default ABACDeleteRoomModal; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.spec.tsx new file mode 100644 index 0000000000000..ee9f3d6e4e43c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.spec.tsx @@ -0,0 +1,60 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; + +import ABACRoomAutocomplete from './ABACRoomAutocomplete'; +import * as stories from './ABACRoomAutocomplete.stories'; +import { createFakeRoom } from '../../../../tests/mocks/data'; + +const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1', fname: 'Room 1' }); +const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2', fname: 'Room 2' }); +const mockRoom3 = createFakeRoom({ t: 'p', name: 'Room 3', fname: 'Room 3', abacAttributes: [] }); + +const appRoot = mockAppRoot() + .withEndpoint('GET', '/v1/rooms.adminRooms', () => ({ + rooms: [mockRoom1 as any, mockRoom2 as any, mockRoom3 as any], + count: 3, + offset: 0, + total: 3, + })) + .build(); + +describe('ABACRoomAutocomplete', () => { + const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + + test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); + }); + + test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + // Aria label added in a higher level + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should populate select options correctly', async () => { + render(, { + wrapper: appRoot, + }); + + const input = screen.getByRole('textbox'); + await userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText('Room 1')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Room 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Room 3')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.stories.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.stories.tsx new file mode 100644 index 0000000000000..e78ec4194514b --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.stories.tsx @@ -0,0 +1,42 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ABACRoomAutocomplete from './ABACRoomAutocomplete'; +import { createFakeRoom } from '../../../../tests/mocks/data'; + +const mockRoom1 = createFakeRoom({ t: 'p', name: 'Room 1' }); +const mockRoom2 = createFakeRoom({ t: 'p', name: 'Room 2' }); + +const meta: Meta = { + component: ABACRoomAutocomplete, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .withEndpoint('GET', '/v1/rooms.adminRooms', () => ({ + rooms: [mockRoom1 as any, mockRoom2 as any], + count: 2, + offset: 0, + total: 2, + })) + .build(); + return ( + + + + ); + }, + ], + args: { + value: '', + onSelectedRoom: action('onChange'), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.tsx new file mode 100644 index 0000000000000..a621f376fcd0a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.tsx @@ -0,0 +1,59 @@ +import { AutoComplete, Option, Box } from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import type { ComponentProps } from 'react'; +import { memo, useState } from 'react'; + +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +const generateQuery = ( + term = '', +): { + filter: string; + types: string[]; +} => ({ filter: term, types: ['p'] }); + +type ABACRoomAutocompleteProps = Omit, 'filter' | 'onChange'> & { + onSelectedRoom: (value: string, label: string) => void; +}; + +const ABACRoomAutocomplete = ({ value, onSelectedRoom, ...props }: ABACRoomAutocompleteProps) => { + const [filter, setFilter] = useState(''); + const filterDebounced = useDebouncedValue(filter, 300); + const roomsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.adminRooms'); + + const result = useQuery({ + queryKey: ABACQueryKeys.rooms.autocomplete(generateQuery(filterDebounced)), + queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)), + placeholderData: keepPreviousData, + select: (data) => + data.rooms + .filter((room) => !room.abacAttributes || room.abacAttributes.length === 0) + .map((room) => ({ + value: room._id, + label: { name: room.fname || room.name }, + })), + }); + + return ( + { + onSelectedRoom(val as string, result.data?.find(({ value }) => value === val)?.label?.name || ''); + }} + value={value} + filter={filter} + setFilter={setFilter} + renderSelected={({ selected: { label } }) => ( + + {label?.name} + + )} + renderItem={({ label, ...props }) => } + options={result.data} + /> + ); +}; + +export default memo(ABACRoomAutocomplete); diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomAutocompleteDummy.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocompleteDummy.tsx new file mode 100644 index 0000000000000..53ff7564a59c6 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocompleteDummy.tsx @@ -0,0 +1,11 @@ +import { Input } from '@rocket.chat/fuselage'; + +type ABACRoomAutocompleteDummyProps = { + roomInfo: { rid: string; name: string }; +}; + +const ABACRoomAutocompleteDummy = ({ roomInfo }: ABACRoomAutocompleteDummyProps) => { + return ; +}; + +export default ABACRoomAutocompleteDummy; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx index b8b84acb77dfb..2f1003f667248 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -6,11 +6,14 @@ import { Trans, useTranslation } from 'react-i18next'; import AdminABACLogs from './AdminABACLogs'; import AdminABACRoomAttributes from './AdminABACRoomAttributes'; +import AdminABACRooms from './AdminABACRooms'; import AdminABACSettings from './AdminABACSettings'; import AdminABACTabs from './AdminABACTabs'; import RoomAttributesContextualBar from './RoomAttributesContextualBar'; import RoomAttributesContextualBarWithData from './RoomAttributesContextualBarWithData'; -import useIsABACAvailable from './hooks/useIsABACAvailable'; +import RoomsContextualBar from './RoomsContextualBar'; +import RoomsContextualBarWithData from './RoomsContextualBarWithData'; +import { useIsABACAvailable } from './hooks/useIsABACAvailable'; import { Page, PageContent, PageHeader } from '../../../components/Page'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { links } from '../../../lib/links'; @@ -66,14 +69,27 @@ const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { {tab === 'settings' && } {tab === 'room-attributes' && } + {tab === 'rooms' && } {tab === 'logs' && } - {tab === 'room-attributes' && context !== undefined && ( + {tab !== undefined && context !== undefined && ( handleCloseContextualbar()}> - {context === 'new' && isABACAvailable && handleCloseContextualbar()} />} - {context === 'edit' && _id && isABACAvailable && ( - handleCloseContextualbar()} /> + {tab === 'room-attributes' && ( + <> + {context === 'new' && isABACAvailable === true && handleCloseContextualbar()} />} + {context === 'edit' && _id && isABACAvailable === true && ( + handleCloseContextualbar()} /> + )} + > + )} + {tab === 'rooms' && ( + <> + {context === 'new' && isABACAvailable === true && handleCloseContextualbar()} />} + {context === 'edit' && _id && isABACAvailable === true && ( + handleCloseContextualbar()} /> + )} + > )} )} diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx index ac6d4eaf120ee..e522647ca1cc6 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributeMenu.tsx @@ -1,7 +1,7 @@ import { GenericMenu } from '@rocket.chat/ui-client'; import { useTranslation } from 'react-i18next'; -import useRoomAttributeItems from './useRoomAttributeOptions'; +import { useRoomAttributeItems } from './useRoomAttributeOptions'; type AdminABACRoomAttributeMenuProps = { attribute: { _id: string; key: string }; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx index b013efcf7d350..96c68cd0afcfe 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributes.tsx @@ -6,7 +6,7 @@ import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import AdminABACRoomAttributeMenu from './AdminABACRoomAttributeMenu'; -import useIsABACAvailable from './hooks/useIsABACAvailable'; +import { useIsABACAvailable } from './hooks/useIsABACAvailable'; import GenericNoResults from '../../../components/GenericNoResults'; import { GenericTable, @@ -49,7 +49,7 @@ const AdminABACRoomAttributes = () => { ); const { data, isLoading } = useQuery({ - queryKey: ABACQueryKeys.roomAttributes.roomAttributesList(query), + queryKey: ABACQueryKeys.roomAttributes.list(query), queryFn: () => getAttributes(query), }); @@ -68,9 +68,9 @@ const AdminABACRoomAttributes = () => { - {(!data || data.attributes.length === 0) && !isLoading ? ( + {(!data || data.attributes?.length === 0) && !isLoading ? ( - + ) : ( <> @@ -81,7 +81,7 @@ const AdminABACRoomAttributes = () => { - {data?.attributes.map((attribute) => ( + {data?.attributes?.map((attribute) => ( {attribute.key} {attribute.values.join(', ')} diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx index 283996289c5ec..4750204f1678a 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.stories.tsx @@ -13,7 +13,18 @@ export default { args: { description: 'Create an attribute that can later be assigned to rooms.', }, - decorators: [mockAppRoot().buildStoryDecorator()], + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + Name: 'Name', + Values: 'Values', + Add_Value: 'Add Value', + Cancel: 'Cancel', + Save: 'Save', + Required_field: '{{field}} is required', + }) + .buildStoryDecorator(), + ], } satisfies Meta; const Template: StoryFn = (args) => ; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx index 33d63bf2b7053..5c043a8cda341 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomAttributesForm.tsx @@ -121,7 +121,7 @@ const AdminABACRoomAttributesForm = ({ onSave, onCancel, description }: AdminABA lockedAttributesFields.length + fields.length >= 10 } > - {t('Add Value')} + {t('Add_Value')} diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomForm.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomForm.tsx new file mode 100644 index 0000000000000..02825a285f2dd --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomForm.tsx @@ -0,0 +1,143 @@ +import { Box, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { GenericModal, ContextualbarScrollableContent } from '@rocket.chat/ui-client'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import type { Dispatch, SetStateAction } from 'react'; +import { useId } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; + +import ABACAttributeField from './ABACAttributeField'; +import ABACRoomAutocomplete from './ABACRoomAutocomplete'; +import ABACRoomAutocompleteDummy from './ABACRoomAutocompleteDummy'; + +type AdminABACRoomFormProps = { + onClose: () => void; + onSave: (data: AdminABACRoomFormData) => void; + roomInfo?: { rid: string; name: string }; + setSelectedRoomLabel: Dispatch>; +}; + +export type AdminABACRoomFormData = { + room: string; + attributes: { key: string; values: string[] }[]; +}; + +const AdminABACRoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: AdminABACRoomFormProps) => { + const { + control, + handleSubmit, + formState: { isValid, errors }, + } = useFormContext(); + + const { t } = useTranslation(); + const formId = useId(); + const nameField = useId(); + + const { fields, append, remove } = useFieldArray({ + name: 'attributes', + control, + }); + + const setModal = useSetModal(); + + const updateAction = useEffectEvent(async (action: () => void) => { + setModal( + { + action(); + setModal(null); + }} + onCancel={() => setModal(null)} + > + }} + /> + , + ); + }); + + const handleSave = useEffectEvent(() => { + if (roomInfo) { + updateAction(handleSubmit(onSave)); + } else { + handleSubmit(onSave)(); + } + }); + + return ( + <> + + + + + {t('ABAC_Room_to_be_managed')} + + + {roomInfo ? ( + + ) : ( + ( + { + field.onChange(value); + setSelectedRoomLabel(label); + }} + /> + )} + /> + )} + + {errors.room?.message} + + {fields.map((field, index) => ( + + + {t('Attribute')} + + { + remove(index); + }} + index={index} + /> + + ))} + = 10} + onClick={() => { + append({ key: '', values: [] }); + }} + > + {t('ABAC_Add_Attribute')} + + + + + + {t('Cancel')} + + {t('Save')} + + + + > + ); +}; + +export default AdminABACRoomForm; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoomMenu.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoomMenu.tsx new file mode 100644 index 0000000000000..c4bc1841b1c2f --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoomMenu.tsx @@ -0,0 +1,28 @@ +import { GenericMenu } from '@rocket.chat/ui-client'; +import { useTranslation } from 'react-i18next'; + +import { useRoomItems } from './hooks/useRoomItems'; + +type AdminABACRoomMenuProps = { + room: { rid: string; name: string }; +}; + +const AdminABACRoomMenu = ({ room }: AdminABACRoomMenuProps) => { + const { t } = useTranslation(); + + const items = useRoomItems(room); + + return ( + + ); +}; + +export default AdminABACRoomMenu; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRooms.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRooms.tsx new file mode 100644 index 0000000000000..a12fb9d01373c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRooms.tsx @@ -0,0 +1,133 @@ +import { Box, Button, Icon, Margins, Pagination, Select, 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, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AdminABACRoomMenu from './AdminABACRoomMenu'; +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 AdminABACRooms = () => { + const { t } = useTranslation(); + + const [text, setText] = useState(''); + const [filterType, setFilterType] = useState<'all' | 'roomName' | 'attribute' | 'value'>('all'); + const debouncedText = useDebouncedValue(text, 200); + const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination(); + const getRooms = useEndpoint('GET', '/v1/abac/rooms'); + const isABACAvailable = useIsABACAvailable(); + + const router = useRouter(); + const handleNewAttribute = useEffectEvent(() => { + router.navigate({ + name: 'admin-ABAC', + params: { + tab: 'rooms', + context: 'new', + }, + }); + }); + + const query = useMemo( + () => ({ + ...(debouncedText ? { filter: debouncedText } : {}), + ...(filterType !== 'all' ? { filterType } : {}), + offset: current, + count: itemsPerPage, + }), + [debouncedText, current, itemsPerPage, filterType], + ); + + // Whenever the user changes the filter or the text, reset the pagination to the first page + useEffect(() => { + setCurrent(0); + }, [debouncedText, filterType, setCurrent]); + + const { data, isLoading } = useQuery({ + queryKey: ABACQueryKeys.rooms.list(query), + queryFn: () => getRooms(query), + }); + + return ( + <> + + + } + placeholder={t('ABAC_Search_rooms')} + value={text} + onChange={(e) => setText((e.target as HTMLInputElement).value)} + /> + + setFilterType(value as 'all' | 'roomName' | 'attribute' | 'value')} + /> + + + {t('Add_room')} + + + + {(!data || data.rooms?.length === 0) && !isLoading ? ( + + + + ) : ( + <> + + + {t('Room')} + {t('Members')} + {t('ABAC_Attributes')} + {t('ABAC_Attribute_Values')} + + + + {data?.rooms?.map((room) => ( + + {room.fname || room.name} + {room.usersCount} + {room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')} + {room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')} + + + + + ))} + + + + > + )} + > + ); +}; + +export default AdminABACRooms; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx index 88878c05af2e6..11b315242ac76 100644 --- a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -20,6 +20,9 @@ const AdminABACTabs = () => { handleTabClick('room-attributes')}> {t('ABAC_Room_Attributes')} + handleTabClick('rooms')}> + {t('Rooms')} + handleTabClick('logs')}> {t('ABAC_Logs')} diff --git a/apps/meteor/client/views/admin/ABAC/RoomsContextualBar.tsx b/apps/meteor/client/views/admin/ABAC/RoomsContextualBar.tsx new file mode 100644 index 0000000000000..e4691eb21f15e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/RoomsContextualBar.tsx @@ -0,0 +1,89 @@ +import { ContextualbarTitle } from '@rocket.chat/fuselage'; +import { ContextualbarClose, ContextualbarHeader } from '@rocket.chat/ui-client'; +import { useEndpoint, useRouteParameter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import AdminABACRoomForm from './AdminABACRoomForm'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type RoomsContextualBarProps = { + attributeId?: string; + roomInfo?: { rid: string; name: string }; + attributesData?: { key: string; values: string[] }[]; + + onClose: () => void; +}; + +const RoomsContextualBar = ({ roomInfo, attributesData, onClose }: RoomsContextualBarProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const methods = useForm<{ + room: string; + attributes: { key: string; values: string[] }[]; + }>({ + defaultValues: { + room: roomInfo?.rid || '', + attributes: attributesData ?? [{ key: '', values: [] }], + }, + mode: 'onChange', + }); + + const { watch } = methods; + + const [selectedRoomLabel, setSelectedRoomLabel] = useState(''); + + const attributeId = useRouteParameter('id'); + const createOrUpdateABACRoom = useEndpoint('POST', '/v1/abac/rooms/:rid/attributes', { rid: watch('room') }); + + const dispatchToastMessage = useToastMessageDispatch(); + + const saveMutation = useMutation({ + mutationFn: async (data: { room: string; attributes: { key: string; values: string[] }[] }) => { + const payload = { + attributes: data.attributes.reduce((acc: Record, attribute) => { + acc[attribute.key] = attribute.values; + return acc; + }, {}), + }; + + await createOrUpdateABACRoom(payload); + }, + onSuccess: () => { + if (attributeId) { + dispatchToastMessage({ type: 'success', message: t('ABAC_Room_updated', { roomName: selectedRoomLabel }) }); + } else { + dispatchToastMessage({ type: 'success', message: t('ABAC_Room_created', { roomName: selectedRoomLabel }) }); + } + onClose(); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ABACQueryKeys.rooms.all() }); + }, + }); + + return ( + <> + + {t(attributeId ? 'ABAC_Edit_Room' : 'ABAC_Add_room')} + + + + saveMutation.mutateAsync(values)} + onClose={onClose} + setSelectedRoomLabel={setSelectedRoomLabel} + /> + + > + ); +}; + +export default RoomsContextualBar; diff --git a/apps/meteor/client/views/admin/ABAC/RoomsContextualBarWithData.tsx b/apps/meteor/client/views/admin/ABAC/RoomsContextualBarWithData.tsx new file mode 100644 index 0000000000000..2483341be2753 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/RoomsContextualBarWithData.tsx @@ -0,0 +1,34 @@ +import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +import RoomsContextualBar from './RoomsContextualBar'; +import { ABACQueryKeys } from '../../../lib/queryKeys'; + +type RoomsContextualBarWithDataProps = { + id: string; + onClose: () => void; +}; + +const RoomsContextualBarWithData = ({ id, onClose }: RoomsContextualBarWithDataProps) => { + const getRoomAttributes = useEndpoint('GET', '/v1/rooms.adminRooms.getRoom'); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ABACQueryKeys.rooms.room(id), + queryFn: () => getRoomAttributes({ rid: id }), + staleTime: 0, + }); + + if (isLoading || isFetching) { + return ; + } + + return ( + + ); +}; + +export default RoomsContextualBarWithData; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACAttributeField.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACAttributeField.spec.tsx.snap new file mode 100644 index 0000000000000..a4253ba900b9e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACAttributeField.spec.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ABACAttributeField renders Default without crashing 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + ABAC_Select_Attribute_Values + + + + + + + + + + + + + + Remove + + + + + + +`; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACDeleteRoomModal.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACDeleteRoomModal.spec.tsx.snap new file mode 100644 index 0000000000000..7551cb9adc8a7 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACDeleteRoomModal.spec.tsx.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ABACDeleteRoomModal should render without crashing 1`] = ` + + + + + + + + + Remove room from ABAC management + + + + + + + + + + + + Removing + + Test Room + + from ABAC management may result in unintended users gaining access. + + + + + + + +`; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACRoomAutocomplete.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACRoomAutocomplete.spec.tsx.snap new file mode 100644 index 0000000000000..22d41be850a8c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/ABACRoomAutocomplete.spec.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`ABACRoomAutocomplete renders Default without crashing 1`] = ` + + + + + + + + + + + + + + +`; diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useABACAttributeList.ts b/apps/meteor/client/views/admin/ABAC/hooks/useABACAttributeList.ts new file mode 100644 index 0000000000000..f81b149930317 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useABACAttributeList.ts @@ -0,0 +1,39 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { useIsABACAvailable } from './useIsABACAvailable'; +import { ABACQueryKeys } from '../../../../lib/queryKeys'; + +export const useABACAttributeList = (filter?: string) => { + const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes'); + const isABACAvailable = useIsABACAvailable(); + + return useInfiniteQuery({ + enabled: isABACAvailable, + queryKey: ABACQueryKeys.roomAttributes.list({ key: filter ?? '' }), + queryFn: async ({ pageParam: offset = 0 }) => { + // TODO: Check endpoint types + const { attributes, ...data } = await attributesAutoCompleteEndpoint({ key: filter, offset, count: 15 }); + + return { + ...data, + attributes: attributes.map((attribute) => ({ + _id: attribute._id, + label: attribute.key, + value: attribute.key, + attributeValues: attribute.values, + })), + }; + }, + select: (data) => data.pages.flatMap((page) => page.attributes), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const offset = lastPage.offset + lastPage.count; + return offset < lastPage.total ? offset : undefined; + }, + initialData: () => ({ + pages: [{ attributes: [], offset: 0, count: 0, total: Infinity }], + pageParams: [0], + }), + }); +}; diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.spec.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.spec.tsx new file mode 100644 index 0000000000000..7d5732d4781f0 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.spec.tsx @@ -0,0 +1,52 @@ +import { faker } from '@faker-js/faker'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useABACDeleteRoomModal } from './useABACDeleteRoomModal'; + +const mockSetModal = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => { + const originalModule = jest.requireActual('@rocket.chat/ui-contexts'); + return { + ...originalModule, + useSetModal: () => mockSetModal, + }; +}); + +describe('useABACDeleteRoomModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSetModal.mockClear(); + }); + + it('should show delete confirmation modal when hook is called', async () => { + const { result } = renderHook( + () => + useABACDeleteRoomModal({ + rid: faker.database.mongodbObjectId(), + name: faker.lorem.words(3), + }), + { + wrapper: mockAppRoot() + .withTranslations('en', 'core', { + Edit: 'Edit', + Remove: 'Remove', + ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', + ABAC_Delete_room: 'Remove room from ABAC management', + ABAC_Delete_room_annotation: 'Proceed with caution', + ABAC_Delete_room_content: + 'Removing {{roomName}} from ABAC management may result in unintended users gaining access.', + Cancel: 'Cancel', + }) + .build(), + }, + ); + + result.current(); + + await waitFor(() => { + expect(mockSetModal).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.tsx new file mode 100644 index 0000000000000..199e3d10b3977 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useABACDeleteRoomModal.tsx @@ -0,0 +1,11 @@ +import { useSetModal } from '@rocket.chat/ui-contexts'; + +import ABACDeleteRoomModal from '../ABACDeleteRoomModal'; + +export const useABACDeleteRoomModal = (room: { rid: string; name: string }) => { + const setModal = useSetModal(); + + return () => { + setModal( setModal(null)} />); + }; +}; diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.ts similarity index 81% rename from apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx rename to apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.ts index abc6c945a587f..357ea543f2ca8 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.tsx +++ b/apps/meteor/client/views/admin/ABAC/hooks/useIsABACAvailable.ts @@ -2,11 +2,9 @@ import { useSetting } from '@rocket.chat/ui-contexts'; import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule'; -const useIsABACAvailable = () => { +export const useIsABACAvailable = () => { const { data: hasABAC = false } = useHasLicenseModule('abac'); const isABACSettingEnabled = useSetting('ABAC_Enabled', false); return hasABAC && isABACSettingEnabled; }; - -export default useIsABACAvailable; diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx new file mode 100644 index 0000000000000..4261e3bc786ac --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx @@ -0,0 +1,124 @@ +import { faker } from '@faker-js/faker'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useRoomItems } from './useRoomItems'; + +const navigateMock = jest.fn(); +const setABACDeleteRoomModalMock = jest.fn(); +const useIsABACAvailableMock = jest.fn(() => true); + +jest.mock('./useIsABACAvailable', () => ({ + useIsABACAvailable: () => useIsABACAvailableMock(), +})); +jest.mock('./useABACDeleteRoomModal', () => ({ + useABACDeleteRoomModal: () => setABACDeleteRoomModalMock, +})); +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useRouter: () => ({ + navigate: navigateMock, + }), +})); + +const mockRoom = { + rid: faker.database.mongodbObjectId(), + name: 'Test Room', +}; + +const createAppRoot = () => + mockAppRoot() + .withTranslations('en', 'core', { + Edit: 'Edit', + Remove: 'Remove', + ABAC_Room_removed: 'Room {{roomName}} removed from ABAC management', + ABAC_Delete_room: 'Remove room from ABAC management', + ABAC_Delete_room_annotation: 'Proceed with caution', + ABAC_Delete_room_content: 'Removing {{roomName}} from ABAC management may result in unintended users gaining access.', + Cancel: 'Cancel', + }) + .withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', async () => null); + +describe('useRoomItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + navigateMock.mockClear(); + useIsABACAvailableMock.mockReturnValue(true); + }); + + it('should return menu items with correct structure', () => { + const { result } = renderHook(() => useRoomItems(mockRoom), { + wrapper: createAppRoot().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: 'cross', + iconColor: 'danger', + }); + }); + + it('should enable edit when ABAC is available', async () => { + const { result } = renderHook(() => useRoomItems(mockRoom), { + wrapper: createAppRoot().build(), + }); + + await waitFor(() => { + expect(result.current[0].disabled).toBe(false); + }); + }); + + it('should navigate to edit page when edit action is clicked', async () => { + const { result } = renderHook(() => useRoomItems(mockRoom), { + wrapper: createAppRoot().build(), + }); + + const editAction = result.current[0].onClick; + if (editAction) { + editAction(); + } + + expect(navigateMock).toHaveBeenCalledWith( + { + name: 'admin-ABAC', + params: { + tab: 'rooms', + context: 'edit', + id: mockRoom.rid, + }, + }, + { replace: true }, + ); + }); + + it('should disable edit when ABAC is not available', () => { + useIsABACAvailableMock.mockReturnValue(false); + + const { result } = renderHook(() => useRoomItems(mockRoom), { + wrapper: createAppRoot().build(), + }); + + expect(result.current[0].disabled).toBe(true); + }); + + it('should show delete modal when delete is clicked', async () => { + const { result } = renderHook(() => useRoomItems(mockRoom), { + wrapper: createAppRoot().build(), + }); + + const deleteAction = result.current[1].onClick; + if (deleteAction) { + deleteAction(); + } + + await waitFor(() => { + expect(setABACDeleteRoomModalMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx b/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx new file mode 100644 index 0000000000000..ea7a320c83de6 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx @@ -0,0 +1,40 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import { useABACDeleteRoomModal } from './useABACDeleteRoomModal'; +import { useIsABACAvailable } from './useIsABACAvailable'; + +export const useRoomItems = (room: { rid: string; name: string }): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const router = useRouter(); + const setABACDeleteRoomModal = useABACDeleteRoomModal(room); + const isABACAvailable = useIsABACAvailable(); + + const editAction = useEffectEvent(() => { + return router.navigate( + { + name: 'admin-ABAC', + params: { + tab: 'rooms', + context: 'edit', + id: room.rid, + }, + }, + { replace: true }, + ); + }); + + return [ + { id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: isABACAvailable !== true }, + { + id: 'delete', + iconColor: 'danger', + icon: 'cross' as const, + content: {t('Remove')}, + onClick: setABACDeleteRoomModal, + }, + ]; +}; diff --git a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx index 9565a3f34d986..d59f577157a7c 100644 --- a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.spec.tsx @@ -1,28 +1,26 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { renderHook, waitFor } from '@testing-library/react'; -import useRoomAttributeItems from './useRoomAttributeOptions'; +import { useRoomAttributeItems } from './useRoomAttributeOptions'; import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; const mockNavigate = jest.fn(); const mockSetModal = jest.fn(); const mockDispatchToastMessage = jest.fn(); +const useIsABACAvailableMock = jest.fn(() => true); -let ABACAvailable = true; +jest.mock('./hooks/useIsABACAvailable', () => ({ + useIsABACAvailable: () => useIsABACAvailableMock(), +})); -jest.mock('./hooks/useIsABACAvailable', () => jest.fn(() => ABACAvailable)); - -jest.mock('@rocket.chat/ui-contexts', () => { - const originalModule = jest.requireActual('@rocket.chat/ui-contexts'); - return { - ...originalModule, - useRouter: () => ({ - navigate: mockNavigate, - }), - useSetModal: () => mockSetModal, - useToastMessageDispatch: () => mockDispatchToastMessage, - }; -}); +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useRouter: () => ({ + navigate: mockNavigate, + }), + useSetModal: () => mockSetModal, + useToastMessageDispatch: () => mockDispatchToastMessage, +})); const mockAttribute = { _id: 'attribute-1', @@ -124,7 +122,7 @@ describe('useRoomAttributeItems', () => { }); it('should disable edit when ABAC is not available', () => { - ABACAvailable = false; + useIsABACAvailableMock.mockReturnValue(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..cd1e79a1cbdb4 100644 --- a/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx +++ b/apps/meteor/client/views/admin/ABAC/useRoomAttributeOptions.tsx @@ -6,10 +6,10 @@ import { useRouter, useSetModal, useEndpoint, useToastMessageDispatch } from '@r import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Trans, useTranslation } from 'react-i18next'; -import useIsABACAvailable from './hooks/useIsABACAvailable'; +import { useIsABACAvailable } from './hooks/useIsABACAvailable'; import { ABACQueryKeys } from '../../../lib/queryKeys'; -const useRoomAttributeItems = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => { +export const useRoomAttributeItems = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => { const { t } = useTranslation(); const router = useRouter(); const setModal = useSetModal(); @@ -99,5 +99,3 @@ const useRoomAttributeItems = (attribute: { _id: string; key: string }): Generic }, ]; }; - -export default useRoomAttributeItems; diff --git a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx index b8108af6da0fc..bcf353f4f5536 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx @@ -348,7 +348,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s )} - {isSuccess && data?.rooms.length > 0 && ( + {isSuccess && data?.rooms?.length > 0 && ( <> {headers} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 0d9413b1d39c9..831581dda0e59 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -44,6 +44,24 @@ "ABAC_No_logs_description": "ABAC-management related activity will appear here.", "ABAC_Search_attributes": "Search attributes", "ABAC_Remove_attribute": "Delete attribute value", + "ABAC_Delete_room": "Remove room from ABAC management", + "ABAC_Delete_room_annotation": "Proceed with caution", + "ABAC_Delete_room_content": "Removing {{roomName}} from ABAC management may result in unintended users gaining access.", + "ABAC_Room_removed": "Room {{roomName}} removed from ABAC management", + "ABAC_Add_Attribute": "Add Attribute", + "ABAC_Attribute_Values": "Attribute Values", + "ABAC_Edit_Room": "Edit Room", + "ABAC_Add_room": "Add Room", + "ABAC_No_repeated_attributes": "Attribute already added", + "ABAC_Room_created": "Access to {{roomName}} is now restricted to attribute-compliant users", + "ABAC_Room_to_be_managed": "Room to be ABAC-managed", + "ABAC_Room_updated": "{{roomName}} ABAC room updated", + "ABAC_Search_Attribute": "Search attribute", + "ABAC_Search_rooms": "Search rooms, attributes or attribute values", + "ABAC_Select_Attribute_Values": "Select attribute values", + "ABAC_Update_room_confirmation_modal_annotation": "Proceed with caution", + "ABAC_Update_room_confirmation_modal_title": "Update ABAC room", + "ABAC_Update_room_content": "{{roomName}} is currently ABAC-managed. Changes may alter who can access this room.", "abac-management": "Manage ABAC configuration", "abac_removed_user_from_the_room": "was removed by ABAC", "ABAC_No_attributes": "No Attributes", @@ -56,6 +74,10 @@ "ABAC_Value_removed": "Value removed", "ABAC_Key_added": "Key added", "ABAC_Key_updated": "Key updated", + "ABAC_Managed": "ABAC Managed", + "ABAC_Managed_description": "Only compliant users have access to attribute-based access controlled rooms. Attributes determine room access.", + "ABAC_Room_Attributes": "Room Attributes", + "ABAC_Logs": "Logs", "AI_Actions": "AI actions", "API": "API", "API_Add_Personal_Access_Token": "Add new Personal Access Token", @@ -122,10 +144,6 @@ "A_new_owner_will_be_assigned_automatically_to_those__count__rooms__rooms__": "A new owner will be assigned automatically to those {{count}} rooms: {{rooms}}.", "A_secure_and_highly_private_self-managed_solution_for_conference_calls": "A secure and highly private self-managed solution for conference calls.", "A_workspace_admin_needs_to_install_and_configure_a_conference_call_app": "A workspace admin needs to install and configure a conference call app.", - "ABAC_Managed": "ABAC Managed", - "ABAC_Managed_description": "Only compliant users have access to attribute-based access controlled rooms. Attributes determine room access.", - "ABAC_Room_Attributes": "Room Attributes", - "ABAC_Logs": "Logs", "Accept": "Accept", "Accept_Call": "Accept Call", "Accept_incoming_livechat_requests_even_if_there_are_no_online_agents": "Accept incoming omnichannel requests even if there are no online agents", @@ -395,6 +413,8 @@ "Activity": "Activity", "Actor": "Actor", "Add": "Add", + "Add_Value": "Add Value", + "Add_room": "Add Room", "Add-on": "Add-on", "Add-on_required": "Add-on required", "Add-on_required_modal_enable_content": "App cannot be enabled without the required subscription add-on. Contact sales to get the add-on for this app.", @@ -743,6 +763,8 @@ "AtlassianCrowd_Description": "Integrate Atlassian Crowd.", "Attachment_File_Uploaded": "File Uploaded", "Attribute_handling": "Attribute handling", + "Attribute_Values": "Attribute Values", + "Attributes": "Attributes", "Attribute_based_access_control": "Attribute-Based Access Control", "Attribute_based_access_control_title": "Automate complex access management across your entire organization", "Attribute_based_access_control_description": "ABAC automates room access, granting or revoking access based on dynamic user attributes rather than fixed roles.", @@ -5683,8 +5705,10 @@ "Utilities": "Utilities", "Validate_email_address": "Validate Email Address", "Validation": "Validation", + "Value": "Value", "Value_messages": "{{value}} messages", "Value_users": "{{value}} users", + "Values": "Values", "Verification": "Verification", "Verification_Description": "You may use the following placeholders: \n - `[Verification_Url]` for the verification URL. \n - `[name]`, `[fname]`, `[lname]` for the user's full name, first name or last name, respectively. \n - `[email]` for the user's email. \n - `[Site_Name]` and `[Site_URL]` for the Application Name and URL respectively. ", "Verification_Email": "Click here to verify your email address.", @@ -5729,6 +5753,7 @@ "Videos": "Videos", "View_All": "View All Members", "View_Logs": "View Logs", + "View_rooms": "View rooms", "View_channels": "View channels", "View_full_conversation": "View full conversation", "View_mode": "View Mode",