snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -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 DeleteRoomModal from './DeleteRoomModal';
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('DeleteRoomModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const rid = faker.database.mongodbObjectId();
const roomName = 'Test Room';
it('should render without crashing', () => {
const { baseElement } = render(<DeleteRoomModal 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(<DeleteRoomModal 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(<DeleteRoomModal 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',
});
});
});
});

View File

@@ -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';
export type DeleteRoomModalProps = {
rid: IRoom['_id'];
roomName: string;
onClose: () => void;
};
const DeleteRoomModal = ({ rid, roomName, onClose }: DeleteRoomModalProps) => {
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 DeleteRoomModal;

View File

@@ -0,0 +1,144 @@
import { Box, Callout, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal, ContextualbarFooter, 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 RoomFormAttributeFields from './RoomFormAttributeFields';
import RoomFormAutocomplete from './RoomFormAutocomplete';
import RoomFormAutocompleteDummy from './RoomFormAutocompleteDummy';
export type RoomFormProps = {
onClose: () => void;
onSave: (data: RoomFormData) => void;
roomInfo?: { rid: string; name: string };
setSelectedRoomLabel: Dispatch<SetStateAction<string>>;
redacted?: boolean;
};
export type RoomFormData = {
room: string;
attributes: { key: string; values: string[] }[];
};
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel, redacted = false }: RoomFormProps) => {
const {
control,
handleSubmit,
formState: { isValid, errors, isDirty },
} = useFormContext<RoomFormData>();
const { t } = useTranslation();
const formId = useId();
const nameField = useId();
const { fields, append, remove } = useFieldArray({
name: 'attributes',
control,
});
const setModal = useSetModal();
const updateAction = useStableCallback(async (action: () => void) => {
setModal(
<GenericModal
variant='info'
icon={null}
title={t('ABAC_Update_room_confirmation_modal_title')}
annotation={t('ABAC_Update_room_confirmation_modal_annotation')}
confirmText={t('Save_changes')}
onConfirm={() => {
action();
setModal(null);
}}
onCancel={() => setModal(null)}
>
<Trans
i18nKey='ABAC_Update_room_content'
values={{ roomName: roomInfo?.name }}
components={{ bold: <Box is='span' fontWeight='bold' /> }}
/>
</GenericModal>,
);
});
const handleSave = useStableCallback(() => {
if (roomInfo) {
updateAction(handleSubmit(onSave));
} else {
handleSubmit(onSave)();
}
});
return (
<>
<ContextualbarScrollableContent is='form' onSubmit={handleSubmit(handleSave)} id={formId}>
<Field>
<FieldLabel id={nameField} required>
{t('ABAC_Room_to_be_managed')}
</FieldLabel>
<FieldRow>
{roomInfo ? (
<RoomFormAutocompleteDummy roomInfo={roomInfo} />
) : (
<Controller
name='room'
control={control}
rules={{ required: t('Required_field', { field: t('ABAC_Room_to_be_managed') }) }}
render={({ field }) => (
<RoomFormAutocomplete
{...field}
error={!!errors.room?.message}
aria-labelledby={nameField}
aria-required='true'
aria-invalid={errors.room ? 'true' : 'false'}
aria-describedby={`${nameField}-error`}
onSelectedRoom={(value: string, label: string) => {
field.onChange(value);
setSelectedRoomLabel(label);
}}
/>
)}
/>
)}
</FieldRow>
{errors.room && (
<FieldError id={`${nameField}-error`} role='alert'>
{errors.room.message}
</FieldError>
)}
</Field>
{redacted && (
<Box marginBlockEnd={16}>
<Callout type='warning' title={t('ABAC_Attributes_Redacted')}>
{t('ABAC_Attributes_Redacted_Description')}
</Callout>
</Box>
)}
<RoomFormAttributeFields fields={fields} remove={remove} disabled={redacted} />
<Button
width='full'
disabled={redacted || fields.length >= 10}
onClick={() => {
append({ key: '', values: [] });
}}
>
{t('ABAC_Add_Attribute')}
</Button>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button type='submit' form={formId} disabled={redacted || !isValid || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default RoomForm;

View File

@@ -0,0 +1,13 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import * as stories from './RoomFormAttributeField.stories';
describe('RoomFormAttributeField', () => {
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();
});
});

View File

@@ -0,0 +1,73 @@
import { Field } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import { FormProvider, useForm } from 'react-hook-form';
import { action } from 'storybook/actions';
import type { RoomFormData } from './RoomForm';
import RoomFormAttributeField from './RoomFormAttributeField';
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 RoomFormAttributeField> = {
component: RoomFormAttributeField,
parameters: {
layout: 'padded',
},
decorators: [
(Story) => {
const AppRoot = mockAppRoot().build();
const methods = useForm<RoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
},
mode: 'onChange',
});
return (
<AppRoot>
<FormProvider {...methods}>
<Field>
<Story />
</Field>
</FormProvider>
</AppRoot>
);
},
],
args: {
onRemove: action('onRemove'),
attributeList: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
index: 0,
},
};
export default meta;
type Story = StoryObj<typeof RoomFormAttributeField>;
export const Default: Story = {};

