From e8df925e072b8062dc42dd5985066e8e23c5741e Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 14 Nov 2025 14:00:16 -0300 Subject: [PATCH 01/26] feat: admin ABAC rooms tab --- apps/meteor/client/lib/queryKeys.ts | 5 + .../admin/ABAC/ABACAttributeField.spec.tsx | 44 ++++ .../admin/ABAC/ABACAttributeField.stories.tsx | 75 ++++++ .../views/admin/ABAC/ABACAttributeField.tsx | 102 ++++++++ .../admin/ABAC/ABACRoomAutocomplete.spec.tsx | 60 +++++ .../ABAC/ABACRoomAutocomplete.stories.tsx | 44 ++++ .../views/admin/ABAC/ABACRoomAutocomplete.tsx | 77 ++++++ .../admin/ABAC/ABACRoomAutocompleteDummy.tsx | 11 + .../client/views/admin/ABAC/AdminABACPage.tsx | 24 +- .../admin/ABAC/AdminABACRoomAttributes.tsx | 6 +- .../ABAC/AdminABACRoomAttributesForm.tsx | 2 +- .../views/admin/ABAC/AdminABACRoomForm.tsx | 130 ++++++++++ .../views/admin/ABAC/AdminABACRoomMenu.tsx | 28 +++ .../views/admin/ABAC/AdminABACRooms.tsx | 134 +++++++++++ .../client/views/admin/ABAC/AdminABACTabs.tsx | 3 + .../views/admin/ABAC/RoomsContextualBar.tsx | 84 +++++++ .../admin/ABAC/RoomsContextualBarWithData.tsx | 32 +++ .../ABACAttributeField.spec.tsx.snap | 97 ++++++++ .../ABACRoomAutocomplete.spec.tsx.snap | 31 +++ .../AdminABACRoomAttributesForm.spec.tsx.snap | 4 +- .../admin/ABAC/hooks/useABACAttributeList.tsx | 36 +++ .../admin/ABAC/hooks/useRoomItems.spec.tsx | 226 ++++++++++++++++++ .../views/admin/ABAC/hooks/useRoomItems.tsx | 83 +++++++ .../currentChats/CurrentChatsPage.tsx | 2 +- packages/i18n/src/locales/en.i18n.json | 35 ++- 25 files changed, 1359 insertions(+), 16 deletions(-) create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributeField.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/ABACRoomAutocompleteDummy.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoomForm.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoomMenu.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRooms.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/RoomsContextualBar.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/RoomsContextualBarWithData.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/__snapshots__/ABACAttributeField.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/__snapshots__/ABACRoomAutocomplete.spec.tsx.snap create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useABACAttributeList.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index e8d60dd4a4138..e7fd9a9c766af 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -129,4 +129,9 @@ export const ABACQueryKeys = { roomAttributesList: (query?: PaginatedRequest) => [...ABACQueryKeys.roomAttributes.all(), 'room-attributes-list', query] as const, attribute: (attributeId: string) => [...ABACQueryKeys.roomAttributes.all(), 'attribute', attributeId] as const, }, + rooms: { + all: () => [...ABACQueryKeys.all, 'rooms'] as const, + roomsList: (query?: PaginatedRequest) => [...ABACQueryKeys.rooms.all(), 'rooms-list', query] as const, + room: (roomId: string) => [...ABACQueryKeys.rooms.all(), 'room', 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..cdcd525b1ec66 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx @@ -0,0 +1,44 @@ +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', () => ({ + __esModule: true, + default: 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..5e9a6d45d4d7b --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx @@ -0,0 +1,102 @@ +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 { Controller, useFormContext, useWatch } 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 { + formState: { errors }, + control, + getValues, + setValue, + } = useFormContext(); + const currentAttribute = useWatch({ control, name: `attributes.${index}.key` }); + const { t } = useTranslation(); + const [filter, setFilter] = useState(); + const filterDebounced = useDebouncedValue(filter, 300); + + const { data: options, fetchNextPage, isLoading } = useABACAttributeList(filterDebounced || undefined); + + const selectedAttribute = options.find((option) => option.value === currentAttribute); + + 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 && attribute.key !== currentAttribute).length > 1; + return repeatedAttributes ? t('ABAC_No_repeated_attributes') : undefined; + }, + [currentAttribute, getValues, t], + ); + + const valueOptions: [string, string][] = useMemo(() => { + if (!selectedAttribute?.attributeValues) { + return []; + } + return selectedAttribute.attributeValues.map((value) => [value, value]); + }, [selectedAttribute]); + + if (isLoading) { + return ; + } + return ( + + + ( + { + setValue(`attributes.${index}.values`, []); + field.onChange(val); + }} + filter={filter} + setFilter={setFilter as (value: string | number | undefined) => void} + options={options} + endReached={() => fetchNextPage()} + placeholder={t('ABAC_Search_Attribute')} + mbe={4} + error={errors.attributes?.[index]?.key?.message} + /> + )} + /> + + {errors.attributes?.[index]?.key?.message || ''} + + + ( + + )} + /> + + {errors.attributes?.[index]?.values?.message || ''} + + + + ); +}; + +export default ABACAttributeField; 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..d01f3658c0df8 --- /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( null} setSelectedRoom={jest.fn()} />, { + 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..1c38fe4a8f086 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.stories.tsx @@ -0,0 +1,44 @@ +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: '', + onChange: action('onChange'), + renderRoomIcon: () => null, + setSelectedRoom: action('setSelectedRoom'), + }, +}; + +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..ff1acd75e3c80 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomAutocomplete.tsx @@ -0,0 +1,77 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +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, Dispatch, ReactElement, SetStateAction } from 'react'; +import { memo, useMemo, useState } from 'react'; + +const generateQuery = ( + term = '', +): { + filter: string; + types: string[]; +} => ({ filter: term, types: ['p'] }); + +type ABACRoomAutocompleteProps = Omit, 'filter'> & { + renderRoomIcon?: (props: { encrypted: IRoom['encrypted']; type: IRoom['t'] }) => ReactElement | null; + setSelectedRoom?: Dispatch>; +}; + +const ABACRoomAutocomplete = ({ value, onChange, renderRoomIcon, setSelectedRoom, ...props }: ABACRoomAutocompleteProps) => { + const [filter, setFilter] = useState(''); + const filterDebounced = useDebouncedValue(filter, 300); + const roomsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.adminRooms'); + + const result = useQuery({ + // TODO use querykeys object + queryKey: ['roomsAdminRooms', filterDebounced], + queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)), + placeholderData: keepPreviousData, + }); + + const options = useMemo( + () => + result.isSuccess && result.data?.rooms?.length > 0 + ? result.data.rooms + // Exclude rooms that are already managed by ABAC + .filter((room) => !room.abacAttributes || room.abacAttributes.length === 0) + .map((room) => { + return { + value: room._id, + label: { name: room.fname || room.name }, + }; + }) + : [], + [result.data?.rooms, result.isSuccess], + ); + + return ( + { + onChange(val); + + if (setSelectedRoom && typeof setSelectedRoom === 'function') { + const selectedRoom = result?.data?.rooms.find(({ _id }) => _id === val) as unknown as IRoom; + setSelectedRoom(selectedRoom); + } + }} + filter={filter} + setFilter={setFilter} + renderSelected={({ selected: { label } }) => ( + <> + + {label?.name} + + {renderRoomIcon?.({ ...label })} + + )} + renderItem={({ label, ...props }) =>