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 || ''} + + + + ); +}; + +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 }) =>