View File

@@ -0,0 +1,123 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Button, FieldError, FieldRow, MultiSelectFiltered, SelectFiltered } from '@rocket.chat/fuselage';
import { useCallback, useMemo } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { RoomFormData } from './RoomForm';
type ABACAttributeAutocompleteProps = {
labelId: string;
onRemove: () => void;
index: number;
attributeList: { value: string; label: string; attributeValues: string[] }[];
required?: boolean;
disabled?: boolean;
};
const RoomFormAttributeField = ({
labelId,
onRemove,
index,
attributeList,
required = false,
disabled = false,
}: ABACAttributeAutocompleteProps) => {
const { t } = useTranslation();
const { control, getValues, resetField } = useFormContext<RoomFormData>();
const options: SelectOption[] = useMemo(() => attributeList.map((attribute) => [attribute.value, attribute.label]), [attributeList]);
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 = attributeList.find((option) => option.value === keyField.value);
return selectedAttributeData?.attributeValues.map((value) => [value, value]) || [];
}, [attributeList, keyField.value]);
return (
<Box display='flex' flexDirection='column' width='full'>
<FieldRow>
<SelectFiltered
{...keyField}
options={options}
required={required}
aria-required={required}
aria-labelledby={labelId}
aria-invalid={keyFieldState.error ? 'true' : 'false'}
aria-describedby={keyFieldState.error ? `${keyField.name}-error` : undefined}
placeholder={t('ABAC_Search_Attribute')}
marginBlockEnd={4}
error={keyFieldState.error?.message}
withTruncatedText
disabled={disabled}
onChange={(value) => {
keyField.onChange(value);
resetField(`attributes.${index}.values`, { defaultValue: [] });
}}
/>
</FieldRow>
{keyFieldState.error && (
<FieldError id={`${keyField.name}-error`} role='alert'>
{keyFieldState.error.message}
</FieldError>
)}
<FieldRow>
<MultiSelectFiltered
required={required}
aria-required={required}
aria-labelledby={labelId}
aria-invalid={valuesFieldState.error ? 'true' : 'false'}
aria-describedby={valuesFieldState.error ? `${valuesField.name}-error` : undefined}
withTruncatedText
{...valuesField}
options={valueOptions}
placeholder={t('ABAC_Select_Attribute_Values')}
error={valuesFieldState.error?.message}
disabled={disabled}
/>
</FieldRow>
{valuesFieldState.error && (
<FieldError id={`${valuesField.name}-error`} role='alert'>
{valuesFieldState.error.message}
</FieldError>
)}
{index !== 0 && (
<Button onClick={onRemove} title={t('Remove')} marginBlockStart={8} disabled={disabled}>
{t('Remove')}
</Button>
)}
</Box>
);
};
export default RoomFormAttributeField;

View File

