diff --git a/apps/meteor/client/hooks/useShowSettingAlerts.tsx b/apps/meteor/client/hooks/useShowSettingAlerts.tsx new file mode 100644 index 0000000000000..1957508df198d --- /dev/null +++ b/apps/meteor/client/hooks/useShowSettingAlerts.tsx @@ -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((resolve) => { + setModal( + { + 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 ( + + + {labelText} + + + , + strong: , + br:
, + ul:
    , + li:
  • , + }} + /> + + + ); + })} + , + ); + }); + }, + [t, i18n, setModal], + ); + + return showAlerts; +}; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 2cda11cbc9694..06462485a2479 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -29,18 +29,18 @@ type MeteorProviderProps = { const MeteorProvider = ({ children }: MeteorProviderProps) => ( - - - - - - - - - - - - + + + + + + + + + + + + @@ -56,18 +56,18 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( - - - - - - - - - - - - + + + + + + + + + + + + ); diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index d90f167e6feaf..581d96de5000d 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -1,5 +1,6 @@ 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'; @@ -7,6 +8,7 @@ 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'; @@ -96,9 +98,15 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { const queryClient = useQueryClient(); + const showAlerts = useShowSettingAlerts(); + const saveSettings = useMethod('saveSettings'); const dispatch = useCallback( - async (changes: Partial[]) => { + async (changes: Partial[], 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') { @@ -106,9 +114,19 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { } }); + if (alerts.length) { + const accepted = await showAlerts(alerts); + + if (!accepted) { + return; + } + } + await saveSettings(changes as Pick[]); + + onSaved?.(); }, - [queryClient, saveSettings], + [queryClient, saveSettings, showAlerts, cachedCollection.store], ); const contextValue = useMemo( diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx index c5e47c924c644..5320ee7fecbfe 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.spec.tsx @@ -18,12 +18,10 @@ const settingStructure = { } as Partial; const dispatchMock = jest.fn(); -const setModalMock = jest.fn(); jest.mock('@rocket.chat/ui-contexts', () => ({ ...jest.requireActual('@rocket.chat/ui-contexts'), useSettingsDispatch: () => dispatchMock, - useSetModal: () => setModalMock, })); jest.mock('@rocket.chat/core-typings', () => ({ ...jest.requireActual('@rocket.chat/core-typings'), @@ -34,7 +32,6 @@ describe('SettingField', () => { beforeEach(() => { jest.useFakeTimers(); dispatchMock.mockClear(); - setModalMock.mockClear(); }); afterEach(() => { @@ -60,7 +57,7 @@ describe('SettingField', () => { }); }); - it('should open the confirmation modal instead of dispatching when the setting has an alert', async () => { + 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(, { @@ -76,40 +73,6 @@ describe('SettingField', () => { jest.runOnlyPendingTimers(); }); - expect(setModalMock).toHaveBeenCalled(); - expect(dispatchMock).not.toHaveBeenCalled(); - - const modal = setModalMock.mock.calls[0][0]; - await act(async () => { - modal.props.onConfirm(); - }); - expect(dispatchMock).toHaveBeenCalledWith([{ _id: 'Test_Setting', value: true }]); - expect(setModalMock).toHaveBeenLastCalledWith(null); - }); - - it('should not dispatch when the confirmation modal is cancelled', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - - render(, { - wrapper: mockAppRoot() - .wrap((children) => {children}) - .withSetting('Test_Setting', false, { ...settingStructure, alert: 'Test_Setting_Alert' }) - .build(), - }); - - await user.click(screen.getByRole('checkbox')); - - await act(async () => { - jest.runOnlyPendingTimers(); - }); - - const modal = setModalMock.mock.calls[0][0]; - await act(async () => { - modal.props.onCancel(); - }); - - expect(dispatchMock).not.toHaveBeenCalled(); - expect(setModalMock).toHaveBeenLastCalledWith(null); }); }); diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx index 0b0c372ecec5a..f83e228339a5c 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx @@ -1,8 +1,7 @@ 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 { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts'; +import { useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import { useEffect, useMemo, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -33,7 +32,6 @@ function SettingField({ className = undefined, settingId, sectionChanged }: Sett } const dispatch = useSettingsDispatch(); - const setModal = useSetModal(); const update = useDebouncedCallback( ({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => { @@ -41,32 +39,6 @@ function SettingField({ className = undefined, settingId, sectionChanged }: Sett return; } - if (persistedSetting.alert) { - setModal( - { - dispatch([ - { - _id: persistedSetting._id, - ...(value !== undefined && { value }), - ...(editor !== undefined && { editor }), - }, - ]); - setModal(null); - }} - onCancel={() => setModal(null)} - onClose={() => setModal(null)} - > - {t(persistedSetting.alert)} - , - ); - return; - } - dispatch([ { _id: persistedSetting._id, diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx index f4b5da5304e44..afb70452445f6 100644 --- a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx @@ -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 }); } diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 39089d7998b84..63e5bec4e0fe4 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -27,6 +27,7 @@ export function addSettings(): Promise { { 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', { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 1908aabc94f69..08bc03aac2900 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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.
    Switching back to local also sets the Attribute store back to local 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", @@ -1280,6 +1281,7 @@ "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", diff --git a/packages/ui-contexts/src/SettingsContext.ts b/packages/ui-contexts/src/SettingsContext.ts index 95e364de2c748..4e408a209c8ee 100644 --- a/packages/ui-contexts/src/SettingsContext.ts +++ b/packages/ui-contexts/src/SettingsContext.ts @@ -18,7 +18,7 @@ export type SettingsContextValue = { readonly querySettings: ( query: SettingsContextQuery, ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ISetting[]]; - readonly dispatch: (changes: Partial[]) => Promise; + readonly dispatch: (changes: Partial[], onSaved?: () => void) => Promise; }; export const SettingsContext = createContext({ diff --git a/packages/ui-contexts/src/hooks/useSettingsDispatch.ts b/packages/ui-contexts/src/hooks/useSettingsDispatch.ts index 2994ea05fc942..d0f126d93fa9a 100644 --- a/packages/ui-contexts/src/hooks/useSettingsDispatch.ts +++ b/packages/ui-contexts/src/hooks/useSettingsDispatch.ts @@ -3,4 +3,5 @@ import { useContext } from 'react'; import { SettingsContext } from '../SettingsContext'; -export const useSettingsDispatch = (): ((changes: Partial[]) => Promise) => useContext(SettingsContext).dispatch; +export const useSettingsDispatch = (): ((changes: Partial[], onSaved?: () => void) => Promise) => + useContext(SettingsContext).dispatch;