Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e8df925
feat: admin ABAC rooms tab
MartinSchoeler Nov 14, 2025
f58f7da
fix test
MartinSchoeler Nov 27, 2025
5929ed5
review: unused if
MartinSchoeler Nov 27, 2025
bbec27e
review: query key
MartinSchoeler Nov 27, 2025
60fc344
review: flatmap
MartinSchoeler Nov 27, 2025
958a574
review: reset page when updating filters
MartinSchoeler Nov 27, 2025
27f2618
review: translation fix
MartinSchoeler Nov 27, 2025
a58fef3
review: un-memoize select value
MartinSchoeler Dec 1, 2025
7b1a395
review: attributesData
MartinSchoeler Dec 1, 2025
39c26d2
review: missing handleSave
MartinSchoeler Dec 1, 2025
1b2cebc
review: use Select and fix some toasts
MartinSchoeler Dec 1, 2025
454ab00
review: don't pass setState to component
MartinSchoeler Dec 2, 2025
1f18a93
review: swap controller for useControl
MartinSchoeler Dec 2, 2025
ce344a9
review: disable useABACAttributeList query when abac not available
MartinSchoeler Dec 2, 2025
24676f8
review: useEndpointMutation
MartinSchoeler Dec 2, 2025
0330116
review: Split delete modal component and tests
MartinSchoeler Dec 2, 2025
8fece47
chore: ts fixes
MartinSchoeler Dec 2, 2025
d39dc67
review: useEndpointMutation on error
MartinSchoeler Dec 3, 2025
ecf0b93
review: remove unused fragment
MartinSchoeler Dec 3, 2025
1c58610
review: add tests
MartinSchoeler Dec 3, 2025
bebe6eb
review: check for whole form validation for submit button
MartinSchoeler Dec 3, 2025
c9c3b4d
Rename query keys
tassoevan Dec 4, 2025
9c28394
Apply test translations to Storybook too
tassoevan Dec 4, 2025
3664222
Replace hook mock
tassoevan Dec 4, 2025
3b261ff
Move deletion mutation to modal component
tassoevan Dec 4, 2025
94b3eff
Use named exports for hooks
tassoevan Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/meteor/client/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
43 changes: 43 additions & 0 deletions apps/meteor/client/views/admin/ABAC/ABACAttributeField.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<Story />);
expect(baseElement).toMatchSnapshot();
});
});
75 changes: 75 additions & 0 deletions apps/meteor/client/views/admin/ABAC/ABACAttributeField.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ABACAttributeField> = {
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<AdminABACRoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
},
mode: 'onChange',
});

return (
<AppRoot>
<FormProvider {...methods}>
<Field>
<Story />
</Field>
</FormProvider>
</AppRoot>
);
},
],
args: {
onRemove: action('onRemove'),
index: 0,
},
};

export default meta;
type Story = StoryObj<typeof ABACAttributeField>;

export const Default: Story = {};
99 changes: 99 additions & 0 deletions apps/meteor/client/views/admin/ABAC/ABACAttributeField.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();
const filterDebounced = useDebouncedValue(filter, 300);

const { control, getValues, resetField } = useFormContext<AdminABACRoomFormData>();

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 <InputBoxSkeleton />;
}
return (
<Box display='flex' flexDirection='column' w='full'>
<FieldRow>
<PaginatedSelectFiltered
{...keyField}
onChange={(val) => {
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}
/>
</FieldRow>
<FieldError>{keyFieldState.error?.message || ''}</FieldError>

<FieldRow>
<MultiSelect
{...valuesField}
options={valueOptions}
placeholder={t('ABAC_Select_Attribute_Values')}
error={valuesFieldState.error?.message}
/>
</FieldRow>
<FieldError>{valuesFieldState.error?.message || ''}</FieldError>

<Button onClick={onRemove} title={t('Remove')} mbs={4}>
{t('Remove')}
</Button>
</Box>
);
};

export default ABACAttributeField;
71 changes: 71 additions & 0 deletions apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 <bold>{{roomName}}</bold> 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(<ABACDeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
wrapper: baseAppRoot.build(),
});

expect(baseElement).toMatchSnapshot();
});

it('should call delete endpoint when delete is confirmed', async () => {
const deleteEndpointMock = jest.fn().mockResolvedValue(null);

render(<ABACDeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
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(<ABACDeleteRoomModal rid={rid} roomName={roomName} onClose={jest.fn()} />, {
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',
});
});
});
});
48 changes: 48 additions & 0 deletions apps/meteor/client/views/admin/ABAC/ABACDeleteRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GenericModal
variant='danger'
icon={null}
title={t('ABAC_Delete_room')}
annotation={t('ABAC_Delete_room_annotation')}
confirmText={t('Remove')}
onConfirm={() => deleteMutation.mutate(undefined)}
onCancel={onClose}
>
<Trans i18nKey='ABAC_Delete_room_content' values={{ roomName }} components={{ bold: <Box is='span' fontWeight='bold' /> }} />
</GenericModal>
);
};

export default ABACDeleteRoomModal;
Loading
Loading