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
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:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
144
apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx
Normal file
144
apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
153
apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx
Normal file
153
apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomsPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user