@@ -0,0 +1,171 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type { RoomFormData } from './RoomForm';
import RoomFormAttributeFields from './RoomFormAttributeFields';
const mockAttribute1 = {
_id: 'attr1',
key: 'Department',
values: ['Engineering', 'Sales', 'Marketing'],
};
const mockAttribute2 = {
_id: 'attr2',
key: 'Security-Level',
values: ['Public', 'Internal', 'Confidential'],
};
const mockAttribute3 = {
_id: 'attr3',
key: 'Location',
values: ['US', 'EU', 'APAC'],
};
jest.mock('../hooks/useAttributeList', () => ({
useAttributeList: jest.fn(() => ({
data: {
attributes: [
{ value: mockAttribute1.key, label: mockAttribute1.key, attributeValues: mockAttribute1.values },
{ value: mockAttribute2.key, label: mockAttribute2.key, attributeValues: mockAttribute2.values },
{ value: mockAttribute3.key, label: mockAttribute3.key, attributeValues: mockAttribute3.values },
],
},
isLoading: false,
})),
}));
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Attribute: 'Attribute',
ABAC_Search_Attribute: 'Search attribute',
ABAC_Select_Attribute_Values: 'Select attribute values',
Remove: 'Remove',
ABAC_Picker_External_Store_Helper: 'Available attributes are limited to those you possess.',
})
.withSetting('ABAC_Attribute_Store', 'local')
.build();
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => {
const methods = useForm<RoomFormData>({
defaultValues: {
room: '',
attributes: [{ key: '', values: [] }],
...defaultValues,
},
mode: 'onChange',
});
return <FormProvider {...methods}>{children}</FormProvider>;
};
describe('RoomFormAttributeFields', () => {
const mockRemove = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render the correct number of fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(3);
});
it('should render a single field', () => {
const fields = [{ id: 'field-1' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(1);
});
it('should render multiple fields', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }, { id: 'field-3' }, { id: 'field-4' }, { id: 'field-5' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(5);
});
it('should render fields with provided default values', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }];
render(
<FormProviderWrapper
defaultValues={{
attributes: [
{ key: 'Department', values: ['Engineering'] },
{ key: 'Security-Level', values: ['Public', 'Internal'] },
],
}}
>
<RoomFormAttributeFields fields={fields} remove={mockRemove} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(2);
expect(screen.getByText('Department')).toBeInTheDocument();
expect(screen.getByText('Engineering')).toBeInTheDocument();
expect(screen.getByText('Security-Level')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
expect(screen.getByText('Internal')).toBeInTheDocument();
});
it('should render fields with disabled inputs when disabled prop is true', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(2);
screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).toBeDisabled());
screen.getAllByRole('combobox').forEach((input) => expect(input).toBeDisabled());
expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled();
});
it('should render fields without disabled state when disabled prop is false', () => {
const fields = [{ id: 'field-1' }];
render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled={false} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);
expect(screen.getAllByText('Attribute')).toHaveLength(1);
screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).not.toBeDisabled());
screen.getAllByRole('combobox').forEach((input) => expect(input).not.toBeDisabled());
});
});

View File

@@ -0,0 +1,52 @@
import { Box, Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import RoomFormAttributeField from './RoomFormAttributeField';
import { useAttributeList } from '../hooks/useAttributeList';
import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore';
export type RoomFormAttributeFieldsProps = {
fields: { id: string }[];
remove: (index: number) => void;
disabled?: boolean;
};
const RoomFormAttributeFields = ({ fields, remove, disabled = false }: RoomFormAttributeFieldsProps) => {
const { t } = useTranslation();
const isExternalAttributeStore = useIsExternalAttributeStore();
const { data: attributeList, isLoading } = useAttributeList();
if (isLoading || !attributeList) {
return <InputBoxSkeleton />;
}
return (
<>
{isExternalAttributeStore && (
<Box marginBlockEnd={8} color='annotation' fontSize='p2'>
{t('ABAC_Picker_External_Store_Helper')}
</Box>
)}
{fields.map((field, index) => (
<Field key={field.id}>
<FieldLabel id={field.id} required={index === 0}>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
labelId={field.id}
attributeList={attributeList.attributes}
required={index === 0}
onRemove={() => {
remove(index);
}}
index={index}
disabled={disabled}
/>
</Field>
))}
</>
);
};
export default RoomFormAttributeFields;

View File

@@ -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 RoomFormAutocomplete from './RoomFormAutocomplete';
import * as stories from './RoomFormAutocomplete.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.privateRooms', () => ({
rooms: [mockRoom1 as any, mockRoom2 as any, mockRoom3 as any],
count: 3,
offset: 0,
total: 3,
}))
.build();
describe('RoomFormAutocomplete', () => {
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();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
// Aria label added in a higher level
const { container } = render(<Story aria-label='ABAC Room Autocomplete' />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should populate select options correctly', async () => {
render(<RoomFormAutocomplete value='' onSelectedRoom={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();
});
});
});

