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,28 @@
|
||||
import { GenericMenu } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAttributeOptions } from '../hooks/useAttributeOptions';
|
||||
|
||||
export type AttributeMenuProps = {
|
||||
attribute: { _id: string; key: string };
|
||||
};
|
||||
|
||||
const AttributeMenu = ({ attribute }: AttributeMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = useAttributeOptions(attribute);
|
||||
|
||||
return (
|
||||
<GenericMenu
|
||||
title={t('Options')}
|
||||
icon='kebab'
|
||||
sections={[
|
||||
{
|
||||
items,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributeMenu;
|
||||
@@ -0,0 +1,102 @@
|
||||
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 { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { AttributesFormFormData } from './AttributesForm';
|
||||
import AttributesForm from './AttributesForm';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
export type AttributesContextualBarProps = {
|
||||
attributeId?: string;
|
||||
attributeData?: {
|
||||
key: string;
|
||||
values: string[];
|
||||
};
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const AttributesContextualBar = ({ attributeData, onClose }: AttributesContextualBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const methods = useForm<{
|
||||
name: string;
|
||||
attributeValues: { value: string }[];
|
||||
lockedAttributes: { value: string }[];
|
||||
}>({
|
||||
defaultValues: attributeData
|
||||
? {
|
||||
name: attributeData.key,
|
||||
attributeValues: [],
|
||||
lockedAttributes: attributeData.values.map((value) => ({ value })),
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
attributeValues: [{ value: '' }],
|
||||
lockedAttributes: [],
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { getValues } = methods;
|
||||
|
||||
const attributeId = useRouteParameter('id');
|
||||
const createAttribute = useEndpoint('POST', '/v1/abac/attributes');
|
||||
const updateAttribute = useEndpoint('PUT', '/v1/abac/attributes/:_id', {
|
||||
_id: attributeId ?? '',
|
||||
});
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (data: AttributesFormFormData) => {
|
||||
const payload = {
|
||||
key: data.name,
|
||||
values: [...data.lockedAttributes.map((attribute) => attribute.value), ...data.attributeValues.map((attribute) => attribute.value)],
|
||||
};
|
||||
if (attributeId) {
|
||||
await updateAttribute(payload);
|
||||
} else {
|
||||
await createAttribute(payload);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (attributeId) {
|
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_updated', { attributeName: getValues('name') }) });
|
||||
} else {
|
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_created', { attributeName: getValues('name') }) });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: (error: { errorType: string; error: string }) => {
|
||||
if (error.errorType === 'invalid-params') {
|
||||
dispatchToastMessage({ type: 'error', message: t('ABAC_Invalid_attribute') });
|
||||
} else {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.list() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>{t(attributeId ? 'ABAC_Edit_attribute' : 'ABAC_New_attribute')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={onClose} />
|
||||
</ContextualbarHeader>
|
||||
<FormProvider {...methods}>
|
||||
<AttributesForm
|
||||
onSave={(values) => saveMutation.mutateAsync(values)}
|
||||
onCancel={onClose}
|
||||
description={t(attributeId ? 'ABAC_Edit_attribute_description' : 'ABAC_New_attribute_description')}
|
||||
/>
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributesContextualBar;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import AttributesContextualBar from './AttributesContextualBar';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
export type AttributesContextualBarWithDataProps = {
|
||||
id: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const AttributesContextualBarWithData = ({ id, onClose }: AttributesContextualBarWithDataProps) => {
|
||||
const getAttributes = useEndpoint('GET', '/v1/abac/attributes/:_id', { _id: id });
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ABACQueryKeys.roomAttributes.attribute(id),
|
||||
queryFn: () => getAttributes(),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (isLoading || isFetching) {
|
||||
return <ContextualbarSkeletonBody />;
|
||||
}
|
||||
|
||||
return <AttributesContextualBar attributeData={data} onClose={onClose} />;
|
||||
};
|
||||
|
||||
export default AttributesContextualBarWithData;
|
||||
@@ -0,0 +1,287 @@
|
||||
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 type { ReactNode } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
|
||||
import * as stories from './AttributesForm.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
const appRoot = mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Name: 'Name',
|
||||
Values: 'Values',
|
||||
Add_Value: 'Add Value',
|
||||
Cancel: 'Cancel',
|
||||
Save: 'Save',
|
||||
Required_field: '{{field}} is required',
|
||||
})
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build();
|
||||
|
||||
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<AttributesFormFormData> }) => {
|
||||
const methods = useForm<AttributesFormFormData>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
attributeValues: [{ value: '' }],
|
||||
lockedAttributes: [],
|
||||
...defaultValues,
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
};
|
||||
|
||||
describe('AttributesForm', () => {
|
||||
const defaultProps = {
|
||||
onSave: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
description: 'Create an attribute that can later be assigned to rooms.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const { baseElement } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />, { wrapper: appRoot });
|
||||
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should show validation errors for required fields', async () => {
|
||||
render(
|
||||
<FormProviderWrapper>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
// Making the form "touched"
|
||||
const nameInput = screen.getByLabelText('Name*');
|
||||
await userEvent.type(nameInput, 'Test Attribute');
|
||||
await userEvent.clear(nameInput);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show validation error for empty attribute values', async () => {
|
||||
render(
|
||||
<FormProviderWrapper>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const nameInput = screen.getByLabelText('Name*');
|
||||
await userEvent.type(nameInput, 'Test Attribute');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Values is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should add new attribute value when Add Value button is clicked', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Test Attribute',
|
||||
attributeValues: [{ value: 'Value 1' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add Value' });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
const valueInputs = screen.getAllByLabelText('Values*');
|
||||
expect(valueInputs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should remove attribute value when trash button is clicked', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Test Attribute',
|
||||
attributeValues: [{ value: 'Value 1' }, { value: 'Value 2' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
|
||||
expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Value 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(trashButtons[0]);
|
||||
|
||||
expect(screen.getByDisplayValue('Value 1')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Value 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should remove locked attribute when trash button is clicked', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Room Type',
|
||||
lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
|
||||
|
||||
expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Locked Value 2')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(trashButtons[0]);
|
||||
|
||||
expect(screen.getByDisplayValue('Locked Value 1')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Locked Value 2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable Add Value button when there are empty values', async () => {
|
||||
render(
|
||||
<FormProviderWrapper>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add Value' });
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable Add Value button when all values are filled', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Test Attribute',
|
||||
attributeValues: [{ value: 'Value 1' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add Value' });
|
||||
expect(addButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should call onSave with correct data when form is submitted', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Test Attribute',
|
||||
attributeValues: [{ value: 'Value 1' }, { value: 'Value 2' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const trashButtons = screen.queryAllByRole('button', { name: 'ABAC_Remove_attribute' });
|
||||
await userEvent.click(trashButtons[0]);
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: 'Save' });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onCancel when Cancel button is clicked', async () => {
|
||||
render(
|
||||
<FormProviderWrapper>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle mixed locked and regular attributes correctly', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Room Type',
|
||||
lockedAttributes: [{ value: 'Locked Value' }],
|
||||
attributeValues: [{ value: 'Regular Value' }],
|
||||
};
|
||||
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{ wrapper: appRoot },
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Locked Value')).toBeDisabled();
|
||||
expect(screen.getByDisplayValue('Regular Value')).not.toBeDisabled();
|
||||
|
||||
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
|
||||
expect(trashButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should show disclaimer when trying to delete a locked attribute value that is in use', async () => {
|
||||
const defaultValues = {
|
||||
name: 'Test Attribute',
|
||||
lockedAttributes: [{ value: 'Value 1' }, { value: 'Value 2' }],
|
||||
};
|
||||
render(
|
||||
<FormProviderWrapper defaultValues={defaultValues}>
|
||||
<AttributesForm {...defaultProps} />
|
||||
</FormProviderWrapper>,
|
||||
{
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true }))
|
||||
.withTranslations('en', 'core', {
|
||||
ABAC_Cannot_delete_attribute_value_in_use: 'Cannot delete attribute value assigned to rooms. <1>View rooms</1>',
|
||||
})
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
const trashButtons = screen.getAllByRole('button', { name: 'ABAC_Remove_attribute' });
|
||||
await userEvent.click(trashButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cannot delete attribute value assigned to rooms.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Cannot delete attribute value assigned to rooms.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import AttributesForm, { type AttributesFormFormData } from './AttributesForm';
|
||||
|
||||
export default {
|
||||
component: AttributesForm,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
args: {
|
||||
description: 'Create an attribute that can later be assigned to rooms.',
|
||||
},
|
||||
decorators: [
|
||||
mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Name: 'Name',
|
||||
Values: 'Values',
|
||||
Add_Value: 'Add Value',
|
||||
Cancel: 'Cancel',
|
||||
Save: 'Save',
|
||||
Required_field: '{{field}} is required',
|
||||
})
|
||||
.buildStoryDecorator(),
|
||||
],
|
||||
} satisfies Meta<typeof AttributesForm>;
|
||||
|
||||
export const NewAttribute: StoryObj<typeof AttributesForm> = {
|
||||
decorators: [
|
||||
(fn) => {
|
||||
const methods = useForm<AttributesFormFormData>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
attributeValues: [{ value: '' }],
|
||||
lockedAttributes: [],
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Contextualbar width='400px' padding={16}>
|
||||
{fn()}
|
||||
</Contextualbar>
|
||||
</FormProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const WithLockedAttributes: StoryObj<typeof AttributesForm> = {
|
||||
args: {
|
||||
description: 'Attribute values cannot be edited, but can be added or deleted.',
|
||||
},
|
||||
|
||||
decorators: [
|
||||
(fn) => {
|
||||
const methods = useForm<AttributesFormFormData>({
|
||||
defaultValues: {
|
||||
name: 'Room Type',
|
||||
lockedAttributes: [{ value: 'Locked Value 1' }, { value: 'Locked Value 2' }, { value: 'Locked Value 3' }],
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Contextualbar width='400px' padding={16}>
|
||||
{fn()}
|
||||
</Contextualbar>
|
||||
</FormProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Box, Button, ButtonGroup, Field, FieldError, FieldLabel, FieldRow, IconButton, TextInput } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { ContextualbarFooter, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useId, useMemo, Fragment, useState } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { useViewRoomsAction } from '../hooks/useViewRoomsAction';
|
||||
|
||||
export type AttributesFormFormData = {
|
||||
name: string;
|
||||
attributeValues: { value: string }[];
|
||||
lockedAttributes: { value: string }[];
|
||||
};
|
||||
|
||||
export type AttributesFormProps = {
|
||||
onSave: (data: AttributesFormFormData) => void;
|
||||
onCancel: () => void;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const AttributesForm = ({ onSave, onCancel, description }: AttributesFormProps) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors, isDirty },
|
||||
watch,
|
||||
getValues,
|
||||
} = useFormContext<AttributesFormFormData>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const attributeValues = watch('attributeValues');
|
||||
const lockedAttributes = watch('lockedAttributes');
|
||||
|
||||
const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: getValues('name') });
|
||||
|
||||
const { fields: lockedAttributesFields, remove: removeLockedAttributeField } = useFieldArray({
|
||||
name: 'lockedAttributes',
|
||||
});
|
||||
|
||||
const validateRepeatedValues = useCallback(
|
||||
(value: string) => {
|
||||
// Only one instance of the same attribute value is allowed to be in the form at a time
|
||||
const repeatedAttributes = [...lockedAttributes, ...attributeValues].filter((attribute) => attribute.value === value).length > 1;
|
||||
return repeatedAttributes ? t('ABAC_No_repeated_values') : undefined;
|
||||
},
|
||||
[lockedAttributes, attributeValues, t],
|
||||
);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'attributeValues',
|
||||
rules: {
|
||||
minLength: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const formId = useId();
|
||||
const nameField = useId();
|
||||
const valuesField = useId();
|
||||
|
||||
const [showDisclaimer, setShowDisclaimer] = useState<number[]>([]);
|
||||
const viewRoomsAction = useViewRoomsAction();
|
||||
|
||||
const removeLockedAttribute = useStableCallback(async (index: number) => {
|
||||
const isInUse = await isAttributeUsed();
|
||||
if (showDisclaimer.includes(index)) {
|
||||
return;
|
||||
}
|
||||
if (isInUse?.inUse) {
|
||||
return setShowDisclaimer((prev) => [...prev, index]);
|
||||
}
|
||||
return removeLockedAttributeField(index);
|
||||
});
|
||||
|
||||
const getAttributeValuesError = useCallback(() => {
|
||||
if (errors.attributeValues?.length && errors.attributeValues?.length > 0) {
|
||||
return errors.attributeValues[0]?.value?.message;
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [errors.attributeValues]);
|
||||
|
||||
const hasValuesErrors = useMemo(() => {
|
||||
const attributeValuesErrors = Array.isArray(errors?.attributeValues) && errors.attributeValues.some((error) => !!error?.value?.message);
|
||||
const lockedAttributesErrors =
|
||||
Array.isArray(errors?.lockedAttributes) && errors.lockedAttributes.some((error) => !!error?.value?.message);
|
||||
return attributeValuesErrors || lockedAttributesErrors;
|
||||
}, [errors.attributeValues, errors.lockedAttributes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent is='form' onSubmit={handleSubmit(onSave)} id={formId}>
|
||||
<Box>{description}</Box>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={nameField} required>
|
||||
{t('Name')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
error={errors.name?.message}
|
||||
id={nameField}
|
||||
aria-required='true'
|
||||
{...register('name', { required: t('Required_field', { field: t('Name') }) })}
|
||||
aria-invalid={errors.name ? 'true' : 'false'}
|
||||
aria-describedby={errors.name ? `${nameField}-error` : undefined}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.name && (
|
||||
<FieldError id={`${nameField}-error`} role='alert'>
|
||||
{errors.name.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel required id={valuesField}>
|
||||
{t('Values')}
|
||||
</FieldLabel>
|
||||
{lockedAttributesFields.map((field, index) => (
|
||||
<Fragment key={field.id}>
|
||||
<FieldRow key={field.id}>
|
||||
<TextInput
|
||||
disabled
|
||||
aria-labelledby={valuesField}
|
||||
aria-describedby={errors.lockedAttributes?.[index]?.value ? `${valuesField}-lockedAttr-${index}-error` : undefined}
|
||||
error={errors.lockedAttributes?.[index]?.value?.message || ''}
|
||||
aria-invalid={errors.lockedAttributes?.[index]?.value ? 'true' : 'false'}
|
||||
aria-required='true'
|
||||
{...register(`lockedAttributes.${index}.value`, {
|
||||
required: t('Required_field', { field: t('Values') }),
|
||||
validate: (value: string) => validateRepeatedValues(value),
|
||||
})}
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<IconButton
|
||||
marginInlineStart={8}
|
||||
small
|
||||
title={t('ABAC_Remove_attribute')}
|
||||
icon='trash'
|
||||
onClick={() => removeLockedAttribute(index)}
|
||||
/>
|
||||
)}
|
||||
</FieldRow>
|
||||
{errors.lockedAttributes?.[index]?.value && (
|
||||
<FieldError id={`${valuesField}-lockedAttr-${index}-error`} role='alert'>
|
||||
{errors.lockedAttributes?.[index]?.value?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
{showDisclaimer.includes(index) && (
|
||||
<FieldError>
|
||||
<Trans
|
||||
i18nKey='ABAC_Cannot_delete_attribute_value_in_use'
|
||||
components={{
|
||||
1: (
|
||||
<Box
|
||||
is='a'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
viewRoomsAction(getValues('name'));
|
||||
}}
|
||||
>
|
||||
{t('ABAC_View_rooms')}
|
||||
</Box>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</FieldError>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{fields.map((field, index) => (
|
||||
<Fragment key={field.id}>
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
aria-labelledby={valuesField}
|
||||
aria-describedby={errors.attributeValues?.[index]?.value ? `${valuesField}-attr-${index}-error` : undefined}
|
||||
error={errors.attributeValues?.[index]?.value?.message || ''}
|
||||
aria-invalid={errors.attributeValues?.[index]?.value ? 'true' : 'false'}
|
||||
aria-required='true'
|
||||
{...register(`attributeValues.${index}.value`, {
|
||||
required: t('Required_field', { field: t('Values') }),
|
||||
validate: (value: string) => validateRepeatedValues(value),
|
||||
})}
|
||||
/>
|
||||
{(index !== 0 || lockedAttributesFields.length > 0) && (
|
||||
<IconButton marginInlineStart={8} small title={t('ABAC_Remove_attribute')} icon='trash' onClick={() => remove(index)} />
|
||||
)}
|
||||
</FieldRow>
|
||||
{errors.attributeValues?.[index]?.value && (
|
||||
<FieldError id={`${valuesField}-attr-${index}-error`} role='alert'>
|
||||
{errors.attributeValues[index].value.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
<Button
|
||||
marginBlock={8}
|
||||
onClick={() => append({ value: '' })}
|
||||
// Checking for values since rhf does consider the newly added field as dirty after an append() call
|
||||
disabled={!!getAttributeValuesError() || attributeValues?.some((value: { value: string }) => value?.value === '')}
|
||||
>
|
||||
{t('Add_Value')}
|
||||
</Button>
|
||||
</Field>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={() => onCancel()}>{t('Cancel')}</Button>
|
||||
<Button type='submit' form={formId} disabled={hasValuesErrors || !!errors.name || !isDirty} primary>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributesForm;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box, Button, Icon, Margins, Pagination, 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 } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AttributeMenu from './AttributeMenu';
|
||||
import GenericNoResults from '../../../../components/GenericNoResults';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
import { useIsABACAvailable } from '../hooks/useIsABACAvailable';
|
||||
|
||||
const AttributesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchTerm = useSearchParameter('searchTerm');
|
||||
const [text, setText] = useState(searchTerm ?? '');
|
||||
|
||||
const debouncedText = useDebouncedValue(text, 400);
|
||||
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
|
||||
const getAttributes = useEndpoint('GET', '/v1/abac/attributes');
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
|
||||
const router = useRouter();
|
||||
const handleNewAttribute = useStableCallback(() => {
|
||||
router.navigate({
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'room-attributes',
|
||||
context: 'new',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...(debouncedText ? { key: debouncedText, values: debouncedText } : {}),
|
||||
offset: current,
|
||||
count: itemsPerPage,
|
||||
}),
|
||||
[debouncedText, current, itemsPerPage],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ABACQueryKeys.roomAttributes.list(query),
|
||||
queryFn: () => getAttributes(query),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Margins block={24}>
|
||||
<Box display='flex'>
|
||||
<TextInput
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
placeholder={t('ABAC_Search_attributes')}
|
||||
value={text}
|
||||
onChange={(e) => setText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<Button onClick={handleNewAttribute} primary marginInlineStart={8} disabled={!isABACAvailable}>
|
||||
{t('ABAC_New_attribute')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Margins>
|
||||
{(!data || data.attributes?.length === 0) && !isLoading ? (
|
||||
<Box display='flex' justifyContent='center' height='full'>
|
||||
<GenericNoResults icon='list-alt' title={t('ABAC_No_attributes')} description={t('ABAC_No_attributes_description')} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<GenericTable>
|
||||
<GenericTableHeader>
|
||||
<GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('Value')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell key='spacer' width={40} />
|
||||
</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{data?.attributes?.map((attribute) => (
|
||||
<GenericTableRow key={attribute._id}>
|
||||
<GenericTableCell withTruncatedText>{attribute.key}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{attribute.values.join(', ')}</GenericTableCell>
|
||||
<GenericTableCell>
|
||||
<AttributeMenu attribute={attribute} />
|
||||
</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data?.total || 0}
|
||||
onSetItemsPerPage={setItemsPerPage}
|
||||
onSetCurrent={setCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributesPage;
|
||||
@@ -0,0 +1,389 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`AttributesForm renders NewAttribute without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1d8k487"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<form
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
id="_r_0_"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
Create an attribute that can later be assigned to rooms.
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="_r_1_"
|
||||
>
|
||||
Name
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
id="_r_1_"
|
||||
name="name"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
id="_r_2_"
|
||||
>
|
||||
Values
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-labelledby="_r_2_"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
name="attributeValues.0.value"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button rcx-css-cnkcl9"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Add Value
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-f51umh"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
form="_r_0_"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Save
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`AttributesForm renders WithLockedAttributes without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-1d8k487"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1svuzur"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
data-overlayscrollbars="host"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer"
|
||||
>
|
||||
<div
|
||||
class="os-size-observer-listener"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
data-overlayscrollbars-viewport="scrollbarHidden overflowXHidden overflowYHidden"
|
||||
style="margin-right: 0px; margin-bottom: 0px; margin-left: 0px; top: 0px; left: 0px; width: calc(100% + 0px); padding: 0px 0px 0px 0px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<form
|
||||
class="rcx-box rcx-box--full rcx-css-zg7o7h"
|
||||
id="_r_3_"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
Attribute values cannot be edited, but can be added or deleted.
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="_r_4_"
|
||||
>
|
||||
Name
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
id="_r_4_"
|
||||
name="name"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1jggbrp rcx-css-1fuhgt0"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
id="_r_5_"
|
||||
>
|
||||
Values
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-label__required rcx-css-b7g1a9"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-labelledby="_r_5_"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
disabled=""
|
||||
name="lockedAttributes.0.value"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-labelledby="_r_5_"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
disabled=""
|
||||
name="lockedAttributes.1.value"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr"
|
||||
title="ABAC_Remove_attribute"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<input
|
||||
aria-invalid="false"
|
||||
aria-labelledby="_r_5_"
|
||||
aria-required="true"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--type-text rcx-input-box"
|
||||
disabled=""
|
||||
name="lockedAttributes.2.value"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ws1yyr"
|
||||
title="ABAC_Remove_attribute"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-trash rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button rcx-css-cnkcl9"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Add Value
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-horizontal os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="os-scrollbar os-scrollbar-vertical os-theme-dark os-scrollbar-auto-hide os-scrollbar-auto-hide-hidden os-scrollbar-handle-interactive os-scrollbar-cornerless os-scrollbar-unusable"
|
||||
style="--os-scroll-percent: 0; --os-viewport-percent: 0; --os-scroll-direction: 0;"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-track"
|
||||
>
|
||||
<div
|
||||
class="os-scrollbar-handle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-f51umh"
|
||||
>
|
||||
<div
|
||||
class="rcx-button-group rcx-button-group--stretch rcx-button-group--align-start"
|
||||
role="group"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--primary rcx-button"
|
||||
disabled=""
|
||||
form="_r_3_"
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
class="rcx-button--content"
|
||||
>
|
||||
Save
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
221
apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx
Normal file
221
apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { AbacAttributeDefinitionChangeType, AbacActionPerformed } from '@rocket.chat/core-typings';
|
||||
import { Box, InputBox, Margins, Pagination } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableBody,
|
||||
GenericTableCell,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableRow,
|
||||
usePagination,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GenericNoResults from '../../../../components/GenericNoResults';
|
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
import DateRangePicker from '../../moderation/helpers/DateRangePicker';
|
||||
|
||||
const LogsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [startDate, setStartDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||
const [endDate, setEndDate] = useState<string>(new Date().toISOString().split('T')[0]);
|
||||
|
||||
const formatDate = useFormatDateAndTime();
|
||||
|
||||
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
|
||||
const getLogs = useEndpoint('GET', '/v1/abac/audit');
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
...(startDate && { start: new Date(`${startDate}T00:00:00.000`).toISOString() }),
|
||||
...(endDate && { end: new Date(`${endDate}T23:59:59.999`).toISOString() }),
|
||||
offset: current,
|
||||
count: itemsPerPage,
|
||||
}),
|
||||
[current, itemsPerPage, startDate, endDate],
|
||||
);
|
||||
|
||||
// Whenever the user changes the filter or the text, reset the pagination to the first page
|
||||
useEffect(() => {
|
||||
setCurrent(0);
|
||||
}, [startDate, endDate, setCurrent]);
|
||||
|
||||
const getActionLabel = (action?: AbacAttributeDefinitionChangeType | AbacActionPerformed | null) => {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return t('Created');
|
||||
case 'updated':
|
||||
return t('Updated');
|
||||
case 'deleted':
|
||||
return t('Deleted');
|
||||
case 'all-deleted':
|
||||
return t('ABAC_All_Attributes_deleted');
|
||||
case 'key-removed':
|
||||
return t('ABAC_Key_removed');
|
||||
case 'key-renamed':
|
||||
return t('ABAC_Key_renamed');
|
||||
case 'value-removed':
|
||||
return t('ABAC_Value_removed');
|
||||
case 'key-added':
|
||||
return t('ABAC_Key_added');
|
||||
case 'key-updated':
|
||||
return t('ABAC_Key_updated');
|
||||
case 'revoked-object-access':
|
||||
return t('ABAC_Revoked_Object_Access');
|
||||
case 'granted-object-access':
|
||||
return t('ABAC_Granted_Object_Access');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ABACQueryKeys.logs.list(query),
|
||||
queryFn: () => getLogs(query),
|
||||
select: (data) => ({
|
||||
events: data.events.map((event) => {
|
||||
const eventInfo = {
|
||||
id: event._id,
|
||||
user: event.actor?.type === 'user' ? event.actor.username : t('System'),
|
||||
...(event.actor?.type === 'user' && { userAvatar: <UserAvatar size='x28' userId={event.actor._id} /> }),
|
||||
timestamp: new Date(event.ts),
|
||||
element: t('ABAC_Room'),
|
||||
action: getActionLabel(event.data?.find((item) => item.key === 'change')?.value),
|
||||
room: undefined,
|
||||
};
|
||||
switch (event.t) {
|
||||
case 'abac.attribute.changed':
|
||||
return {
|
||||
...eventInfo,
|
||||
element: t('ABAC_Room_Attribute'),
|
||||
name: event.data?.find((item) => item.key === 'attributeKey')?.value ?? '',
|
||||
};
|
||||
case 'abac.action.performed':
|
||||
return {
|
||||
...eventInfo,
|
||||
name: event.data?.find((item) => item.key === 'subject')?.value?.username ?? '',
|
||||
action: getActionLabel(event.data?.find((item) => item.key === 'action')?.value),
|
||||
room: event.data?.find((item) => item.key === 'object')?.value?.name ?? '',
|
||||
element: t('ABAC_room_membership'),
|
||||
};
|
||||
case 'abac.object.attribute.changed':
|
||||
case 'abac.object.attributes.removed':
|
||||
return {
|
||||
...eventInfo,
|
||||
name:
|
||||
event.data
|
||||
?.find((item) => item.key === 'current')
|
||||
?.value?.map((item) => item.key)
|
||||
.join(', ') ?? t('Empty'),
|
||||
room: event.data?.find((item) => item.key === 'room')?.value?.name ?? '',
|
||||
};
|
||||
case 'abac.attribute.store.switched':
|
||||
return {
|
||||
...eventInfo,
|
||||
element: t('ABAC_Attribute_Store'),
|
||||
action: t('ABAC_Attribute_Store_Switched'),
|
||||
name: `${event.data?.find((item) => item.key === 'from')?.value} -> ${event.data?.find((item) => item.key === 'to')?.value}`,
|
||||
room: t('ABAC_Rooms_Affected', { count: Number(event.data?.find((item) => item.key === 'roomsAffected')?.value ?? 0) }),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
count: data.count,
|
||||
offset: data.offset,
|
||||
total: data.total,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Margins block={24}>
|
||||
<Box display='flex'>
|
||||
<InputBox
|
||||
type='date'
|
||||
placeholder={t('Start_date')}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<Margins inlineStart={8}>
|
||||
<InputBox
|
||||
type='date'
|
||||
placeholder={t('End_date')}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</Margins>
|
||||
<Margins inlineStart={8}>
|
||||
<DateRangePicker
|
||||
defaultSelectedKey='today'
|
||||
onChange={(range) => {
|
||||
setStartDate(range.start);
|
||||
setEndDate(range.end);
|
||||
}}
|
||||
/>
|
||||
</Margins>
|
||||
</Box>
|
||||
</Margins>
|
||||
{(!data || data.events?.length === 0) && !isLoading ? (
|
||||
<Box display='flex' justifyContent='center' height='full'>
|
||||
<GenericNoResults icon='extended-view' title={t('ABAC_No_logs')} description={t('ABAC_No_logs_description')} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<GenericTable>
|
||||
<GenericTableHeader>
|
||||
<GenericTableHeaderCell>{t('User')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('Action')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('Room')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('ABAC_Element')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('ABAC_Element_Name')}</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell>{t('Timestamp')}</GenericTableHeaderCell>
|
||||
</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{data?.events.map((eventInfo) => {
|
||||
if (!eventInfo) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GenericTableRow key={eventInfo.id}>
|
||||
<GenericTableCell withTruncatedText>
|
||||
{eventInfo.userAvatar && (
|
||||
<Box is='span' marginInlineEnd={4}>
|
||||
{eventInfo.userAvatar}
|
||||
</Box>
|
||||
)}
|
||||
{eventInfo.user}
|
||||
</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{eventInfo.action}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{eventInfo.room}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{eventInfo.element}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText title={eventInfo.name}>
|
||||
{eventInfo.name}
|
||||
</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{formatDate(eventInfo.timestamp)}</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
);
|
||||
})}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data?.total || 0}
|
||||
onSetItemsPerPage={setItemsPerPage}
|
||||
onSetCurrent={setCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { SettingValue } from '@rocket.chat/core-typings';
|
||||
import { useSetModal, useSettingsDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import WarningModal from './WarningModal';
|
||||
import type { EditableSetting } from '../../EditableSettingsContext';
|
||||
import { useEditableSetting } from '../../EditableSettingsContext';
|
||||
import MemoizedSetting from '../../settings/Setting/MemoizedSetting';
|
||||
import SettingSkeleton from '../../settings/Setting/SettingSkeleton';
|
||||
|
||||
export type ABACEnabledToggleProps = {
|
||||
hasABAC: 'loading' | boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ABACEnabledToggle = ({ className, hasABAC }: ABACEnabledToggleProps) => {
|
||||
const setting = useEditableSetting('ABAC_Enabled');
|
||||
const setModal = useSetModal();
|
||||
const dispatch = useSettingsDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [value, setValue] = useState<boolean>(setting?.value === true);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(setting?.value === true);
|
||||
}, [setting]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: boolean) => {
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleChange = (value: boolean, setting: EditableSetting) => {
|
||||
setValue(value);
|
||||
dispatch([{ _id: setting._id, value }]);
|
||||
};
|
||||
|
||||
if (value === false) {
|
||||
return setModal(
|
||||
<WarningModal
|
||||
onConfirm={() => {
|
||||
handleChange(value, setting);
|
||||
setModal();
|
||||
}}
|
||||
onCancel={() => setModal()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
handleChange(value, setting);
|
||||
},
|
||||
[dispatch, setModal, setting],
|
||||
);
|
||||
|
||||
const onReset = useCallback(() => {
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
const value = setting.packageValue as boolean;
|
||||
setModal(
|
||||
<WarningModal
|
||||
onConfirm={() => {
|
||||
setValue(value);
|
||||
dispatch([{ _id: setting._id, value }]);
|
||||
setModal();
|
||||
}}
|
||||
onCancel={() => setModal()}
|
||||
/>,
|
||||
);
|
||||
}, [dispatch, setModal, setting]);
|
||||
|
||||
if (!setting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasABAC === 'loading') {
|
||||
return <SettingSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MemoizedSetting
|
||||
type='boolean'
|
||||
_id={setting._id}
|
||||
label={t(setting.i18nLabel)}
|
||||
value={value}
|
||||
packageValue={setting.packageValue === true}
|
||||
hint={t(setting.i18nDescription || '')}
|
||||
disabled={!hasABAC || setting.blocked}
|
||||
hasResetButton={hasABAC && setting.packageValue !== setting.value}
|
||||
onChangeValue={(value: SettingValue) => onChange(value === true)}
|
||||
onResetButtonClick={() => onReset()}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default ABACEnabledToggle;
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import SettingField from './SettingField';
|
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
|
||||
|
||||
const settingStructure = {
|
||||
packageValue: false,
|
||||
blocked: false,
|
||||
public: true,
|
||||
type: 'boolean',
|
||||
i18nLabel: 'Test_Setting',
|
||||
i18nDescription: 'Test_Setting_Description',
|
||||
enableQuery: undefined,
|
||||
displayQuery: undefined,
|
||||
} as Partial<ISetting>;
|
||||
|
||||
const dispatchMock = jest.fn();
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useSettingsDispatch: () => dispatchMock,
|
||||
}));
|
||||
jest.mock('@rocket.chat/core-typings', () => ({
|
||||
...jest.requireActual('@rocket.chat/core-typings'),
|
||||
isSetting: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
describe('SettingField', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
dispatchMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should call dispatch when setting value is changed', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
|
||||
render(<SettingField settingId='Test_Setting' />, {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
|
||||
.withSetting('Test_Setting', false, settingStructure)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch when the setting has an alert (confirmation is handled by the settings dispatch)', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
|
||||
render(<SettingField settingId='Test_Setting' />, {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
|
||||
.withSetting('Test_Setting', false, { ...settingStructure, alert: 'Test_Setting_Alert' })
|
||||
.build(),
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
await act(async () => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledWith([{ _id: 'Test_Setting', value: true }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/core-typings';
|
||||
import { isSettingColor, isSetting } from '@rocket.chat/core-typings';
|
||||
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import MarkdownText from '../../../../components/MarkdownText';
|
||||
import { useEditableSetting, useEditableSettingVisibilityQuery } from '../../EditableSettingsContext';
|
||||
import MemoizedSetting from '../../settings/Setting/MemoizedSetting';
|
||||
import { useHasSettingModule } from '../../settings/hooks/useHasSettingModule';
|
||||
|
||||
export type SettingFieldProps = {
|
||||
className?: string;
|
||||
settingId: string;
|
||||
sectionChanged?: boolean;
|
||||
};
|
||||
|
||||
function SettingField({ className = undefined, settingId, sectionChanged }: SettingFieldProps) {
|
||||
const setting = useEditableSetting(settingId);
|
||||
const persistedSetting = useSettingStructure(settingId);
|
||||
const hasSettingModule = useHasSettingModule(setting);
|
||||
|
||||
if (!setting || !persistedSetting) {
|
||||
throw new Error(`Setting ${settingId} not found`);
|
||||
}
|
||||
|
||||
// Checks if setting has at least required fields before doing anything
|
||||
if (!isSetting(setting)) {
|
||||
throw new Error(`Setting ${settingId} is not valid`);
|
||||
}
|
||||
|
||||
const dispatch = useSettingsDispatch();
|
||||
|
||||
const update = useDebouncedCallback(
|
||||
({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => {
|
||||
if (!persistedSetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch([
|
||||
{
|
||||
_id: persistedSetting._id,
|
||||
...(value !== undefined && { value }),
|
||||
...(editor !== undefined && { editor }),
|
||||
},
|
||||
]);
|
||||
},
|
||||
230,
|
||||
[persistedSetting, dispatch],
|
||||
);
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const [value, setValue] = useState(setting.value);
|
||||
const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(setting.value);
|
||||
}, [setting.value]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(isSettingColor(setting) ? setting.editor : undefined);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [(setting as ISettingColor).editor]);
|
||||
|
||||
const onChangeValue = useCallback(
|
||||
(value: SettingValue) => {
|
||||
setValue(value);
|
||||
update({ value });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const onChangeEditor = useCallback(
|
||||
(editor: SettingEditor) => {
|
||||
setEditor(editor);
|
||||
update({ editor });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
const onResetButtonClick = useCallback(() => {
|
||||
setValue(setting.value);
|
||||
setEditor(isSettingColor(setting) ? setting.editor : undefined);
|
||||
update({
|
||||
value: persistedSetting.packageValue,
|
||||
...(isSettingColor(persistedSetting) && { editor: persistedSetting.packageEditor }),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setting.value, (setting as ISettingColor).editor, update, persistedSetting]);
|
||||
|
||||
const { _id, readonly, type, packageValue, i18nLabel, i18nDescription, alert } = setting;
|
||||
|
||||
const disabled = !useEditableSettingVisibilityQuery(persistedSetting.enableQuery);
|
||||
const invisible = !useEditableSettingVisibilityQuery(persistedSetting.displayQuery);
|
||||
|
||||
const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id;
|
||||
|
||||
const hint = useMemo(
|
||||
() => (i18nDescription && i18n.exists(i18nDescription) ? <MarkdownText variant='inline' content={t(i18nDescription)} /> : undefined),
|
||||
[i18n, i18nDescription, t],
|
||||
);
|
||||
|
||||
const callout = useMemo(
|
||||
() =>
|
||||
alert && <span dangerouslySetInnerHTML={{ __html: i18n.exists(alert) ? DOMPurify.sanitize(t(alert)) : DOMPurify.sanitize(alert) }} />,
|
||||
[alert, i18n, t],
|
||||
);
|
||||
|
||||
const shouldDisableEnterprise = setting.enterprise && !hasSettingModule;
|
||||
|
||||
const hasResetButton =
|
||||
!shouldDisableEnterprise &&
|
||||
!readonly &&
|
||||
type !== 'asset' &&
|
||||
((isSettingColor(setting) && JSON.stringify(setting.packageEditor) !== JSON.stringify(editor)) ||
|
||||
JSON.stringify(value) !== JSON.stringify(packageValue)) &&
|
||||
!disabled;
|
||||
|
||||
// @todo: type check props based on setting type
|
||||
|
||||
return (
|
||||
<MemoizedSetting
|
||||
className={className}
|
||||
label={labelText}
|
||||
hint={hint}
|
||||
callout={callout}
|
||||
sectionChanged={sectionChanged}
|
||||
{...setting}
|
||||
disabled={disabled || shouldDisableEnterprise}
|
||||
value={value}
|
||||
editor={editor}
|
||||
hasResetButton={hasResetButton}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeEditor={onChangeEditor}
|
||||
onResetButtonClick={onResetButtonClick}
|
||||
invisible={invisible}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingField;
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle';
|
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
|
||||
|
||||
const settingStructure = {
|
||||
packageValue: false,
|
||||
blocked: false,
|
||||
public: true,
|
||||
type: 'boolean',
|
||||
i18nLabel: 'ABAC_Enabled',
|
||||
i18nDescription: 'ABAC_Enabled_Description',
|
||||
} as Partial<ISetting>;
|
||||
|
||||
const baseAppRoot = mockAppRoot()
|
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
|
||||
.withTranslations('en', 'core', {
|
||||
ABAC_Enabled: 'Enable ABAC',
|
||||
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control',
|
||||
ABAC_Warning_Modal_Title: 'Disable ABAC',
|
||||
ABAC_Warning_Modal_Confirm_Text: 'Disable',
|
||||
Cancel: 'Cancel',
|
||||
});
|
||||
|
||||
describe('AbacEnabledToggle', () => {
|
||||
it('should render the setting toggle when setting exists', () => {
|
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show warning modal when disabling ABAC', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('checkbox');
|
||||
await waitFor(() => {
|
||||
expect(toggle).not.toBeDisabled();
|
||||
});
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disable ABAC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
it('should not show warning modal when enabling ABAC', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('checkbox');
|
||||
await user.click(toggle);
|
||||
|
||||
// The modal should not appear when enabling ABAC
|
||||
expect(screen.queryByText('Disable ABAC')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show warning modal when resetting setting', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
|
||||
const resetButton = screen.getByRole('button', { name: /reset/i });
|
||||
await user.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disable ABAC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: discover how to automatically unmount all modals after each test
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
it('should have no accessibility violations', async () => {
|
||||
const { container } = render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should handle setting change correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
|
||||
});
|
||||
|
||||
const toggle = await screen.findByRole('checkbox', { busy: false });
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await user.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
it('should be disabled when abac license is not installed', () => {
|
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC={false} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('checkbox');
|
||||
expect(toggle).toBeDisabled();
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show skeleton when loading', () => {
|
||||
const { baseElement } = render(<AbacEnabledToggle hasABAC='loading' />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show reset button when value differs from package value', () => {
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show reset button when value matches package value', () => {
|
||||
render(<AbacEnabledToggle hasABAC={true} />, {
|
||||
wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle';
|
||||
import EditableSettingsProvider from '../../settings/EditableSettingsProvider';
|
||||
|
||||
const meta: Meta<typeof AbacEnabledToggle> = {
|
||||
component: AbacEnabledToggle,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const AppRoot = mockAppRoot()
|
||||
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
|
||||
.withTranslations('en', 'core', {
|
||||
ABAC_Enabled: 'Enable ABAC',
|
||||
ABAC_Enabled_Description: 'Enable Attribute-Based Access Control',
|
||||
ABAC_Warning_Modal_Title: 'Disable ABAC',
|
||||
ABAC_Warning_Modal_Confirm_Text: 'Disable',
|
||||
Cancel: 'Cancel',
|
||||
})
|
||||
.withSetting('ABAC_Enabled', true, {
|
||||
packageValue: false,
|
||||
blocked: false,
|
||||
public: true,
|
||||
type: 'boolean',
|
||||
i18nLabel: 'ABAC_Enabled',
|
||||
i18nDescription: 'ABAC_Enabled_Description',
|
||||
})
|
||||
.build();
|
||||
|
||||
return (
|
||||
<AppRoot>
|
||||
<Story />
|
||||
</AppRoot>
|
||||
);
|
||||
},
|
||||
],
|
||||
args: {
|
||||
hasABAC: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbacEnabledToggle>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
hasABAC: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
hasABAC: 'loading',
|
||||
},
|
||||
};
|
||||
|
||||
export const False: Story = {
|
||||
args: {
|
||||
hasABAC: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Accordion, AccordionItem, Box, Callout, FieldGroup } from '@rocket.chat/fuselage';
|
||||
import { ExternalLink } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import AbacEnabledToggle from './AbacEnabledToggle';
|
||||
import SettingField from './SettingField';
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
import { links } from '../../../../lib/links';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: hasABAC = false } = useHasLicenseModule('abac');
|
||||
const pdpType = useSetting('ABAC_PDP_Type', 'local');
|
||||
|
||||
return (
|
||||
<Box maxWidth='x600' width='full' alignSelf='center' overflow='auto' marginBlock={24}>
|
||||
<FieldGroup>
|
||||
<AbacEnabledToggle hasABAC={hasABAC} />
|
||||
<SettingField settingId='ABAC_PDP_Type' />
|
||||
{pdpType !== 'local' && <SettingField settingId='ABAC_Attribute_Store' />}
|
||||
<SettingField settingId='ABAC_ShowAttributesInRooms' />
|
||||
<SettingField settingId='Abac_Cache_Decision_Time_Seconds' />
|
||||
|
||||
{pdpType === 'local' && (
|
||||
<Callout>
|
||||
<Trans i18nKey='ABAC_Enabled_callout'>
|
||||
User attributes are synchronized via LDAP
|
||||
<ExternalLink to={links.go.abacLDAPDocs}>Learn more</ExternalLink>
|
||||
</Trans>
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<Accordion>
|
||||
<AccordionItem title={t('ABAC_Classification_Banners')}>
|
||||
<FieldGroup>
|
||||
<SettingField settingId='ABAC_Classification_Banners_Enabled' />
|
||||
<SettingField settingId='ABAC_Classification_Banners_Config' />
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title={t('LDAP_DataSync_ABAC')}>
|
||||
<FieldGroup>
|
||||
<SettingField settingId='LDAP_Background_Sync_ABAC_Attributes' />
|
||||
<SettingField settingId='LDAP_Background_Sync_ABAC_Attributes_Interval' />
|
||||
<SettingField settingId='LDAP_ABAC_AttributeMap' />
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem title={t('ABAC_Virtru_PDP_Configuration')}>
|
||||
<SettingField settingId='ABAC_Virtru_Base_URL' />
|
||||
<SettingField settingId='ABAC_Virtru_Client_ID' />
|
||||
<SettingField settingId='ABAC_Virtru_Client_Secret' />
|
||||
<SettingField settingId='ABAC_Virtru_OIDC_Endpoint' />
|
||||
<SettingField settingId='ABAC_Virtru_Default_Entity_Key' />
|
||||
<SettingField settingId='ABAC_Virtru_Attribute_Namespace' />
|
||||
<SettingField settingId='ABAC_Virtru_Sync_Interval' />
|
||||
<SettingField settingId='ABAC_Virtru_Test_Connection' />
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</FieldGroup>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
export type WarningModalProps = {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const WarningModal = ({ onConfirm, onCancel }: WarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const handleNavigate = () => {
|
||||
onCancel();
|
||||
router.navigate({
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'rooms',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
title={t('ABAC_Warning_Modal_Title')}
|
||||
variant='secondary-danger'
|
||||
confirmText={t('ABAC_Warning_Modal_Confirm_Text')}
|
||||
cancelText={t('Cancel')}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
onClose={onCancel}
|
||||
onDismiss={onCancel}
|
||||
>
|
||||
<Trans i18nKey='ABAC_Warning_Modal_Content'>
|
||||
You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access
|
||||
control, it must be removed from ABAC management in
|
||||
<Box is='a' onClick={handleNavigate}>
|
||||
{' '}
|
||||
ABAC {'>'} Rooms
|
||||
</Box>
|
||||
.
|
||||
</Trans>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarningModal;
|
||||
@@ -0,0 +1,149 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`AbacEnabledToggle should be disabled when abac license is not installed 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1u2ihfm"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="ABAC_Enabled"
|
||||
title="ABAC_Enabled"
|
||||
>
|
||||
Enable ABAC
|
||||
</label>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-127j9mz"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__input"
|
||||
disabled=""
|
||||
id="ABAC_Enabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__hint"
|
||||
>
|
||||
Enable Attribute-Based Access Control
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`AbacEnabledToggle should render the setting toggle when setting exists 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field rcx-css-1gfu76s"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1u2ihfm"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row rcx-css-ctk2ij"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label"
|
||||
for="ABAC_Enabled"
|
||||
title="ABAC_Enabled"
|
||||
>
|
||||
Enable ABAC
|
||||
</label>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-127j9mz"
|
||||
>
|
||||
<button
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--icon-danger rcx-button--square rcx-button--icon rcx-button rcx-css-1rtu0k9"
|
||||
title="Reset"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-undo rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
❰
|
||||
</i>
|
||||
</button>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__input"
|
||||
id="ABAC_Enabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-toggle-switch__fake"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__hint"
|
||||
>
|
||||
Enable Attribute-Based Access Control
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`AbacEnabledToggle should show skeleton when loading 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-field"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-field__label rcx-label rcx-css-1exa5vl"
|
||||
>
|
||||
<span
|
||||
class="rcx-skeleton rcx-skeleton--text rcx-css-1v1eapn"
|
||||
/>
|
||||
</label>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-field__row"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-skeleton__input"
|
||||
>
|
||||
<span
|
||||
class="rcx-skeleton rcx-skeleton--text rcx-css-1qcz93u"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
115
apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx
Normal file
115
apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Box, Button, ButtonGroup, Callout } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { ContextualbarDialog, Page, PageContent, PageHeader } from '@rocket.chat/ui-client';
|
||||
import { useSetting, useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import AttributesContextualBar from './ABACAttributesTab/AttributesContextualBar';
|
||||
import AttributesContextualBarWithData from './ABACAttributesTab/AttributesContextualBarWithData';
|
||||
import AttributesPage from './ABACAttributesTab/AttributesPage';
|
||||
import LogsPage from './ABACLogsTab/LogsPage';
|
||||
import RoomsContextualBar from './ABACRoomsTab/RoomsContextualBar';
|
||||
import RoomsContextualBarWithData from './ABACRoomsTab/RoomsContextualBarWithData';
|
||||
import RoomsPage from './ABACRoomsTab/RoomsPage';
|
||||
import SettingsPage from './ABACSettingTab/SettingsPage';
|
||||
import AdminABACTabs from './AdminABACTabs';
|
||||
import { useABACTabPermissions } from './hooks/useABACTabPermissions';
|
||||
import { useIsABACAvailable } from './hooks/useIsABACAvailable';
|
||||
import { useExternalLink } from '../../../hooks/useExternalLink';
|
||||
import { useLdapSync } from '../../../hooks/useLdapSync';
|
||||
import { links } from '../../../lib/links';
|
||||
|
||||
export type AdminABACPageProps = {
|
||||
shouldShowWarning: boolean;
|
||||
};
|
||||
|
||||
const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const tab = useRouteParameter('tab');
|
||||
const _id = useRouteParameter('id');
|
||||
const context = useRouteParameter('context');
|
||||
const learnMore = useExternalLink();
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
const ldapEnabled = useSetting('LDAP_Enable');
|
||||
const abacEnabled = useSetting('ABAC_Enabled');
|
||||
const handleSyncNow = useLdapSync();
|
||||
const isSyncDisabled = !ldapEnabled || !abacEnabled;
|
||||
const tabPermissions = useABACTabPermissions();
|
||||
|
||||
const handleCloseContextualbar = useStableCallback((): void => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.navigate(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: { ...router.getRouteParameters(), context: '', id: '' },
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page>
|
||||
<PageHeader title={t('ABAC')}>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
disabled={isSyncDisabled}
|
||||
title={isSyncDisabled ? t('Enable_ABAC_and_LDAP_to_sync') : undefined}
|
||||
onClick={handleSyncNow}
|
||||
>
|
||||
{t('LDAP_Sync_Now')}
|
||||
</Button>
|
||||
<Button icon='new-window' secondary onClick={() => learnMore(links.go.abacDocs)}>
|
||||
{t('ABAC_Learn_More')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</PageHeader>
|
||||
{shouldShowWarning && (
|
||||
<Box marginInline={24} marginBlock={16}>
|
||||
<Callout type='warning' title={t('ABAC_automatically_disabled_callout')}>
|
||||
<Trans
|
||||
i18nKey='ABAC_automatically_disabled_callout_description'
|
||||
components={{
|
||||
1: (
|
||||
<a href={links.go.abacDocs} rel='noopener noreferrer' target='_blank'>
|
||||
ABAC capabilities without restriction.
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
</Box>
|
||||
)}
|
||||
<AdminABACTabs />
|
||||
<PageContent>
|
||||
{tab === 'settings' && tabPermissions.settings && <SettingsPage />}
|
||||
{tab === 'room-attributes' && tabPermissions['room-attributes'] && <AttributesPage />}
|
||||
{tab === 'rooms' && tabPermissions.rooms && <RoomsPage />}
|
||||
{tab === 'logs' && tabPermissions.logs && <LogsPage />}
|
||||
</PageContent>
|
||||
</Page>
|
||||
{isABACAvailable === true && tab !== undefined && context !== undefined && (
|
||||
<ContextualbarDialog onClose={() => handleCloseContextualbar()}>
|
||||
{tab === 'room-attributes' && tabPermissions['room-attributes'] && (
|
||||
<>
|
||||
{context === 'new' && <AttributesContextualBar onClose={() => handleCloseContextualbar()} />}
|
||||
{context === 'edit' && _id && <AttributesContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} />}
|
||||
</>
|
||||
)}
|
||||
{tab === 'rooms' && tabPermissions.rooms && (
|
||||
<>
|
||||
{context === 'new' && <RoomsContextualBar onClose={() => handleCloseContextualbar()} />}
|
||||
{context === 'edit' && _id && <RoomsContextualBarWithData id={_id} onClose={() => handleCloseContextualbar()} />}
|
||||
</>
|
||||
)}
|
||||
</ContextualbarDialog>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminABACPage;
|
||||
74
apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx
Normal file
74
apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts';
|
||||
import { memo, useEffect, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AdminABACPage from './AdminABACPage';
|
||||
import type { ABACTab } from './hooks/useABACTabPermissions';
|
||||
import { ABAC_TAB_ORDER, useABACTabPermissions } from './hooks/useABACTabPermissions';
|
||||
import { useIsExternalAttributeStore } from './hooks/useIsExternalAttributeStore';
|
||||
import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal';
|
||||
import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks';
|
||||
import PageSkeleton from '../../../components/PageSkeleton';
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
import SettingsProvider from '../../../providers/SettingsProvider';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
import EditableSettingsProvider from '../settings/EditableSettingsProvider';
|
||||
|
||||
const AdminABACRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const canViewABACPage = usePermission('abac-management');
|
||||
const { data: hasABAC = false } = useHasLicenseModule('abac');
|
||||
const isModalOpen = !!useCurrentModal();
|
||||
const tab = useRouteParameter('tab');
|
||||
const router = useRouter();
|
||||
const tabPermissions = useABACTabPermissions();
|
||||
const isExternalStore = useIsExternalAttributeStore();
|
||||
const firstAllowedTab = ABAC_TAB_ORDER.find((t) => tabPermissions[t] && !(t === 'room-attributes' && isExternalStore));
|
||||
const isAllowedTab =
|
||||
(ABAC_TAB_ORDER as readonly string[]).includes(tab ?? '') &&
|
||||
tabPermissions[tab as ABACTab] &&
|
||||
!(tab === 'room-attributes' && isExternalStore);
|
||||
|
||||
const ABACEnabledSetting = useSettingStructure('ABAC_Enabled');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (firstAllowedTab && !isAllowedTab) {
|
||||
router.navigate(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: { tab: firstAllowedTab },
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [router, firstAllowedTab, isAllowedTab]);
|
||||
|
||||
const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC);
|
||||
|
||||
const setModal = useSetModal();
|
||||
|
||||
useEffect(() => {
|
||||
// WS has never activated ABAC
|
||||
if (shouldShowUpsell && ABACEnabledSetting === undefined) {
|
||||
setModal(<ABACUpsellModal onClose={() => setModal(null)} onConfirm={handleManageSubscription} />);
|
||||
}
|
||||
}, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]);
|
||||
|
||||
if (isModalOpen) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!canViewABACPage || !firstAllowedTab || (ABACEnabledSetting === undefined && !hasABAC)) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<EditableSettingsProvider>
|
||||
<AdminABACPage shouldShowWarning={ABACEnabledSetting !== undefined && !hasABAC} />
|
||||
</EditableSettingsProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(AdminABACRoute);
|
||||
46
apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx
Normal file
46
apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Tabs, TabsItem } from '@rocket.chat/fuselage';
|
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useABACTabPermissions } from './hooks/useABACTabPermissions';
|
||||
import { useIsExternalAttributeStore } from './hooks/useIsExternalAttributeStore';
|
||||
|
||||
const AdminABACTabs = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const tab = useRouteParameter('tab');
|
||||
const tabPermissions = useABACTabPermissions();
|
||||
const isExternalStore = useIsExternalAttributeStore();
|
||||
const handleTabClick = (tab: string) => {
|
||||
router.navigate({
|
||||
name: 'admin-ABAC',
|
||||
params: { tab },
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Tabs>
|
||||
{tabPermissions.settings && (
|
||||
<TabsItem selected={tab === 'settings'} onClick={() => handleTabClick('settings')}>
|
||||
{t('Settings')}
|
||||
</TabsItem>
|
||||
)}
|
||||
{tabPermissions['room-attributes'] && !isExternalStore && (
|
||||
<TabsItem selected={tab === 'room-attributes'} onClick={() => handleTabClick('room-attributes')}>
|
||||
{t('ABAC_Room_Attributes')}
|
||||
</TabsItem>
|
||||
)}
|
||||
{tabPermissions.rooms && (
|
||||
<TabsItem selected={tab === 'rooms'} onClick={() => handleTabClick('rooms')}>
|
||||
{t('Rooms')}
|
||||
</TabsItem>
|
||||
)}
|
||||
{tabPermissions.logs && (
|
||||
<TabsItem selected={tab === 'logs'} onClick={() => handleTabClick('logs')}>
|
||||
{t('ABAC_Logs')}
|
||||
</TabsItem>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminABACTabs;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export type ABACTab = 'settings' | 'room-attributes' | 'rooms' | 'logs';
|
||||
|
||||
export const ABAC_TAB_ORDER: ABACTab[] = ['settings', 'room-attributes', 'rooms', 'logs'];
|
||||
|
||||
export const useABACTabPermissions = (): Record<ABACTab, boolean> => {
|
||||
return {
|
||||
'settings': usePermission('manage-abac-admin-settings'),
|
||||
'room-attributes': usePermission('manage-abac-admin-room-attributes'),
|
||||
'rooms': usePermission('manage-abac-admin-rooms'),
|
||||
'logs': usePermission('view-abac-admin-audit'),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useIsABACAvailable } from './useIsABACAvailable';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
const COUNT = 150;
|
||||
const ATTRIBUTE_LIST_STALE_TIME = 15_000;
|
||||
|
||||
export const useAttributeList = () => {
|
||||
const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes');
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
|
||||
return useQuery({
|
||||
enabled: isABACAvailable,
|
||||
staleTime: ATTRIBUTE_LIST_STALE_TIME,
|
||||
queryKey: ABACQueryKeys.roomAttributes.list(),
|
||||
queryFn: async () => {
|
||||
const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT });
|
||||
const { attributes: firstPageAttributes, total } = firstPage;
|
||||
|
||||
let currentPage = COUNT;
|
||||
const pages = [];
|
||||
|
||||
while (currentPage < total) {
|
||||
pages.push(attributesAutoCompleteEndpoint({ offset: currentPage, count: COUNT }));
|
||||
currentPage += COUNT;
|
||||
}
|
||||
const remainingPages = await Promise.all(pages);
|
||||
|
||||
return {
|
||||
attributes: [...firstPageAttributes, ...remainingPages.flatMap((page) => page.attributes)].map((attribute) => ({
|
||||
_id: attribute._id,
|
||||
label: attribute.key,
|
||||
value: attribute.key,
|
||||
attributeValues: attribute.values,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAttributeOptions } from './useAttributeOptions';
|
||||
import { createFakeLicenseInfo } from '../../../../../tests/mocks/data';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockSetModal = jest.fn();
|
||||
const mockDispatchToastMessage = jest.fn();
|
||||
const useIsABACAvailableMock = jest.fn(() => true);
|
||||
|
||||
jest.mock('./useIsABACAvailable', () => ({
|
||||
useIsABACAvailable: () => useIsABACAvailableMock(),
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useRouter: () => ({
|
||||
navigate: mockNavigate,
|
||||
}),
|
||||
useSetModal: () => mockSetModal,
|
||||
useToastMessageDispatch: () => mockDispatchToastMessage,
|
||||
}));
|
||||
|
||||
const mockAttribute = {
|
||||
_id: 'attribute-1',
|
||||
key: 'Room Type',
|
||||
};
|
||||
|
||||
const baseAppRoot = mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Edit: 'Edit',
|
||||
Delete: 'Delete',
|
||||
ABAC_Attribute_deleted: 'Attribute {{attributeName}} deleted',
|
||||
ABAC_Cannot_delete_attribute: 'Cannot delete attribute',
|
||||
ABAC_Cannot_delete_attribute_content:
|
||||
'The attribute <bold>{{attributeName}}</bold> is currently in use and cannot be deleted. Please remove it from all rooms before deleting.',
|
||||
ABAC_Delete_room_attribute: 'Delete room attribute',
|
||||
ABAC_Delete_room_attribute_content:
|
||||
'Are you sure you want to delete the attribute <bold>{{attributeName}}</bold>? This action cannot be undone.',
|
||||
View_rooms: 'View rooms',
|
||||
Cancel: 'Cancel',
|
||||
})
|
||||
.withSetting('ABAC_Enabled', true, {
|
||||
packageValue: false,
|
||||
blocked: false,
|
||||
public: true,
|
||||
type: 'boolean',
|
||||
i18nLabel: 'ABAC_Enabled',
|
||||
i18nDescription: 'ABAC_Enabled_Description',
|
||||
})
|
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({
|
||||
license: createFakeLicenseInfo({ activeModules: ['abac'] }),
|
||||
}));
|
||||
|
||||
describe('useAttributeOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockNavigate.mockClear();
|
||||
mockSetModal.mockClear();
|
||||
mockDispatchToastMessage.mockClear();
|
||||
});
|
||||
|
||||
it('should return menu items with correct structure', () => {
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current).toHaveLength(2);
|
||||
expect(result.current[0]).toMatchObject({
|
||||
id: 'edit',
|
||||
icon: 'edit',
|
||||
content: 'Edit',
|
||||
});
|
||||
expect(result.current[1]).toMatchObject({
|
||||
id: 'delete',
|
||||
icon: 'trash',
|
||||
iconColor: 'danger',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable edit when ABAC is available', async () => {
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[0].disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to edit page when edit action is clicked', async () => {
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
const editAction = result.current[0].onClick;
|
||||
if (editAction) {
|
||||
editAction();
|
||||
}
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'room-attributes',
|
||||
context: 'edit',
|
||||
id: mockAttribute._id,
|
||||
},
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable edit when ABAC is not available', () => {
|
||||
useIsABACAvailableMock.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withSetting('ABAC_Enabled', false, {
|
||||
packageValue: false,
|
||||
blocked: false,
|
||||
public: true,
|
||||
type: 'boolean',
|
||||
i18nLabel: 'ABAC_Enabled',
|
||||
i18nDescription: 'ABAC_Enabled_Description',
|
||||
})
|
||||
.withEndpoint('GET', '/v1/licenses.info', async () => ({
|
||||
license: createFakeLicenseInfo({ activeModules: [] }),
|
||||
}))
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current[0].disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should show warning modal when delete is clicked and attribute is in use', async () => {
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', async () => null)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: true }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
const deleteAction = result.current[1].onClick;
|
||||
if (deleteAction) {
|
||||
deleteAction();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const modalCall = mockSetModal.mock.calls[0][0];
|
||||
expect(modalCall.props.variant).toBe('warning');
|
||||
expect(modalCall.props.title).toBe('Cannot delete attribute');
|
||||
});
|
||||
|
||||
it('should show delete confirmation modal when delete is clicked and attribute is not in use', async () => {
|
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
const deleteAction = result.current[1].onClick;
|
||||
if (deleteAction) {
|
||||
deleteAction();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const modalCall = mockSetModal.mock.calls[0][0];
|
||||
expect(modalCall.props.variant).toBe('danger');
|
||||
expect(modalCall.props.title).toBe('Delete room attribute');
|
||||
expect(modalCall.props.confirmText).toBe('Delete');
|
||||
});
|
||||
|
||||
it('should call delete endpoint when delete is confirmed', async () => {
|
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
|
||||
|
||||
let confirmHandler: (() => void) | undefined;
|
||||
|
||||
mockSetModal.mockImplementation((modal) => {
|
||||
if (modal?.props?.onConfirm) {
|
||||
confirmHandler = modal.props.onConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
const deleteAction = result.current[1].onClick;
|
||||
if (deleteAction) {
|
||||
deleteAction();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
if (confirmHandler) {
|
||||
confirmHandler();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteEndpointMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success toast when delete succeeds', async () => {
|
||||
const deleteEndpointMock = jest.fn().mockResolvedValue(null);
|
||||
|
||||
let confirmHandler: (() => void) | undefined;
|
||||
|
||||
mockSetModal.mockImplementation((modal) => {
|
||||
if (modal?.props?.onConfirm) {
|
||||
confirmHandler = modal.props.onConfirm;
|
||||
}
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAttributeOptions(mockAttribute), {
|
||||
wrapper: baseAppRoot
|
||||
.withEndpoint('DELETE', '/v1/abac/attributes/:_id', deleteEndpointMock)
|
||||
.withEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', async () => ({ inUse: false }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
const deleteAction = result.current[1].onClick;
|
||||
if (deleteAction) {
|
||||
deleteAction();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
if (confirmHandler) {
|
||||
confirmHandler();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDispatchToastMessage).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Attribute Room Type deleted',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useRouter, useSetModal, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { useIsABACAvailable } from './useIsABACAvailable';
|
||||
import { useViewRoomsAction } from './useViewRoomsAction';
|
||||
import { ABACQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
export const useAttributeOptions = (attribute: { _id: string; key: string }): GenericMenuItemProps[] => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const setModal = useSetModal();
|
||||
const queryClient = useQueryClient();
|
||||
const deleteAttribute = useEndpoint('DELETE', '/v1/abac/attributes/:_id', { _id: attribute._id });
|
||||
const isAttributeUsed = useEndpoint('GET', '/v1/abac/attributes/:key/is-in-use', { key: attribute.key });
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
const viewRoomsAction = useViewRoomsAction();
|
||||
|
||||
const editAction = useStableCallback(() => {
|
||||
return router.navigate(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'room-attributes',
|
||||
context: 'edit',
|
||||
id: attribute._id,
|
||||
},
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAttribute,
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('ABAC_Attribute_deleted', { attributeName: attribute.key }) });
|
||||
},
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ABACQueryKeys.roomAttributes.all() });
|
||||
setModal(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteAction = useStableCallback(async () => {
|
||||
const isUsed = await isAttributeUsed();
|
||||
if (isUsed.inUse) {
|
||||
return setModal(
|
||||
<GenericModal
|
||||
variant='warning'
|
||||
icon={null}
|
||||
title={t('ABAC_Cannot_delete_attribute')}
|
||||
confirmText={t('View_rooms')}
|
||||
onConfirm={() => {
|
||||
viewRoomsAction(attribute.key);
|
||||
setModal(null);
|
||||
}}
|
||||
onCancel={() => setModal(null)}
|
||||
>
|
||||
<Trans
|
||||
i18nKey='ABAC_Cannot_delete_attribute_content'
|
||||
values={{ attributeName: attribute.key }}
|
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
</GenericModal>,
|
||||
);
|
||||
}
|
||||
setModal(
|
||||
<GenericModal
|
||||
variant='danger'
|
||||
icon={null}
|
||||
title={t('ABAC_Delete_room_attribute')}
|
||||
confirmText={t('Delete')}
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutateAsync(undefined);
|
||||
}}
|
||||
onCancel={() => setModal(null)}
|
||||
>
|
||||
<Trans
|
||||
i18nKey='ABAC_Delete_room_attribute_content'
|
||||
values={{ attributeName: attribute.key }}
|
||||
components={{ bold: <Box is='span' fontWeight='bold' /> }}
|
||||
/>
|
||||
</GenericModal>,
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: !isABACAvailable },
|
||||
{
|
||||
id: 'delete',
|
||||
iconColor: 'danger',
|
||||
icon: 'trash' as const,
|
||||
content: <Box color='danger'>{t('Delete')}</Box>,
|
||||
onClick: () => deleteAction(),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useDeleteRoomModal } from './useDeleteRoomModal';
|
||||
|
||||
const mockSetModal = jest.fn();
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => {
|
||||
const originalModule = jest.requireActual('@rocket.chat/ui-contexts');
|
||||
return {
|
||||
...originalModule,
|
||||
useSetModal: () => mockSetModal,
|
||||
};
|
||||
});
|
||||
|
||||
describe('useDeleteRoomModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSetModal.mockClear();
|
||||
});
|
||||
|
||||
it('should show delete confirmation modal when hook is called', async () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDeleteRoomModal({
|
||||
rid: faker.database.mongodbObjectId(),
|
||||
name: faker.lorem.words(3),
|
||||
}),
|
||||
{
|
||||
wrapper: 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',
|
||||
})
|
||||
.build(),
|
||||
},
|
||||
);
|
||||
|
||||
result.current();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import DeleteRoomModal from '../ABACRoomsTab/DeleteRoomModal';
|
||||
|
||||
export const useDeleteRoomModal = (room: { rid: string; name: string }) => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
return () => {
|
||||
setModal(<DeleteRoomModal rid={room.rid} roomName={room.name} onClose={() => setModal(null)} />);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { useHasLicenseModule } from '../../../../hooks/useHasLicenseModule';
|
||||
|
||||
export const useIsABACAvailable = () => {
|
||||
const { data: hasABAC = false } = useHasLicenseModule('abac');
|
||||
const isABACSettingEnabled = useSetting('ABAC_Enabled', false);
|
||||
|
||||
return hasABAC && isABACSettingEnabled;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { isABACManagedRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import { useIsABACAvailable } from './useIsABACAvailable';
|
||||
|
||||
export const useIsABACManagedRoom = (room: Partial<IRoom>): boolean => {
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
return isABACAvailable && isABACManagedRoom(room);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useIsExternalAttributeStore = (): boolean => {
|
||||
const pdpType = useSetting('ABAC_PDP_Type', 'local');
|
||||
const attributeStore = useSetting('ABAC_Attribute_Store', 'local');
|
||||
|
||||
return pdpType !== 'local' && attributeStore !== 'local';
|
||||
};
|
||||
124
apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.ts
Normal file
124
apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useRoomItems } from './useRoomItems';
|
||||
|
||||
const navigateMock = jest.fn();
|
||||
const setDeleteRoomModalMock = jest.fn();
|
||||
const useIsABACAvailableMock = jest.fn(() => true);
|
||||
|
||||
jest.mock('./useIsABACAvailable', () => ({
|
||||
useIsABACAvailable: () => useIsABACAvailableMock(),
|
||||
}));
|
||||
jest.mock('./useDeleteRoomModal', () => ({
|
||||
useDeleteRoomModal: () => setDeleteRoomModalMock,
|
||||
}));
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useRouter: () => ({
|
||||
navigate: navigateMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockRoom = {
|
||||
rid: faker.database.mongodbObjectId(),
|
||||
name: 'Test Room',
|
||||
};
|
||||
|
||||
const createAppRoot = () =>
|
||||
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',
|
||||
})
|
||||
.withEndpoint('DELETE', '/v1/abac/rooms/:rid/attributes', async () => null);
|
||||
|
||||
describe('useRoomItems', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
navigateMock.mockClear();
|
||||
useIsABACAvailableMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should return menu items with correct structure', () => {
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), {
|
||||
wrapper: createAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toHaveLength(2);
|
||||
expect(result.current[0]).toMatchObject({
|
||||
id: 'edit',
|
||||
icon: 'edit',
|
||||
content: 'Edit',
|
||||
});
|
||||
expect(result.current[1]).toMatchObject({
|
||||
id: 'delete',
|
||||
icon: 'cross',
|
||||
iconColor: 'danger',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable edit when ABAC is available', async () => {
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), {
|
||||
wrapper: createAppRoot().build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current[0].disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to edit page when edit action is clicked', async () => {
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), {
|
||||
wrapper: createAppRoot().build(),
|
||||
});
|
||||
|
||||
const editAction = result.current[0].onClick;
|
||||
if (editAction) {
|
||||
editAction();
|
||||
}
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'rooms',
|
||||
context: 'edit',
|
||||
id: mockRoom.rid,
|
||||
},
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable edit when ABAC is not available', () => {
|
||||
useIsABACAvailableMock.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), {
|
||||
wrapper: createAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current[0].disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should show delete modal when delete is clicked', async () => {
|
||||
const { result } = renderHook(() => useRoomItems(mockRoom), {
|
||||
wrapper: createAppRoot().build(),
|
||||
});
|
||||
|
||||
const deleteAction = result.current[1].onClick;
|
||||
if (deleteAction) {
|
||||
deleteAction();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setDeleteRoomModalMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
40
apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx
Normal file
40
apps/meteor/client/views/admin/ABAC/hooks/useRoomItems.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDeleteRoomModal } from './useDeleteRoomModal';
|
||||
import { useIsABACAvailable } from './useIsABACAvailable';
|
||||
|
||||
export const useRoomItems = (room: { rid: string; name: string }): GenericMenuItemProps[] => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const setDeleteRoomModal = useDeleteRoomModal(room);
|
||||
const isABACAvailable = useIsABACAvailable();
|
||||
|
||||
const editAction = useStableCallback(() => {
|
||||
return router.navigate(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'rooms',
|
||||
context: 'edit',
|
||||
id: room.rid,
|
||||
},
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
{ id: 'edit', icon: 'edit' as const, content: t('Edit'), onClick: () => editAction(), disabled: isABACAvailable !== true },
|
||||
{
|
||||
id: 'delete',
|
||||
iconColor: 'danger',
|
||||
icon: 'cross' as const,
|
||||
content: <Box color='danger'>{t('Remove')}</Box>,
|
||||
onClick: setDeleteRoomModal,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useViewRoomsAction = () => {
|
||||
const router = useRouter();
|
||||
return useStableCallback((key: string) => {
|
||||
return router.navigate(
|
||||
{
|
||||
name: 'admin-ABAC',
|
||||
params: {
|
||||
tab: 'rooms',
|
||||
context: '',
|
||||
id: '',
|
||||
},
|
||||
search: {
|
||||
searchTerm: key,
|
||||
type: 'attribute',
|
||||
},
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
21
apps/meteor/client/views/admin/AdministrationLayout.tsx
Normal file
21
apps/meteor/client/views/admin/AdministrationLayout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import AdminSidebar from './sidebar/AdminSidebar';
|
||||
import SidebarPortal from '../../portals/SidebarPortal';
|
||||
|
||||
export type AdministrationLayoutProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const AdministrationLayout = ({ children }: AdministrationLayoutProps) => {
|
||||
return (
|
||||
<>
|
||||
<SidebarPortal>
|
||||
<AdminSidebar />
|
||||
</SidebarPortal>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdministrationLayout;
|
||||
57
apps/meteor/client/views/admin/AdministrationRouter.tsx
Normal file
57
apps/meteor/client/views/admin/AdministrationRouter.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
|
||||
import AdministrationLayout from './AdministrationLayout';
|
||||
import { getAdminSidebarItems } from './sidebarItems';
|
||||
import PageSkeleton from '../../components/PageSkeleton';
|
||||
import type { Item, SidebarDivider, SidebarItem } from '../../lib/createSidebarItems';
|
||||
import { isGoRocketChatLink } from '../../lib/createSidebarItems';
|
||||
import SettingsProvider from '../../providers/SettingsProvider';
|
||||
|
||||
const isSidebarDivider = (sidebarItem: SidebarItem): sidebarItem is SidebarDivider => {
|
||||
return (sidebarItem as SidebarDivider).divider === true;
|
||||
};
|
||||
|
||||
const firstSidebarPage = (sidebarItem: SidebarItem): sidebarItem is Item => {
|
||||
if (isSidebarDivider(sidebarItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(sidebarItem.permissionGranted?.());
|
||||
};
|
||||
|
||||
export type AdministrationRouterProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const AdministrationRouter = ({ children }: AdministrationRouterProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
router.subscribeToRouteChange(() => {
|
||||
if (router.getRouteName() !== 'admin-index') {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultRoutePath = getAdminSidebarItems().find(firstSidebarPage)?.href ?? '/admin/workspace';
|
||||
|
||||
if (isGoRocketChatLink(defaultRoutePath)) {
|
||||
window.open(defaultRoutePath, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
router.navigate(defaultRoutePath, { replace: true });
|
||||
}),
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdministrationLayout>
|
||||
<SettingsProvider>{children ? <Suspense fallback={<PageSkeleton />}>{children}</Suspense> : <PageSkeleton />}</SettingsProvider>
|
||||
</AdministrationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdministrationRouter;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mockAppRoot as _mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useEditableSettingVisibilityQuery } from './EditableSettingsContext';
|
||||
import EditableSettingsProvider from './settings/EditableSettingsProvider';
|
||||
|
||||
const mockAppRoot = () => _mockAppRoot().wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>);
|
||||
|
||||
describe('useEditableSettingVisibilityQuery', () => {
|
||||
it('should return true when no query is provided', () => {
|
||||
const { result } = renderHook(() => useEditableSettingVisibilityQuery(), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle settings with a query', () => {
|
||||
const { result } = renderHook(() => useEditableSettingVisibilityQuery({ _id: 'setting1', value: true }), {
|
||||
wrapper: mockAppRoot().withSetting('setting1', true).build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple conditions in enableQuery', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useEditableSettingVisibilityQuery([
|
||||
{ _id: 'setting5', value: true },
|
||||
{ _id: 'setting6', value: true },
|
||||
]),
|
||||
{
|
||||
wrapper: mockAppRoot().withSetting('setting5', true).withSetting('setting6', true).build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
const { result: result2 } = renderHook(
|
||||
() =>
|
||||
useEditableSettingVisibilityQuery([
|
||||
{ _id: 'setting5', value: true },
|
||||
{ _id: 'setting6', value: true },
|
||||
]),
|
||||
{
|
||||
wrapper: mockAppRoot().withSetting('setting5', true).withSetting('setting6', false).build(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result2.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle string queries', () => {
|
||||
const { result } = renderHook(() => useEditableSettingVisibilityQuery(JSON.stringify({ _id: 'setting7', value: true })), {
|
||||
wrapper: mockAppRoot().withSetting('setting7', true).build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
162
apps/meteor/client/views/admin/EditableSettingsContext.ts
Normal file
162
apps/meteor/client/views/admin/EditableSettingsContext.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { ISetting } from '@rocket.chat/core-typings';
|
||||
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
|
||||
export type EditableSetting = ISetting & {
|
||||
disabled: boolean;
|
||||
changed: boolean;
|
||||
invisible: boolean;
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export const compareSettings = (a: EditableSetting, b: EditableSetting): number => {
|
||||
const sorter = a.sorter - b.sorter;
|
||||
if (sorter !== 0) return sorter;
|
||||
|
||||
const tab = (a.tab ?? '').localeCompare(b.tab ?? '');
|
||||
if (tab !== 0) return tab;
|
||||
|
||||
const i18nLabel = a.i18nLabel.localeCompare(b.i18nLabel);
|
||||
|
||||
return i18nLabel;
|
||||
};
|
||||
|
||||
export const performSettingQuery = (
|
||||
query:
|
||||
| string
|
||||
| {
|
||||
_id: string;
|
||||
value: unknown;
|
||||
}
|
||||
| {
|
||||
_id: string;
|
||||
value: unknown;
|
||||
}[]
|
||||
| undefined,
|
||||
settings: ISetting[],
|
||||
) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const queries = [].concat(typeof query === 'string' ? JSON.parse(query) : query);
|
||||
return queries.every((query) => settings.some(createPredicateFromFilter(query)));
|
||||
};
|
||||
|
||||
type EditableSettingsContextQuery =
|
||||
| {
|
||||
group: ISetting['_id'];
|
||||
}
|
||||
| {
|
||||
group: ISetting['_id'];
|
||||
section: string;
|
||||
tab?: ISetting['_id'];
|
||||
}
|
||||
| {
|
||||
group: ISetting['_id'];
|
||||
changed: true;
|
||||
};
|
||||
|
||||
export interface IEditableSettingsState {
|
||||
state: EditableSetting[];
|
||||
initialState: ISetting[];
|
||||
sync(newInitialState: ISetting[]): void;
|
||||
mutate(changes: Partial<EditableSetting>[]): void;
|
||||
}
|
||||
|
||||
export type EditableSettingsContextValue = {
|
||||
useEditableSettingsStore: UseBoundStore<StoreApi<IEditableSettingsState>>;
|
||||
};
|
||||
|
||||
export const EditableSettingsContext = createContext<EditableSettingsContextValue>({
|
||||
useEditableSettingsStore: create<IEditableSettingsState>()(() => ({
|
||||
state: [],
|
||||
initialState: [],
|
||||
sync: () => undefined,
|
||||
mutate: () => undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
export const useEditableSetting = (_id: ISetting['_id']): EditableSetting | undefined => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
|
||||
return useEditableSettingsStore((state) => state.state.find((x) => x._id === _id));
|
||||
};
|
||||
|
||||
export const useEditableSettings = (query: EditableSettingsContextQuery): EditableSetting[] => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
|
||||
return useEditableSettingsStore(
|
||||
useShallow((state) =>
|
||||
state.state
|
||||
.filter((x) => {
|
||||
if ('changed' in query) {
|
||||
return x.group === query.group && x.changed;
|
||||
}
|
||||
|
||||
if ('section' in query) {
|
||||
return (
|
||||
x.group === query.group &&
|
||||
(query.section ? x.section === query.section : !x.section) &&
|
||||
(query.tab ? x.tab === query.tab : !x.tab)
|
||||
);
|
||||
}
|
||||
|
||||
return x.group === query.group;
|
||||
})
|
||||
.sort(compareSettings),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const useEditableSettingsGroupSections = (_id: ISetting['_id'], tab?: ISetting['_id']): string[] => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
|
||||
return useEditableSettingsStore(
|
||||
useShallow((state) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
state.state
|
||||
.filter((x) => x.group === _id && (tab !== undefined ? x.tab === tab : !x.tab))
|
||||
.sort(compareSettings)
|
||||
.map(({ section }) => section || ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const useEditableSettingsGroupTabs = (_id: ISetting['_id']): ISetting['_id'][] => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
|
||||
return useEditableSettingsStore(
|
||||
useShallow((state) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
state.state
|
||||
.filter((x) => x.group === _id)
|
||||
.sort(compareSettings)
|
||||
.map(({ tab }) => tab || ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const useEditableSettingsDispatch = (): ((changes: Partial<EditableSetting>[]) => void) => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
return useEditableSettingsStore((state) => state.mutate);
|
||||
};
|
||||
|
||||
export const useEditableSettingVisibilityQuery = (query?: ISetting['enableQuery'] | ISetting['displayQuery']): boolean => {
|
||||
const { useEditableSettingsStore } = useContext(EditableSettingsContext);
|
||||
|
||||
return useEditableSettingsStore((state) => {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return performSettingQuery(query, state.state);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Box, Button, Card, CardBody, CardControls, CardHeader, CardTitle, FramedIcon } from '@rocket.chat/fuselage';
|
||||
import type { ComponentProps, ReactElement, ReactNode } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
export type AICenterCapabilityCardProps = {
|
||||
icon: ComponentProps<typeof FramedIcon>['icon'];
|
||||
title: string;
|
||||
description: string;
|
||||
status?: ReactNode;
|
||||
actionLabel: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
const AICenterCapabilityCard = ({ icon, title, description, status, actionLabel, href }: AICenterCapabilityCardProps): ReactElement => {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
|
||||
return (
|
||||
<Card height='full' role='region' aria-labelledby={titleId} aria-describedby={descriptionId}>
|
||||
<CardHeader>
|
||||
<FramedIcon icon={icon} />
|
||||
{status && <Box marginInlineStart='auto'>{status}</Box>}
|
||||
</CardHeader>
|
||||
<CardTitle id={titleId}>{title}</CardTitle>
|
||||
<CardBody id={descriptionId} flexDirection='column'>
|
||||
{description}
|
||||
</CardBody>
|
||||
<CardControls>
|
||||
<Button is='a' href={href} medium>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</CardControls>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICenterCapabilityCard;
|
||||
88
apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx
Normal file
88
apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search';
|
||||
import { Box, Button, Callout, CardGrid, Tag } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
|
||||
import { useRouter, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AICenterCapabilityCard from './AICenterCapabilityCard';
|
||||
import PageSkeleton from '../../../components/PageSkeleton';
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
|
||||
const AICenterOverview = (): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data: hasAILicense, isPending } = useHasLicenseModule(AI_LICENSE_MODULE);
|
||||
const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false);
|
||||
const mcpEnabled = useSetting('MCP_Enabled', false);
|
||||
const searchSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'search' } });
|
||||
const llmSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'llm-providers' } });
|
||||
const mcpSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'mcp' } });
|
||||
const subscriptionHref = router.buildRoutePath({ name: 'subscription' });
|
||||
|
||||
if (isPending) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
let aiSearchStatus: ReactNode;
|
||||
let llmProviderStatus: ReactNode;
|
||||
let mcpStatus: ReactNode;
|
||||
if (hasAILicense === false) {
|
||||
aiSearchStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
|
||||
llmProviderStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
|
||||
mcpStatus = <Tag variant='danger'>{t('Locked')}</Tag>;
|
||||
} else if (hasAILicense) {
|
||||
aiSearchStatus = intelligentSearchEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
|
||||
llmProviderStatus = <Tag>{t('Available')}</Tag>;
|
||||
mcpStatus = mcpEnabled ? <Tag variant='primary'>{t('Enabled')}</Tag> : <Tag>{t('Disabled')}</Tag>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page background='tint'>
|
||||
<PageHeader title={t('AI_Center')} />
|
||||
<PageScrollableContentWithShadow padding={24}>
|
||||
<Box marginInline='auto' width='full'>
|
||||
{hasAILicense === false && (
|
||||
<Callout type='info' icon='stars' title={t('AI_Center_license_required_title')} marginBlockEnd={24}>
|
||||
<Box>{t('AI_Center_license_required_description')}</Box>
|
||||
<Button is='a' href={subscriptionHref} small marginBlockStart={12}>
|
||||
{t('View_options')}
|
||||
</Button>
|
||||
</Callout>
|
||||
)}
|
||||
<Box is='h2' fontScale='h3' marginBlockEnd={16}>
|
||||
{t('Capabilities')}
|
||||
</Box>
|
||||
<CardGrid breakpoints={{ xs: 4, sm: 4, md: 6, lg: 6, xl: 6 }}>
|
||||
<AICenterCapabilityCard
|
||||
icon='stars'
|
||||
title={t('Intelligent_Search')}
|
||||
description={t('AI_Center_Intelligent_Search_card_description')}
|
||||
status={aiSearchStatus}
|
||||
actionLabel={t('Configure')}
|
||||
href={searchSettingsHref}
|
||||
/>
|
||||
<AICenterCapabilityCard
|
||||
icon='smart'
|
||||
title={t('AI_Center_LLM_Providers')}
|
||||
description={t('AI_Center_LLM_Providers_card_description')}
|
||||
status={llmProviderStatus}
|
||||
actionLabel={t('Manage')}
|
||||
href={llmSettingsHref}
|
||||
/>
|
||||
<AICenterCapabilityCard
|
||||
icon='link'
|
||||
title={t('MCP')}
|
||||
description={t('AI_Center_MCP_card_description')}
|
||||
status={mcpStatus}
|
||||
actionLabel={t('Configure')}
|
||||
href={mcpSettingsHref}
|
||||
/>
|
||||
</CardGrid>
|
||||
</Box>
|
||||
</PageScrollableContentWithShadow>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICenterOverview;
|
||||
31
apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx
Normal file
31
apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useIsPrivilegedSettingsContext, useRouteParameter } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import AICenterOverview from './AICenterOverview';
|
||||
import AISettingsSection from './AISettingsSection';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const AICenterRoute = (): ReactElement => {
|
||||
const hasPermission = useIsPrivilegedSettingsContext();
|
||||
const section = useRouteParameter('section');
|
||||
|
||||
if (!hasPermission) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
if (section === 'search') {
|
||||
return <AISettingsSection section='Intelligent_Search' />;
|
||||
}
|
||||
|
||||
if (section === 'llm-providers') {
|
||||
return <AISettingsSection section='AI_LLM_Provider' />;
|
||||
}
|
||||
|
||||
if (section === 'mcp') {
|
||||
return <AISettingsSection section='MCP' />;
|
||||
}
|
||||
|
||||
return <AICenterOverview />;
|
||||
};
|
||||
|
||||
export default AICenterRoute;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import EditableSettingsProvider from '../settings/EditableSettingsProvider';
|
||||
import GenericGroupPage from '../settings/groups/GenericGroupPage';
|
||||
|
||||
export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider' | 'MCP';
|
||||
|
||||
export type AISettingsSectionProps = {
|
||||
section: AISettingsSectionName;
|
||||
};
|
||||
|
||||
const sectionTitles: Record<AISettingsSectionName, string> = {
|
||||
Intelligent_Search: 'Intelligent_Search',
|
||||
AI_LLM_Provider: 'AI_Center_LLM_Providers',
|
||||
MCP: 'MCP',
|
||||
};
|
||||
|
||||
const AISettingsSection = ({ section }: AISettingsSectionProps): ReactElement => {
|
||||
const router = useRouter();
|
||||
const title = sectionTitles[section];
|
||||
|
||||
return (
|
||||
<EditableSettingsProvider>
|
||||
<GenericGroupPage _id='AI_Center' i18nLabel={title} sections={[section]} onClickBack={() => router.navigate('/admin/ai-center')} />
|
||||
</EditableSettingsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISettingsSection;
|
||||
128
apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx
Normal file
128
apps/meteor/client/views/admin/customEmoji/AddCustomEmoji.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, FieldError, IconButton } from '@rocket.chat/fuselage';
|
||||
import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation';
|
||||
import { useSingleFileInput } from '../../../hooks/useSingleFileInput';
|
||||
|
||||
export type AddCustomEmojiProps = {
|
||||
close: () => void;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
const AddCustomEmoji = ({ close, onChange, ...props }: AddCustomEmojiProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [aliases, setAliases] = useState('');
|
||||
const [emojiFile, setEmojiFile] = useState<Blob>();
|
||||
const [newEmojiPreview, setNewEmojiPreview] = useState('');
|
||||
const [errors, setErrors] = useState({ name: false, emoji: false, aliases: false });
|
||||
|
||||
const setEmojiPreview = useCallback(
|
||||
async (file: Blob) => {
|
||||
setEmojiFile(file);
|
||||
setNewEmojiPreview(URL.createObjectURL(file));
|
||||
setErrors((prevState) => ({ ...prevState, emoji: false }));
|
||||
},
|
||||
[setEmojiFile],
|
||||
);
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/emoji-custom.create', {
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Added_Successfully') });
|
||||
queryClient.invalidateQueries({ queryKey: ['emoji-custom.list'] });
|
||||
onChange();
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name) {
|
||||
return setErrors((prevState) => ({ ...prevState, name: true }));
|
||||
}
|
||||
|
||||
if (name === aliases) {
|
||||
return setErrors((prevState) => ({ ...prevState, aliases: true }));
|
||||
}
|
||||
|
||||
if (!emojiFile) {
|
||||
return setErrors((prevState) => ({ ...prevState, emoji: true }));
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('emoji', emojiFile);
|
||||
formData.append('name', name);
|
||||
formData.append('aliases', aliases);
|
||||
await saveAction(formData);
|
||||
}, [emojiFile, name, aliases, saveAction]);
|
||||
|
||||
const [clickUpload] = useSingleFileInput(setEmojiPreview, 'emoji');
|
||||
|
||||
const handleChangeName = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.currentTarget.value !== '') {
|
||||
setErrors((prevState) => ({ ...prevState, name: false }));
|
||||
}
|
||||
|
||||
return setName(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleChangeAliases = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.currentTarget.value !== name) {
|
||||
setErrors((prevState) => ({ ...prevState, aliases: false }));
|
||||
}
|
||||
|
||||
return setAliases(e.currentTarget.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent {...props}>
|
||||
<Field>
|
||||
<FieldLabel>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput value={name} onChange={handleChangeName} placeholder={t('Name')} />
|
||||
</FieldRow>
|
||||
{errors.name && <FieldError>{t('Required_field', { field: t('Name') })}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Aliases')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput value={aliases} onChange={handleChangeAliases} placeholder={t('Aliases')} />
|
||||
</FieldRow>
|
||||
{errors.aliases && <FieldError>{t('Custom_Emoji_Error_Same_Name_And_Alias')}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel alignSelf='stretch' display='flex' justifyContent='space-between' alignItems='center'>
|
||||
{t('Custom_Emoji')}
|
||||
<IconButton secondary small icon='upload' onClick={clickUpload} />
|
||||
</FieldLabel>
|
||||
{errors.emoji && <FieldError>{t('Required_field', { field: t('Custom_Emoji') })}</FieldError>}
|
||||
{newEmojiPreview && (
|
||||
<Box display='flex' flexDirection='row' marginInline='neg-x4' justifyContent='center'>
|
||||
<Margins inline={4}>
|
||||
<Box is='img' objectFit='contain' width='x120' height='x120' src={newEmojiPreview} />
|
||||
</Margins>
|
||||
</Box>
|
||||
)}
|
||||
</Field>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={close}>{t('Cancel')}</Button>
|
||||
<Button primary onClick={handleSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomEmoji;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import CustomEmoji from './CustomEmoji';
|
||||
|
||||
const appRoot = mockAppRoot().withEndpoint('GET', '/v1/emoji-custom.all', () => ({
|
||||
count: 1,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
success: true,
|
||||
emojis: [
|
||||
{
|
||||
_id: '1',
|
||||
name: 'smile',
|
||||
aliases: ['happy', 'joy'],
|
||||
extension: 'webp',
|
||||
_updatedAt: new Date().toISOString(),
|
||||
etag: 'abcdef',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
describe('CustomEmoji Component', () => {
|
||||
const mockRef = { current: jest.fn() };
|
||||
const mockOnClick = jest.fn();
|
||||
|
||||
it('renders emoji list', async () => {
|
||||
render(<CustomEmoji onClick={mockOnClick} reload={mockRef} />, {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('smile')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders emoji's aliases as comma-separated values when aliases is an array", async () => {
|
||||
render(<CustomEmoji onClick={mockOnClick} reload={mockRef} />, {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('happy, joy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders emoji's aliases values when aliases is a string", async () => {
|
||||
render(<CustomEmoji onClick={mockOnClick} reload={mockRef} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/emoji-custom.all', () => ({
|
||||
count: 1,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
success: true,
|
||||
emojis: [
|
||||
{
|
||||
_id: '1',
|
||||
name: 'smile',
|
||||
aliases: 'happy' as any,
|
||||
extension: 'webp',
|
||||
_updatedAt: new Date().toISOString(),
|
||||
etag: 'abcdef',
|
||||
},
|
||||
],
|
||||
}))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('happy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
131
apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx
Normal file
131
apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Box, Pagination, States, StatesActions, StatesAction, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableBody,
|
||||
GenericTableCell,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableLoadingTable,
|
||||
GenericTableRow,
|
||||
usePagination,
|
||||
useSort,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import FilterByText from '../../../components/FilterByText';
|
||||
import GenericNoResults from '../../../components/GenericNoResults';
|
||||
|
||||
export type CustomEmojiProps = {
|
||||
reload: MutableRefObject<() => void>;
|
||||
onClick: (emoji: string) => () => void;
|
||||
};
|
||||
|
||||
const CustomEmoji = ({ onClick, reload }: CustomEmojiProps) => {
|
||||
const t = useTranslation();
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const { sortBy, sortDirection, setSort } = useSort<'name'>('name');
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
|
||||
const query = useDebouncedValue(
|
||||
useMemo(
|
||||
() => ({
|
||||
name: text,
|
||||
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
|
||||
count: itemsPerPage,
|
||||
offset: current,
|
||||
}),
|
||||
[text, itemsPerPage, current, sortBy, sortDirection],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const headers = useMemo(
|
||||
() => [
|
||||
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name' width='x200'>
|
||||
{t('Name')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='aliases' width='x200'>
|
||||
{t('Aliases')}
|
||||
</GenericTableHeaderCell>,
|
||||
],
|
||||
[setSort, sortDirection, sortBy, t],
|
||||
);
|
||||
|
||||
const getEmojiList = useEndpoint('GET', '/v1/emoji-custom.all');
|
||||
const { data, refetch, isSuccess, isLoading, isError } = useQuery({
|
||||
queryKey: ['getEmojiList', query],
|
||||
queryFn: () => getEmojiList(query),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reload.current = refetch;
|
||||
}, [reload, refetch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterByText value={text} onChange={(event) => setText(event.target.value)} />
|
||||
{isLoading && (
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
<GenericTableLoadingTable headerCells={2} />
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
)}
|
||||
{isSuccess && data && data.emojis.length > 0 && (
|
||||
<>
|
||||
<GenericTable aria-label={t('Emoji')}>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{isSuccess &&
|
||||
data?.emojis.map((emojis) => (
|
||||
<GenericTableRow
|
||||
key={emojis._id}
|
||||
onKeyDown={onClick(emojis._id)}
|
||||
onClick={onClick(emojis._id)}
|
||||
tabIndex={0}
|
||||
role='link'
|
||||
action
|
||||
qa-emoji-id={emojis._id}
|
||||
>
|
||||
<GenericTableCell color='default'>
|
||||
<Box withTruncatedText>{emojis.name}</Box>
|
||||
</GenericTableCell>
|
||||
<GenericTableCell color='default'>
|
||||
<Box withTruncatedText>{Array.isArray(emojis.aliases) ? emojis.aliases.join(', ') : emojis.aliases}</Box>
|
||||
</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data?.total || 0}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSuccess && data && data.emojis.length === 0 && <GenericNoResults />}
|
||||
{isError && (
|
||||
<States>
|
||||
<StatesIcon name='warning' variation='danger' />
|
||||
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEmoji;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarClose,
|
||||
ContextualbarDialog,
|
||||
ContextualbarTitle,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageContent,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoute, useRouteParameter, usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AddCustomEmoji from './AddCustomEmoji';
|
||||
import CustomEmoji from './CustomEmoji';
|
||||
import EditCustomEmojiWithData from './EditCustomEmojiWithData';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const CustomEmojiRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const route = useRoute('emoji-custom');
|
||||
const context = useRouteParameter('context');
|
||||
const id = useRouteParameter('id');
|
||||
const canManageEmoji = usePermission('manage-emoji');
|
||||
|
||||
const handleItemClick = (_id: string) => (): void => {
|
||||
route.push({
|
||||
context: 'edit',
|
||||
id: _id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddEmoji = useCallback(() => {
|
||||
route.push({ context: 'new' });
|
||||
}, [route]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
route.push({});
|
||||
};
|
||||
|
||||
const reload = useRef(() => null);
|
||||
|
||||
const handleChange = useCallback(() => {
|
||||
reload.current();
|
||||
}, [reload]);
|
||||
|
||||
if (!canManageEmoji) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page name='admin-emoji-custom'>
|
||||
<PageHeader title={t('Emoji')}>
|
||||
<Button primary onClick={handleAddEmoji} aria-label={t('New')}>
|
||||
{t('New')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<CustomEmoji reload={reload} onClick={handleItemClick} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
{context && (
|
||||
<ContextualbarDialog onClose={handleClose}>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>
|
||||
{context === 'edit' && t('Custom_Emoji_Info')}
|
||||
{context === 'new' && t('Custom_Emoji_Add')}
|
||||
</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={handleClose} />
|
||||
</ContextualbarHeader>
|
||||
{context === 'edit' && id && <EditCustomEmojiWithData _id={id} close={handleClose} onChange={handleChange} />}
|
||||
{context === 'new' && <AddCustomEmoji close={handleClose} onChange={handleChange} />}
|
||||
</ContextualbarDialog>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEmojiRoute;
|
||||
204
apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx
Normal file
204
apps/meteor/client/views/admin/customEmoji/EditCustomEmoji.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Margins,
|
||||
TextInput,
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldRow,
|
||||
FieldError,
|
||||
IconButton,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { GenericModal, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useAbsoluteUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useEndpointMutation } from '../../../hooks/useEndpointMutation';
|
||||
import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation';
|
||||
import { useSingleFileInput } from '../../../hooks/useSingleFileInput';
|
||||
|
||||
export type EditCustomEmojiProps = {
|
||||
close: () => void;
|
||||
onChange: () => void;
|
||||
data: {
|
||||
_id: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
extension: string;
|
||||
etag?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const EditCustomEmoji = ({ close, onChange, data, ...props }: EditCustomEmojiProps) => {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
const absoluteUrl = useAbsoluteUrl();
|
||||
const queryClient = useQueryClient();
|
||||
const [errors, setErrors] = useState({ name: false, aliases: false });
|
||||
|
||||
const { _id, name: previousName, aliases: previousAliases } = data || {};
|
||||
|
||||
const [name, setName] = useState(() => data?.name ?? '');
|
||||
const [aliases, setAliases] = useState(() => data?.aliases?.join(', ') ?? '');
|
||||
const [emojiFile, setEmojiFile] = useState<Blob>();
|
||||
const newEmojiPreview = useMemo(() => {
|
||||
if (emojiFile) {
|
||||
return URL.createObjectURL(emojiFile);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return absoluteUrl(`/emoji-custom/${encodeURIComponent(data.name)}.${data.extension}${data.etag ? `?etag=${data.etag}` : ''}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [absoluteUrl, data, emojiFile]);
|
||||
|
||||
useEffect(() => {
|
||||
setName(previousName || '');
|
||||
setAliases(previousAliases?.join(', ') || '');
|
||||
}, [previousName, previousAliases, _id]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() => previousName !== name || aliases !== previousAliases.join(', ') || !!emojiFile,
|
||||
[previousName, name, aliases, previousAliases, emojiFile],
|
||||
);
|
||||
|
||||
const { mutateAsync: saveAction } = useEndpointUploadMutation('/v1/emoji-custom.update', {
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Updated_Successfully') });
|
||||
queryClient.invalidateQueries({ queryKey: ['emoji-custom.list'] });
|
||||
onChange();
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name) {
|
||||
return setErrors((prevState) => ({ ...prevState, name: true }));
|
||||
}
|
||||
|
||||
if (name === aliases) {
|
||||
return setErrors((prevState) => ({ ...prevState, aliases: true }));
|
||||
}
|
||||
|
||||
if (!emojiFile && !newEmojiPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
emojiFile && formData.append('emoji', emojiFile);
|
||||
formData.append('_id', _id);
|
||||
formData.append('name', name);
|
||||
formData.append('aliases', aliases);
|
||||
await saveAction(formData);
|
||||
}, [emojiFile, _id, name, aliases, saveAction, newEmojiPreview]);
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const { mutateAsync: deleteAction } = useEndpointMutation('POST', '/v1/emoji-custom.delete', {
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Emoji_Has_Been_Deleted') });
|
||||
queryClient.invalidateQueries({ queryKey: ['emoji-custom.list'] });
|
||||
},
|
||||
onSettled: () => {
|
||||
onChange();
|
||||
setModal(null);
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteButtonClick = useCallback(() => {
|
||||
const handleDelete = async () => {
|
||||
await deleteAction({ emojiId: _id });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal variant='danger' onConfirm={handleDelete} onCancel={handleCancel} onClose={handleCancel} confirmText={t('Delete')}>
|
||||
{t('Custom_Emoji_Delete_Warning')}
|
||||
</GenericModal>,
|
||||
);
|
||||
}, [setModal, deleteAction, _id, t]);
|
||||
|
||||
const handleChangeAliases = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.currentTarget.value !== name) {
|
||||
setErrors((prevState) => ({ ...prevState, aliases: false }));
|
||||
}
|
||||
|
||||
return setAliases(e.currentTarget.value);
|
||||
},
|
||||
[setAliases, name],
|
||||
);
|
||||
|
||||
const [clickUpload] = useSingleFileInput(setEmojiFile, 'emoji');
|
||||
|
||||
const handleChangeName = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.currentTarget.value !== '') {
|
||||
setErrors((prevState) => ({ ...prevState, name: false }));
|
||||
}
|
||||
|
||||
return setName(e.currentTarget.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent {...props}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput value={name} onChange={handleChangeName} placeholder={t('Name')} />
|
||||
</FieldRow>
|
||||
{errors.name && <FieldError>{t('Required_field', { field: t('Name') })}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Aliases')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput value={aliases} onChange={handleChangeAliases} placeholder={t('Aliases')} />
|
||||
</FieldRow>
|
||||
{errors.aliases && <FieldError>{t('Custom_Emoji_Error_Same_Name_And_Alias')}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel alignSelf='stretch' display='flex' justifyContent='space-between' alignItems='center'>
|
||||
{t('Custom_Emoji')}
|
||||
<IconButton icon='upload' secondary onClick={clickUpload} />
|
||||
</FieldLabel>
|
||||
{newEmojiPreview && (
|
||||
<Box display='flex' flexDirection='row' marginBlockStart='none' justifyContent='center'>
|
||||
<Margins inline={4}>
|
||||
<Box is='img' objectFit='contain' width='x120' height='x120' src={newEmojiPreview} />
|
||||
</Margins>
|
||||
</Box>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={close}>{t('Cancel')}</Button>
|
||||
<Button primary onClick={handleSave} disabled={!hasUnsavedChanges}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Box marginBlockStart={8}>
|
||||
<ButtonGroup stretch>
|
||||
<Button icon='trash' danger onClick={handleDeleteButtonClick}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCustomEmoji;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Callout } from '@rocket.chat/fuselage';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EditCustomEmoji from './EditCustomEmoji';
|
||||
import { FormSkeleton } from '../../../components/Skeleton';
|
||||
|
||||
export type EditCustomEmojiWithDataProps = {
|
||||
_id: string;
|
||||
close: () => void;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
const EditCustomEmojiWithData = ({ _id, onChange, close, ...props }: EditCustomEmojiWithDataProps) => {
|
||||
const { t } = useTranslation();
|
||||
const query = useMemo(() => ({ _id }), [_id]);
|
||||
|
||||
const getEmojis = useEndpoint('GET', '/v1/emoji-custom.list');
|
||||
|
||||
const { data, isPending, error, refetch } = useQuery({
|
||||
queryKey: ['custom-emojis', query],
|
||||
|
||||
queryFn: async () => {
|
||||
const emoji = await getEmojis(query);
|
||||
return emoji;
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <FormSkeleton paddingInline={20} />;
|
||||
}
|
||||
|
||||
if (error || !data || !data.emojis || data.emojis.update.length < 1) {
|
||||
return <Callout title={t('Custom_Emoji_Error_Invalid_Emoji')} type='danger' />;
|
||||
}
|
||||
|
||||
const handleChange = (): void => {
|
||||
onChange?.();
|
||||
refetch?.();
|
||||
};
|
||||
|
||||
return <EditCustomEmoji data={data.emojis.update[0]} close={close} onChange={handleChange} {...props} />;
|
||||
};
|
||||
|
||||
export default EditCustomEmojiWithData;
|
||||
116
apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx
Normal file
116
apps/meteor/client/views/admin/customSounds/AddCustomSound.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Field, FieldLabel, FieldRow, TextInput, Box, Margins, Button, ButtonGroup, IconButton } from '@rocket.chat/fuselage';
|
||||
import { ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, type UploadResult } from '@rocket.chat/ui-contexts';
|
||||
import fileSize from 'filesize';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { validate } from './lib';
|
||||
import { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants';
|
||||
import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation';
|
||||
import { useSingleFileInput } from '../../../hooks/useSingleFileInput';
|
||||
|
||||
export type AddCustomSoundProps = {
|
||||
goToNew: (_id: string) => () => void;
|
||||
close: () => void;
|
||||
onChange: () => void;
|
||||
};
|
||||
|
||||
type CustomSoundCreateResult = UploadResult & {
|
||||
sound: {
|
||||
_id: string;
|
||||
};
|
||||
};
|
||||
|
||||
const AddCustomSound = ({ goToNew, close, onChange, ...props }: AddCustomSoundProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [sound, setSound] = useState<File | undefined>();
|
||||
|
||||
const { mutate: saveAction } = useEndpointUploadMutation<'/v1/custom-sounds.create', CustomSoundCreateResult>(
|
||||
'/v1/custom-sounds.create',
|
||||
{
|
||||
onSuccess: ({ sound }) => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') });
|
||||
onChange();
|
||||
goToNew(sound._id)();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleChangeFile = useCallback((soundFile: File) => {
|
||||
setSound(soundFile);
|
||||
}, []);
|
||||
|
||||
const [clickUpload] = useSingleFileInput(
|
||||
handleChangeFile,
|
||||
CUSTOM_SOUND_ALLOWED_MIME_TYPES.join(','),
|
||||
'audio',
|
||||
MAX_CUSTOM_SOUND_SIZE_BYTES,
|
||||
() => {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('File_exceeds_allowed_size_of_bytes', { size: fileSize(MAX_CUSTOM_SOUND_SIZE_BYTES, { base: 2, standard: 'jedec' }) }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmedName = name.trim();
|
||||
const validation = validate({ name: trimmedName }, sound) as Array<Parameters<typeof t>[0]>;
|
||||
if (validation.length > 0) {
|
||||
const firstInvalidField = validation[0];
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('Required_field', { field: t(firstInvalidField) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
if (sound) {
|
||||
formData.append('sound', sound);
|
||||
}
|
||||
formData.append('name', trimmedName);
|
||||
saveAction(formData);
|
||||
}, [sound, name, saveAction, t, dispatchToastMessage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent {...props}>
|
||||
<Field>
|
||||
<FieldLabel>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => setName(e.currentTarget.value)}
|
||||
placeholder={t('Name')}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel alignSelf='stretch'>{t('Sound File')}</FieldLabel>
|
||||
<Box display='flex' flexDirection='row' marginBlockStart='none' alignItems='center'>
|
||||
<Margins inline={4}>
|
||||
<IconButton secondary small icon='upload' onClick={clickUpload} />
|
||||
{sound?.name || t('None')}
|
||||
</Margins>
|
||||
</Box>
|
||||
</Field>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={close}>{t('Cancel')}</Button>
|
||||
<Button primary onClick={handleSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCustomSound;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarHeader,
|
||||
ContextualbarDialog,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageContent,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoute, useRouteParameter } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AddCustomSound from './AddCustomSound';
|
||||
import CustomSoundsTable from './CustomSoundsTable';
|
||||
import EditCustomSound from './EditCustomSound';
|
||||
|
||||
const CustomSoundsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const id = useRouteParameter('id');
|
||||
const route = useRoute('custom-sounds');
|
||||
const context = useRouteParameter('context');
|
||||
|
||||
const reload = useRef(() => null);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(_id: string) => (): void => {
|
||||
route.push({
|
||||
context: 'edit',
|
||||
id: _id,
|
||||
});
|
||||
},
|
||||
[route],
|
||||
);
|
||||
|
||||
const handleNewButtonClick = useCallback(() => {
|
||||
route.push({ context: 'new' });
|
||||
}, [route]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
route.push({});
|
||||
}, [route]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
reload.current();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page name='admin-custom-sounds'>
|
||||
<PageHeader title={t('Sounds')}>
|
||||
<Button primary onClick={handleNewButtonClick} aria-label={t('New')}>
|
||||
{t('New')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<CustomSoundsTable reload={reload} onClick={handleItemClick} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
{context && (
|
||||
<ContextualbarDialog onClose={handleClose}>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>
|
||||
{context === 'edit' && t('Custom_Sound_Edit')}
|
||||
{context === 'new' && t('Custom_Sound_Add')}
|
||||
</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={handleClose} />
|
||||
</ContextualbarHeader>
|
||||
{context === 'edit' && <EditCustomSound _id={id} close={handleClose} onChange={handleReload} />}
|
||||
{context === 'new' && <AddCustomSound goToNew={handleItemClick} close={handleClose} onChange={handleReload} />}
|
||||
</ContextualbarDialog>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSoundsPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import CustomSoundsPage from './CustomSoundsPage';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const CustomSoundsRoute = () => {
|
||||
const canManageCustomSounds = usePermission('manage-sounds');
|
||||
|
||||
if (!canManageCustomSounds) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return <CustomSoundsPage />;
|
||||
};
|
||||
|
||||
export default CustomSoundsRoute;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ICustomSound } from '@rocket.chat/core-typings';
|
||||
import { Box, IconButton } from '@rocket.chat/fuselage';
|
||||
import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client';
|
||||
import { useCustomSound } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type CustomSoundRowProps = {
|
||||
onClick: (soundId: ICustomSound['_id']) => () => void;
|
||||
sound: {
|
||||
name: string;
|
||||
_id: string;
|
||||
};
|
||||
};
|
||||
|
||||
const CustomSoundRow = ({ onClick, sound }: CustomSoundRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isPlay, setPlayAudio] = useState(false);
|
||||
const customSound = useCustomSound();
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(sound: ICustomSound['_id']) => {
|
||||
setPlayAudio(!isPlay);
|
||||
if (!isPlay) {
|
||||
return customSound.play(sound);
|
||||
}
|
||||
|
||||
return customSound.pause(sound);
|
||||
},
|
||||
[customSound, isPlay],
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericTableRow
|
||||
key={sound._id}
|
||||
onKeyDown={onClick(sound._id)}
|
||||
onClick={onClick(sound._id)}
|
||||
tabIndex={0}
|
||||
role='link'
|
||||
action
|
||||
qa-emoji-id={sound._id}
|
||||
>
|
||||
<GenericTableCell>
|
||||
<Box withTruncatedText>{sound.name}</Box>
|
||||
</GenericTableCell>
|
||||
<GenericTableCell>
|
||||
<IconButton
|
||||
icon={!isPlay ? 'play' : 'pause'}
|
||||
small
|
||||
aria-label={!isPlay ? t('Play') : t('Pause')}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggle(sound._id);
|
||||
}}
|
||||
/>
|
||||
</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSoundRow;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import * as stories from './CustomSoundsTable.stories';
|
||||
|
||||
const mockSounds = Array.from({ length: 25 }, (_, i) => ({
|
||||
_id: `sound-${i}`,
|
||||
name: `Custom Sound ${i + 1}`,
|
||||
extension: 'mp3',
|
||||
_updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
}));
|
||||
|
||||
const getMockedAppRoot = () =>
|
||||
mockAppRoot().withEndpoint('GET', '/v1/custom-sounds.list', () => ({
|
||||
sounds: mockSounds,
|
||||
total: 50,
|
||||
count: 25,
|
||||
offset: 0,
|
||||
}));
|
||||
|
||||
test('should enable pagination when data.total exceeds itemsPerPage', async () => {
|
||||
const { Default } = composeStories(stories);
|
||||
render(<Default />, { wrapper: getMockedAppRoot().build() });
|
||||
|
||||
const firstSound = await screen.findByText('Custom Sound 1');
|
||||
expect(firstSound).toBeInTheDocument();
|
||||
|
||||
const nextPageButton = screen.getByRole('button', { name: 'Next page' });
|
||||
expect(nextPageButton).toBeInTheDocument();
|
||||
expect(nextPageButton).not.toBeDisabled();
|
||||
|
||||
const pageTwoButton = screen.getByRole('button', { name: 'Page 2' });
|
||||
expect(pageTwoButton).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Margins } from '@rocket.chat/fuselage';
|
||||
import { PageContent } from '@rocket.chat/ui-client';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { useRef } from 'react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import CustomSoundsTable from './CustomSoundsTable';
|
||||
|
||||
export default {
|
||||
component: CustomSoundsTable,
|
||||
decorators: [
|
||||
(fn) => (
|
||||
<PageContent marginBlock='neg-x8'>
|
||||
<Margins block={8}>{fn()}</Margins>
|
||||
</PageContent>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof CustomSoundsTable>;
|
||||
|
||||
const Template: StoryFn<typeof CustomSoundsTable> = (args) => {
|
||||
const reloadRef = useRef(() => undefined);
|
||||
return <CustomSoundsTable {...args} reload={reloadRef} />;
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
render: Template,
|
||||
|
||||
args: {
|
||||
onClick: () => action('clicked'),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Pagination, States, StatesIcon, StatesActions, StatesAction, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableBody,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableLoadingTable,
|
||||
usePagination,
|
||||
useSort,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
|
||||
import CustomSoundRow from './CustomSoundRow';
|
||||
import FilterByText from '../../../../components/FilterByText';
|
||||
import GenericNoResults from '../../../../components/GenericNoResults';
|
||||
|
||||
export type CustomSoundsTableProps = {
|
||||
onClick: (soundId: string) => () => void;
|
||||
reload: MutableRefObject<() => void>;
|
||||
};
|
||||
|
||||
const CustomSoundsTable = ({ reload, onClick }: CustomSoundsTableProps) => {
|
||||
const t = useTranslation();
|
||||
const { sortBy, sortDirection, setSort } = useSort<'name'>('name');
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const query = useDebouncedValue(
|
||||
useMemo(
|
||||
() => ({
|
||||
name: text,
|
||||
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
|
||||
...(itemsPerPage && { count: itemsPerPage }),
|
||||
...(current && { offset: current }),
|
||||
}),
|
||||
[text, itemsPerPage, current, sortBy, sortDirection],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const getSounds = useEndpoint('GET', '/v1/custom-sounds.list');
|
||||
const { data, refetch, isLoading, isError, isSuccess } = useQuery({
|
||||
queryKey: ['custom-sounds', query],
|
||||
queryFn: async () => getSounds(query),
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reload.current = refetch;
|
||||
}, [reload, refetch]);
|
||||
|
||||
const headers = (
|
||||
<>
|
||||
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
|
||||
{t('Name')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell width='x40' key='action' />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterByText value={text} onChange={(event) => setText(event.target.value)} />
|
||||
{isLoading && (
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
<GenericTableLoadingTable headerCells={2} />
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
)}
|
||||
{isSuccess && data?.sounds.length > 0 && (
|
||||
<>
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{data.sounds.map((sound) => (
|
||||
<CustomSoundRow key={sound._id} sound={sound} onClick={onClick} />
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data?.total || 0}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSuccess && data?.sounds.length === 0 && <GenericNoResults />}
|
||||
{isError && (
|
||||
<States>
|
||||
<StatesIcon name='warning' variation='danger' />
|
||||
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomSoundsTable;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CustomSoundsTable';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ContextualbarEmptyContent } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EditSound from './EditSound';
|
||||
import { FormSkeleton } from '../../../components/Skeleton';
|
||||
|
||||
export type EditCustomSoundProps = {
|
||||
_id: string | undefined;
|
||||
onChange?: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function EditCustomSound({ _id, onChange, close, ...props }: EditCustomSoundProps) {
|
||||
const getSound = useEndpoint('GET', '/v1/custom-sounds.getOne');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['custom-sound', _id],
|
||||
queryFn: () => {
|
||||
if (!_id) {
|
||||
throw new Error('Cannot fetch custom sound: missing _id in query.');
|
||||
}
|
||||
return getSound({ _id });
|
||||
},
|
||||
enabled: !!_id,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <FormSkeleton paddingInline={20} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <ContextualbarEmptyContent icon='warning' title={t('No_results_found')} />;
|
||||
}
|
||||
|
||||
const handleChange: () => void = () => {
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
return <EditSound data={data.sound} close={close} onChange={handleChange} {...props} />;
|
||||
}
|
||||
|
||||
export default EditCustomSound;
|
||||
154
apps/meteor/client/views/admin/customSounds/EditSound.tsx
Normal file
154
apps/meteor/client/views/admin/customSounds/EditSound.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Box, Button, ButtonGroup, Margins, TextInput, Field, FieldLabel, FieldRow, IconButton } from '@rocket.chat/fuselage';
|
||||
import { GenericModal, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import fileSize from 'filesize';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { validate } from './lib';
|
||||
import { CUSTOM_SOUND_ALLOWED_MIME_TYPES, MAX_CUSTOM_SOUND_SIZE_BYTES } from '../../../../lib/constants';
|
||||
import { useEndpointUploadMutation } from '../../../hooks/useEndpointUploadMutation';
|
||||
import { useSingleFileInput } from '../../../hooks/useSingleFileInput';
|
||||
|
||||
export type EditSoundProps = {
|
||||
close: () => void;
|
||||
onChange: () => void;
|
||||
data: {
|
||||
_id: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
};
|
||||
};
|
||||
|
||||
function EditSound({ close, onChange, data, ...props }: EditSoundProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const { _id, name: previousName } = data || {};
|
||||
|
||||
const [name, setName] = useState(() => data?.name ?? '');
|
||||
const [file, setFile] = useState<File | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setName(previousName || '');
|
||||
setFile(undefined);
|
||||
}, [_id, previousName]);
|
||||
|
||||
const deleteCustomSoundEndpoint = useEndpoint('POST', '/v1/custom-sounds.delete');
|
||||
|
||||
const { mutate: saveAction } = useEndpointUploadMutation('/v1/custom-sounds.update', {
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Saved_Successfully') });
|
||||
onChange();
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangeFile = useCallback((soundFile: File) => {
|
||||
setFile(soundFile);
|
||||
}, []);
|
||||
|
||||
const hasUnsavedChanges = useMemo(() => previousName !== name || !!file, [name, previousName, file]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmedName = name.trim();
|
||||
const validation = validate({ _id, name: trimmedName }, file);
|
||||
if (validation.length > 0) {
|
||||
const firstInvalidField = validation[0];
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('Required_field', { field: t(firstInvalidField) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_id', _id);
|
||||
formData.append('name', trimmedName);
|
||||
if (file) {
|
||||
formData.append('sound', file);
|
||||
}
|
||||
saveAction(formData);
|
||||
}, [_id, dispatchToastMessage, name, saveAction, file, t]);
|
||||
|
||||
const handleDeleteButtonClick = useCallback(() => {
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteCustomSoundEndpoint({ _id });
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_Sound_Has_Been_Deleted') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
close?.();
|
||||
onChange();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (): void => setModal(null);
|
||||
|
||||
setModal(
|
||||
<GenericModal variant='danger' onConfirm={handleDelete} onCancel={handleCancel} onClose={handleCancel} confirmText={t('Delete')}>
|
||||
{t('Custom_Sound_Delete_Warning')}
|
||||
</GenericModal>,
|
||||
);
|
||||
}, [_id, close, deleteCustomSoundEndpoint, dispatchToastMessage, onChange, setModal, t]);
|
||||
|
||||
const [clickUpload] = useSingleFileInput(
|
||||
handleChangeFile,
|
||||
CUSTOM_SOUND_ALLOWED_MIME_TYPES.join(','),
|
||||
'audio',
|
||||
MAX_CUSTOM_SOUND_SIZE_BYTES,
|
||||
() => {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('File_exceeds_allowed_size_of_bytes', { size: fileSize(MAX_CUSTOM_SOUND_SIZE_BYTES, { base: 2, standard: 'jedec' }) }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent {...props}>
|
||||
<Field>
|
||||
<FieldLabel>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => setName(e.currentTarget.value)}
|
||||
placeholder={t('Name')}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel alignSelf='stretch'>{t('Sound File')}</FieldLabel>
|
||||
<Box display='flex' flexDirection='row' marginBlockStart='none' alignItems='center'>
|
||||
<Margins inline={4}>
|
||||
<IconButton secondary small icon='upload' onClick={clickUpload} />
|
||||
{file?.name || (data?.name && data?.extension && `${data.name}.${data.extension}`) || t('None')}
|
||||
</Margins>
|
||||
</Box>
|
||||
</Field>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={close}>{t('Cancel')}</Button>
|
||||
<Button primary onClick={handleSave} disabled={!hasUnsavedChanges}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Box marginBlockStart={8}>
|
||||
<ButtonGroup stretch>
|
||||
<Button icon='trash' danger onClick={handleDeleteButtonClick}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSound;
|
||||
25
apps/meteor/client/views/admin/customSounds/lib.ts
Normal file
25
apps/meteor/client/views/admin/customSounds/lib.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CUSTOM_SOUND_ALLOWED_MIME_TYPES } from '../../../../lib/constants';
|
||||
|
||||
type ClientCustomSoundData = {
|
||||
_id?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// Here previousData will define if it is an update or a new entry
|
||||
export function validate(soundData: ClientCustomSoundData, soundFile?: File): ('Name' | 'Sound File' | 'FileType')[] {
|
||||
const errors: ('Name' | 'Sound File' | 'FileType')[] = [];
|
||||
|
||||
if (!soundData.name) {
|
||||
errors.push('Name');
|
||||
}
|
||||
|
||||
if (!soundData._id && !soundFile) {
|
||||
errors.push('Sound File');
|
||||
}
|
||||
|
||||
if (soundFile && !CUSTOM_SOUND_ALLOWED_MIME_TYPES.includes(soundFile.type)) {
|
||||
errors.push('FileType');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { GenericResourceUsage, GenericResourceUsageSkeleton } from '../../../components/GenericResourceUsage';
|
||||
import { useActiveConnections } from '../../hooks/useActiveConnections';
|
||||
|
||||
const CustomUserActiveConnections = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const result = useActiveConnections();
|
||||
|
||||
if (result.isPending || result.isError) {
|
||||
return <GenericResourceUsageSkeleton title={t('Active_connections')} />;
|
||||
}
|
||||
|
||||
const { current, max, percentage } = result.data;
|
||||
|
||||
return <GenericResourceUsage title={t('Active_connections')} value={current} max={max} percentage={percentage} />;
|
||||
};
|
||||
|
||||
export default CustomUserActiveConnections;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type CustomUserStatusDisabledModalProps = { isAdmin: boolean; onConfirm: () => void; onClose: () => void };
|
||||
|
||||
const CustomUserStatusDisabledModal = ({ isAdmin, onConfirm, onClose }: CustomUserStatusDisabledModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
return isAdmin ? (
|
||||
<GenericModal
|
||||
title={t('User_status_disabled_learn_more')}
|
||||
cancelText={t('Close')}
|
||||
confirmText={t('Go_to_workspace_settings')}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
icon={null}
|
||||
variant='warning'
|
||||
>
|
||||
{t('User_status_disabled_learn_more_description')}
|
||||
</GenericModal>
|
||||
) : (
|
||||
<GenericModal title={t('User_status_disabled_learn_more')} confirmText={t('Close')} onConfirm={onConfirm} onClose={onClose} icon={null}>
|
||||
{t('User_status_disabled_learn_more_description')}
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatusDisabledModal;
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { IUserStatus } from '@rocket.chat/core-typings';
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { FieldGroup, Button, ButtonGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, Select, Box } from '@rocket.chat/fuselage';
|
||||
import { GenericModal, ContextualbarScrollableContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useRoute, useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useId, useCallback } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
|
||||
type CustomUserStatusFormFormData = {
|
||||
name: string;
|
||||
statusType: string;
|
||||
};
|
||||
|
||||
export type CustomUserStatusFormProps = {
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
status?: IUserStatus;
|
||||
};
|
||||
|
||||
const CustomUserStatusForm = ({ onClose, onReload, status }: CustomUserStatusFormProps) => {
|
||||
const t = useTranslation();
|
||||
const { _id } = status || {};
|
||||
const setModal = useSetModal();
|
||||
const route = useRoute('user-status');
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const formId = useId();
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<CustomUserStatusFormFormData>({
|
||||
defaultValues: { name: status?.name ?? '', statusType: status?.statusType ?? '' },
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const createStatus = useEndpoint('POST', '/v1/custom-user-status.create');
|
||||
const updateStatus = useEndpoint('POST', '/v1/custom-user-status.update');
|
||||
const deleteStatus = useEndpoint('POST', '/v1/custom-user-status.delete');
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (data: CustomUserStatusFormFormData) => {
|
||||
try {
|
||||
if (status?._id) {
|
||||
await updateStatus({ _id: status?._id, ...data });
|
||||
} else {
|
||||
await createStatus({ ...data });
|
||||
}
|
||||
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Custom_User_Status_Updated_Successfully'),
|
||||
});
|
||||
|
||||
onReload();
|
||||
route.push({});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[status?._id, dispatchToastMessage, t, onReload, route, updateStatus, createStatus],
|
||||
);
|
||||
|
||||
const handleDeleteStatus = useCallback(() => {
|
||||
const handleCancel = (): void => {
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteStatus({ customUserStatusId: status?._id ?? '' });
|
||||
dispatchToastMessage({ type: 'success', message: t('Custom_User_Status_Has_Been_Deleted') });
|
||||
onReload();
|
||||
route.push({});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal variant='danger' onConfirm={handleDelete} onCancel={handleCancel} onClose={handleCancel} confirmText={t('Delete')}>
|
||||
{t('Custom_User_Status_Delete_Warning')}
|
||||
</GenericModal>,
|
||||
);
|
||||
}, [status?._id, route, deleteStatus, dispatchToastMessage, onReload, setModal, t]);
|
||||
|
||||
const presenceOptions: SelectOption[] = [
|
||||
['online', t('Online')],
|
||||
['busy', t('Busy')],
|
||||
['away', t('Away')],
|
||||
['offline', t('Offline')],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarScrollableContent>
|
||||
<FieldGroup id={formId} is='form' onSubmit={handleSubmit(handleSave)}>
|
||||
<Field>
|
||||
<FieldLabel>{t('Name')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput {...register('name', { required: t('Required_field', { field: t('Name') }) })} placeholder={t('Name')} />
|
||||
</FieldRow>
|
||||
{errors.name && <FieldError>{errors.name.message}</FieldError>}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t('Presence')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='statusType'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Presence') }) }}
|
||||
render={({ field }) => <Select {...field} placeholder={t('Presence')} options={presenceOptions} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.statusType && <FieldError>{errors.statusType.message}</FieldError>}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={onClose}>{t('Cancel')}</Button>
|
||||
<Button form={formId} primary type='submit'>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{_id && (
|
||||
<Box marginBlockStart={8}>
|
||||
<ButtonGroup stretch>
|
||||
<Button icon='trash' danger onClick={handleDeleteStatus}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatusForm;
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { IUserStatus } from '@rocket.chat/core-typings';
|
||||
import { Box, Callout } from '@rocket.chat/fuselage';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CustomUserStatusForm from './CustomUserStatusForm';
|
||||
import { FormSkeleton } from '../../../components/Skeleton';
|
||||
|
||||
export type CustomUserStatusFormWithDataProps = {
|
||||
_id?: IUserStatus['_id'];
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
|
||||
const CustomUserStatusFormWithData = ({ _id, onReload, onClose }: CustomUserStatusFormWithDataProps) => {
|
||||
const { t } = useTranslation();
|
||||
const query = useMemo(() => ({ _id }), [_id]);
|
||||
|
||||
const getCustomUserStatus = useEndpoint('GET', '/v1/custom-user-status.list');
|
||||
|
||||
const { data, isPending, error, refetch } = useQuery({
|
||||
queryKey: ['custom-user-statuses', query],
|
||||
|
||||
queryFn: async () => {
|
||||
const customUserStatus = await getCustomUserStatus(query);
|
||||
return customUserStatus;
|
||||
},
|
||||
});
|
||||
|
||||
const handleReload = (): void => {
|
||||
onReload?.();
|
||||
refetch?.();
|
||||
};
|
||||
|
||||
if (!_id) {
|
||||
return <CustomUserStatusForm onReload={handleReload} onClose={onClose} />;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <FormSkeleton paddingInline={20} />;
|
||||
}
|
||||
|
||||
if (error || !data || data.count < 1) {
|
||||
return (
|
||||
<Box padding={20}>
|
||||
<Callout type='danger'>{t('Custom_User_Status_Error_Invalid_User_Status')}</Callout>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomUserStatusForm status={data.statuses[0]} onReload={handleReload} onClose={onClose} />;
|
||||
};
|
||||
|
||||
export default CustomUserStatusFormWithData;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarClose,
|
||||
ContextualbarTitle,
|
||||
ContextualbarDialog,
|
||||
Page,
|
||||
PageHeader,
|
||||
PageContent,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoute, useRouteParameter, usePermission, useTranslation, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
import CustomUserActiveConnections from './CustomUserActiveConnections';
|
||||
import CustomUserStatusFormWithData from './CustomUserStatusFormWithData';
|
||||
import CustomUserStatusService from './CustomUserStatusService';
|
||||
import CustomUserStatusTable from './CustomUserStatusTable';
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const CustomUserStatusRoute = () => {
|
||||
const t = useTranslation();
|
||||
const route = useRoute('user-status');
|
||||
const context = useRouteParameter('context');
|
||||
const id = useRouteParameter('id');
|
||||
const canManageUserStatus = usePermission('manage-user-status');
|
||||
const { data: license } = useIsEnterprise();
|
||||
const presenceDisabled = useSetting('Presence_broadcast_disabled', false);
|
||||
|
||||
useEffect(() => {
|
||||
presenceDisabled && route.push({ context: 'presence-service' });
|
||||
}, [presenceDisabled, route]);
|
||||
|
||||
const handleItemClick = (id: string): void => {
|
||||
route.push({
|
||||
context: 'edit',
|
||||
id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewButtonClick = useCallback(() => {
|
||||
route.push({ context: 'new' });
|
||||
}, [route]);
|
||||
|
||||
const handlePresenceServiceClick = useCallback(() => {
|
||||
route.push({ context: 'presence-service' });
|
||||
}, [route]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
route.push({});
|
||||
}, [route]);
|
||||
|
||||
const reload = useRef(() => null);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
reload.current();
|
||||
}, [reload]);
|
||||
|
||||
if (!canManageUserStatus) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page name='admin-user-status'>
|
||||
<PageHeader title={t('User_Status')}>
|
||||
{!license?.isEnterprise && <CustomUserActiveConnections />}
|
||||
<ButtonGroup>
|
||||
<Button onClick={handlePresenceServiceClick}>{t('Presence_service')}</Button>
|
||||
<Button onClick={handleNewButtonClick}>{t('New_custom_status')}</Button>
|
||||
</ButtonGroup>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<CustomUserStatusTable reload={reload} onClick={handleItemClick} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
{context && (
|
||||
<ContextualbarDialog onClose={handleClose}>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>
|
||||
{context === 'edit' && t('Custom_User_Status_Edit')}
|
||||
{context === 'new' && t('Custom_User_Status_Add')}
|
||||
{context === 'presence-service' && t('Presence_service_cap')}
|
||||
</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={handleClose} />
|
||||
</ContextualbarHeader>
|
||||
{context === 'presence-service' && <CustomUserStatusService />}
|
||||
{(context === 'new' || context === 'edit') && (
|
||||
<CustomUserStatusFormWithData _id={id} onClose={handleClose} onReload={handleReload} />
|
||||
)}
|
||||
</ContextualbarDialog>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatusRoute;
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Callout,
|
||||
Margins,
|
||||
ProgressBar,
|
||||
Skeleton,
|
||||
StatesAction,
|
||||
StatesIcon,
|
||||
StatesSubtitle,
|
||||
ToggleSwitch,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { ContextualbarContent, ContextualbarFooter } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { useIsEnterprise } from '../../../hooks/useIsEnterprise';
|
||||
import { links } from '../../../lib/links';
|
||||
import { useActiveConnections } from '../../hooks/useActiveConnections';
|
||||
|
||||
const CustomUserStatusService = () => {
|
||||
const { t } = useTranslation();
|
||||
const result = useActiveConnections();
|
||||
const presenceDisabled = useSetting('Presence_broadcast_disabled', false);
|
||||
const togglePresenceServiceEndpoint = useEndpoint('POST', '/v1/presence.enableBroadcast');
|
||||
const disablePresenceService = useMutation({
|
||||
mutationFn: () => togglePresenceServiceEndpoint(),
|
||||
});
|
||||
const { data: license, isPending: licenseIsLoading } = useIsEnterprise();
|
||||
|
||||
if (result.isPending || disablePresenceService.isPending || licenseIsLoading) {
|
||||
return (
|
||||
<Box paddingInline={16} paddingBlock={8}>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (result.isError || disablePresenceService.isError) {
|
||||
return (
|
||||
<Box display='flex' flexDirection='column' alignItems='center' paddingBlock={20} color='default'>
|
||||
<StatesIcon name='circle-exclamation' />
|
||||
<StatesSubtitle>{t('Unable_to_load_active_connections')}</StatesSubtitle>
|
||||
<StatesAction icon='reload' onClick={() => result.refetch()}>
|
||||
{t('Retry')}
|
||||
</StatesAction>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { current, max, percentage } = result.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarContent display='flex' flexDirection='column' justifyContent='space-between' color='default'>
|
||||
<div>
|
||||
<Box display='flex' justifyContent='space-between' marginBlock={16}>
|
||||
<Box fontScale='p1'>{t('Service_status')}</Box>
|
||||
<ToggleSwitch
|
||||
disabled={disablePresenceService.isPending || !presenceDisabled || percentage === 100}
|
||||
checked={!presenceDisabled}
|
||||
onChange={() => disablePresenceService.mutate()}
|
||||
/>
|
||||
</Box>
|
||||
<Box display='flex' fontScale='c1' justifyContent='space-between' marginBlock={16}>
|
||||
<Box>{t('Active_connections')}</Box>
|
||||
<Box>{license?.isEnterprise ? current : `${current}/${max}`}</Box>
|
||||
</Box>
|
||||
{!license?.isEnterprise && <ProgressBar percentage={percentage} variant={percentage > 80 ? 'danger' : 'success'} />}
|
||||
{presenceDisabled && (
|
||||
<Margins block={16}>
|
||||
<Callout type='danger' title={t('Service_disabled')}>
|
||||
{t('Service_disabled_description')}
|
||||
</Callout>
|
||||
</Margins>
|
||||
)}
|
||||
</div>
|
||||
<Box display='flex' flexDirection='column' marginBlock={16}>
|
||||
{license?.isEnterprise ? (
|
||||
<>
|
||||
<Box fontScale='p2' marginBlock={8}>
|
||||
{t('Premium_cap_description')}
|
||||
</Box>
|
||||
<Box fontScale='p2' marginBlock={8}>
|
||||
<Trans i18nKey='Larger_amounts_of_active_connections'>
|
||||
For larger amounts of active connections you can consider our
|
||||
<Box is='a' href={links.scaling} target='_blank' color='info'>
|
||||
multiple instance solutions
|
||||
</Box>
|
||||
.
|
||||
</Trans>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box fontScale='p2' marginBlock={8}>
|
||||
{t('Community_cap_description')}
|
||||
</Box>
|
||||
<Box fontScale='p2' marginBlock={8}>
|
||||
{t('Premium_cap_description')}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</ContextualbarContent>
|
||||
{!license?.isEnterprise && (
|
||||
<ContextualbarFooter borderBlockStartWidth='default' borderBlockColor='extra-light'>
|
||||
<ButtonGroup stretch vertical>
|
||||
<Button primary width='100%' is='a' href={links.enterprise} target='_blank'>
|
||||
{t('More_about_Premium_plans')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatusService;
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IUserStatus } from '@rocket.chat/core-typings';
|
||||
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import MarkdownText from '../../../../components/MarkdownText';
|
||||
|
||||
const style: CSSProperties = { whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' };
|
||||
|
||||
export type CustomUserStatusRowProps = {
|
||||
status: IUserStatus;
|
||||
onClick: (id: string) => void;
|
||||
};
|
||||
|
||||
const CustomUserStatusRow = ({ status, onClick }: CustomUserStatusRowProps) => {
|
||||
const { _id, name, statusType } = status;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GenericTableRow key={_id} onKeyDown={() => onClick(_id)} onClick={() => onClick(_id)} tabIndex={0} action>
|
||||
<GenericTableCell fontScale='p2' color='default' style={style}>
|
||||
<MarkdownText content={name} parseEmoji={true} variant='inline' />
|
||||
</GenericTableCell>
|
||||
<GenericTableCell fontScale='p2' color='default' style={style}>
|
||||
{t(statusType)}
|
||||
</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatusRow;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Pagination } from '@rocket.chat/fuselage';
|
||||
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableBody,
|
||||
GenericTableLoadingTable,
|
||||
usePagination,
|
||||
useSort,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CustomUserStatusRow from './CustomUserStatusRow';
|
||||
import FilterByText from '../../../../components/FilterByText';
|
||||
import GenericNoResult from '../../../../components/GenericNoResults';
|
||||
|
||||
export type CustomUserStatusProps = {
|
||||
reload: MutableRefObject<() => void>;
|
||||
onClick: (id: string) => void;
|
||||
};
|
||||
|
||||
// TODO: Missing error state
|
||||
const CustomUserStatus = ({ reload, onClick }: CustomUserStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
const { sortBy, sortDirection, setSort } = useSort<'name' | 'statusType'>('name');
|
||||
|
||||
const query = useDebouncedValue(
|
||||
useMemo(
|
||||
() => ({
|
||||
name: escapeRegExp(text),
|
||||
sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`,
|
||||
count: itemsPerPage,
|
||||
offset: current,
|
||||
}),
|
||||
[text, itemsPerPage, current, sortBy, sortDirection],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const getCustomUserStatus = useEndpoint('GET', '/v1/custom-user-status.list');
|
||||
|
||||
const { data, isLoading, refetch, isFetched } = useQuery({
|
||||
queryKey: ['custom-user-statuses', query],
|
||||
|
||||
queryFn: async () => {
|
||||
const { statuses } = await getCustomUserStatus(query);
|
||||
return statuses;
|
||||
},
|
||||
meta: {
|
||||
apiErrorToastMessage: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reload.current = refetch;
|
||||
}, [reload, refetch]);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterByText value={text} onChange={(event) => setText(event.target.value)} />
|
||||
{data.length === 0 && <GenericNoResult />}
|
||||
{data && data.length > 0 && (
|
||||
<>
|
||||
<GenericTable>
|
||||
<GenericTableHeader>
|
||||
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort} sort='name'>
|
||||
{t('Name')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell
|
||||
key='presence'
|
||||
direction={sortDirection}
|
||||
active={sortBy === 'statusType'}
|
||||
onClick={setSort}
|
||||
sort='statusType'
|
||||
>
|
||||
{t('Presence')}
|
||||
</GenericTableHeaderCell>
|
||||
</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{isLoading && <GenericTableLoadingTable headerCells={2} />}
|
||||
{data?.map((status) => (
|
||||
<CustomUserStatusRow key={status._id} status={status} onClick={onClick} />
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
{isFetched && (
|
||||
<Pagination
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data.length}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomUserStatus;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CustomUserStatusTable';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRole, useRoute, useSetModal } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import CustomUserStatusDisabledModal from '../CustomUserStatusDisabledModal';
|
||||
|
||||
export const useStatusDisabledModal = () => {
|
||||
const userStatusRoute = useRoute('user-status');
|
||||
const setModal = useSetModal();
|
||||
const closeModal = useStableCallback(() => setModal());
|
||||
const handleGoToSettings = useStableCallback(() => {
|
||||
userStatusRoute.push({ context: 'presence-service' });
|
||||
closeModal();
|
||||
});
|
||||
const isAdmin = useRole('admin');
|
||||
|
||||
const handleSetModal = () => {
|
||||
setModal(<CustomUserStatusDisabledModal isAdmin={isAdmin} onConfirm={handleGoToSettings} onClose={closeModal} />);
|
||||
};
|
||||
|
||||
return handleSetModal;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ContextualbarDialog, Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DeviceManagementAdminTable from './DeviceManagementAdminTable';
|
||||
import DeviceManagementInfo from './DeviceManagementInfo';
|
||||
|
||||
const DeviceManagementAdminPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const context = useRouteParameter('context');
|
||||
const deviceId = useRouteParameter('id');
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page>
|
||||
<PageHeader title={t('Device_Management')} />
|
||||
<PageContent>
|
||||
<DeviceManagementAdminTable />
|
||||
</PageContent>
|
||||
</Page>
|
||||
{context === 'info' && deviceId && (
|
||||
<ContextualbarDialog onClose={() => router.navigate('/admin/device-management')}>
|
||||
<DeviceManagementInfo deviceId={deviceId} />
|
||||
</ContextualbarDialog>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceManagementAdminPage;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { usePermission, useRouter, useSetModal, useCurrentModal } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DeviceManagementAdminPage from './DeviceManagementAdminPage';
|
||||
import GenericUpsellModal from '../../../components/GenericUpsellModal';
|
||||
import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks';
|
||||
import PageSkeleton from '../../../components/PageSkeleton';
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
import { getURL } from '../../../lib/getURL';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const DeviceManagementAdminRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const setModal = useSetModal();
|
||||
const isModalOpen = !!useCurrentModal();
|
||||
|
||||
const { data: hasDeviceManagement = false, isPending } = useHasLicenseModule('device-management');
|
||||
const canViewDeviceManagement = usePermission('view-device-management');
|
||||
|
||||
const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasDeviceManagement);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowUpsell) {
|
||||
setModal(
|
||||
<GenericUpsellModal
|
||||
aria-label={t('Device_Management')}
|
||||
title={t('Device_Management')}
|
||||
img={getURL('images/device-management.png')}
|
||||
subtitle={t('Ensure_secure_workspace_access')}
|
||||
description={t('Manage_which_devices')}
|
||||
onClose={() => setModal(null)}
|
||||
onConfirm={handleManageSubscription}
|
||||
onCancel={() => setModal(null)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}, [shouldShowUpsell, router, setModal, t, handleManageSubscription]);
|
||||
|
||||
if (isModalOpen || isPending) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (!canViewDeviceManagement || !hasDeviceManagement) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return <DeviceManagementAdminPage />;
|
||||
};
|
||||
|
||||
export default DeviceManagementAdminRoute;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useMediaQuery, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { GenericMenu, GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
|
||||
import { useRoute } from '@rocket.chat/ui-contexts';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DeviceIcon from '../../../../components/deviceManagement/DeviceIcon';
|
||||
import { useDeviceLogout } from '../../../../hooks/useDeviceLogout';
|
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
||||
|
||||
type DeviceRowProps = {
|
||||
_id: string;
|
||||
username?: string;
|
||||
ip: string;
|
||||
deviceName?: string;
|
||||
deviceType?: string;
|
||||
deviceOSName?: string;
|
||||
loginAt: string;
|
||||
rcVersion?: string;
|
||||
};
|
||||
|
||||
const DeviceManagementAdminRow = ({
|
||||
_id,
|
||||
username,
|
||||
ip,
|
||||
deviceName,
|
||||
deviceType = 'browser',
|
||||
deviceOSName = '',
|
||||
loginAt,
|
||||
rcVersion,
|
||||
}: DeviceRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deviceManagementRouter = useRoute('device-management');
|
||||
const formatDateAndTime = useFormatDateAndTime();
|
||||
const mediaQuery = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const handleDeviceLogout = useDeviceLogout(_id, '/v1/sessions/logout');
|
||||
|
||||
const handleClick = useStableCallback((): void => {
|
||||
deviceManagementRouter.push({
|
||||
context: 'info',
|
||||
id: _id,
|
||||
});
|
||||
});
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLOrSVGElement>): void => {
|
||||
if (!['Enter', 'Space'].includes(e.nativeEvent.code)) {
|
||||
return;
|
||||
}
|
||||
handleClick();
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
const menuItems: GenericMenuItemProps[] = [
|
||||
{
|
||||
id: 'logoutDevice',
|
||||
icon: 'sign-out',
|
||||
content: t('Logout_Device'),
|
||||
onClick: () => handleDeviceLogout(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<GenericTableRow key={_id} onKeyDown={handleKeyDown} onClick={handleClick} tabIndex={0} action aria-label={_id}>
|
||||
<GenericTableCell>
|
||||
<Box display='flex' alignItems='center'>
|
||||
<DeviceIcon deviceType={deviceType} />
|
||||
{deviceName && <Box withTruncatedText>{deviceName}</Box>}
|
||||
</Box>
|
||||
</GenericTableCell>
|
||||
<GenericTableCell>{rcVersion}</GenericTableCell>
|
||||
<GenericTableCell>{deviceOSName}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{username}</GenericTableCell>
|
||||
{mediaQuery && <GenericTableCell>{formatDateAndTime(loginAt)}</GenericTableCell>}
|
||||
{mediaQuery && <GenericTableCell withTruncatedText>{_id}</GenericTableCell>}
|
||||
{mediaQuery && <GenericTableCell withTruncatedText>{ip}</GenericTableCell>}
|
||||
<GenericTableCell onClick={(e) => e.stopPropagation()}>
|
||||
<GenericMenu detached placement='bottom-end' title={t('Options')} items={menuItems} />
|
||||
</GenericTableCell>
|
||||
</GenericTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceManagementAdminRow;
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { DeviceManagementPopulatedSession, DeviceManagementSession, Serialized } from '@rocket.chat/core-typings';
|
||||
import { useDebouncedValue, useMediaQuery } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericTableHeaderCell, usePagination, useSort } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DeviceManagementAdminRow from './DeviceManagementAdminRow';
|
||||
import FilterByText from '../../../../components/FilterByText';
|
||||
import DeviceManagementTable from '../../../../components/deviceManagement/DeviceManagementTable';
|
||||
import { deviceManagementQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
const sortMapping = {
|
||||
client: 'device.name',
|
||||
username: '_user.username',
|
||||
os: 'device.os.name',
|
||||
loginAt: 'loginAt',
|
||||
};
|
||||
|
||||
const isSessionPopulatedSession = (
|
||||
session: Serialized<DeviceManagementPopulatedSession | DeviceManagementSession>,
|
||||
): session is Serialized<DeviceManagementPopulatedSession> => '_user' in session;
|
||||
|
||||
const DeviceManagementAdminTable = () => {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState('');
|
||||
const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = usePagination();
|
||||
const { sortBy, sortDirection, setSort } = useSort<'client' | 'os' | 'username' | 'loginAt'>('username');
|
||||
|
||||
const query = useDebouncedValue(
|
||||
useMemo(
|
||||
() => ({
|
||||
filter: text,
|
||||
sort: JSON.stringify({ [sortMapping[sortBy]]: sortDirection === 'asc' ? 1 : -1 }),
|
||||
count: itemsPerPage,
|
||||
offset: text ? undefined : current,
|
||||
}),
|
||||
[text, itemsPerPage, current, sortBy, sortDirection],
|
||||
),
|
||||
500,
|
||||
);
|
||||
|
||||
const listAllSessions = useEndpoint('GET', '/v1/sessions/list.all');
|
||||
const queryResult = useQuery({
|
||||
queryKey: deviceManagementQueryKeys.sessions(query),
|
||||
queryFn: () => listAllSessions(query),
|
||||
});
|
||||
|
||||
const mediaQuery = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const headers = useMemo(
|
||||
() => [
|
||||
<GenericTableHeaderCell key='client' direction={sortDirection} active={sortBy === 'client'} onClick={setSort} sort='client'>
|
||||
{t('Client')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='rcVersion'>{t('Version')}</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='os' direction={sortDirection} active={sortBy === 'os'} onClick={setSort} sort='os'>
|
||||
{t('OS')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='username' direction={sortDirection} active={sortBy === 'username'} onClick={setSort} sort='username'>
|
||||
{t('User')}
|
||||
</GenericTableHeaderCell>,
|
||||
mediaQuery && (
|
||||
<GenericTableHeaderCell key='loginAt' direction={sortDirection} active={sortBy === 'loginAt'} onClick={setSort} sort='loginAt'>
|
||||
{t('Last_login')}
|
||||
</GenericTableHeaderCell>
|
||||
),
|
||||
mediaQuery && <GenericTableHeaderCell key='_id'>{t('Device_ID')}</GenericTableHeaderCell>,
|
||||
mediaQuery && <GenericTableHeaderCell key='ip'>{t('IP_Address')}</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell width='5%' key='menu' />,
|
||||
],
|
||||
[t, mediaQuery, setSort, sortDirection, sortBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterByText placeholder={t('Search_Devices_Users')} value={text} onChange={(event) => setText(event.target.value)} />
|
||||
<DeviceManagementTable
|
||||
{...queryResult}
|
||||
headers={headers}
|
||||
renderRow={(session) => (
|
||||
<DeviceManagementAdminRow
|
||||
key={session._id}
|
||||
_id={session._id}
|
||||
username={isSessionPopulatedSession(session) ? session._user?.username : ''}
|
||||
ip={session.ip}
|
||||
deviceName={session?.device?.name}
|
||||
deviceType={session?.device?.type}
|
||||
deviceOSName={session?.device?.os?.name}
|
||||
rcVersion={session?.device?.version}
|
||||
loginAt={session.loginAt}
|
||||
/>
|
||||
)}
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
setCurrent={setCurrent}
|
||||
setItemsPerPage={setItemsPerPage}
|
||||
paginationProps={paginationProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceManagementAdminTable;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DeviceManagementAdminTable';
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { DeviceManagementPopulatedSession } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, ButtonGroup, StatusBullet } from '@rocket.chat/fuselage';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarClose,
|
||||
ContextualbarScrollableContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarTitle,
|
||||
InfoPanel,
|
||||
InfoPanelField,
|
||||
InfoPanelLabel,
|
||||
InfoPanelText,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoute, useUserPresence } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDeviceLogout } from '../../../../hooks/useDeviceLogout';
|
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
||||
|
||||
export type DeviceManagementInfoProps = DeviceManagementPopulatedSession;
|
||||
|
||||
const DeviceManagementInfo = ({ device, sessionId, loginAt, ip, userId, _user }: DeviceManagementInfoProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deviceManagementRouter = useRoute('device-management');
|
||||
const formatDateAndTime = useFormatDateAndTime();
|
||||
|
||||
const handleDeviceLogout = useDeviceLogout(sessionId, '/v1/sessions/logout');
|
||||
|
||||
const { name: clientName, os, version: rcVersion } = device || {};
|
||||
const { username, name } = _user || {};
|
||||
const userPresence = useUserPresence(userId);
|
||||
|
||||
const handleCloseContextualBar = useCallback((): void => deviceManagementRouter.push({}), [deviceManagementRouter]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>{t('Device_Info')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={handleCloseContextualBar} />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarScrollableContent>
|
||||
<InfoPanel>
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Client')}</InfoPanelLabel>
|
||||
<InfoPanelText>{clientName}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Version')}</InfoPanelLabel>
|
||||
<InfoPanelText>{rcVersion || '—'}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('OS')}</InfoPanelLabel>
|
||||
<InfoPanelText>{`${os?.name || ''} ${os?.version || ''}`}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
|
||||
{username && (
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('User')}</InfoPanelLabel>
|
||||
<Box>
|
||||
<UserAvatar username={username} etag={userPresence?.avatarETag} />
|
||||
<Box is='span' paddingInline={8}>
|
||||
<StatusBullet status={userPresence?.status} />
|
||||
</Box>
|
||||
{name && <Box is='span'>{name}</Box>}
|
||||
<Box is='span' color='hint'>
|
||||
{`(${username})`}
|
||||
</Box>
|
||||
</Box>
|
||||
</InfoPanelField>
|
||||
)}
|
||||
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Last_login')}</InfoPanelLabel>
|
||||
<InfoPanelText>{formatDateAndTime(loginAt)}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('Device_ID')}</InfoPanelLabel>
|
||||
<InfoPanelText>{sessionId}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
|
||||
<InfoPanelField>
|
||||
<InfoPanelLabel>{t('IP_Address')}</InfoPanelLabel>
|
||||
<InfoPanelText>{ip}</InfoPanelText>
|
||||
</InfoPanelField>
|
||||
</InfoPanel>
|
||||
</ContextualbarScrollableContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button primary onClick={() => handleDeviceLogout()}>
|
||||
{t('Logout_Device')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceManagementInfo;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Serialized, DeviceManagementPopulatedSession } from '@rocket.chat/core-typings';
|
||||
import { Box, States, StatesIcon, StatesTitle, StatesSubtitle } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarClose,
|
||||
ContextualbarContent,
|
||||
ContextualbarTitle,
|
||||
ContextualbarSkeletonBody,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DeviceManagementInfo from './DeviceManagementInfo';
|
||||
import { deviceManagementQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
const convertSessionFromAPI = ({
|
||||
loginAt,
|
||||
logoutAt,
|
||||
...rest
|
||||
}: Serialized<DeviceManagementPopulatedSession>): DeviceManagementPopulatedSession => ({
|
||||
loginAt: new Date(loginAt),
|
||||
...(logoutAt && { logoutAt: new Date(logoutAt) }),
|
||||
...rest,
|
||||
});
|
||||
|
||||
export type DeviceInfoWithDataProps = { deviceId: string };
|
||||
|
||||
const DeviceInfoWithData = ({ deviceId }: DeviceInfoWithDataProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getSessionInfo = useEndpoint('GET', '/v1/sessions/info.admin');
|
||||
const { isPending, isError, error, data } = useQuery({
|
||||
queryKey: deviceManagementQueryKeys.sessionInfo(deviceId),
|
||||
queryFn: () => getSessionInfo({ sessionId: deviceId }),
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <ContextualbarSkeletonBody />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>{t('Device_Info')}</ContextualbarTitle>
|
||||
<ContextualbarClose />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarContent>
|
||||
<Box display='flex' justifyContent='center' alignItems='center' height='100%'>
|
||||
<States>
|
||||
<StatesIcon name='warning' variation='danger' />
|
||||
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
|
||||
<StatesSubtitle>{t('We_Could_not_retrive_any_data')}</StatesSubtitle>
|
||||
<StatesSubtitle>{error.message}</StatesSubtitle>
|
||||
</States>
|
||||
</Box>
|
||||
</ContextualbarContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <DeviceManagementInfo {...convertSessionFromAPI(data)} />;
|
||||
};
|
||||
|
||||
export default DeviceInfoWithData;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DeviceManagementInfoWithData';
|
||||
629
apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx
Normal file
629
apps/meteor/client/views/admin/emailInbox/EmailInboxForm.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
import type { IEmailInboxPayload } from '@rocket.chat/core-typings';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
TextInput,
|
||||
TextAreaInput,
|
||||
Field,
|
||||
ToggleSwitch,
|
||||
FieldGroup,
|
||||
Box,
|
||||
Margins,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
FieldLabel,
|
||||
FieldRow,
|
||||
FieldError,
|
||||
FieldHint,
|
||||
} from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { validateEmail } from '@rocket.chat/tools';
|
||||
import { GenericModal, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useToastMessageDispatch, useRoute, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useId, useCallback } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AutoCompleteDepartment from '../../omnichannel/components/AutoCompleteDepartment';
|
||||
|
||||
type EmailInboxFormData = {
|
||||
active: boolean;
|
||||
name: string;
|
||||
email: string;
|
||||
description?: string;
|
||||
senderInfo?: string;
|
||||
department?: string;
|
||||
smtpServer: string;
|
||||
smtpPort: string;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string;
|
||||
smtpSecure: boolean;
|
||||
imapServer: string;
|
||||
imapPort: string;
|
||||
imapUsername: string;
|
||||
imapPassword: string;
|
||||
imapSecure: boolean;
|
||||
imapRetries: string;
|
||||
};
|
||||
|
||||
export type EmailInboxFormProps = {
|
||||
inboxData?: IEmailInboxPayload;
|
||||
};
|
||||
|
||||
const EmailInboxForm = ({ inboxData }: EmailInboxFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const setModal = useSetModal();
|
||||
const router = useRoute('admin-email-inboxes');
|
||||
|
||||
const handleBack = useCallback(() => router.push({}), [router]);
|
||||
|
||||
const saveEmailInbox = useEndpoint('POST', '/v1/email-inbox');
|
||||
const deleteInboxAction = useEndpoint('DELETE', '/v1/email-inbox/:_id', { _id: inboxData?._id ?? '' });
|
||||
const emailAlreadyExistsAction = useEndpoint('GET', '/v1/email-inbox.search');
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<EmailInboxFormData>({
|
||||
values: {
|
||||
active: inboxData?.active ?? true,
|
||||
name: inboxData?.name ?? '',
|
||||
email: inboxData?.email ?? '',
|
||||
description: inboxData?.description ?? '',
|
||||
senderInfo: inboxData?.senderInfo ?? '',
|
||||
department: inboxData?.department ?? '',
|
||||
// SMTP
|
||||
smtpServer: inboxData?.smtp.server ?? '',
|
||||
smtpPort: String(inboxData?.smtp.port ?? 587),
|
||||
smtpUsername: inboxData?.smtp.username ?? '',
|
||||
smtpPassword: inboxData?.smtp.password ?? '',
|
||||
smtpSecure: inboxData?.smtp.secure ?? false,
|
||||
// IMAP
|
||||
imapServer: inboxData?.imap.server ?? '',
|
||||
imapPort: String(inboxData?.imap.port ?? 993),
|
||||
imapUsername: inboxData?.imap.username ?? '',
|
||||
imapPassword: inboxData?.imap.password ?? '',
|
||||
imapSecure: inboxData?.imap.secure ?? false,
|
||||
imapRetries: String(inboxData?.imap.maxRetries ?? 10),
|
||||
},
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const handleDelete = useStableCallback(() => {
|
||||
const deleteInbox = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteInboxAction();
|
||||
dispatchToastMessage({ type: 'success', message: t('Email_Inbox_has_been_removed') });
|
||||
handleBack();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal variant='danger' onConfirm={deleteInbox} onCancel={(): void => setModal(null)} confirmText={t('Delete')}>
|
||||
{t('You_will_not_be_able_to_recover_email_inbox')}
|
||||
</GenericModal>,
|
||||
);
|
||||
});
|
||||
|
||||
const handleSave = useStableCallback(
|
||||
async ({
|
||||
active,
|
||||
name,
|
||||
email,
|
||||
description,
|
||||
senderInfo,
|
||||
department,
|
||||
smtpServer,
|
||||
smtpPort,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
smtpSecure,
|
||||
imapServer,
|
||||
imapPort,
|
||||
imapUsername,
|
||||
imapPassword,
|
||||
imapSecure,
|
||||
imapRetries,
|
||||
}: EmailInboxFormData) => {
|
||||
const smtp = {
|
||||
server: smtpServer,
|
||||
port: parseInt(smtpPort, 10),
|
||||
username: smtpUsername,
|
||||
password: smtpPassword,
|
||||
secure: smtpSecure,
|
||||
};
|
||||
|
||||
const imap = {
|
||||
server: imapServer,
|
||||
port: parseInt(imapPort, 10),
|
||||
username: imapUsername,
|
||||
password: imapPassword,
|
||||
secure: imapSecure,
|
||||
maxRetries: parseInt(imapRetries, 10),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
...(inboxData?._id && { _id: inboxData?._id }),
|
||||
active,
|
||||
name,
|
||||
email,
|
||||
description,
|
||||
senderInfo,
|
||||
...(department && { department }),
|
||||
smtp,
|
||||
imap,
|
||||
};
|
||||
|
||||
try {
|
||||
await saveEmailInbox(payload);
|
||||
dispatchToastMessage({ type: 'success', message: t('Email_Inbox_has_been_added') });
|
||||
handleBack();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const checkEmailExists = useStableCallback(async (email: string) => {
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
return t('error-invalid-email-address');
|
||||
}
|
||||
|
||||
const { emailInbox } = await emailAlreadyExistsAction({ email });
|
||||
|
||||
if (!emailInbox || (inboxData?._id && emailInbox._id === inboxData?._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return t('Email_already_exists');
|
||||
});
|
||||
|
||||
const activeField = useId();
|
||||
const nameField = useId();
|
||||
const emailField = useId();
|
||||
const descriptionField = useId();
|
||||
const senderInfoField = useId();
|
||||
const departmentField = useId();
|
||||
|
||||
const smtpServerField = useId();
|
||||
const smtpPortField = useId();
|
||||
const smtpUsernameField = useId();
|
||||
const smtpPasswordField = useId();
|
||||
const smtpSecureField = useId();
|
||||
|
||||
const imapServerField = useId();
|
||||
const imapPortField = useId();
|
||||
const imapUsernameField = useId();
|
||||
const imapPasswordField = useId();
|
||||
const imapRetriesField = useId();
|
||||
const imapSecureField = useId();
|
||||
|
||||
return (
|
||||
<PageScrollableContentWithShadow>
|
||||
<Box maxWidth='x600' width='full' alignSelf='center'>
|
||||
<Accordion>
|
||||
<AccordionItem defaultExpanded title={t('Inbox_Info')}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={activeField}>{t('Active')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='active'
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<ToggleSwitch id={activeField} ref={ref} checked={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={nameField} required>
|
||||
{t('Name')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='name'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Name') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={nameField}
|
||||
{...field}
|
||||
error={errors.name?.message}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
aria-describedby={`${nameField}-error`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.name && (
|
||||
<FieldError aria-live='assertive' id={`${nameField}-error`}>
|
||||
{errors.name?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={emailField} required>
|
||||
{t('Email')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='email'
|
||||
control={control}
|
||||
rules={{
|
||||
required: t('Required_field', { field: t('Email') }),
|
||||
validate: (value) => checkEmailExists(value),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
id={emailField}
|
||||
error={errors.email?.message}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={`${emailField}-error`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.email && (
|
||||
<FieldError aria-live='assertive' id={`${emailField}-error`}>
|
||||
{errors.email?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={descriptionField}>{t('Description')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='description'
|
||||
control={control}
|
||||
render={({ field }) => <TextAreaInput id={descriptionField} {...field} rows={4} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={senderInfoField}>{t('Sender_Info')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='senderInfo'
|
||||
control={control}
|
||||
render={({ field }) => <TextInput id={senderInfoField} {...field} aria-describedby={`${senderInfoField}-hint`} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${senderInfoField}-hint`}>{t('Will_Appear_In_From')}</FieldHint>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={departmentField}>{t('Department')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
control={control}
|
||||
name='department'
|
||||
render={({ field: { onChange, onBlur, name, value } }) => (
|
||||
<AutoCompleteDepartment
|
||||
id={departmentField}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-describedby={`${departmentField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${departmentField}-hint`}>{t('Only_Members_Selected_Department_Can_View_Channel')}</FieldHint>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
<AccordionItem defaultExpanded={!inboxData?._id} title={t('Configure_Outgoing_Mail_SMTP')}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={smtpServerField} required>
|
||||
{t('Server')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='smtpServer'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Server') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={smtpServerField}
|
||||
{...field}
|
||||
error={errors.smtpServer?.message}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={`${smtpServerField}-error`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.smtpServer && (
|
||||
<FieldError aria-live='assertive' id={`${smtpServerField}-error`}>
|
||||
{errors.smtpServer?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={smtpPortField} required>
|
||||
{t('Port')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='smtpPort'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Port') }) }}
|
||||
render={({ field }) => (
|
||||
<NumberInput
|
||||
id={smtpPortField}
|
||||
{...field}
|
||||
error={errors.smtpPort?.message}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
aria-describedby={`${smtpPortField}-error`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.smtpPort && (
|
||||
<FieldError aria-live='assertive' id={`${smtpPortField}-error`}>
|
||||
{errors.smtpPort?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={smtpUsernameField} required>
|
||||
{t('Username')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='smtpUsername'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Username') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={smtpUsernameField}
|
||||
{...field}
|
||||
error={errors.smtpUsername?.message}
|
||||
aria-describedby={`${smtpUsernameField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.smtpUsername && (
|
||||
<FieldError aria-live='assertive' id={`${smtpUsernameField}-error`}>
|
||||
{errors.smtpUsername?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={smtpPasswordField} required>
|
||||
{t('Password')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='smtpPassword'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Password') }) }}
|
||||
render={({ field }) => (
|
||||
<PasswordInput
|
||||
id={smtpPasswordField}
|
||||
{...field}
|
||||
error={errors.smtpPassword?.message}
|
||||
aria-describedby={`${smtpPasswordField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.smtpPassword && (
|
||||
<FieldError aria-live='assertive' id={`${smtpPasswordField}-error`}>
|
||||
{errors.smtpPassword?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={smtpSecureField}>{t('Connect_SSL_TLS')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='smtpSecure'
|
||||
render={({ field: { value, ...field } }) => <ToggleSwitch id={smtpSecureField} {...field} checked={value} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
<AccordionItem defaultExpanded={!inboxData?._id} title={t('Configure_Incoming_Mail_IMAP')}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={imapServerField} required>
|
||||
{t('Server')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='imapServer'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Server') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={imapServerField}
|
||||
{...field}
|
||||
error={errors.imapServer?.message}
|
||||
aria-describedby={`${imapServerField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.imapServer && (
|
||||
<FieldError aria-live='assertive' id={`${imapServerField}-error`}>
|
||||
{errors.imapServer?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={imapPortField} required>
|
||||
{t('Port')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='imapPort'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Port') }) }}
|
||||
render={({ field }) => (
|
||||
<NumberInput
|
||||
id={imapPortField}
|
||||
{...field}
|
||||
error={errors.imapPort?.message}
|
||||
aria-describedby={`${imapPortField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.imapPort && (
|
||||
<FieldError aria-live='assertive' id={`${imapPortField}-error`}>
|
||||
{errors.imapPort?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={imapUsernameField} required>
|
||||
{t('Username')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='imapUsername'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Username') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
id={imapUsernameField}
|
||||
{...field}
|
||||
error={errors.imapUsername?.message}
|
||||
aria-describedby={`${imapUsernameField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.imapUsername && (
|
||||
<FieldError aria-live='assertive' id={`${imapUsernameField}-error`}>
|
||||
{errors.imapUsername?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={imapPasswordField} required>
|
||||
{t('Password')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='imapPassword'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Password') }) }}
|
||||
render={({ field }) => (
|
||||
<PasswordInput
|
||||
id={imapPasswordField}
|
||||
{...field}
|
||||
error={errors.imapPassword?.message}
|
||||
aria-describedby={`${imapPasswordField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.imapPassword && (
|
||||
<FieldError aria-live='assertive' id={`${imapPasswordField}-error`}>
|
||||
{errors.imapPassword?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={imapRetriesField} required>
|
||||
{t('Max_Retry')}
|
||||
</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='imapRetries'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Max_Retry') }) }}
|
||||
render={({ field }) => (
|
||||
<NumberInput
|
||||
id={imapRetriesField}
|
||||
{...field}
|
||||
error={errors.imapRetries?.message}
|
||||
aria-describedby={`${imapRetriesField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.imapRetries && (
|
||||
<FieldError aria-live='assertive' id={`${imapRetriesField}-error`}>
|
||||
{errors.imapRetries?.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={imapSecureField}>{t('Connect_SSL_TLS')}</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='imapSecure'
|
||||
render={({ field: { value, ...field } }) => <ToggleSwitch id={imapSecureField} {...field} checked={value} />}
|
||||
/>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</AccordionItem>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={handleBack}>{t('Cancel')}</Button>
|
||||
<Button disabled={!isDirty} primary onClick={handleSubmit(handleSave)}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Margins blockStart={16}>
|
||||
<ButtonGroup stretch>
|
||||
{inboxData?._id && (
|
||||
<Button danger onClick={handleDelete}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Margins>
|
||||
</FieldRow>
|
||||
</Field>
|
||||
</Accordion>
|
||||
</Box>
|
||||
</PageScrollableContentWithShadow>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailInboxForm;
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IEmailInbox } from '@rocket.chat/core-typings';
|
||||
import { States, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import EmailInboxForm from './EmailInboxForm';
|
||||
import { FormSkeleton } from '../../../components/Skeleton';
|
||||
|
||||
export type EmailInboxFormWithDataProps = { id: IEmailInbox['_id'] };
|
||||
|
||||
const EmailInboxFormWithData = ({ id }: EmailInboxFormWithDataProps) => {
|
||||
const t = useTranslation();
|
||||
const getEmailInboxById = useEndpoint('GET', '/v1/email-inbox/:_id', { _id: id });
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: ['email-inbox', id],
|
||||
queryFn: () => getEmailInboxById(),
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <FormSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<States>
|
||||
<StatesIcon name='circle-exclamation' variation='danger' />
|
||||
<StatesTitle>{t('error-email-inbox-not-found')}</StatesTitle>
|
||||
</States>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmailInboxForm inboxData={data} />;
|
||||
};
|
||||
|
||||
export default EmailInboxFormWithData;
|
||||
37
apps/meteor/client/views/admin/emailInbox/EmailInboxPage.tsx
Normal file
37
apps/meteor/client/views/admin/emailInbox/EmailInboxPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import EmailInboxForm from './EmailInboxForm';
|
||||
import EmailInboxFormWithData from './EmailInboxFormWithData';
|
||||
import EmailInboxTable from './EmailInboxTable';
|
||||
|
||||
const EmailInboxPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const id = useRouteParameter('_id');
|
||||
const context = useRouteParameter('context');
|
||||
|
||||
return (
|
||||
<Page flexDirection='row'>
|
||||
<Page>
|
||||
<PageHeader title={t('Email_Inboxes')} onClickBack={context ? () => router.navigate('/admin/email-inboxes') : undefined}>
|
||||
{!context && (
|
||||
<Button primary onClick={() => router.navigate('/admin/email-inboxes/new')}>
|
||||
{t('New_Email_Inbox')}
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
{!context && <EmailInboxTable />}
|
||||
{context === 'new' && <EmailInboxForm />}
|
||||
{context === 'edit' && id && <EmailInboxFormWithData id={id} />}
|
||||
</PageContent>
|
||||
</Page>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailInboxPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import EmailInboxPage from './EmailInboxPage';
|
||||
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const EmailInboxRoute = () => {
|
||||
const canViewEmailInbox = usePermission('manage-email-inbox');
|
||||
|
||||
if (!canViewEmailInbox) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return <EmailInboxPage />;
|
||||
};
|
||||
|
||||
export default EmailInboxRoute;
|
||||
123
apps/meteor/client/views/admin/emailInbox/EmailInboxTable.tsx
Normal file
123
apps/meteor/client/views/admin/emailInbox/EmailInboxTable.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Pagination, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableBody,
|
||||
GenericTableCell,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableLoadingTable,
|
||||
GenericTableRow,
|
||||
usePagination,
|
||||
useSort,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoute, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
|
||||
import SendTestButton from './SendTestButton';
|
||||
import GenericNoResults from '../../../components/GenericNoResults';
|
||||
|
||||
const EmailInboxTable = () => {
|
||||
const t = useTranslation();
|
||||
const router = useRoute('admin-email-inboxes');
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
const { sortBy, sortDirection, setSort } = useSort<'name' | 'email' | 'active'>('name');
|
||||
|
||||
const onClick = useCallback(
|
||||
(_id: string) => (): void => {
|
||||
router.push({
|
||||
context: 'edit',
|
||||
_id,
|
||||
});
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const endpoint = useEndpoint('GET', '/v1/email-inbox.list');
|
||||
|
||||
const query = {
|
||||
sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }),
|
||||
...(itemsPerPage && { count: itemsPerPage }),
|
||||
...(current && { offset: current }),
|
||||
};
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: ['email-list', query],
|
||||
queryFn: () => endpoint(query),
|
||||
});
|
||||
|
||||
const headers = useMemo(
|
||||
() => [
|
||||
<GenericTableHeaderCell key='name' direction={sortDirection} active={sortBy === 'name'} onClick={setSort}>
|
||||
{t('Name')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='email' direction={sortDirection} active={sortBy === 'email'} onClick={setSort}>
|
||||
{t('Email')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='active' direction={sortDirection} active={sortBy === 'active'} onClick={setSort}>
|
||||
{t('Active')}
|
||||
</GenericTableHeaderCell>,
|
||||
<GenericTableHeaderCell key='action'>{t('Action')}</GenericTableHeaderCell>,
|
||||
],
|
||||
[setSort, sortBy, sortDirection, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{result.isPending && (
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
<GenericTableLoadingTable headerCells={4} />
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
)}
|
||||
{result.isSuccess && result.data.emailInboxes.length > 0 && (
|
||||
<>
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{result.data.emailInboxes.map((emailInbox) => (
|
||||
<GenericTableRow
|
||||
key={emailInbox._id}
|
||||
onKeyDown={onClick(emailInbox._id)}
|
||||
onClick={onClick(emailInbox._id)}
|
||||
tabIndex={0}
|
||||
role='link'
|
||||
action
|
||||
width='full'
|
||||
>
|
||||
<GenericTableCell withTruncatedText>{emailInbox.name}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{emailInbox.email}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{emailInbox.active ? t('Yes') : t('No')}</GenericTableCell>
|
||||
<SendTestButton id={emailInbox._id} />
|
||||
</GenericTableRow>
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={result.data.count}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{result.isSuccess && result.data.emailInboxes.length === 0 && <GenericNoResults />}
|
||||
{result.isError && (
|
||||
<States>
|
||||
<StatesIcon name='warning' variation='danger' />
|
||||
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
|
||||
<StatesActions>
|
||||
<StatesAction onClick={() => result.refetch()}>{t('Reload_page')}</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailInboxTable;
|
||||
42
apps/meteor/client/views/admin/emailInbox/SendTestButton.tsx
Normal file
42
apps/meteor/client/views/admin/emailInbox/SendTestButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { IEmailInboxPayload } from '@rocket.chat/core-typings';
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import { GenericTableCell } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type SendTestButtonProps = { id: IEmailInboxPayload['_id'] };
|
||||
|
||||
const SendTestButton = ({ id }: SendTestButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const sendTest = useEndpoint('POST', '/v1/email-inbox.send-test/:_id', { _id: id });
|
||||
|
||||
const handleOnClick = async (e: MouseEvent<HTMLElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
await sendTest();
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t('Email_sent'),
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericTableCell withTruncatedText>
|
||||
<Button icon='send' small onClick={handleOnClick}>
|
||||
{t('Send_Test_Email')}
|
||||
</Button>
|
||||
</GenericTableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export default SendTestButton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user