diff --git a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx index c2fd14fb4feb9..584b64aece7fb 100644 --- a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx +++ b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx @@ -4,11 +4,7 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import ABACUpsellModal from './ABACUpsellModal'; - -// Mock the hooks used by ABACUpsellModal -jest.mock('../../../hooks/useHasLicenseModule', () => ({ - useHasLicenseModule: jest.fn(() => false), -})); +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; jest.mock('../../GenericUpsellModal/hooks', () => ({ useUpsellActions: jest.fn(() => ({ @@ -34,6 +30,9 @@ const appRoot = mockAppRoot() Upgrade: 'Upgrade', Cancel: 'Cancel', }) + .withEndpoint('GET', '/v1/licenses.info', async () => ({ + license: createFakeLicenseInfo(), + })) .build(); describe('ABACUpsellModal', () => { diff --git a/apps/meteor/client/lib/links.ts b/apps/meteor/client/lib/links.ts index 4c97d4696fff5..a1e28ddc853d3 100644 --- a/apps/meteor/client/lib/links.ts +++ b/apps/meteor/client/lib/links.ts @@ -31,6 +31,10 @@ export const links = { trial: `${GO_ROCKET_CHAT_PREFIX}/i/docs-trial`, versionSupport: `${GO_ROCKET_CHAT_PREFIX}/i/version-support`, updateProduct: `${GO_ROCKET_CHAT_PREFIX}/i/update-product`, + // TODO: implement abac links when available + abacDocs: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-DOCS`, + abacLicenseRenewalUrl: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-LICENSE-RENEWAL-URL`, + abacLDAPDocs: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-LDAP-DOCS`, }, /** @deprecated use `go.rocket.chat` links */ desktopAppDownload: 'https://rocket.chat/download', diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx new file mode 100644 index 0000000000000..d8352181e4df9 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -0,0 +1,47 @@ +import { Box, Button, Callout } from '@rocket.chat/fuselage'; +import { useRouteParameter } from '@rocket.chat/ui-contexts'; +import { Trans, useTranslation } from 'react-i18next'; + +import AdminABACSettings from './AdminABACSettings'; +import AdminABACTabs from './AdminABACTabs'; +import { Page, PageContent, PageHeader } from '../../../components/Page'; +import { useExternalLink } from '../../../hooks/useExternalLink'; +import { links } from '../../../lib/links'; + +type AdminABACPageProps = { + shouldShowWarning: boolean; +}; + +const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { + const { t } = useTranslation(); + const tab = useRouteParameter('tab'); + const learnMore = useExternalLink(); + + return ( + + + + + + {shouldShowWarning && ( + + + + Renew your license to continue using all{' '} + + ABAC capabilities without restriction. + + + + + )} + + {tab === 'settings' && } + + + ); +}; + +export default AdminABACPage; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx new file mode 100644 index 0000000000000..20a1db6239b81 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx @@ -0,0 +1,64 @@ +import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { memo, useEffect, useLayoutEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AdminABACPage from './AdminABACPage'; +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 = (): ReactElement => { + const { t } = useTranslation(); + // TODO: Check what permission is needed to view the ABAC page + const canViewABACPage = usePermission('abac-management'); + const hasABAC = useHasLicenseModule('abac') === true; + const isModalOpen = !!useCurrentModal(); + const tab = useRouteParameter('tab'); + const router = useRouter(); + + // Check if setting exists in the DB to decide if we show warning or upsell + const ABACEnabledSetting = useSettingStructure('ABAC_Enabled'); + + useLayoutEffect(() => { + if (!tab) { + router.navigate({ + name: 'admin-ABAC', + params: { tab: 'settings' }, + }); + } + }, [tab, router]); + + const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC); + + const setModal = useSetModal(); + + useEffect(() => { + // WS has never activated ABAC + if (shouldShowUpsell && ABACEnabledSetting === undefined) { + setModal( setModal(null)} onConfirm={handleManageSubscription} />); + } + }, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]); + + if (isModalOpen) { + return ; + } + + if (!canViewABACPage || (ABACEnabledSetting === undefined && !hasABAC)) { + return ; + } + + return ( + + + + + + ); +}; + +export default memo(AdminABACRoute); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx new file mode 100644 index 0000000000000..f235bce08db84 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx @@ -0,0 +1,142 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +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 AdminABACSettingToggle from './AdminABACSettingToggle'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const settingStructure = { + packageValue: false, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', +} as Partial; + +const baseAppRoot = mockAppRoot() + .wrap((children) => {children}) + .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('AdminABACSettingToggle', () => { + it('should render the setting toggle when setting exists', () => { + const { baseElement } = render(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + 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(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show reset button when value differs from package value', () => { + render(, { + 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(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), + }); + + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx new file mode 100644 index 0000000000000..6c5626baa0dc1 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx @@ -0,0 +1,65 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const meta: Meta = { + title: 'Admin/ABAC/AdminABACSettingToggle', + component: AdminABACSettingToggle, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .wrap((children) => {children}) + .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 ( + + + + ); + }, + ], + args: { + hasABAC: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + hasABAC: true, + }, +}; + +export const Loading: Story = { + args: { + hasABAC: 'loading', + }, +}; + +export const False: Story = { + args: { + hasABAC: false, + }, +}; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx new file mode 100644 index 0000000000000..7d7077e08364c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx @@ -0,0 +1,95 @@ +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 type { EditableSetting } from '../EditableSettingsContext'; +import { useEditableSetting } from '../EditableSettingsContext'; +import AdminABACWarningModal from './AdminABACWarningModal'; +import MemoizedSetting from '../settings/Setting/MemoizedSetting'; +import SettingSkeleton from '../settings/Setting/SettingSkeleton'; + +type AdminABACSettingToggleProps = { + hasABAC: 'loading' | boolean; +}; + +const AdminABACSettingToggle = ({ hasABAC }: AdminABACSettingToggleProps) => { + const setting = useEditableSetting('ABAC_Enabled'); + const setModal = useSetModal(); + const dispatch = useSettingsDispatch(); + const { t } = useTranslation(); + + const [value, setValue] = useState(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( + { + 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( + { + setValue(value); + dispatch([{ _id: setting._id, value }]); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + }, [dispatch, setModal, setting]); + + if (!setting) { + return null; + } + + if (hasABAC === 'loading') { + return ; + } + + return ( + onChange(value === true)} + onResetButtonClick={() => onReset()} + /> + ); +}; +export default AdminABACSettingToggle; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx new file mode 100644 index 0000000000000..3741720317692 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx @@ -0,0 +1,30 @@ +import { Box, Callout, Margins } from '@rocket.chat/fuselage'; +import { Trans } from 'react-i18next'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import { links } from '../../../lib/links'; + +const AdminABACSettings = () => { + const hasABAC = useHasLicenseModule('abac'); + return ( + + + + + + + + User attributes are synchronized via LDAP + + Learn more + + + + + + + ); +}; + +export default AdminABACSettings; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx new file mode 100644 index 0000000000000..4fc73a491f44e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -0,0 +1,24 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +const AdminABACTabs = () => { + const { t } = useTranslation(); + const router = useRouter(); + const tab = useRouteParameter('tab'); + const handleTabClick = (tab: string) => { + router.navigate({ + name: 'admin-ABAC', + params: { tab }, + }); + }; + return ( + + handleTabClick('settings')}> + {t('Settings')} + + + ); +}; + +export default AdminABACTabs; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx new file mode 100644 index 0000000000000..a6be0c83dcbcc --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx @@ -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'; + +type AdminABACWarningModalProps = { + onConfirm: () => void; + onCancel: () => void; +}; + +const AdminABACWarningModal = ({ onConfirm, onCancel }: AdminABACWarningModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const handleNavigate = () => { + onCancel(); + router.navigate({ + name: 'admin-ABAC', + params: { + tab: 'rooms', + }, + }); + }; + + return ( + + + 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 + + {' '} + ABAC {'>'} Rooms + + . + + + ); +}; + +export default AdminABACWarningModal; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap new file mode 100644 index 0000000000000..39af2d46aa2f0 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AdminABACSettingToggle should be disabled when abac license is not installed 1`] = ` + +
+
+
+
+ + +
+ +