View File

@@ -0,0 +1,42 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { Meta, StoryObj } from '@storybook/react';
import { action } from 'storybook/actions';
import RoomFormAutocomplete from './RoomFormAutocomplete';
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<typeof RoomFormAutocomplete> = {
component: RoomFormAutocomplete,
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 (
<AppRoot>
<Story />
</AppRoot>
);
},
],
args: {
value: '',
onSelectedRoom: action('onChange'),
},
};
export default meta;
type Story = StoryObj<typeof RoomFormAutocomplete>;
export const Default: Story = {};

View File

@@ -0,0 +1,58 @@
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;
} => ({ filter: term });
export type RoomFormAutocompleteProps = Omit<ComponentProps<typeof AutoComplete>, 'filter' | 'onChange'> & {
onSelectedRoom: (value: string, label: string) => void;
};
const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutocompleteProps) => {
const [filter, setFilter] = useState('');
const filterDebounced = useDebouncedValue(filter, 300);
const roomsAutoCompleteEndpoint = useEndpoint('GET', '/v1/rooms.adminRooms.privateRooms');
const result = useQuery({
queryKey: ABACQueryKeys.rooms.autocomplete(generateQuery(filterDebounced)),
queryFn: () => roomsAutoCompleteEndpoint(generateQuery(filterDebounced)),
placeholderData: keepPreviousData,
select: (data) =>
data.rooms
.filter((room) => !room.abacAttributesRedacted && (!room.abacAttributes || room.abacAttributes.length === 0))
.map((room) => ({
value: room._id,
label: { name: room.fname || room.name },
})),
});
return (
<AutoComplete
{...props}
onChange={(val) => {
onSelectedRoom(val as string, result.data?.find(({ value }) => value === val)?.label?.name || '');
}}
value={value}
filter={filter}
setFilter={setFilter}
renderSelected={({ selected: { label } }) => (
<Box margin='none' marginInline={2}>
{label?.name}
</Box>
)}
renderItem={({ label, ...props }) => <Option {...props} label={label.name} />}
options={result.data}
/>
);
};
export default memo(RoomFormAutocomplete);

View File

@@ -0,0 +1,11 @@
import { Input } from '@rocket.chat/fuselage';
export type RoomFormAutocompleteDummyProps = {
roomInfo: { rid: string; name: string };
};
const RoomFormAutocompleteDummy = ({ roomInfo }: RoomFormAutocompleteDummyProps) => {
return <Input value={roomInfo.name} disabled />;
};
export default RoomFormAutocompleteDummy;

View File

@@ -0,0 +1,28 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useRoomItems } from '../hooks/useRoomItems';
export type RoomMenuProps = {
room: { rid: string; name: string };
};
const RoomMenu = ({ room }: RoomMenuProps) => {
const { t } = useTranslation();
const items = useRoomItems(room);
return (
<GenericMenu
title={t('Options')}
icon='kebab'
sections={[
{
items,
},
]}
/>
);
};
export default RoomMenu;

View File

