Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/all-readers-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---

Shows a confirmation modal when switching attribute store setting
72 changes: 72 additions & 0 deletions apps/meteor/client/hooks/useShowSettingAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Box, Callout } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';

export const useShowSettingAlerts = () => {
const { t, i18n } = useTranslation();
const setModal = useSetModal();

const showAlerts = useCallback(
(persistedSettingsWithAlert: ISetting[]) => {
return new Promise<boolean>((resolve) => {
setModal(
<GenericModal
variant='danger'
icon={null}
maxHeight='x600'
title={t('Confirm_settings_change')}
confirmText={t('Save_changes')}
cancelText={t('Cancel')}
onConfirm={() => {
resolve(true);
setModal(null);
}}
onCancel={() => {
resolve(false);
return setModal(null);
}}
onClose={() => {
resolve(false);
return setModal(null);
}}
>
{persistedSettingsWithAlert.map(({ _id, i18nLabel, alert }) => {
if (!alert) {
return null;
}

const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id;

return (
<Box key={_id} mbe={24}>
<Box fontScale='h4' mbe={8}>
{labelText}
</Box>
<Callout type='warning'>
<Trans
i18nKey={i18n.exists(alert) ? alert : undefined}
defaults={alert}
components={{
b: <b />,
strong: <strong />,
br: <br />,
ul: <ul />,
li: <li />,
}}
/>
</Callout>
</Box>
);
})}
</GenericModal>,
);
});
},
[t, i18n, setModal],
);

return showAlerts;
};
48 changes: 24 additions & 24 deletions apps/meteor/client/providers/MeteorProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,18 @@ type MeteorProviderProps = {
const MeteorProvider = ({ children }: MeteorProviderProps) => (
<ServerProvider>
<RouterProvider>
<SettingsProvider>
<TranslationProvider>
<SessionProvider>
<TooltipProvider>
<ToastMessagesProvider>
<AvatarUrlProvider>
<UserProvider>
<LayoutProvider>
<AuthenticationProvider>
<CustomSoundProvider>
<DeviceProvider>
<ModalProvider>
<ModalProvider>
<SettingsProvider>
<TranslationProvider>
<SessionProvider>
<TooltipProvider>
<ToastMessagesProvider>
<AvatarUrlProvider>
<UserProvider>
<LayoutProvider>
<AuthenticationProvider>
<CustomSoundProvider>
<DeviceProvider>
<AuthorizationProvider>
<EmojiPickerProvider>
<OmnichannelRoomIconProvider>
Expand All @@ -56,18 +56,18 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => (
</OmnichannelRoomIconProvider>
</EmojiPickerProvider>
</AuthorizationProvider>
</ModalProvider>
</DeviceProvider>
</CustomSoundProvider>
</AuthenticationProvider>
</LayoutProvider>
</UserProvider>
</AvatarUrlProvider>
</ToastMessagesProvider>
</TooltipProvider>
</SessionProvider>
</TranslationProvider>
</SettingsProvider>
</DeviceProvider>
</CustomSoundProvider>
</AuthenticationProvider>
</LayoutProvider>
</UserProvider>
</AvatarUrlProvider>
</ToastMessagesProvider>
</TooltipProvider>
</SessionProvider>
</TranslationProvider>
</SettingsProvider>
</ModalProvider>
</RouterProvider>
</ServerProvider>
);
Expand Down
22 changes: 20 additions & 2 deletions apps/meteor/client/providers/SettingsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
import { isTruthy } from '@rocket.chat/tools';
import type { SettingsContextQuery, SettingsContextValue } from '@rocket.chat/ui-contexts';
import { SettingsContext, useAtLeastOnePermission, useMethod } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useCallback, useMemo } from 'react';

import { PublicSettingsCachedStore, PrivateSettingsCachedStore } from '../cachedStores';
import { useShowSettingAlerts } from '../hooks/useShowSettingAlerts';
import { PrivateCachedStore } from '../lib/cachedStores/CachedStore';
import { applyQueryOptions } from '../lib/cachedStores/applyQueryOptions';

Expand Down Expand Up @@ -96,19 +98,35 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => {

const queryClient = useQueryClient();

const showAlerts = useShowSettingAlerts();

const saveSettings = useMethod('saveSettings');
const dispatch = useCallback(
async (changes: Partial<ISetting>[]) => {
async (changes: Partial<ISetting>[], onSaved?: () => void) => {
const changedSettingIds = changes.map((s) => s._id).filter(isTruthy);
const alerts = cachedCollection.store
.getState()
.filter((setting) => Boolean(setting.alert && changedSettingIds.includes(setting._id)));
// FIXME: This is a temporary solution to invalidate queries when settings change
changes.forEach((val) => {
if (val._id === 'Enterprise_License') {
queryClient.invalidateQueries({ queryKey: ['licenses'] });
}
});

if (alerts.length) {
const accepted = await showAlerts(alerts);

if (!accepted) {
return;
}
}

await saveSettings(changes as Pick<ISetting, '_id' | 'value'>[]);

onSaved?.();
},
[queryClient, saveSettings],
[queryClient, saveSettings, showAlerts, cachedCollection.store],
);

const contextValue = useMemo<SettingsContextValue>(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import SettingField from './SettingField';
Expand Down Expand Up @@ -31,6 +31,7 @@ jest.mock('@rocket.chat/core-typings', () => ({
describe('SettingField', () => {
beforeEach(() => {
jest.useFakeTimers();
dispatchMock.mockClear();
});

afterEach(() => {
Expand All @@ -55,4 +56,23 @@ describe('SettingField', () => {
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 }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ const SettingsGroupPage = ({
}

try {
await dispatch(changes);
dispatchToastMessage({ type: 'success', message: t('Settings_updated') });
await dispatch(changes, () => dispatchToastMessage({ type: 'success', message: t('Settings_updated') }));
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/ee/server/settings/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function addSettings(): Promise<void> {
{ key: 'local', i18nLabel: 'ABAC_PDP_Type_Local' },
{ key: 'virtru', i18nLabel: 'ABAC_PDP_Type_Virtru' },
],
alert: 'ABAC_PDP_Type_Switch_Alert',
enableQuery: abacEnabledQuery,
});
await this.add('ABAC_Attribute_Store', 'local', {
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"ABAC_PDP_Type_Description": "Select the Policy Decision Point engine to use for access control decisions.",
"ABAC_PDP_Type_Local": "Local",
"ABAC_PDP_Type_Virtru": "Virtru",
"ABAC_PDP_Type_Switch_Alert": "Changing the Policy Decision Point changes how room access is evaluated and may unexpectedly remove users from rooms.<br/>Switching back to <b>local</b> also sets the Attribute store back to <b>local</b> to ensure compatibility.",
"ABAC_Attribute_Store": "Attribute store",
"ABAC_Attribute_Store_Description": "Where assignable room attributes come from. With Virtru, attributes are managed externally and limited to those the acting admin possesses; the Room Attributes tab is hidden.",
"ABAC_Attribute_Store_Local": "Local",
Expand Down Expand Up @@ -1279,6 +1280,8 @@
"Confirm_your_password": "Confirm your password",
"Confirm_contact_removal": "Confirm Contact Removal",
"Confirmation": "Confirmation",
"Confirm_setting_change": "Confirm setting change",
"Confirm_settings_change": "Confirm settings change",
"Conflicts_found": "Conflicts found",
"Connect": "Connect",
"ConnectWorkspace_Button": "Connect workspace",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-contexts/src/SettingsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type SettingsContextValue = {
readonly querySettings: (
query: SettingsContextQuery,
) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting[]];
readonly dispatch: (changes: Partial<ISetting>[]) => Promise<void>;
readonly dispatch: (changes: Partial<ISetting>[], onSaved?: () => void) => Promise<void>;
};

export const SettingsContext = createContext<SettingsContextValue>({
Expand Down
3 changes: 2 additions & 1 deletion packages/ui-contexts/src/hooks/useSettingsDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { useContext } from 'react';

import { SettingsContext } from '../SettingsContext';

export const useSettingsDispatch = (): ((changes: Partial<ISetting>[]) => Promise<void>) => useContext(SettingsContext).dispatch;
export const useSettingsDispatch = (): ((changes: Partial<ISetting>[], onSaved?: () => void) => Promise<void>) =>
useContext(SettingsContext).dispatch;
Loading