@@ -0,0 +1,90 @@
import { ContextualbarClose, ContextualbarHeader, ContextualbarTitle } 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 RoomForm from './RoomForm';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
export type RoomsContextualBarProps = {
attributeId?: string;
roomInfo?: { rid: string; name: string };
attributesData?: { key: string; values: string[] }[];
redacted?: boolean;
onClose: () => void;
};
const RoomsContextualBar = ({ roomInfo, attributesData, redacted = false, 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<string>('');
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<string, string[]>, 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.list() });
},
});
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_Room' : 'ABAC_Add_room')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<FormProvider {...methods}>
<RoomForm
roomInfo={roomInfo}
onSave={(values) => saveMutation.mutateAsync(values)}
onClose={onClose}
setSelectedRoomLabel={setSelectedRoomLabel}
redacted={redacted}
/>
</FormProvider>
</>
);
};
export default RoomsContextualBar;

View File

@@ -0,0 +1,37 @@
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';
export 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 <ContextualbarSkeletonBody />;
}
const redacted = data?.abacAttributesRedacted === true;
return (
<RoomsContextualBar
roomInfo={{ rid: id, name: data?.fname || data?.name || id }}
attributesData={data?.abacAttributes}
redacted={redacted}
onClose={onClose}
/>
);
};
export default RoomsContextualBarWithData;

View File

@@ -0,0 +1,153 @@
import { Box, Button, Icon, Margins, Pagination, Select, TextInput } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useEndpoint, useRouter, useSearchParameter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import RoomMenu from './RoomMenu';
import GenericNoResults from '../../../../components/GenericNoResults';
import { RoomIcon } from '../../../../components/RoomIcon';
import { ABACQueryKeys } from '../../../../lib/queryKeys';
import { useIsABACAvailable } from '../hooks/useIsABACAvailable';
import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore';
const RoomsPage = () => {
const { t } = useTranslation();
const router = useRouter();
const searchTerm = useSearchParameter('searchTerm');
const searchType = useSearchParameter('type') as 'roomName' | 'attribute' | 'value';
const [text, setText] = useState(searchTerm ?? '');
const [filterType, setFilterType] = useState<'all' | 'roomName' | 'attribute' | 'value'>(searchType ?? 'all');
const debouncedText = useDebouncedValue(text, 200);
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const getRooms = useEndpoint('GET', '/v1/abac/rooms');
const isABACAvailable = useIsABACAvailable();
const isExternalStore = useIsExternalAttributeStore();
const handleNewAttribute = useStableCallback(() => {
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),
...(isExternalStore && { staleTime: 0, gcTime: 0 }),
});
return (
<>
<Margins block={24}>
<Box display='flex'>
<TextInput
endAddon={<Icon name='magnifier' size='x20' />}
placeholder={t('ABAC_Search_rooms')}
value={text}
onChange={(e) => setText((e.target as HTMLInputElement).value)}
/>
<Box paddingInlineStart={8} maxWidth={200}>
<Select
options={[
['all', t('All'), true],
['roomName', t('Rooms'), false],
['attribute', t('Attributes'), false],
['value', t('Values'), false],
]}
value={filterType}
onChange={(value) => setFilterType(value as 'all' | 'roomName' | 'attribute' | 'value')}
/>
</Box>
<Button onClick={handleNewAttribute} primary marginInlineStart={8} disabled={isABACAvailable !== true}>
{t('Add_room')}
</Button>
</Box>
</Margins>
{(!data || data.rooms?.length === 0) && !isLoading ? (
<Box display='flex' justifyContent='center' height='full'>
<GenericNoResults icon='list-alt' title={t('ABAC_No_rooms')} description={t('ABAC_No_rooms_description')} />
</Box>
) : (
<>
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Members')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Attributes')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('ABAC_Attribute_Values')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='spacer' width={40} />
</GenericTableHeader>
<GenericTableBody>
{data?.rooms?.map((room) => (
<GenericTableRow key={room._id}>
<GenericTableCell withTruncatedText>
<Margins inline={8}>
<RoomIcon room={room} size='x20' />
</Margins>
{room.fname || room.name}
</GenericTableCell>
<GenericTableCell>{room.usersCount}</GenericTableCell>
<GenericTableCell withTruncatedText>
{room.abacAttributesRedacted
? t('ABAC_Redacted_Placeholder')
: room.abacAttributes?.flatMap((attribute) => attribute.key ?? []).join(', ')}
</GenericTableCell>
<GenericTableCell withTruncatedText>
{room.abacAttributesRedacted
? t('ABAC_Redacted_Placeholder')
: room.abacAttributes?.flatMap((attribute) => attribute.values ?? []).join(', ')}
</GenericTableCell>
<GenericTableCell>
<RoomMenu room={{ rid: room._id, name: room.fname || room.name || room._id }} />
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default RoomsPage;

View File

@@ -0,0 +1,99 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`DeleteRoomModal should render without crashing 1`] = `
<body>
<div>
<dialog
aria-labelledby="_r_0_-title"
aria-modal="true"
class="rcx-box rcx-box--full rcx-modal"
open=""
>
<div
class="rcx-box rcx-box--full rcx-modal__inner rcx-css-qtwduz"
>
<header
class="rcx-box rcx-box--full rcx-modal__header"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-inner"
>
<div
class="rcx-box rcx-box--full rcx-modal__header-text rcx-css-trljwa rcx-css-lma364"
>
<h2
class="rcx-box rcx-box--full rcx-modal__title"
id="_r_0_-title"
>
Remove room from ABAC management
</h2>
</div>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-trljwa rcx-css-lma364"
tabindex="-1"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</header>
<div
class="rcx-box rcx-box--full rcx-modal__content rcx-css-1vw7itl"
>
<div
class="rcx-box rcx-box--full rcx-modal__content-wrapper rcx-css-r1bpeb"
>
Removing
<span
class="rcx-box rcx-box--full rcx-css-skyl9j"
>
Test Room
</span>
from ABAC management may result in unintended users gaining access.
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-modal__footer rcx-css-rzpqmp"
>
<div
class="rcx-box rcx-box--full rcx-modal__footer-annotation"
>
Proceed with caution
</div>
<div
class="rcx-button-group rcx-button-group--align-end"
role="group"
>
<button
class="rcx-box rcx-box--full rcx-button--secondary rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Cancel
</span>
</button>
<button
class="rcx-box rcx-box--full rcx-button--danger rcx-button"
type="button"
>
<span
class="rcx-button--content"
>
Remove
</span>
</button>
</div>
</div>
</div>
</dialog>
</div>
</body>
`;

View File

@@ -0,0 +1,89 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomFormAttributeField renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-css-1g16yxh"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
aria-invalid="false"
aria-required="false"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1hx4au7"
name="attributes.0.key"
>
<div
class="rcx-box rcx-box--full rcx-select__wrapper--hidden rcx-select__wrapper rcx-css-bpb89k"
>
<span
class="rcx-box rcx-box--full rcx-select__item rcx-css-1rqlrtn"
>
ABAC_Search_Attribute
</span>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-select__focus rcx-css-1l0s473"
placeholder="ABAC_Search_Attribute"
value=""
/>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-6bg1ps"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</div>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1te28na"
>
<div
class="rcx-box rcx-box--full rcx-css-1sr8su7"
>
<div
class="rcx-box rcx-box--full rcx-css-w398ts"
>
<input
aria-expanded="false"
aria-haspopup="listbox"
aria-invalid="false"
aria-required="false"
autocomplete="off"
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-pqpp8q"
name="attributes.0.values"
placeholder="ABAC_Select_Attribute_Values"
role="combobox"
value=""
/>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-select__addon rcx-css-x7bl3q rcx-css-1bprq55"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomFormAutocomplete renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-box--animated rcx-autocomplete rcx-css-t3n91h"
>
<div
class="rcx-box rcx-box--full rcx-css-6d871f"
role="group"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box rcx-css-trljwa rcx-css-rcil7k"
value=""
/>
</div>
<div
class="rcx-box rcx-box--full rcx-autocomplete__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-l5txo2"
>
</i>
</div>
</div>
</div>
</body>
`;