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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page } from '@rocket.chat/ui-client';
import {
useEndpoint,
useLoginWithToken,
useRouteParameter,
useRouter,
useSetModal,
useToastMessageDispatch,
} from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import TwoFactorModal from '../../components/TwoFactorModal/TwoFactorModal';
const throwErrorOnInvalidMethod = (method: never): never => {
throw new Error(`Invalid Two Factor method: ${method}`);
};
const OAuthTwoFactorAuthenticationRouter = () => {
const method = useRouteParameter('method') as 'totp' | 'email' | undefined;
const challengeId = useRouteParameter('challengeId');
const router = useRouter();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const loginWithToken = useLoginWithToken();
const { t } = useTranslation();
const verifyChallenge = useEndpoint('POST', '/v1/twoFactorChallenges.verifyChallenge');
const sendEmailCode = useEndpoint('POST', '/v1/twoFactorChallenges.sendEmailCode');
const navigateToHome = useStableCallback(() => {
setModal(null);
router.navigate('/home', { replace: true });
});
const resendEmail = useStableCallback(async () => {
if (!challengeId) {
return null;
}
await sendEmailCode({ challengeId });
return null;
});
const onConfirm = useStableCallback(async (code: string) => {
if (!challengeId || !code) {
return;
}
try {
const { loginToken, userId } = await verifyChallenge({ challengeId, code });
const { loginClient } = router.getSearchParameters();
if (loginClient === 'mobile' || loginClient === 'desktop') {
setModal(null);
router.navigate({ name: 'home', search: { resumeToken: loginToken, userId, loginClient } }, { replace: true });
return;
}
await loginWithToken(loginToken);
navigateToHome();
} catch (error: any) {
console.error('Failed to verify challenge', error);
if (error.errorType === 'totp-max-attempts') {
setModal(null);
dispatchToastMessage({ type: 'error', message: t('Maximum_number_of_attempts_reached_please_try_again_later') });
router.navigate('/login', { replace: true });
return;
}
if (error.errorType === 'error-challenge-expired' || error.errorType === 'error-challenge-not-found') {
setModal(null);
dispatchToastMessage({ type: 'error', message: t('Challenge_expired_please_try_again_later') });
router.navigate('/login', { replace: true });
return;
}
throw error;
}
});
useEffect(() => {
if (!method || !challengeId) {
router.navigate('/home');
return;
}
if (method === 'email') {
setModal(<TwoFactorModal method={method} onConfirm={onConfirm} challengeId={challengeId} onClose={navigateToHome} />);
return;
}
if (method === 'totp') {
setModal(<TwoFactorModal method={method} onConfirm={onConfirm} onClose={navigateToHome} />);
return;
}
throwErrorOnInvalidMethod(method);
}, [method, challengeId, router, setModal, onConfirm, resendEmail, navigateToHome]);
return <Page />;
};
export default OAuthTwoFactorAuthenticationRouter;

View File

@@ -0,0 +1,40 @@
import { useRouter } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { Suspense, useEffect } from 'react';
import AccountSidebar from './AccountSidebar';
import PageSkeleton from '../../components/PageSkeleton';
import SidebarPortal from '../../portals/SidebarPortal';
export type AccountRouterProps = {
children?: ReactNode;
};
const AccountRouter = ({ children }: AccountRouterProps) => {
const router = useRouter();
useEffect(
() =>
router.subscribeToRouteChange(() => {
if (router.getRouteName() !== 'account-index') {
return;
}
router.navigate('/account/profile', { replace: true });
}),
[router],
);
return children ? (
<>
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
<SidebarPortal>
<AccountSidebar />
</SidebarPortal>
</>
) : (
<PageSkeleton />
);
};
export default AccountRouter;

View File

@@ -0,0 +1,30 @@
import { useCurrentRoutePath, useTranslation, useLayout } from '@rocket.chat/ui-contexts';
import { memo, useSyncExternalStore } from 'react';
import { getAccountSidebarItems, subscribeToAccountSidebarItems } from './sidebarItems';
import Sidebar from '../../components/Sidebar';
import SettingsProvider from '../../providers/SettingsProvider';
const AccountSidebar = () => {
const t = useTranslation();
const items = useSyncExternalStore(subscribeToAccountSidebarItems, getAccountSidebarItems);
const { sidebar } = useLayout();
const currentPath = useCurrentRoutePath();
// TODO: uplift this provider
return (
<SettingsProvider>
<Sidebar aria-label={t('Account')}>
<Sidebar.Header onClose={sidebar.close} title={t('Account')} />
<Sidebar.Content>
<Sidebar.ItemsAssembler items={items} currentPath={currentPath} />
</Sidebar.Content>
</Sidebar>
</SettingsProvider>
);
};
export default memo(AccountSidebar);

View File

@@ -0,0 +1,224 @@
import { VisuallyHidden } from '@react-aria/visually-hidden';
import { css } from '@rocket.chat/css-in-js';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Accordion, AccordionItem, Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import {
FieldDescription,
Field,
FieldGroup,
FieldHint,
FieldLabel,
FieldRow,
RadioButton,
Select,
ToggleSwitch,
} from '@rocket.chat/fuselage-forms';
import { ExternalLink, Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useTranslation, useToastMessageDispatch, useEndpoint, useSetting, useLocationHash } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useId, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { fontSizes } from './fontSizes';
import type { AccessibilityPreferencesData } from './hooks/useAcessibilityPreferencesValues';
import { useAccessiblityPreferencesValues } from './hooks/useAcessibilityPreferencesValues';
import { useCreateFontStyleElement } from './hooks/useCreateFontStyleElement';
import { themeItems as themes } from './themeItems';
import { getDirtyFields } from '../../../lib/getDirtyFields';
import { links } from '../../../lib/links';
const AccessibilityPage = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const preferencesValues = useAccessiblityPreferencesValues();
const createFontStyleElement = useCreateFontStyleElement();
const displayRolesEnabled = useSetting('UI_DisplayRoles');
const shouldExpand = useLocationHash().length > 1;
const timeFormatOptions = useMemo(
(): SelectOption[] => [
['0', t('Default')],
['1', t('12_Hour')],
['2', t('24_Hour')],
],
[t],
);
const pageFormId = useId();
const linkListId = useId();
const {
formState: { isDirty, dirtyFields, isSubmitting },
handleSubmit,
control,
reset,
watch,
} = useForm({
defaultValues: preferencesValues,
});
const currentData = watch();
const setUserPreferencesEndpoint = useEndpoint('POST', '/v1/users.setPreferences');
const setPreferencesAction = useMutation({
mutationFn: setUserPreferencesEndpoint,
onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Preferences_saved') }),
onError: (error) => dispatchToastMessage({ type: 'error', message: error }),
onSettled: (_data, _error, { data: { fontSize } }) => {
reset(currentData);
dirtyFields.fontSize && fontSize && createFontStyleElement(fontSize);
},
});
const handleSaveData = (formData: AccessibilityPreferencesData) => {
const data = getDirtyFields(formData, dirtyFields);
setPreferencesAction.mutateAsync({ data });
};
return (
<Page>
<PageHeader title={t('Accessibility_and_Appearance')} />
<PageScrollableContentWithShadow>
<Box
is='form'
id={pageFormId}
onSubmit={handleSubmit(handleSaveData)}
maxWidth='x600'
width='full'
alignSelf='center'
marginBlock={40}
marginInline={36}
>
<Box fontScale='p1' marginBlockEnd={24}>
<Box paddingBlock={16} is='p'>
{t('Accessibility_activation')}
</Box>
<p id={linkListId}>{t('Learn_more_about_accessibility')}</p>
<ul aria-labelledby={linkListId}>
<li>
<ExternalLink to={links.go.accessibilityStatement}>{t('Accessibility_statement')}</ExternalLink>
</li>
<li>
<ExternalLink to={links.go.glossary}>{t('Glossary_of_simplified_terms')}</ExternalLink>
</li>
<li>
<ExternalLink to={links.go.accessibilityAndAppearance}>{t('Accessibility_feature_documentation')}</ExternalLink>
</li>
</ul>
</Box>
<Accordion>
<AccordionItem defaultExpanded={true} title={t('Theme')}>
{themes.map(({ id, title, description }, index) => {
return (
<Field
key={id}
paddingBlockEnd={themes.length - 1 ? undefined : 'x28'}
paddingBlockStart={index === 0 ? undefined : 'x28'}
>
<FieldRow>
<FieldLabel display='flex' alignItems='center'>
{t(title)}
</FieldLabel>
<Controller
control={control}
name='themeAppearence'
render={({ field: { value, onChange, ...field } }) => (
<RadioButton id={id} {...field} onChange={() => onChange(id)} checked={value === id} />
)}
/>
</FieldRow>
<FieldHint marginBlockStart={12} style={{ whiteSpace: 'break-spaces' }}>
{t(description)}
</FieldHint>
</Field>
);
})}
</AccordionItem>
<AccordionItem defaultExpanded={shouldExpand} title={t('Adjustable_layout')}>
<FieldGroup>
<VisuallyHidden>
<legend>{t('Adjustable_layout')}</legend>
</VisuallyHidden>
<Field>
<FieldLabel marginBlockEnd={12}>{t('Font_size')}</FieldLabel>
<FieldRow>
<Controller control={control} name='fontSize' render={({ field }) => <Select {...field} options={fontSizes(t)} />} />
</FieldRow>
<FieldDescription marginBlock={12}>{t('Adjustable_font_size_description')}</FieldDescription>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Mentions_with_@_symbol')}</FieldLabel>
<Controller
control={control}
name='mentionsWithSymbol'
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldDescription
className={css`
white-space: break-spaces;
`}
marginBlock={12}
>
{t('Mentions_with_@_symbol_description')}
</FieldDescription>
</Field>
<Field id='clockMode'>
<FieldLabel>{t('Message_TimeFormat')}</FieldLabel>
<FieldRow>
<Controller
name='clockMode'
control={control}
render={({ field: { value, ...field } }) => <Select {...field} value={`${value}`} options={timeFormatOptions} />}
/>
</FieldRow>
</Field>
<Field id='hideUsernames'>
<FieldRow>
<FieldLabel>{t('Show_usernames')}</FieldLabel>
<Controller
name='hideUsernames'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<ToggleSwitch {...field} checked={!value} onChange={(e) => onChange(!(e.target as HTMLInputElement).checked)} />
)}
/>
</FieldRow>
<FieldDescription>{t('Show_or_hide_the_username_of_message_authors')}</FieldDescription>
</Field>
{displayRolesEnabled && (
<Field id='hideRoles'>
<FieldRow>
<FieldLabel>{t('Show_roles')}</FieldLabel>
<Controller
name='hideRoles'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<ToggleSwitch {...field} checked={!value} onChange={(e) => onChange(!(e.target as HTMLInputElement).checked)} />
)}
/>
</FieldRow>
<FieldDescription>{t('Show_or_hide_the_user_roles_of_message_authors')}</FieldDescription>
</Field>
)}
</FieldGroup>
</AccordionItem>
</Accordion>
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset(preferencesValues)}>{t('Cancel')}</Button>
<Button primary disabled={!isDirty} loading={isSubmitting} form={pageFormId} type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AccessibilityPage;

View File

@@ -0,0 +1,12 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
type TranslationFunction = (key: TranslationKey) => string;
export const fontSizes = (t: TranslationFunction): SelectOption[] => [
[`${(14 / 16) * 100}%`, t('Font_Small')],
['100%', t('Font_Default')],
[`${(18 / 16) * 100}%`, t('Font_Medium')],
[`${(20 / 16) * 100}%`, t('Font_Large')],
[`${(24 / 16) * 100}%`, t('Font_Extra_large')],
];

View File

@@ -0,0 +1,31 @@
import type { ThemePreference } from '@rocket.chat/core-typings';
import type { FontSize } from '@rocket.chat/rest-typings';
import { useUserPreference } from '@rocket.chat/ui-contexts';
export type AccessibilityPreferencesData = {
themeAppearence?: ThemePreference;
fontSize?: FontSize;
fontSizePreference?: FontSize;
mentionsWithSymbol?: boolean;
clockMode?: 0 | 1 | 2;
hideUsernames?: boolean;
hideRoles?: boolean;
};
export const useAccessiblityPreferencesValues = (): AccessibilityPreferencesData => {
const themeAppearence = useUserPreference<ThemePreference>('themeAppearence') || 'auto';
const fontSize = useUserPreference<FontSize>('fontSize') || '100%';
const mentionsWithSymbol = useUserPreference<boolean>('mentionsWithSymbol') || false;
const clockMode = useUserPreference<0 | 1 | 2>('clockMode') ?? 0;
const hideUsernames = useUserPreference<boolean>('hideUsernames');
const hideRoles = useUserPreference<boolean>('hideRoles');
return {
themeAppearence,
fontSize,
mentionsWithSymbol,
clockMode,
hideUsernames,
hideRoles,
};
};

View File

@@ -0,0 +1,20 @@
import { useCallback } from 'react';
const createStyleElement = (id: string) => {
const styleElement = document.getElementById(id);
if (styleElement) {
return styleElement;
}
const newStyleElement = document.createElement('style');
newStyleElement.setAttribute('id', id);
return newStyleElement;
};
export const useCreateFontStyleElement = (): ((fontSize: string) => void) => {
return useCallback((fontSize: string) => {
const styleElement = createStyleElement('rcx-font-size');
const css = `html { font-size: ${fontSize}; }`;
styleElement.innerHTML = css;
document.head.appendChild(styleElement);
}, []);
};

View File

@@ -0,0 +1,29 @@
import type { TranslationKey } from '@rocket.chat/ui-contexts';
type ThemeItem = {
id: string;
title: TranslationKey;
description: TranslationKey;
};
export const themeItems: ThemeItem[] = [
{
id: 'light',
title: 'Theme_light',
description: 'Theme_light_description',
},
{
id: 'dark',
title: 'Theme_dark',
description: 'Theme_dark_description',
},
{
id: 'high-contrast',
title: 'Theme_high_contrast',
description: 'Theme_high_contrast_description',
},
{
id: 'auto',
title: 'Theme_match_system',
description: 'Theme_match_system_description',
},
];

View File

@@ -0,0 +1,19 @@
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import DeviceManagementAccountTable from './DeviceManagementAccountTable';
const DeviceManagementAccountPage = () => {
const { t } = useTranslation();
return (
<Page>
<PageHeader title={t('Manage_Devices')} />
<PageContent>
<DeviceManagementAccountTable />
</PageContent>
</Page>
);
};
export default DeviceManagementAccountPage;

View File

@@ -0,0 +1,49 @@
import { Box, Button } from '@rocket.chat/fuselage';
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { GenericTableCell, GenericTableRow } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import DeviceIcon from '../../../../components/deviceManagement/DeviceIcon';
import { useDeviceLogout } from '../../../../hooks/useDeviceLogout';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
type DevicesRowProps = {
_id: string;
deviceName?: string;
deviceType?: string;
deviceOSName?: string;
loginAt: string;
current?: boolean;
};
const DeviceManagementAccountRow = ({ _id, deviceName, deviceType = 'browser', deviceOSName, loginAt, current }: DevicesRowProps) => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const mediaQuery = useMediaQuery('(min-width: 1024px)');
const handleDeviceLogout = useDeviceLogout(_id, '/v1/sessions/logout.me', current);
return (
<GenericTableRow key={_id} aria-label={_id}>
<GenericTableCell>
<Box display='flex' alignItems='center'>
<DeviceIcon deviceType={deviceType} />
{deviceName && (
<Box withTruncatedText>
{deviceName}
{current && ` (${t('current')})`}
</Box>
)}
</Box>
</GenericTableCell>
<GenericTableCell>{deviceOSName || ''}</GenericTableCell>
<GenericTableCell>{formatDateAndTime(loginAt)}</GenericTableCell>
{mediaQuery && <GenericTableCell>{_id}</GenericTableCell>}
<GenericTableCell align='end'>
<Button onClick={() => handleDeviceLogout()}>{t('Logout')}</Button>
</GenericTableCell>
</GenericTableRow>
);
};
export default DeviceManagementAccountRow;

View File

@@ -0,0 +1,81 @@
import { 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 { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import DeviceManagementAccountRow from './DeviceManagementAccountRow';
import DeviceManagementTable from '../../../../components/deviceManagement/DeviceManagementTable';
import { deviceManagementQueryKeys } from '../../../../lib/queryKeys';
const sortMapping = {
client: 'device.name',
os: 'device.os.name',
loginAt: 'loginAt',
};
const DeviceManagementAccountTable = () => {
const { t } = useTranslation();
const { current, itemsPerPage, setCurrent, setItemsPerPage, ...paginationProps } = usePagination();
const { sortBy, sortDirection, setSort } = useSort<'client' | 'os' | 'loginAt'>('loginAt');
const query = useMemo(
() => ({
sort: JSON.stringify({ [sortMapping[sortBy]]: sortDirection === 'asc' ? 1 : -1 }),
count: itemsPerPage,
offset: current,
}),
[itemsPerPage, current, sortBy, sortDirection],
);
const listSessions = useEndpoint('GET', '/v1/sessions/list');
const queryResult = useQuery({
queryKey: deviceManagementQueryKeys.userSessions(query),
queryFn: () => listSessions(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='os' direction={sortDirection} active={sortBy === 'os'} onClick={setSort} sort='os'>
{t('OS')}
</GenericTableHeaderCell>,
<GenericTableHeaderCell key='loginAt' direction={sortDirection} active={sortBy === 'loginAt'} onClick={setSort} sort='loginAt'>
{t('Last_login')}
</GenericTableHeaderCell>,
mediaQuery && <GenericTableHeaderCell key='_id'>{t('Device_ID')}</GenericTableHeaderCell>,
<GenericTableHeaderCell key='logout' />,
],
[t, mediaQuery, sortDirection, sortBy, setSort],
);
return (
<DeviceManagementTable
{...queryResult}
headers={headers}
renderRow={(session) => (
<DeviceManagementAccountRow
key={session._id}
_id={session._id}
deviceName={session.device?.name}
deviceType={session.device?.type}
deviceOSName={session.device?.os.name}
loginAt={session.loginAt}
current={session.current}
/>
)}
current={current}
itemsPerPage={itemsPerPage}
setCurrent={setCurrent}
setItemsPerPage={setItemsPerPage}
paginationProps={paginationProps}
/>
);
};
export default DeviceManagementAccountTable;

View File

@@ -0,0 +1 @@
export { default } from './DeviceManagementAccountTable';

View File

@@ -0,0 +1,140 @@
import {
ButtonGroup,
Button,
Box,
ToggleSwitch,
States,
StatesIcon,
StatesTitle,
Accordion,
AccordionItem,
Field,
FieldGroup,
FieldLabel,
FieldRow,
FieldHint,
Callout,
Margins,
} from '@rocket.chat/fuselage';
import { usePreferenceFeaturePreviewList, Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useToastMessageDispatch, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useEffect, Fragment } from 'react';
import { useForm } from 'react-hook-form';
import { useFeaturePreviewEnableQuery } from '../../../hooks/useFeaturePreviewEnableQuery';
const AccountFeaturePreviewPage = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const { features, unseenFeatures } = usePreferenceFeaturePreviewList();
const setUserPreferences = useEndpoint('POST', '/v1/users.setPreferences');
useEffect(() => {
if (unseenFeatures) {
const featuresPreview = features.map((feature) => ({
name: feature.name,
value: feature.value,
}));
void setUserPreferences({ data: { featuresPreview } });
}
}, [setUserPreferences, features, unseenFeatures]);
const {
watch,
formState: { isDirty },
setValue,
handleSubmit,
reset,
} = useForm({
defaultValues: { featuresPreview: features },
});
const { featuresPreview } = watch();
const handleSave = async () => {
const featuresToBeSaved = featuresPreview.map((feature) => ({ name: feature.name, value: feature.value }));
try {
await setUserPreferences({ data: { featuresPreview: featuresToBeSaved } });
dispatchToastMessage({ type: 'success', message: t('Preferences_saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
reset({ featuresPreview });
}
};
const handleFeatures = (e: ChangeEvent<HTMLInputElement>) => {
const updated = featuresPreview.map((item) => (item.name === e.target.name ? { ...item, value: e.target.checked } : item));
setValue('featuresPreview', updated, { shouldDirty: true });
};
const grouppedFeaturesPreview = useFeaturePreviewEnableQuery(featuresPreview);
return (
<Page>
<PageHeader title={t('Feature_preview')} />
<PageScrollableContentWithShadow>
<Box maxWidth='x600' width='full' alignSelf='center'>
{featuresPreview.length === 0 && (
<States>
<StatesIcon name='magnifier' />
<StatesTitle>{t('No_feature_to_preview')}</StatesTitle>
</States>
)}
{featuresPreview.length > 0 && (
<>
<Box>
<Margins block={24}>
<Box fontScale='p1'>{t('Feature_preview_page_description')}</Box>
<Callout>{t('Feature_preview_page_callout')}</Callout>
</Margins>
</Box>
<Accordion>
{grouppedFeaturesPreview?.map(([group, features], index) => (
<AccordionItem defaultExpanded={index === 0} key={group} title={t(group as TranslationKey)}>
<FieldGroup>
{features.map((feature) => (
<Fragment key={feature.name}>
<Field>
<FieldRow>
<FieldLabel htmlFor={feature.name}>{t(feature.i18n)}</FieldLabel>
<ToggleSwitch
id={feature.name}
checked={feature.value}
name={feature.name}
onChange={handleFeatures}
disabled={feature.disabled}
/>
</FieldRow>
{feature.description && <FieldHint marginBlockStart={12}>{t(feature.description)}</FieldHint>}
</Field>
{feature.imageUrl && (
<Box is='img' width='100%' height='auto' marginBlockStart={16} src={feature.imageUrl} alt='' />
)}
</Fragment>
))}
</FieldGroup>
</AccordionItem>
))}
</Accordion>
</>
)}
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset({ featuresPreview: features })}>{t('Cancel')}</Button>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AccountFeaturePreviewPage;

View File

@@ -0,0 +1,2 @@
export { registerAccountRoute } from './routes';
export { registerAccountSidebarItem, unregisterSidebarItem } from './sidebarItems';

View File

@@ -0,0 +1,70 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Button } from '@rocket.chat/fuselage';
import { Select, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useRemoveWebDAVAccountIntegrationMutation } from './hooks/useRemoveWebDAVAccountIntegrationMutation';
import { useWebDAVAccountIntegrationsQuery } from '../../../hooks/webdav/useWebDAVAccountIntegrationsQuery';
import { getWebdavServerName } from '../../../lib/getWebdavServerName';
const AccountIntegrationsPage = () => {
const { data: webdavAccountIntegrations } = useWebDAVAccountIntegrationsQuery();
const {
handleSubmit,
control,
formState: { errors },
} = useForm<{ accountSelected: string }>();
const options: SelectOption[] = useMemo(
() => webdavAccountIntegrations?.map(({ _id, ...current }) => [_id, getWebdavServerName(current)]) ?? [],
[webdavAccountIntegrations],
);
const dispatchToastMessage = useToastMessageDispatch();
const { t } = useTranslation();
const removeMutation = useRemoveWebDAVAccountIntegrationMutation({
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Webdav_account_removed') });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
});
const handleSubmitForm = ({ accountSelected }: { accountSelected: string }) => {
removeMutation.mutate({ accountSelected });
};
return (
<Page>
<PageHeader title={t('Integrations')} />
<PageScrollableContentWithShadow>
<Box is='form' maxWidth='x600' width='full' alignSelf='center' onSubmit={handleSubmit(handleSubmitForm)}>
<Field>
<FieldLabel>{t('WebDAV_Accounts')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='accountSelected'
rules={{ required: t('Required_field', { field: t('WebDAV_Accounts') }) }}
render={({ field }) => <Select {...field} options={options} placeholder={t('Select_an_option')} />}
/>
<Button type='submit' danger>
{t('Remove')}
</Button>
</FieldRow>
{errors?.accountSelected && <FieldError>{errors.accountSelected.message}</FieldError>}
</Field>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
};
export default AccountIntegrationsPage;

View File

@@ -0,0 +1,16 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import AccountIntegrationsPage from './AccountIntegrationsPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AccountIntegrationsRoute = () => {
const webdavEnabled = useSetting('Webdav_Integration_Enabled', false);
if (!webdavEnabled) {
return <NotAuthorizedPage />;
}
return <AccountIntegrationsPage />;
};
export default AccountIntegrationsRoute;

View File

@@ -0,0 +1,16 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
type UseRemoveWebDAVAccountIntegrationMutationOptions = Omit<UseMutationOptions<void, unknown, { accountSelected: string }>, 'mutationFn'>;
export const useRemoveWebDAVAccountIntegrationMutation = (options?: UseRemoveWebDAVAccountIntegrationMutationOptions) => {
const removeWebdavAccount = useEndpoint('POST', '/v1/webdav.removeWebdavAccount');
return useMutation({
mutationFn: async ({ accountSelected }: { accountSelected: string }) => {
await removeWebdavAccount({ accountId: accountSelected });
},
...options,
});
};

View File

@@ -0,0 +1,74 @@
import { ButtonGroup, Button, Box, Accordion } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useEndpoint, useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
import { useForm, FormProvider } from 'react-hook-form';
import PreferencesConversationTranscript from './PreferencesConversationTranscript';
import { PreferencesGeneral } from './PreferencesGeneral';
type FormData = {
omnichannelTranscriptPDF: boolean;
omnichannelTranscriptEmail: boolean;
};
const OmnichannelPreferencesPage = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const alwaysSendEmailTranscript = useSetting('Livechat_transcript_send_always', false);
const omnichannelTranscriptPDF = useUserPreference<boolean>('omnichannelTranscriptPDF') ?? false;
const omnichannelTranscriptEmail = useUserPreference<boolean>('omnichannelTranscriptEmail') ?? false;
const omnichannelHideConversationAfterClosing = useUserPreference<boolean>('omnichannelHideConversationAfterClosing') ?? true;
const methods = useForm({
defaultValues: {
omnichannelTranscriptPDF,
omnichannelTranscriptEmail: alwaysSendEmailTranscript || omnichannelTranscriptEmail,
omnichannelHideConversationAfterClosing,
},
});
const {
handleSubmit,
formState: { isDirty },
reset,
} = methods;
const saveFn = useEndpoint('POST', '/v1/users.setPreferences');
const handleSave = async (data: FormData) => {
try {
await saveFn({ data });
reset(data);
dispatchToastMessage({ type: 'success', message: t('Preferences_saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<Page>
<PageHeader title={t('Omnichannel')} />
<PageScrollableContentWithShadow is='form' onSubmit={handleSubmit(handleSave)}>
<Box maxWidth='x600' width='full' alignSelf='center'>
<Accordion>
<FormProvider {...methods}>
<PreferencesGeneral />
<PreferencesConversationTranscript />
</FormProvider>
</Accordion>
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset({ omnichannelTranscriptPDF, omnichannelTranscriptEmail })}>{t('Cancel')}</Button>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default OmnichannelPreferencesPage;

View File

@@ -0,0 +1,60 @@
import { AccordionItem, Tag } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldLabel, FieldRow, FieldHint, ToggleSwitch } from '@rocket.chat/fuselage-forms';
import { useTranslation, usePermission, useSetting } from '@rocket.chat/ui-contexts';
import { Controller, useFormContext } from 'react-hook-form';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
const PreferencesConversationTranscript = () => {
const t = useTranslation();
const { control } = useFormContext();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const alwaysSendEmailTranscript = useSetting('Livechat_transcript_send_always');
const canSendTranscriptPDF = usePermission('request-pdf-transcript');
const canSendTranscriptEmailPermission = usePermission('send-omnichannel-chat-transcript');
const canSendTranscriptEmail = canSendTranscriptEmailPermission && !alwaysSendEmailTranscript;
const cantSendTranscriptPDF = !canSendTranscriptPDF || !hasLicense;
return (
<AccordionItem defaultExpanded title={t('Conversational_transcript')}>
<FieldGroup>
<Field>
<FieldRow>
<FieldLabel>
{t('Omnichannel_transcript_pdf')}
{!hasLicense && (
<Tag marginInline={4} variant='featured'>
{t('Premium')}
</Tag>
)}
{!canSendTranscriptPDF && hasLicense && <Tag marginInline={4}>{t('No_permission')}</Tag>}
</FieldLabel>
<Controller
control={control}
name='omnichannelTranscriptPDF'
render={({ field: { value, ...field } }) => <ToggleSwitch disabled={cantSendTranscriptPDF} {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description')}</FieldHint>
</Field>
<Field>
<FieldRow>
<FieldLabel>
{t('Omnichannel_transcript_email')}
{!canSendTranscriptEmailPermission && <Tag marginInline={4}>{t('No_permission')}</Tag>}
</FieldLabel>
<Controller
control={control}
name='omnichannelTranscriptEmail'
render={({ field: { value, ...field } }) => <ToggleSwitch disabled={!canSendTranscriptEmail} {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description')}</FieldHint>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesConversationTranscript;

View File

@@ -0,0 +1,24 @@
import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage-forms';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export const PreferencesGeneral = () => {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<FieldGroup marginBlockEnd='1.5rem' paddingInline='0.5rem'>
<Field>
<FieldRow>
<FieldLabel>{t('Omnichannel_hide_conversation_after_closing')}</FieldLabel>
<Controller
control={control}
name='omnichannelHideConversationAfterClosing'
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Omnichannel_hide_conversation_after_closing_description')}</FieldHint>
</Field>
</FieldGroup>
);
};

View File

@@ -0,0 +1,105 @@
import { ButtonGroup, Button, Box, Accordion } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useSetting, useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useId } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import PreferencesGlobalSection from './PreferencesGlobalSection';
import PreferencesHighlightsSection from './PreferencesHighlightsSection';
import PreferencesLocalizationSection from './PreferencesLocalizationSection';
import PreferencesMessagesSection from './PreferencesMessagesSection';
import PreferencesMyDataSection from './PreferencesMyDataSection';
import PreferencesNotificationsSection from './PreferencesNotificationsSection';
import PreferencesSoundSection from './PreferencesSoundSection';
import PreferencesUserPresenceSection from './PreferencesUserPresenceSection';
import type { AccountPreferencesData } from './useAccountPreferencesValues';
import { useAccountPreferencesValues } from './useAccountPreferencesValues';
import { getDirtyFields } from '../../../lib/getDirtyFields';
const AccountPreferencesPage = () => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const dataDownloadEnabled = useSetting('UserData_EnableDownload');
const preferencesValues = useAccountPreferencesValues();
const methods = useForm({ defaultValues: preferencesValues });
const {
handleSubmit,
reset,
watch,
formState: { isDirty, dirtyFields },
} = methods;
const currentData = watch();
const setPreferencesEndpoint = useEndpoint('POST', '/v1/users.setPreferences');
const setPreferencesAction = useMutation({
mutationFn: setPreferencesEndpoint,
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Preferences_saved') });
},
onError: (error) => {
dispatchToastMessage({ type: 'error', message: error });
},
onSettled: () => reset(currentData),
});
const handleSaveData = async (formData: AccountPreferencesData) => {
const { highlights, dontAskAgainList, ...data } = getDirtyFields(formData, dirtyFields);
if (highlights || highlights === '') {
Object.assign(data, {
highlights:
typeof highlights === 'string' &&
highlights
.split(/,|\n/)
.map((val) => val.trim())
.filter(Boolean),
});
}
if (dontAskAgainList) {
const list =
Array.isArray(dontAskAgainList) && dontAskAgainList.length > 0
? dontAskAgainList.map(([action, label]) => ({ action, label }))
: [];
Object.assign(data, { dontAskAgainList: list });
}
setPreferencesAction.mutateAsync({ data });
};
const preferencesFormId = useId();
return (
<Page>
<PageHeader title={t('Preferences')} />
<PageScrollableContentWithShadow>
<FormProvider {...methods}>
<Box id={preferencesFormId} is='form' maxWidth='x600' width='full' alignSelf='center' onSubmit={handleSubmit(handleSaveData)}>
<Accordion>
<PreferencesLocalizationSection />
<PreferencesGlobalSection />
<PreferencesUserPresenceSection />
<PreferencesNotificationsSection />
<PreferencesMessagesSection />
<PreferencesHighlightsSection />
<PreferencesSoundSection />
{dataDownloadEnabled && <PreferencesMyDataSection />}
</Accordion>
</Box>
</FormProvider>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset(preferencesValues)}>{t('Cancel')}</Button>
<Button form={preferencesFormId} primary type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AccountPreferencesPage;

View File

@@ -0,0 +1,48 @@
import {
Button,
Box,
Modal,
ModalHeader,
ModalIcon,
ModalTitle,
ModalClose,
ModalContent,
ModalFooter,
ModalFooterControllers,
} from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
export type MyDataModalProps = {
onCancel: () => void;
title: string;
text?: ReactNode;
};
const MyDataModal = ({ onCancel, title, text, ...props }: MyDataModalProps) => {
const { t } = useTranslation();
return (
<Modal {...props}>
<ModalHeader>
<ModalIcon color='status-font-on-success' name='circle-check' />
<ModalTitle>{title}</ModalTitle>
<ModalClose onClick={onCancel} />
</ModalHeader>
{text && (
<ModalContent fontScale='p2'>
<Box marginBlock={8}>{text}</Box>
</ModalContent>
)}
<ModalFooter>
<ModalFooterControllers>
<Button primary onClick={onCancel}>
{t('Ok')}
</Button>
</ModalFooterControllers>
</ModalFooter>
</Modal>
);
};
export default MyDataModal;

View File

@@ -0,0 +1,36 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldLabel, FieldRow, MultiSelect } from '@rocket.chat/fuselage-forms';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const PreferencesGlobalSection = () => {
const { t } = useTranslation();
const userDontAskAgainList = useUserPreference<{ action: string; label: string }[]>('dontAskAgainList') || [];
const options: SelectOption[] = userDontAskAgainList.map(({ action, label }) => [action, label]);
const { control } = useFormContext();
return (
<AccordionItem title={t('Global')}>
<FieldGroup>
<Field>
<FieldLabel>{t('Dont_ask_me_again_list')}</FieldLabel>
<FieldRow>
<Controller
name='dontAskAgainList'
control={control}
render={({ field: { value, onChange } }) => (
<MultiSelect placeholder={t('Nothing_found')} value={value} onChange={onChange} options={options} />
)}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesGlobalSection;

View File

@@ -0,0 +1,25 @@
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldLabel, FieldRow, FieldHint, TextAreaInput } from '@rocket.chat/fuselage-forms';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const PreferencesHighlightsSection = () => {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<AccordionItem title={t('Highlights')}>
<FieldGroup>
<Field>
<FieldLabel>{t('Highlights_List')}</FieldLabel>
<FieldRow>
<Controller control={control} name='highlights' render={({ field }) => <TextAreaInput {...field} rows={4} />} />
</FieldRow>
<FieldHint>{t('Highlights_How_To')}</FieldHint>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesHighlightsSection;

View File

@@ -0,0 +1,31 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage-forms';
import { useLanguages } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const PreferencesLocalizationSection = () => {
const { t } = useTranslation();
const languages = useLanguages();
const { control } = useFormContext();
const languageOptions = useMemo(() => languages.map(({ key, name }): SelectOption => [key, name]), [languages]);
return (
<AccordionItem title={t('Localization')} defaultExpanded>
<FieldGroup>
<Field>
<FieldLabel>{t('Language')}</FieldLabel>
<FieldRow>
<Controller control={control} name='language' render={({ field }) => <Select {...field} options={languageOptions} />} />
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesLocalizationSection;

View File

@@ -0,0 +1,169 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldHint, FieldLabel, FieldLink, FieldRow, Select, ToggleSwitch } from '@rocket.chat/fuselage-forms';
import { useId, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const PreferencesMessagesSection = () => {
const { t } = useTranslation();
const { control } = useFormContext();
const messageTimeFormatLabelId = useId();
const hideUsernamesLabelId = useId();
const hideRolesLabelId = useId();
const alsoSendThreadMessageToChannelOptions = useMemo(
(): SelectOption[] => [
['default', t('Selected_first_reply_unselected_following_replies')],
['always', t('Selected_by_default')],
['never', t('Unselected_by_default')],
],
[t],
);
const sendOnEnterOptions = useMemo(
(): SelectOption[] => [
['normal', t('Enter_Normal')],
['alternative', t('Enter_Alternative')],
['desktop', t('Only_On_Desktop')],
],
[t],
);
return (
<AccordionItem title={t('Messages')}>
<FieldGroup>
<Field>
<FieldRow>
<FieldLabel>{t('Unread_Tray_Icon_Alert')}</FieldLabel>
<Controller
name='unreadAlert'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Always_show_thread_replies_in_main_channel')}</FieldLabel>
<Controller
name='showThreadsInMainChannel'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Accounts_Default_User_Preferences_showThreadsInMainChannel_Description')}</FieldHint>
</Field>
<Field>
<FieldLabel>{t('Also_send_thread_message_to_channel_behavior')}</FieldLabel>
<FieldRow>
<Controller
name='alsoSendThreadToChannel'
control={control}
render={({ field }) => <Select {...field} options={alsoSendThreadMessageToChannelOptions} />}
/>
</FieldRow>
<FieldHint>{t('Accounts_Default_User_Preferences_alsoSendThreadToChannel_Description')}</FieldHint>
</Field>
<Field>
<FieldLabel id={messageTimeFormatLabelId}>{t('Message_TimeFormat')}</FieldLabel>
<FieldLink aria-describedby={messageTimeFormatLabelId} href='/account/accessibility-and-appearance#clockMode'>
{t('Go_to_accessibility_and_appearance')}
</FieldLink>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Use_Emojis')}</FieldLabel>
<Controller
name='useEmojis'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Convert_Ascii_Emojis')}</FieldLabel>
<Controller
name='convertAsciiEmoji'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Auto_Load_Images')}</FieldLabel>
<Controller
name='autoImageLoad'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Save_Mobile_Bandwidth')}</FieldLabel>
<Controller
name='saveMobileBandwidth'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Collapse_Embedded_Media_By_Default')}</FieldLabel>
<Controller
name='collapseMediaByDefault'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel id={hideUsernamesLabelId}>{t('Hide_usernames')}</FieldLabel>
<FieldLink aria-describedby={hideUsernamesLabelId} href='/account/accessibility-and-appearance#hideUsernames'>
{t('Go_to_accessibility_and_appearance')}
</FieldLink>
</Field>
<Field>
<FieldLabel id={hideRolesLabelId}>{t('Hide_roles')}</FieldLabel>
<FieldLink aria-describedby={hideRolesLabelId} href='/account/accessibility-and-appearance#hideRoles'>
{t('Go_to_accessibility_and_appearance')}
</FieldLink>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Hide_flextab')}</FieldLabel>
<Controller
name='hideFlexTab'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Display_avatars')}</FieldLabel>
<Controller
name='displayAvatars'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Enter_Behaviour')}</FieldLabel>
<FieldRow>
<Controller name='sendOnEnter' control={control} render={({ field }) => <Select {...field} options={sendOnEnterOptions} />} />
</FieldRow>
<FieldHint>{t('Enter_Behaviour_Description')}</FieldHint>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesMessagesSection;

View File

@@ -0,0 +1,92 @@
import { AccordionItem, Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import MyDataModal from './MyDataModal';
const PreferencesMyDataSection = () => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const requestDataDownload = useEndpoint('GET', '/v1/users.requestDataDownload');
const downloadData = useCallback(
async (fullExport: boolean) => {
try {
const result = await requestDataDownload({ fullExport: fullExport ? 'true' : 'false' });
if (result.requested) {
const text = t('UserDataDownload_Requested_Text', {
pending_operations: result.pendingOperationsBeforeMyRequest,
});
setModal(
<MyDataModal
title={t('UserDataDownload_Requested')}
text={<Box dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }} />}
onCancel={() => setModal(null)}
/>,
);
return;
}
if (result.exportOperation) {
if (result.exportOperation.status === 'completed') {
const text = result.url
? t('UserDataDownload_CompletedRequestExistedWithLink_Text', {
download_link: result.url,
})
: t('UserDataDownload_CompletedRequestExisted_Text');
setModal(
<MyDataModal
title={t('UserDataDownload_Requested')}
text={<Box dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }} />}
onCancel={() => setModal(null)}
/>,
);
return;
}
const text = t('UserDataDownload_RequestExisted_Text', {
pending_operations: result.pendingOperationsBeforeMyRequest,
});
setModal(
<MyDataModal
title={t('UserDataDownload_Requested')}
text={<Box dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(text) }} />}
onCancel={() => setModal(null)}
/>,
);
return;
}
setModal(<MyDataModal title={t('UserDataDownload_Requested')} onCancel={() => setModal(null)} />);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[dispatchToastMessage, requestDataDownload, setModal, t],
);
const handleClickDownload = useCallback(() => downloadData(false), [downloadData]);
const handleClickExport = useCallback(() => downloadData(true), [downloadData]);
return (
<AccordionItem title={t('My Data')}>
<ButtonGroup stretch>
<Button icon='download' onClick={handleClickDownload}>
{t('Download_My_Data')}
</Button>
<Button icon='download' onClick={handleClickExport}>
{t('Export_My_Data')}
</Button>
</ButtonGroup>
</AccordionItem>
);
};
export default PreferencesMyDataSection;

View File

@@ -0,0 +1,212 @@
import type { INotificationDesktop } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem, Button } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow, Select, ToggleSwitch } from '@rocket.chat/fuselage-forms';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSetting, useUserPreference, useUser } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNotification } from '../../../hooks/notification/useNotification';
const notificationOptionsLabelMap = {
all: 'All_messages',
mentions: 'Mentions',
nothing: 'Nothing',
};
const emailNotificationOptionsLabelMap = {
mentions: 'Email_Notification_Mode_All',
nothing: 'Email_Notification_Mode_Disabled',
};
const PreferencesNotificationsSection = () => {
const { t, i18n } = useTranslation();
const user = useUser();
const [notificationsPermission, setNotificationsPermission] = useState<NotificationPermission>();
const defaultDesktopNotifications = useSetting(
'Accounts_Default_User_Preferences_desktopNotifications',
) as keyof typeof notificationOptionsLabelMap;
const defaultMobileNotifications = useSetting(
'Accounts_Default_User_Preferences_pushNotifications',
) as keyof typeof notificationOptionsLabelMap;
const canChangeEmailNotification = useSetting('Accounts_AllowEmailNotifications');
const loginEmailEnabled = useSetting('Device_Management_Enable_Login_Emails');
const allowLoginEmailPreference = useSetting('Device_Management_Allow_Login_Email_preference');
const showNewLoginEmailPreference = loginEmailEnabled && allowLoginEmailPreference;
const showVideoConfMobileRinging = useSetting('VideoConf_Mobile_Ringing');
const showVoipMobileRinging = useSetting('VoIP_TeamCollab_Mobile_Ringing_Enabled');
const showMobileRinging = showVideoConfMobileRinging || showVoipMobileRinging;
const notify = useNotification();
const userEmailNotificationMode = useUserPreference('emailNotificationMode') as keyof typeof emailNotificationOptionsLabelMap;
useEffect(() => setNotificationsPermission(window.Notification && Notification.permission), []);
const onSendNotification = useCallback(() => {
notify({
payload: {
sender: { _id: 'rocket.cat', username: 'rocket.cat' },
rid: 'GENERAL',
} as INotificationDesktop['payload'],
title: t('Desktop_Notification_Test'),
text: t('This_is_a_desktop_notification'),
});
}, [notify, t]);
const onAskNotificationPermission = useCallback(() => {
window.Notification && Notification.requestPermission().then((val) => setNotificationsPermission(val));
}, []);
const notificationOptions = useMemo(
() => Object.entries(notificationOptionsLabelMap).map(([key, val]) => i18n.exists(val) && [key, t(val)]),
[i18n, t],
) as SelectOption[];
const desktopNotificationOptions = useMemo<SelectOption[]>((): SelectOption[] => {
const optionsCp = notificationOptions.slice();
optionsCp.unshift(['default', `${t('Default')} (${t(notificationOptionsLabelMap[defaultDesktopNotifications] as TranslationKey)})`]);
return optionsCp;
}, [defaultDesktopNotifications, notificationOptions, t]);
const mobileNotificationOptions = useMemo(() => {
const optionsCp = notificationOptions.slice();
optionsCp.unshift(['default', `${t('Default')} (${t(notificationOptionsLabelMap[defaultMobileNotifications] as TranslationKey)})`]);
return optionsCp;
}, [defaultMobileNotifications, notificationOptions, t]);
const emailNotificationOptions = useMemo(() => {
const options = Object.entries(emailNotificationOptionsLabelMap).map(
([key, val]) => i18n.exists(val) && [key, t(val)],
) as SelectOption[];
options.unshift(['default', `${t('Default')} (${t(emailNotificationOptionsLabelMap[userEmailNotificationMode] as TranslationKey)})`]);
return options;
}, [i18n, t, userEmailNotificationMode]);
const { control } = useFormContext();
const desktopNotificationsLabelId = useId();
const showCalendarPreference = user?.settings?.calendar?.outlook?.Enabled;
return (
<AccordionItem title={t('Notifications')}>
<FieldGroup>
<Field>
<FieldLabel>{t('Desktop_Notifications')}</FieldLabel>
<FieldRow>
{notificationsPermission === 'denied' && t('Desktop_Notifications_Disabled')}
{notificationsPermission === 'granted' && (
<Button primary onClick={onSendNotification} aria-labelledby={desktopNotificationsLabelId}>
{t('Test_Desktop_Notifications')}
</Button>
)}
{notificationsPermission !== 'denied' && notificationsPermission !== 'granted' && (
<Button primary onClick={onAskNotificationPermission} aria-labelledby={desktopNotificationsLabelId}>
{t('Enable_Desktop_Notifications')}
</Button>
)}
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Notification_RequireInteraction')}</FieldLabel>
<Controller
name='desktopNotificationRequireInteraction'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Only_works_with_chrome_version_greater_50')}</FieldHint>
</Field>
<Field>
<FieldLabel>{t('Notification_Desktop_Default_For')}</FieldLabel>
<FieldRow>
<Controller
name='desktopNotifications'
control={control}
render={({ field }) => <Select {...field} options={desktopNotificationOptions} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Notification_Desktop_show_voice_calls')}</FieldLabel>
<Controller
name='desktopNotificationVoiceCalls'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Notification_Push_Default_For')}</FieldLabel>
<FieldRow>
<Controller
name='pushNotifications'
control={control}
render={({ field }) => <Select {...field} options={mobileNotificationOptions} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Email_Notification_Mode')}</FieldLabel>
<FieldRow>
<Controller
name='emailNotificationMode'
control={control}
render={({ field }) => <Select disabled={!canChangeEmailNotification} {...field} options={emailNotificationOptions} />}
/>
</FieldRow>
<FieldHint>
{canChangeEmailNotification && t('You_need_to_verifiy_your_email_address_to_get_notications')}
{!canChangeEmailNotification && t('Email_Notifications_Change_Disabled')}
</FieldHint>
</Field>
{showNewLoginEmailPreference && (
<Field>
<FieldRow>
<FieldLabel>{t('Receive_Login_Detection_Emails')}</FieldLabel>
<Controller
name='receiveLoginDetectionEmail'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Receive_Login_Detection_Emails_Description')}</FieldHint>
</Field>
)}
{showCalendarPreference && (
<Field>
<FieldRow>
<FieldLabel>{t('Notify_Calendar_Events')}</FieldLabel>
<Controller
name='notifyCalendarEvents'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
)}
{showMobileRinging && (
<Field>
<FieldRow>
<FieldLabel>{t('VideoConf_Mobile_Ringing')}</FieldLabel>
<Controller
name='enableMobileRinging'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
)}
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesNotificationsSection;

View File

@@ -0,0 +1,129 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow, Select, ToggleSwitch, Slider } from '@rocket.chat/fuselage-forms';
import { type TranslationKey, useCustomSound, useTranslation } from '@rocket.chat/ui-contexts';
import { Controller, useFormContext } from 'react-hook-form';
const PreferencesSoundSection = () => {
const t = useTranslation();
const customSound = useCustomSound();
const soundsList: SelectOption[] = customSound.list?.map((value) => [value._id, t(value.name as TranslationKey)]) || [];
const { control, watch } = useFormContext<{
newMessageNotification: string;
notificationsSoundVolume: number;
masterVolume: number;
voipRingerVolume: number;
newRoomNotification: string;
muteFocusedConversations: boolean;
}>();
const { newMessageNotification, notificationsSoundVolume = 100, masterVolume = 100, voipRingerVolume = 100 } = watch();
return (
<AccordionItem title={t('Sound')}>
<FieldGroup>
<Field>
<FieldLabel>{t('Master_volume')}</FieldLabel>
<FieldHint marginBlockEnd={4}>{t('Master_volume_hint')}</FieldHint>
<FieldRow>
<Controller name='masterVolume' control={control} render={({ field }) => <Slider {...field} minValue={0} maxValue={100} />} />
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Notification_volume')}</FieldLabel>
<FieldHint marginBlockEnd={4}>{t('Notification_volume_hint')}</FieldHint>
<FieldRow>
<Controller
name='notificationsSoundVolume'
control={control}
render={({ field: { onChange, ...field } }) => (
<Slider
{...field}
minValue={0}
maxValue={100}
onChange={(value: number) => {
const soundVolume = (notificationsSoundVolume * masterVolume) / 100;
customSound.play(newMessageNotification, { volume: soundVolume / 100 });
onChange(value);
}}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Call_ringer_volume')}</FieldLabel>
<FieldHint marginBlockEnd={4}>{t('Call_ringer_volume_hint')}</FieldHint>
<FieldRow>
<Controller
name='voipRingerVolume'
control={control}
render={({ field: { onChange, ...field } }) => (
<Slider
{...field}
minValue={0}
maxValue={100}
onChange={(value: number) => {
const soundVolume = (voipRingerVolume * masterVolume) / 100;
customSound.play('telephone', { volume: soundVolume / 100 });
onChange(value);
}}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('New_Room_Notification')}</FieldLabel>
<FieldRow>
<Controller
name='newRoomNotification'
control={control}
render={({ field: { onChange, ...field } }) => (
<Select
{...field}
options={soundsList}
onChange={(value) => {
onChange(value);
customSound.play(String(value), { volume: notificationsSoundVolume / 100 });
}}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('New_Message_Notification')}</FieldLabel>
<FieldRow>
<Controller
name='newMessageNotification'
control={control}
render={({ field: { onChange, ...field } }) => (
<Select
{...field}
options={soundsList}
onChange={(value) => {
onChange(value);
customSound.play(String(value), { volume: notificationsSoundVolume / 100 });
}}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel>{t('Mute_Focused_Conversations')}</FieldLabel>
<Controller
name='muteFocusedConversations'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesSoundSection;

View File

@@ -0,0 +1,40 @@
import { AccordionItem } from '@rocket.chat/fuselage';
import { Field, FieldGroup, FieldHint, FieldLabel, FieldRow, ToggleSwitch, NumberInput } from '@rocket.chat/fuselage-forms';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
const PreferencesUserPresenceSection = () => {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<AccordionItem title={t('User_Presence')}>
<FieldGroup>
<Field>
<FieldRow>
<FieldLabel>{t('Enable_Auto_Away')}</FieldLabel>
<Controller
name='enableAutoAway'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Enable_Auto_Away_Description')}</FieldHint>
</Field>
<Field>
<FieldLabel>{t('Idle_Time_Limit')}</FieldLabel>
<FieldRow>
<Controller
name='idleTimeLimit'
control={control}
render={({ field: { value, ...field } }) => <NumberInput {...field} value={value} />}
/>
</FieldRow>
<FieldHint>{t('Idle_Time_Limit_Description')}</FieldHint>
</Field>
</FieldGroup>
</AccordionItem>
);
};
export default PreferencesUserPresenceSection;

View File

@@ -0,0 +1,115 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
export type AccountPreferencesData = {
language?: string;
dontAskAgainList?: string[];
enableAutoAway?: boolean;
idleTimeLimit?: number;
desktopNotificationRequireInteraction?: boolean;
desktopNotifications?: string;
pushNotifications?: string;
emailNotificationMode?: string;
receiveLoginDetectionEmail?: boolean;
notifyCalendarEvents?: boolean;
enableMobileRinging?: boolean;
unreadAlert?: boolean;
showThreadsInMainChannel?: boolean;
alsoSendThreadToChannel?: 'default' | 'always' | 'never';
useEmojis?: boolean;
convertAsciiEmoji?: boolean;
autoImageLoad?: boolean;
saveMobileBandwidth?: boolean;
collapseMediaByDefault?: boolean;
hideFlexTab?: boolean;
sendOnEnter?: 'normal' | 'alternative' | 'desktop';
highlights?: string;
newRoomNotification?: string;
newMessageNotification?: string;
muteFocusedConversations?: boolean;
enableNewMessageTemplate?: boolean;
displayAvatars?: boolean;
sidebarShowFavorites?: boolean;
sidebarShowUnread?: boolean;
sidebarSortby?: string;
sidebarViewMode?: string;
sidebarDisplayAvatar?: boolean;
sidebarGroupByType?: boolean;
masterVolume?: number;
notificationsSoundVolume?: number;
voipRingerVolume?: number;
desktopNotificationVoiceCalls?: boolean;
};
export const useAccountPreferencesValues = (): AccountPreferencesData => {
const language = useUserPreference<string>('language') || '';
const userDontAskAgainList = useUserPreference<{ action: string; label: string }[]>('dontAskAgainList') || [];
const dontAskAgainList = userDontAskAgainList.map(({ action }) => action);
const enableAutoAway = useUserPreference<boolean>('enableAutoAway');
const idleTimeLimit = useUserPreference<number>('idleTimeLimit');
const desktopNotificationRequireInteraction = useUserPreference<boolean>('desktopNotificationRequireInteraction');
const desktopNotifications = useUserPreference<string>('desktopNotifications');
const pushNotifications = useUserPreference<string>('pushNotifications');
const emailNotificationMode = useUserPreference<string>('emailNotificationMode');
const receiveLoginDetectionEmail = useUserPreference<boolean>('receiveLoginDetectionEmail', true);
const notifyCalendarEvents = useUserPreference<boolean>('notifyCalendarEvents');
const enableMobileRinging = useUserPreference<boolean>('enableMobileRinging');
const unreadAlert = useUserPreference<boolean>('unreadAlert');
const showThreadsInMainChannel = useUserPreference<boolean>('showThreadsInMainChannel');
const alsoSendThreadToChannel = useUserPreference<'default' | 'always' | 'never'>('alsoSendThreadToChannel');
const useEmojis = useUserPreference<boolean>('useEmojis');
const convertAsciiEmoji = useUserPreference<boolean>('convertAsciiEmoji');
const autoImageLoad = useUserPreference<boolean>('autoImageLoad');
const saveMobileBandwidth = useUserPreference<boolean>('saveMobileBandwidth');
const collapseMediaByDefault = useUserPreference<boolean>('collapseMediaByDefault');
const hideFlexTab = useUserPreference<boolean>('hideFlexTab');
const sendOnEnter = useUserPreference<'normal' | 'alternative' | 'desktop'>('sendOnEnter');
const displayAvatars = useUserPreference<boolean>('displayAvatars');
const highlights = useUserPreference<string[]>('highlights')?.join(',\n') ?? '';
const newRoomNotification = useUserPreference<string>('newRoomNotification');
const newMessageNotification = useUserPreference<string>('newMessageNotification');
const muteFocusedConversations = useUserPreference<boolean>('muteFocusedConversations');
const masterVolume = useUserPreference<number>('masterVolume', 100);
const notificationsSoundVolume = useUserPreference<number>('notificationsSoundVolume', 100);
const voipRingerVolume = useUserPreference<number>('voipRingerVolume', 100);
const desktopNotificationVoiceCalls = useUserPreference<boolean>('desktopNotificationVoiceCalls');
return {
language,
dontAskAgainList,
enableAutoAway,
idleTimeLimit,
desktopNotificationRequireInteraction,
desktopNotifications,
pushNotifications,
emailNotificationMode,
receiveLoginDetectionEmail,
notifyCalendarEvents,
enableMobileRinging,
unreadAlert,
showThreadsInMainChannel,
alsoSendThreadToChannel,
useEmojis,
convertAsciiEmoji,
autoImageLoad,
saveMobileBandwidth,
collapseMediaByDefault,
hideFlexTab,
sendOnEnter,
displayAvatars,
highlights,
newRoomNotification,
newMessageNotification,
muteFocusedConversations,
masterVolume,
notificationsSoundVolume,
voipRingerVolume,
desktopNotificationVoiceCalls,
};
};

View File

@@ -0,0 +1,447 @@
import { VisuallyHidden } from '@react-aria/visually-hidden';
import { UserStatus } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Button, Divider, Icon, InputBox, Margins } from '@rocket.chat/fuselage';
import {
Field,
FieldGroup,
FieldLabel,
FieldRow,
FieldError,
FieldHint,
TextInput,
TextAreaInput,
Select,
} from '@rocket.chat/fuselage-forms';
import { validateEmail } from '@rocket.chat/tools';
import { CustomFieldsForm } from '@rocket.chat/ui-client';
import {
useAccountsCustomFields,
useToastMessageDispatch,
useTranslation,
useEndpoint,
useUser,
useLayout,
useSetting,
} from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import type { AllHTMLAttributes, ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import type { AccountProfileFormValues } from './getProfileInitialValues';
import { useAccountProfileSettings } from './useAccountProfileSettings';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple';
import UserStatusMenu from '../../../components/UserStatusMenu';
import UserAvatarEditor from '../../../components/avatar/UserAvatarEditor';
import { useUpdateAvatar } from '../../../hooks/useUpdateAvatar';
import { USER_STATUS_TEXT_MAX_LENGTH, BIO_TEXT_MAX_LENGTH } from '../../../lib/constants';
import { STATUS_DURATION_OPTIONS, validateStatusExpiration } from '../../../lib/statusDurations';
const AccountProfileForm = (props: AllHTMLAttributes<HTMLFormElement>) => {
const t = useTranslation();
const user = useUser();
const dispatchToastMessage = useToastMessageDispatch();
const { isMobile } = useLayout();
const setPreferences = useEndpoint('POST', '/v1/users.setPreferences');
const statusVisibilityEnabled = useSetting('Accounts_StatusVisibility_Enabled', false);
const checkUsernameAvailability = useEndpoint('GET', '/v1/users.checkUsernameAvailability');
const sendConfirmationEmail = useEndpoint('POST', '/v1/users.sendConfirmationEmail');
const customFieldsMetadata = useAccountsCustomFields();
const {
allowRealNameChange,
allowUserStatusMessageChange,
allowEmailChange,
allowUserAvatarChange,
canChangeUsername,
requireName,
namesRegex,
} = useAccountProfileSettings();
const {
control,
watch,
handleSubmit,
reset,
setValue,
formState: { errors, dirtyFields },
} = useFormContext<AccountProfileFormValues>();
const { email, avatar, username, name: userFullName, statusDuration, statusType, statusText } = watch();
const isExpirationDisabled = statusType === UserStatus.ONLINE && !statusText?.trim();
useEffect(() => {
if (isExpirationDisabled) {
setValue('statusDuration', '', { shouldValidate: true });
}
}, [isExpirationDisabled, setValue]);
const statusDurationOptions: SelectOption[] = useMemo(
() => STATUS_DURATION_OPTIONS.map(({ value, labelKey }) => [value, t(labelKey)]),
[t],
);
const previousEmail = user ? getUserEmailAddress(user) : '';
const previousUsername = user?.username || '';
const isUserVerified = user?.emails?.[0]?.verified ?? false;
const mutateConfirmationEmail = useMutation({
mutationFn: sendConfirmationEmail,
onSuccess: () => dispatchToastMessage({ type: 'success', message: t('Verification_email_sent') }),
onError: (error) => dispatchToastMessage({ type: 'error', message: error }),
});
const handleSendConfirmationEmail = useCallback(async () => {
if (email !== previousEmail) {
return;
}
mutateConfirmationEmail.mutateAsync({ email });
}, [email, previousEmail, mutateConfirmationEmail]);
const validateUsername = async (username: string): Promise<string | undefined> => {
if (!username) {
return;
}
if (username === previousUsername) {
return;
}
if (!namesRegex.test(username)) {
return t('error-invalid-username');
}
const { result: isAvailable } = await checkUsernameAvailability({ username });
if (!isAvailable) {
return t('Username_already_exist');
}
};
const updateOwnBasicInfo = useEndpoint('POST', '/v1/users.updateOwnBasicInfo');
const setUserStatus = useEndpoint('POST', '/v1/users.setStatus');
const updateAvatar = useUpdateAvatar(avatar, user?._id || '');
const handleSave = async (values: AccountProfileFormValues) => {
const {
email,
name,
username,
statusType,
statusText,
statusDuration,
statusCustomDate,
statusCustomTime,
nickname,
bio,
customFields,
statusVisibilityDenied,
} = values;
const expiresAt = STATUS_DURATION_OPTIONS.find((o) => o.value === statusDuration)?.getExpiresAt?.({
now: new Date(),
customDate: statusCustomDate,
customTime: statusCustomTime,
});
const statusDirty =
dirtyFields.statusText ||
dirtyFields.statusType ||
dirtyFields.statusDuration ||
dirtyFields.statusCustomDate ||
dirtyFields.statusCustomTime;
try {
await updateOwnBasicInfo({
data: {
name,
...(user ? getUserEmailAddress(user) !== email && { email } : {}),
username,
nickname,
bio,
},
customFields,
});
if (dirtyFields.statusVisibilityDenied) {
await setPreferences({ data: { statusVisibilityDenied } });
}
if (statusDirty) {
await setUserStatus({
status: statusType,
...(allowUserStatusMessageChange && { message: statusText }),
...(allowUserStatusMessageChange && expiresAt && { expiresAt: expiresAt.toISOString() }),
});
}
await updateAvatar();
dispatchToastMessage({ type: 'success', message: t('Profile_saved_successfully') });
reset(values);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<Box {...props} is='form' autoComplete='off' onSubmit={handleSubmit(handleSave)}>
<FieldGroup>
<VisuallyHidden>
<legend>{t('Profile_details')}</legend>
</VisuallyHidden>
<Field>
<Controller
control={control}
name='avatar'
render={({ field: { onChange } }) => (
<UserAvatarEditor
etag={user?.avatarETag}
currentUsername={user?.username}
name={userFullName}
username={username}
setAvatarObj={onChange}
disabled={!allowUserAvatarChange}
/>
)}
/>
</Field>
<Box display='flex' flexDirection={isMobile ? 'column' : 'row'} style={{ gap: '16px' }}>
<Field flexShrink={1}>
<FieldLabel required={requireName}>{t('Name')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='name'
rules={{ required: requireName && t('Required_field', { field: t('Name') }) }}
render={({ field }) => (
<TextInput {...field} disabled={!allowRealNameChange} error={errors.name?.message} aria-required={true} />
)}
/>
</FieldRow>
{errors.name && <FieldError>{errors.name.message}</FieldError>}
{!allowRealNameChange && <FieldHint>{t('RealName_Change_Disabled')}</FieldHint>}
</Field>
<Field flexShrink={1}>
<FieldLabel required>{t('Username')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='username'
rules={{
required: t('Required_field', { field: t('Username') }),
validate: validateUsername,
}}
render={({ field }) => (
<TextInput
{...field}
aria-required={true}
disabled={!canChangeUsername}
endAddon={<Icon name='at' size='x20' />}
error={errors.username?.message}
/>
)}
/>
</FieldRow>
{errors.username && <FieldError>{errors.username.message}</FieldError>}
{!canChangeUsername && <FieldHint>{t('Username_Change_Disabled')}</FieldHint>}
</Field>
</Box>
<Divider marginBlockStart={24} marginBlockEnd={0} />
<Field>
<FieldLabel>{t('Status')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusText'
rules={{
maxLength: {
value: USER_STATUS_TEXT_MAX_LENGTH,
message: t('Max_length_is', { limit: USER_STATUS_TEXT_MAX_LENGTH }),
},
}}
render={({ field }) => (
<TextInput
{...field}
placeholder={t('StatusMessage_Placeholder')}
disabled={!allowUserStatusMessageChange}
flexGrow={1}
error={errors.statusText?.message}
startAddon={
<Controller
control={control}
name='statusType'
render={({ field: { value, onChange } }) => <UserStatusMenu onChange={onChange} initialStatus={value} />}
/>
}
/>
)}
/>
</FieldRow>
{errors.statusText && <FieldError>{errors.statusText.message}</FieldError>}
{!allowUserStatusMessageChange && <FieldHint>{t('StatusMessage_Change_Disabled')}</FieldHint>}
{allowUserStatusMessageChange && <FieldHint>{t('Status_you_can_use_emoji')}</FieldHint>}
</Field>
<Field>
<FieldLabel>{t('Status_clear_after')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusDuration'
rules={{
deps: ['statusCustomDate', 'statusCustomTime'],
validate: (value, { statusCustomDate, statusCustomTime }) =>
validateStatusExpiration(value, { statusCustomDate, statusCustomTime }, t),
}}
render={({ field: { value, onChange } }) => (
<Select
value={value}
options={statusDurationOptions}
disabled={!allowUserStatusMessageChange || isExpirationDisabled}
onChange={(next) => onChange(String(next))}
/>
)}
/>
</FieldRow>
{statusDuration === 'custom' && (
<Box display='flex' marginInline='neg-x4' marginBlockStart={8}>
<Margins inline={4}>
<Controller
control={control}
name='statusCustomDate'
render={({ field: { value, onChange } }) => (
<InputBox
aria-label={t('Status_expiration_date')}
type='date'
disabled={!allowUserStatusMessageChange}
flexGrow={1}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.currentTarget.value)}
min={new Date().toLocaleDateString('en-CA')}
/>
)}
/>
<Controller
control={control}
name='statusCustomTime'
render={({ field: { value, onChange } }) => (
<InputBox
aria-label={t('Status_expiration_time')}
type='time'
disabled={!allowUserStatusMessageChange}
flexGrow={1}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.currentTarget.value)}
/>
)}
/>
</Margins>
</Box>
)}
{errors.statusDuration && <FieldError>{errors.statusDuration.message}</FieldError>}
<FieldHint>{t('Status_new_status_warning')}</FieldHint>
</Field>
{statusVisibilityEnabled && (
<Field>
<FieldLabel>{t('Accounts_StatusVisibility_HideStatusFromUsers')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='statusVisibilityDenied'
render={({ field: { onChange, value } }) => (
<UserAutoCompleteMultiple
value={value}
onChange={onChange}
exceptions={user?.username ? [user.username] : undefined}
placeholder={t('Select_users')}
/>
)}
/>
</FieldRow>
<FieldHint>{t('Accounts_StatusVisibility_HideFromUsers_Description')}</FieldHint>
</Field>
)}
<Divider marginBlockStart={24} marginBlockEnd={0} />
<Field>
<FieldLabel>{t('Nickname')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='nickname'
render={({ field }) => <TextInput {...field} flexGrow={1} endAddon={<Icon name='edit' size='x20' alignSelf='center' />} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel>{t('Bio')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='bio'
rules={{
maxLength: { value: BIO_TEXT_MAX_LENGTH, message: t('Max_length_is', { limit: BIO_TEXT_MAX_LENGTH }) },
}}
render={({ field }) => (
<TextAreaInput
{...field}
rows={3}
flexGrow={1}
error={errors.bio?.message}
endAddon={<Icon name='edit' size='x20' alignSelf='center' />}
/>
)}
/>
</FieldRow>
{errors.bio && <FieldError>{errors.bio.message}</FieldError>}
</Field>
<Field>
<FieldLabel required>{t('Email')}</FieldLabel>
<FieldRow
flexDirection={isMobile ? 'column' : 'row'}
alignItems='stretch'
className={css`
gap: 8px;
`}
>
<Controller
control={control}
name='email'
rules={{
required: t('Required_field', { field: t('Email') }),
validate: {
validateEmail: (email) => (validateEmail(email) ? undefined : t('error-invalid-email-address')),
},
}}
render={({ field }) => (
<TextInput
{...field}
aria-required={true}
flexGrow={1}
disabled={!allowEmailChange}
endAddon={<Icon name={isUserVerified ? 'circle-check' : 'mail'} size='x20' />}
error={errors.email?.message}
/>
)}
/>
{!isUserVerified && (
<Button disabled={email !== previousEmail} onClick={handleSendConfirmationEmail}>
{t('Resend_verification_email')}
</Button>
)}
</FieldRow>
{errors.email && <FieldError>{errors.email.message}</FieldError>}
{!allowEmailChange && <FieldHint>{t('Email_Change_Disabled')}</FieldHint>}
</Field>
{customFieldsMetadata && <CustomFieldsForm formName='customFields' formControl={control} metadata={customFieldsMetadata} />}
</FieldGroup>
</Box>
);
};
export default AccountProfileForm;

View File

@@ -0,0 +1,154 @@
import { ButtonGroup, Button, Box } from '@rocket.chat/fuselage';
import { SHA256 } from '@rocket.chat/sha256';
import { Page, PageFooter, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import {
useSetModal,
useToastMessageDispatch,
useUser,
useLogout,
useEndpoint,
useTranslation,
useSetting,
useLayout,
} from '@rocket.chat/ui-contexts';
import { useId, useState, useCallback } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import AccountProfileForm from './AccountProfileForm';
import ActionConfirmModal from './ActionConfirmModal';
import { getProfileInitialValues } from './getProfileInitialValues';
import ConfirmOwnerChangeModal from '../../../components/ConfirmOwnerChangeModal';
import { useAllowPasswordChange } from '../security/useAllowPasswordChange';
// TODO: enforce useMutation
const AccountProfilePage = () => {
const t = useTranslation();
const user = useUser();
const dispatchToastMessage = useToastMessageDispatch();
const { isMobile } = useLayout();
const setModal = useSetModal();
const logout = useLogout();
const [loggingOut, setLoggingOut] = useState(false);
const erasureType = useSetting('Message_ErasureType');
const allowDeleteOwnAccount = useSetting('Accounts_AllowDeleteOwnAccount');
const { hasLocalPassword } = useAllowPasswordChange();
const methods = useForm({
defaultValues: getProfileInitialValues(user),
reValidateMode: 'onBlur',
});
const {
reset,
formState: { isDirty, isSubmitting },
} = methods;
const logoutOtherClients = useEndpoint('POST', '/v1/users.logoutOtherClients');
const deleteOwnAccount = useEndpoint('POST', '/v1/users.deleteOwnAccount');
const handleLogoutOtherLocations = useCallback(async () => {
setLoggingOut(true);
try {
await logoutOtherClients();
dispatchToastMessage({
type: 'success',
message: t('Logged_out_of_other_clients_successfully'),
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
setLoggingOut(false);
}, [logoutOtherClients, dispatchToastMessage, t]);
const handleConfirmOwnerChange = useCallback(
(passwordOrUsername: string, shouldChangeOwner: string[], shouldBeRemoved: string[]) => {
const handleConfirm = async (): Promise<void> => {
try {
await deleteOwnAccount({ password: SHA256(passwordOrUsername), confirmRelinquish: true });
dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') });
setModal(null);
logout();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return setModal(
<ConfirmOwnerChangeModal
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
contentTitle={t(`Delete_User_Warning_${erasureType}` as TranslationKey)}
confirmText={t('Delete')}
shouldChangeOwner={shouldChangeOwner}
shouldBeRemoved={shouldBeRemoved}
/>,
);
},
[erasureType, setModal, t, deleteOwnAccount, dispatchToastMessage, logout],
);
const handleDeleteOwnAccount = useCallback(async () => {
const handleConfirm = async (passwordOrUsername: string): Promise<void> => {
try {
await deleteOwnAccount({ password: SHA256(passwordOrUsername) });
dispatchToastMessage({ type: 'success', message: t('User_has_been_deleted') });
setModal(null);
} catch (error: any) {
if (error.error === 'user-last-owner') {
const { shouldChangeOwner, shouldBeRemoved } = error.details;
return handleConfirmOwnerChange(passwordOrUsername, shouldChangeOwner, shouldBeRemoved);
}
if (error.errorType === 'error-invalid-password') {
throw error;
}
dispatchToastMessage({ type: 'error', message: error });
}
};
return setModal(<ActionConfirmModal onConfirm={handleConfirm} onCancel={() => setModal(null)} isPassword={hasLocalPassword} />);
}, [dispatchToastMessage, hasLocalPassword, setModal, handleConfirmOwnerChange, deleteOwnAccount, t]);
const profileFormId = useId();
return (
<Page>
<PageHeader title={t('Profile')} />
<PageScrollableContentWithShadow>
<Box maxWidth='600px' width='full' alignSelf='center'>
<FormProvider {...methods}>
<AccountProfileForm id={profileFormId} />
</FormProvider>
<Box marginBlock={12}>
<ButtonGroup stretch vertical={isMobile}>
<Button onClick={handleLogoutOtherLocations} flexGrow={0} loading={loggingOut}>
{t('Logout_Others')}
</Button>
{allowDeleteOwnAccount && (
<Button icon='trash' danger onClick={handleDeleteOwnAccount}>
{t('Delete_my_account')}
</Button>
)}
</ButtonGroup>
</Box>
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button disabled={!isDirty} onClick={() => reset(getProfileInitialValues(user))}>
{t('Cancel')}
</Button>
<Button form={profileFormId} primary disabled={!isDirty || loggingOut} loading={isSubmitting} type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AccountProfilePage;

View File

@@ -0,0 +1,16 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import AccountProfilePage from './AccountProfilePage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AccountProfileRoute = () => {
const canViewProfile = useSetting('Accounts_AllowUserProfileChange');
if (!canViewProfile) {
return <NotAuthorizedPage />;
}
return <AccountProfilePage />;
};
export default AccountProfileRoute;

View File

@@ -0,0 +1,91 @@
import { Box, PasswordInput, TextInput, FieldGroup, Field, FieldRow, FieldError, FieldLabel } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useId } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type ActionConfirmModalProps = {
isPassword: boolean;
onConfirm: (input: string) => Promise<void>;
onCancel: () => void;
};
const ActionConfirmModal = ({ isPassword, onConfirm, onCancel }: ActionConfirmModalProps) => {
const { t } = useTranslation();
const credentialFieldId = useId();
const credentialFieldError = `${credentialFieldId}-error`;
const {
control,
handleSubmit,
setError,
formState: { errors },
} = useForm({
defaultValues: { credential: '' },
mode: 'onBlur',
});
const handleSave = async ({ credential }: { credential: string }) => {
try {
await onConfirm(credential);
} catch (error: any) {
if (error.errorType === 'error-invalid-password') {
setError('credential', { message: t('Invalid_password') });
}
}
};
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleSave)} {...props} />}
onCancel={onCancel}
variant='danger'
title={t('Delete_account?')}
confirmText={t('Delete_account')}
>
<FieldGroup width='full'>
<Field>
<FieldLabel required htmlFor={credentialFieldId}>
{isPassword ? t('Enter_your_password_to_delete_your_account') : t('Enter_your_username_to_delete_your_account')}
</FieldLabel>
<FieldRow>
<Controller
name='credential'
control={control}
rules={{ required: t('error-the-field-is-required', { field: isPassword ? t('Password') : t('Username') }) }}
render={({ field }) =>
isPassword ? (
<PasswordInput
{...field}
id={credentialFieldId}
error={errors.credential?.message}
aria-invalid={errors.credential ? 'true' : 'false'}
aria-describedby={errors.credential ? credentialFieldError : undefined}
aria-required='true'
/>
) : (
<TextInput
{...field}
id={credentialFieldId}
placeholder={t('Username')}
error={errors.credential?.message}
aria-invalid={errors.credential ? 'true' : 'false'}
aria-describedby={errors.credential ? credentialFieldError : undefined}
aria-required='true'
/>
)
}
/>
</FieldRow>
{errors.credential && (
<FieldError role='alert' id={credentialFieldError}>
{errors.credential.message}
</FieldError>
)}
</Field>
</FieldGroup>
</GenericModal>
);
};
export default ActionConfirmModal;

View File

@@ -0,0 +1,32 @@
import type { AvatarObject, IUser } from '@rocket.chat/core-typings';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import type { UserStatusInitialValues } from '../../../lib/getUserInitialStatus';
import { getUserStatusInitialValues } from '../../../lib/getUserInitialStatus';
export type AccountProfileFormValues = {
email: string;
name: string;
username: string;
avatar: AvatarObject;
url: string;
bio: string;
customFields: Record<string, string>;
nickname: string;
statusVisibilityDenied: string[];
} & UserStatusInitialValues;
export const getProfileInitialValues = (user: IUser | null): AccountProfileFormValues => {
return {
email: user ? getUserEmailAddress(user) || '' : '',
name: user?.name ?? '',
username: user?.username ?? '',
avatar: '' as AvatarObject,
url: '',
bio: user?.bio ?? '',
customFields: user?.customFields ?? {},
nickname: user?.nickname ?? '',
statusVisibilityDenied: user?.settings?.preferences?.statusVisibilityDenied ?? [],
...getUserStatusInitialValues(user),
};
};

View File

@@ -0,0 +1,25 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
export const useAccountProfileSettings = () => {
const allowRealNameChange = useSetting('Accounts_AllowRealNameChange', true);
const allowUserStatusMessageChange = useSetting('Accounts_AllowUserStatusMessageChange', true);
const canChangeUsername = useSetting('Accounts_AllowUsernameChange', true);
const allowEmailChange = useSetting('Accounts_AllowEmailChange', true);
const allowUserAvatarChange = useSetting('Accounts_AllowUserAvatarChange', true);
const requireName = useSetting('Accounts_RequireNameForSignUp', true);
const namesRegexSetting = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
const namesRegex = useMemo(() => new RegExp(`^${namesRegexSetting}$`), [namesRegexSetting]);
return {
allowRealNameChange,
allowUserStatusMessageChange,
allowEmailChange,
allowUserAvatarChange,
canChangeUsername,
requireName,
namesRegex,
};
};

View File

@@ -0,0 +1,90 @@
import { lazy } from 'react';
import { createRouteGroup } from '../../lib/createRouteGroup';
declare module '@rocket.chat/ui-contexts' {
interface IRouterPaths {
'account-index': {
pathname: '/account';
pattern: '/account';
};
'preferences': {
pathname: '/account/preferences';
pattern: '/account/preferences';
};
'profile': {
pathname: '/account/profile';
pattern: '/account/profile';
};
'security': {
pathname: '/account/security';
pattern: '/account/security';
};
'integrations': {
pathname: '/account/integrations';
pattern: '/account/integrations';
};
'tokens': {
pathname: '/account/tokens';
pattern: '/account/tokens';
};
'omnichannel': {
pathname: '/account/omnichannel';
pattern: '/account/omnichannel';
};
'feature-preview': {
pathname: '/account/feature-preview';
pattern: '/account/feature-preview';
};
'accessibility-and-appearance': {
pathname: '/account/accessibility-and-appearance';
pattern: '/account/accessibility-and-appearance';
};
}
}
export const registerAccountRoute = createRouteGroup(
'account',
'/account',
lazy(() => import('./AccountRouter')),
);
registerAccountRoute('/preferences', {
name: 'preferences',
component: lazy(() => import('./preferences/AccountPreferencesPage')),
});
registerAccountRoute('/profile', {
name: 'profile',
component: lazy(() => import('./profile/AccountProfileRoute')),
});
registerAccountRoute('/security', {
name: 'security',
component: lazy(() => import('./security/AccountSecurityRoute')),
});
registerAccountRoute('/integrations', {
name: 'integrations',
component: lazy(() => import('./integrations/AccountIntegrationsRoute')),
});
registerAccountRoute('/tokens', {
name: 'tokens',
component: lazy(() => import('./tokens/AccountTokensRoute')),
});
registerAccountRoute('/omnichannel', {
name: 'omnichannel',
component: lazy(() => import('./omnichannel/OmnichannelPreferencesPage')),
});
registerAccountRoute('/feature-preview', {
name: 'feature-preview',
component: lazy(() => import('./featurePreview/AccountFeaturePreviewPage')),
});
registerAccountRoute('/accessibility-and-appearance', {
name: 'accessibility-and-appearance',
component: lazy(() => import('./accessibility/AccessibilityPage')),
});

View File

@@ -0,0 +1,94 @@
import { Box, Accordion, AccordionItem, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import ChangePassword from './ChangePassword';
import EndToEnd from './EndToEnd';
import TwoFactorEmail from './TwoFactorEmail';
import TwoFactorTOTP from './TwoFactorTOTP';
import { useRequire2faSetup } from '../../hooks/useRequire2faSetup';
const passwordDefaultValues = { password: '', confirmationPassword: '' };
const AccountSecurityPage = () => {
const t = useTranslation();
const user = useUser();
const isEmail2FAAvailableForOAuth = useSetting('Accounts_twoFactorAuthentication_email_available_for_OAuth_users');
const isOAuthUser = user?.isOAuthUser;
const isEmail2FAAllowed = !isOAuthUser || isEmail2FAAvailableForOAuth;
const methods = useForm({
defaultValues: passwordDefaultValues,
mode: 'all',
});
const {
reset,
formState: { isDirty },
} = methods;
const twoFactorEnabled = useSetting('Accounts_TwoFactorAuthentication_Enabled');
const twoFactorTOTP = useSetting('Accounts_TwoFactorAuthentication_By_TOTP_Enabled');
const twoFactorByEmailEnabled = useSetting('Accounts_TwoFactorAuthentication_By_Email_Enabled');
const e2eEnabled = useSetting('E2E_Enable');
const allowPasswordChange = useSetting('Accounts_AllowPasswordChange');
const showEmailTwoFactor = twoFactorByEmailEnabled && isEmail2FAAllowed;
const passwordFormId = useId();
const require2faSetup = useRequire2faSetup();
return (
<Page>
<PageHeader title={t('Security')} />
<PageScrollableContentWithShadow>
<Box maxWidth='x600' width='full' alignSelf='center' color='default'>
{allowPasswordChange && (
<FormProvider {...methods}>
<Accordion>
<AccordionItem title={t('Password')} defaultExpanded={!require2faSetup}>
<ChangePassword id={passwordFormId} />
</AccordionItem>
</Accordion>
</FormProvider>
)}
<Accordion>
{(twoFactorTOTP || showEmailTwoFactor) && twoFactorEnabled && (
<AccordionItem defaultExpanded={require2faSetup} title={t('Two Factor Authentication')}>
{require2faSetup && (
<Callout type='warning' title={t('Enable_two-factor_authentication')} marginBlockEnd='24px'>
{t('Enable_two-factor_authentication_callout_description')}
</Callout>
)}
{twoFactorTOTP && <TwoFactorTOTP />}
{showEmailTwoFactor && <TwoFactorEmail />}
</AccordionItem>
)}
{e2eEnabled && (
<AccordionItem
title={t('End-to-end_encryption')}
aria-label={t('End-to-end_encryption')}
defaultExpanded={!twoFactorEnabled}
data-qa-type='e2e-encryption-section'
>
<EndToEnd />
</AccordionItem>
)}
</Accordion>
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset(passwordDefaultValues)}>{t('Cancel')}</Button>
<Button form={passwordFormId} primary disabled={!isDirty} type='submit'>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AccountSecurityPage;

View File

@@ -0,0 +1,20 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import AccountSecurityPage from './AccountSecurityPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AccountSecurityRoute = () => {
const isTwoFactorEnabled = useSetting('Accounts_TwoFactorAuthentication_Enabled');
const isE2EEnabled = useSetting('E2E_Enable');
const allowPasswordChange = useSetting('Accounts_AllowPasswordChange');
const canViewSecurity = isTwoFactorEnabled || isE2EEnabled || allowPasswordChange;
if (!canViewSecurity) {
return <NotAuthorizedPage />;
}
return <AccountSecurityPage />;
};
export default AccountSecurityRoute;

View File

@@ -0,0 +1,33 @@
import { Box, CodeSnippet } from '@rocket.chat/fuselage';
import { useClipboard } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export type BackupCodesModalProps = {
codes: string[];
onClose: () => void;
};
const BackupCodesModal = ({ codes, onClose }: BackupCodesModalProps) => {
const { t } = useTranslation();
const codesText = useMemo(() => codes.join(' '), [codes]);
const { copy, hasCopied } = useClipboard(codesText);
return (
<GenericModal title={t('Backup_codes')} onCancel={onClose} cancelText={t('Close')}>
<Box marginBlock={8} withRichContent>
{t('Make_sure_you_have_a_copy_of_your_codes_1')}
</Box>
<CodeSnippet buttonText={hasCopied ? t('Copied') : t('Copy')} buttonDisabled={hasCopied} onClick={() => copy()} marginBlockStart={8}>
{codesText}
</CodeSnippet>
<Box marginBlock={8} withRichContent>
{t('Make_sure_you_have_a_copy_of_your_codes_2')}
</Box>
</GenericModal>
);
};
export default BackupCodesModal;

View File

@@ -0,0 +1,201 @@
import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, PasswordInput, Button } from '@rocket.chat/fuselage';
import { PasswordVerifierList } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, usePasswordPolicy } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useEffect, useId } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { e2e } from '../../../lib/e2ee/rocketchat.e2e';
import { useE2EEState } from '../../room/hooks/useE2EEState';
const PASSPHRASE_POLICY = Object.freeze({
enabled: true,
minLength: 30,
mustContainAtLeastOneLowercase: true,
mustContainAtLeastOneUppercase: true,
mustContainAtLeastOneNumber: true,
mustContainAtLeastOneSpecialCharacter: true,
forbidRepeatingCharacters: false,
});
const useKeysExist = () => {
const state = useE2EEState();
return state === 'READY' || state === 'SAVE_PASSWORD';
};
const useValidatePassphrase = (passphrase: string) => {
const validate = usePasswordPolicy(PASSPHRASE_POLICY);
return validate(passphrase);
};
const useChangeE2EPasswordMutation = () => {
return useMutation({
mutationFn: async (newPassword: string) => {
await e2e.changePassword(newPassword);
},
});
};
const defaultValues = {
passphrase: '',
confirmationPassphrase: '',
};
export const ChangePassphrase = () => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const uniqueId = useId();
const passphraseId = `passphrase-${uniqueId}`;
const passphraseHintId = `${passphraseId}-hint`;
const passphraseErrorId = `${passphraseId}-error`;
const confirmPassphraseId = `confirm-passphrase-${uniqueId}`;
const confirmPassphraseErrorId = `${confirmPassphraseId}-error`;
const passphraseVerifierId = `verifier-${uniqueId}`;
const e2ePasswordExplanationId = `explanation-${uniqueId}`;
const {
watch,
formState: { errors, isValid },
handleSubmit,
reset,
resetField,
control,
trigger,
} = useForm({
defaultValues,
mode: 'all',
});
const { passphrase, confirmationPassphrase } = watch();
const { validations, valid } = useValidatePassphrase(passphrase);
useEffect(() => {
if (!valid) {
resetField('confirmationPassphrase');
return;
}
if (confirmationPassphrase) {
const validateConfirmation = async () => {
await trigger('confirmationPassphrase');
};
void validateConfirmation();
}
}, [valid, confirmationPassphrase, resetField, trigger]);
const keysExist = useKeysExist();
const updatePassword = useChangeE2EPasswordMutation();
const handleSave = async ({ passphrase }: { passphrase: string }) => {
try {
await updatePassword.mutateAsync(passphrase);
dispatchToastMessage({ type: 'success', message: t('Encryption_key_saved_successfully') });
reset();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<>
<Box is='p' fontScale='p1' id={e2ePasswordExplanationId}>
<Trans i18nKey='E2E_Encryption_Password_Explanation' />
</Box>
<Box marginBlockStart={36} width='full'>
<Box is='h3' fontScale='h4' marginBlockEnd={12}>
{t('Change_E2EE_password')}
</Box>
<FieldGroup width='full'>
<Field>
<FieldLabel htmlFor={passphraseId}>{t('New_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='passphrase'
rules={{
required: t('Required_field', { field: t('New_E2EE_password') }),
validate: () => (valid ? true : t('Password_must_meet_the_complexity_requirements')),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passphraseId}
error={errors.passphrase?.message}
disabled={!keysExist}
aria-describedby={[
e2ePasswordExplanationId,
keysExist ? passphraseVerifierId : passphraseHintId,
errors.passphrase && passphraseErrorId,
]
.filter(Boolean)
.join(' ')}
aria-invalid={errors.passphrase ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{errors.passphrase && (
<FieldError aria-live='assertive' id={passphraseErrorId}>
{errors.passphrase.message}
</FieldError>
)}
{keysExist ? (
<PasswordVerifierList id={passphraseVerifierId} validations={validations} />
) : (
<FieldHint id={passphraseHintId}>
<Trans i18nKey='Enter_current_E2EE_password_to_set_new'>
To set a new password, first
<Box
is='a'
href='#'
onClick={async (e) => {
e.preventDefault();
await e2e.decodePrivateKeyFlow();
}}
>
enter your current E2EE password.
</Box>
</Trans>
</FieldHint>
)}
</Field>
{valid && (
<Field>
<FieldLabel htmlFor={confirmPassphraseId}>{t('Confirm_new_E2EE_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='confirmationPassphrase'
rules={{
required: t('Required_field', { field: t('Confirm_password') }),
validate: (value) => (passphrase !== value ? t('Passwords_do_not_match') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={confirmPassphraseId}
error={errors.confirmationPassphrase?.message}
flexGrow={1}
disabled={!keysExist || !valid}
aria-required={passphrase ? 'true' : 'false'}
aria-invalid={errors.confirmationPassphrase ? 'true' : 'false'}
aria-describedby={errors.confirmationPassphrase ? confirmPassphraseErrorId : undefined}
/>
)}
/>
</FieldRow>
{errors.confirmationPassphrase && (
<FieldError aria-live='assertive' id={confirmPassphraseErrorId} role='alert'>
{errors.confirmationPassphrase.message}
</FieldError>
)}
</Field>
)}
</FieldGroup>
<Button primary disabled={!(keysExist && valid && isValid)} onClick={handleSubmit(handleSave)} marginBlockStart={12}>
{t('Save_changes')}
</Button>
</Box>
</>
);
};

View File

@@ -0,0 +1,133 @@
import { Box, Field, FieldError, FieldGroup, FieldHint, FieldLabel, FieldRow, PasswordInput } from '@rocket.chat/fuselage';
import { PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes } from 'react';
import { useId } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useAllowPasswordChange } from './useAllowPasswordChange';
type PasswordFieldValues = { password: string; confirmationPassword: string };
function getAriaDescribedbyForPassword(
passwordVerifierId: string,
passwordId: string,
allowPasswordChange: boolean,
passwordError: boolean,
) {
const error = !allowPasswordChange || passwordError;
return [passwordVerifierId, !allowPasswordChange && `${passwordId}-hint`, error && `${passwordId}-error`].filter(Boolean).join(' ');
}
const ChangePassword = (props: AllHTMLAttributes<HTMLFormElement>) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const passwordId = useId();
const confirmPasswordId = useId();
const passwordVerifierId = useId();
const {
watch,
formState: { errors },
handleSubmit,
reset,
control,
} = useFormContext<PasswordFieldValues>();
const password = watch('password');
const passwordIsValid = useValidatePassword(password);
const { allowPasswordChange } = useAllowPasswordChange();
const updatePassword = useEndpoint('POST', '/v1/users.updateOwnBasicInfo');
const handleSave = async ({ password }: { password?: string }) => {
try {
await updatePassword({
data: {
newPassword: password,
},
});
dispatchToastMessage({ type: 'success', message: t('Password_changed_successfully') });
reset();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<Box {...props} is='form' autoComplete='off' onSubmit={handleSubmit(handleSave)}>
<FieldGroup>
<Field>
<FieldLabel htmlFor={passwordId}>{t('New_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='password'
rules={{
required: t('Required_field', { field: t('New_password') }),
validate: () => (password?.length && !passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={passwordId}
error={errors.password?.message}
flexGrow={1}
disabled={!allowPasswordChange}
aria-describedby={getAriaDescribedbyForPassword(
passwordVerifierId,
passwordId,
!!allowPasswordChange,
!!errors?.password,
)}
aria-invalid={errors.password ? 'true' : 'false'}
/>
)}
/>
</FieldRow>
{!allowPasswordChange && <FieldHint id={`${passwordId}-hint`}>{t('Password_Change_Disabled')}</FieldHint>}
{errors?.password && (
<FieldError aria-live='assertive' id={`${passwordId}-error`}>
{errors.password.message}
</FieldError>
)}
{allowPasswordChange && <PasswordVerifier password={password} id={passwordVerifierId} />}
</Field>
<Field>
<FieldLabel htmlFor={confirmPasswordId}>{t('Confirm_password')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='confirmationPassword'
rules={{
required: t('Required_field', { field: t('Confirm_password') }),
validate: (confirmationPassword) => (password !== confirmationPassword ? t('Passwords_do_not_match') : true),
}}
render={({ field }) => (
<PasswordInput
{...field}
id={confirmPasswordId}
error={errors.confirmationPassword?.message}
flexGrow={1}
disabled={!allowPasswordChange || !passwordIsValid}
aria-required={password !== '' ? 'true' : 'false'}
aria-invalid={errors.confirmationPassword ? 'true' : 'false'}
aria-describedby={errors.confirmationPassword ? `${confirmPasswordId}-error` : undefined}
/>
)}
/>
</FieldRow>
{errors.confirmationPassword && (
<FieldError aria-live='assertive' id={`${confirmPasswordId}-error`}>
{errors.confirmationPassword.message}
</FieldError>
)}
</Field>
</FieldGroup>
</Box>
);
};
export default ChangePassword;

View File

@@ -0,0 +1,16 @@
import { Box, Divider } from '@rocket.chat/fuselage';
import { ChangePassphrase } from './ChangePassphrase';
import { ResetPassphrase } from './ResetPassphrase';
const EndToEnd = () => {
return (
<Box display='flex' flexDirection='column' alignItems='flex-start'>
<ChangePassphrase />
<Divider marginBlock={36} width='full' />
<ResetPassphrase />
</Box>
);
};
export default EndToEnd;

View File

@@ -0,0 +1,20 @@
import { Box, Button } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { useResetE2EPasswordMutation } from '../../hooks/useResetE2EPasswordMutation';
export const ResetPassphrase = () => {
const { t } = useTranslation();
const resetE2EPassword = useResetE2EPasswordMutation();
return (
<>
<Box is='h4' fontScale='h4' marginBlockEnd={12}>
{t('Reset_E2EE_password')}
</Box>
<Box is='p' fontScale='p1' marginBlockEnd={12}>
{t('Reset_E2EE_password_description')}
</Box>
<Button onClick={() => resetE2EPassword.mutate()}>{t('Reset_E2EE_password')}</Button>
</>
);
};

View File

@@ -0,0 +1,54 @@
import { Box, Field, FieldLabel, FieldRow, Margins, ToggleSwitch } from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useUser } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ChangeEvent } from 'react';
import { useCallback, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useEndpointMutation } from '../../../hooks/useEndpointMutation';
const TwoFactorEmail = (props: ComponentProps<typeof Box>) => {
const { t } = useTranslation();
const user = useUser();
const emailId = useId();
const isEnabled = user?.services?.email2fa?.enabled;
const dispatchToastMessage = useToastMessageDispatch();
const { mutateAsync: enable2faAction } = useEndpointMutation('POST', '/v1/users.2fa.enableEmail', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_enabled') });
},
});
const { mutateAsync: disable2faAction } = useEndpointMutation('POST', '/v1/users.2fa.disableEmail', {
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_disabled') });
},
});
const handleEnable = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget.checked) {
await enable2faAction();
} else {
await disable2faAction();
}
},
[disable2faAction, enable2faAction],
);
return (
<Box display='flex' flexDirection='column' alignItems='flex-start' marginBlockStart={16} {...props}>
<Margins blockEnd={8}>
<Field>
<FieldRow>
<FieldLabel htmlFor={emailId}>{t('Two-factor_authentication_email')}</FieldLabel>
<ToggleSwitch id={emailId} checked={isEnabled} onChange={handleEnable} />
</FieldRow>
</Field>
</Margins>
</Box>
);
};
export default TwoFactorEmail;

View File

@@ -0,0 +1,187 @@
import { Box, Button, TextInput, Margins, Field, FieldRow, FieldLabel, ToggleSwitch } from '@rocket.chat/fuselage';
import { useStableCallback, useSafely } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useUser, useEndpoint } from '@rocket.chat/ui-contexts';
import type { ComponentPropsWithoutRef, ChangeEvent } from 'react';
import { useState, useCallback, useEffect, useId } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import qrcode from 'yaqrcode';
import BackupCodesModal from './BackupCodesModal';
import TextCopy from '../../../components/TextCopy';
import TwoFactorTotpModal from '../../../components/TwoFactorModal/TwoFactorTotpModal';
type TwoFactorTOTPFormData = {
authCode: string;
};
export type TwoFactorTOTPProps = ComponentPropsWithoutRef<typeof Box>;
const isInvalidTotpError = (error: unknown): boolean => {
const { error: errorCode, errorType } = (error ?? {}) as { error?: string; errorType?: string };
return errorCode === 'invalid-totp' || errorType === 'invalid-totp';
};
const TwoFactorTOTP = (props: TwoFactorTOTPProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const user = useUser();
const setModal = useSetModal();
const enableTotpFn = useEndpoint('POST', '/v1/users.enableTotp');
const disableTotpFn = useEndpoint('POST', '/v1/users.disableTotp');
const verifyCodeFn = useEndpoint('POST', '/v1/users.validateTotp');
const checkCodesRemainingFn = useEndpoint('GET', '/v1/users.totpCodesRemaining');
const regenerateCodesFn = useEndpoint('POST', '/v1/users.regenerateTotpCodes');
const [registeringTotp, setRegisteringTotp] = useSafely(useState(false));
const [qrCode, setQrCode] = useSafely(useState<string>());
const [totpSecret, setTotpSecret] = useSafely(useState<string>());
const [codesRemaining, setCodesRemaining] = useSafely(useState(0));
const { register, handleSubmit } = useForm<TwoFactorTOTPFormData>({ defaultValues: { authCode: '' } });
const totpEnabled = user?.services?.totp?.enabled;
const closeModal = useCallback(() => setModal(null), [setModal]);
useEffect(() => {
const updateCodesRemaining = async (): Promise<void | boolean> => {
if (!totpEnabled) {
return false;
}
const result = await checkCodesRemainingFn();
setCodesRemaining(result.remaining);
};
updateCodesRemaining();
}, [checkCodesRemainingFn, setCodesRemaining, totpEnabled]);
const enableTotp = useStableCallback(async () => {
try {
const result = await enableTotpFn();
setTotpSecret(result.secret);
setQrCode(qrcode(result.url, { size: 200 }));
setRegisteringTotp(true);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const disableTotp = useStableCallback(async () => {
if (!totpEnabled) {
setRegisteringTotp(false);
return;
}
const onDisable = async (authCode: string): Promise<void> => {
try {
const { disabled } = await disableTotpFn({ code: authCode });
if (!disabled) {
dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
return;
}
dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_disabled') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
closeModal();
};
setModal(<TwoFactorTotpModal onConfirm={onDisable} onClose={closeModal} />);
});
const handleToggleTotp = useStableCallback(async (e: ChangeEvent<HTMLInputElement>) => {
if (e.currentTarget?.checked) {
void enableTotp();
} else {
void disableTotp();
}
});
const totpId = useId();
const totpCodeId = useId();
const handleVerifyCode = useCallback(
async ({ authCode }: TwoFactorTOTPFormData) => {
try {
const result = await verifyCodeFn({ code: authCode });
setRegisteringTotp(false);
setModal(<BackupCodesModal codes={result.codes} onClose={closeModal} />);
dispatchToastMessage({ type: 'success', message: t('Two-factor_authentication_enabled') });
} catch (error) {
if (isInvalidTotpError(error)) {
return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
}
dispatchToastMessage({ type: 'error', message: error });
}
},
[closeModal, dispatchToastMessage, setModal, t, verifyCodeFn, setRegisteringTotp],
);
const handleRegenerateCodes = useCallback(() => {
const onRegenerate = async (authCode: string): Promise<void> => {
try {
const { codes } = await regenerateCodesFn({ code: authCode });
setModal(<BackupCodesModal codes={codes} onClose={closeModal} />);
} catch (error) {
if (isInvalidTotpError(error)) {
return dispatchToastMessage({ type: 'error', message: t('Invalid_two_factor_code') });
}
dispatchToastMessage({ type: 'error', message: error });
}
};
setModal(<TwoFactorTotpModal onDismiss={() => undefined} onConfirm={onRegenerate} onClose={closeModal} />);
}, [closeModal, dispatchToastMessage, setModal, regenerateCodesFn, t]);
return (
<Box display='flex' flexDirection='column' alignItems='flex-start' {...props}>
<Margins blockEnd={8}>
<Field>
<FieldRow>
<FieldLabel htmlFor={totpId}>{t('Two-factor_authentication_via_TOTP')}</FieldLabel>
<ToggleSwitch id={totpId} checked={registeringTotp || totpEnabled} onChange={handleToggleTotp} />
</FieldRow>
</Field>
{!totpEnabled && registeringTotp && (
<>
<Box>{t('Scan_QR_code')}</Box>
<Box>{t('Scan_QR_code_alternative_s')}</Box>
<TextCopy text={totpSecret || ''} />
<Box marginInlineStart='-16px' marginBlock='-16px' is='img' size='x200' src={qrCode} aria-hidden='true' />
<Field>
<FieldLabel htmlFor={totpCodeId}>{t('Enter_code_provided_by_authentication_app')}</FieldLabel>
<FieldRow>
<TextInput id={totpCodeId} marginInlineEnd='8px' {...register('authCode')} />
<Button primary onClick={handleSubmit(handleVerifyCode)}>
{t('Verify')}
</Button>
</FieldRow>
</Field>
</>
)}
{totpEnabled && (
<>
<Box fontScale='p2m' marginBlockStart={8}>
{t('Backup_codes')}
</Box>
<Box color='font-secondary-info'>{t('You_have_n_codes_remaining', { number: codesRemaining })}</Box>
<Button onClick={handleRegenerateCodes}>{t('Regenerate_codes')}</Button>
</>
)}
</Margins>
</Box>
);
};
export default TwoFactorTOTP;

View File

@@ -0,0 +1,16 @@
import { useSetting, useUser } from '@rocket.chat/ui-contexts';
export const useAllowPasswordChange = () => {
const user = useUser();
let allowPasswordChange = useSetting('Accounts_AllowPasswordChange');
const allowOAuthPasswordChange = useSetting('Accounts_AllowPasswordChangeForOAuthUsers');
const hasLocalPassword = Boolean(user?.services?.password?.exists);
if (allowPasswordChange && !allowOAuthPasswordChange) {
allowPasswordChange = hasLocalPassword;
}
return { allowPasswordChange, hasLocalPassword };
};

View File

@@ -0,0 +1,63 @@
import { defaultFeaturesPreview, FeaturePreviewBadge } from '@rocket.chat/ui-client';
import { hasPermission, hasAtLeastOnePermission } from '../../lib/authorization';
import { createSidebarItems } from '../../lib/createSidebarItems';
import { settings } from '../../lib/settings';
export const {
registerSidebarItem: registerAccountSidebarItem,
unregisterSidebarItem,
getSidebarItems: getAccountSidebarItems,
subscribeToSidebarItems: subscribeToAccountSidebarItems,
} = createSidebarItems([
{
href: '/account/profile',
i18nLabel: 'Profile',
icon: 'user',
permissionGranted: (): boolean => settings.peek('Accounts_AllowUserProfileChange') ?? true,
},
{
href: '/account/preferences',
i18nLabel: 'Preferences',
icon: 'customize',
},
{
href: '/account/security',
i18nLabel: 'Security',
icon: 'lock',
permissionGranted: (): boolean =>
(settings.peek('Accounts_TwoFactorAuthentication_Enabled') ?? true) ||
(settings.peek('E2E_Enable') ?? false) ||
(settings.peek('Accounts_AllowPasswordChange') ?? true),
},
{
href: '/account/integrations',
i18nLabel: 'Integrations',
icon: 'code',
permissionGranted: (): boolean => settings.peek('Webdav_Integration_Enabled') ?? false,
},
{
href: '/account/tokens',
i18nLabel: 'Personal_Access_Tokens',
icon: 'key',
permissionGranted: (): boolean => hasPermission('create-personal-access-tokens'),
},
{
href: '/account/omnichannel',
i18nLabel: 'Omnichannel',
icon: 'headset',
permissionGranted: (): boolean => hasAtLeastOnePermission(['send-omnichannel-chat-transcript', 'request-pdf-transcript']),
},
{
href: '/account/feature-preview',
i18nLabel: 'Feature_preview',
icon: 'flask',
badge: () => <FeaturePreviewBadge />,
permissionGranted: () => settings.peek('Accounts_AllowFeaturePreview') && defaultFeaturesPreview?.length > 0,
},
{
href: '/account/accessibility-and-appearance',
i18nLabel: 'Accessibility_and_Appearance',
icon: 'person-arms-spread',
},
]);

View File

@@ -0,0 +1,19 @@
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import AccountTokensTable from './AccountTokensTable';
const AccountTokensPage = () => {
const { t } = useTranslation();
return (
<Page>
<PageHeader title={t('Personal_Access_Tokens')} />
<PageContent>
<AccountTokensTable />
</PageContent>
</Page>
);
};
export default AccountTokensPage;

View File

@@ -0,0 +1,16 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import AccountTokensPage from './AccountTokensPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AccountTokensRoute = () => {
const canCreateTokens = usePermission('create-personal-access-tokens');
if (!canCreateTokens) {
return <NotAuthorizedPage />;
}
return <AccountTokensPage />;
};
export default AccountTokensRoute;

View File

@@ -0,0 +1,39 @@
import type { IPersonalAccessToken, Serialized } from '@rocket.chat/core-typings';
import { ButtonGroup, IconButton } from '@rocket.chat/fuselage';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
export type AccountTokensRowProps = {
isMedium: boolean;
onRegenerate: (name: string) => void;
onRemove: (name: string) => void;
} & Serialized<Pick<IPersonalAccessToken, 'name' | 'createdAt' | 'lastTokenPart' | 'bypassTwoFactor'>>;
const AccountTokensRow = ({ bypassTwoFactor, createdAt, isMedium, lastTokenPart, name, onRegenerate, onRemove }: AccountTokensRowProps) => {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const handleRegenerate = useCallback(() => onRegenerate(name), [name, onRegenerate]);
const handleRemove = useCallback(() => onRemove(name), [name, onRemove]);
return (
<GenericTableRow key={name} tabIndex={0} role='link' qa-token-name={name}>
<GenericTableCell withTruncatedText color='default' fontScale='p2m'>
{name}
</GenericTableCell>
{isMedium && <GenericTableCell withTruncatedText>{formatDateAndTime(createdAt)}</GenericTableCell>}
<GenericTableCell withTruncatedText>...{lastTokenPart}</GenericTableCell>
<GenericTableCell withTruncatedText>{bypassTwoFactor ? t('Ignore') : t('Require')}</GenericTableCell>
<GenericTableCell withTruncatedText>
<ButtonGroup>
<IconButton title={t('Refresh')} icon='refresh' small secondary onClick={handleRegenerate} />
<IconButton title={t('Remove')} icon='trash' small secondary onClick={handleRemove} />
</ButtonGroup>
</GenericTableCell>
</GenericTableRow>
);
};
export default AccountTokensRow;

View File

@@ -0,0 +1,188 @@
import { Box, Pagination, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
import {
GenericModal,
GenericTable,
GenericTableHeader,
GenericTableBody,
GenericTableLoadingTable,
GenericTableHeaderCell,
usePagination,
} from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useUserId, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import AccountTokensRow from './AccountTokensRow';
import AddToken from './AddToken';
import GenericNoResults from '../../../../components/GenericNoResults';
import { useResizeInlineBreakpoint } from '../../../../hooks/useResizeInlineBreakpoint';
import { miscQueryKeys } from '../../../../lib/queryKeys';
const AccountTokensTable = () => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();
const userId = useUserId();
const regenerateToken = useEndpoint('POST', '/v1/users.regeneratePersonalAccessToken');
const removeToken = useEndpoint('POST', '/v1/users.removePersonalAccessToken');
const getPersonalAccessTokens = useEndpoint('GET', '/v1/users.getPersonalAccessTokens');
const { isPending, isSuccess, data, isError, error } = useQuery({
queryKey: miscQueryKeys.personalAccessTokens,
queryFn: () => getPersonalAccessTokens(),
});
const queryClient = useQueryClient();
const [ref, isMedium] = useResizeInlineBreakpoint<HTMLElement>([600], 200);
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const filteredTokens = useMemo(() => {
if (!data?.tokens) {
return null;
}
const sliceStart = current > data?.tokens.length ? data?.tokens.length - itemsPerPage : current;
return data?.tokens.slice(sliceStart, sliceStart + itemsPerPage);
}, [current, data?.tokens, itemsPerPage]);
const closeModal = useCallback(() => setModal(null), [setModal]);
const headers = useMemo(
() =>
[
<GenericTableHeaderCell key='name'>{t('API_Personal_Access_Token_Name')}</GenericTableHeaderCell>,
isMedium && <GenericTableHeaderCell key='createdAt'>{t('Created_at')}</GenericTableHeaderCell>,
<GenericTableHeaderCell key='lastTokenPart'>{t('Last_token_part')}</GenericTableHeaderCell>,
<GenericTableHeaderCell key='2fa'>{t('Two Factor Authentication')}</GenericTableHeaderCell>,
<GenericTableHeaderCell key='actions' />,
].filter(Boolean),
[isMedium, t],
);
const handleRegenerate = useCallback(
(name: string) => {
const onConfirm: () => Promise<void> = async () => {
try {
setModal(null);
const { token } = await regenerateToken({ tokenName: name });
setModal(
<GenericModal title={t('API_Personal_Access_Token_Generated')} onConfirm={closeModal}>
<Box>
<Trans i18nKey='API_Personal_Access_Token_Generated_Text_Token_s_UserId_s' values={{ token, userId }} />
</Box>
</GenericModal>,
);
queryClient.invalidateQueries({ queryKey: miscQueryKeys.personalAccessTokens });
} catch (error) {
setModal(null);
dispatchToastMessage({ type: 'error', message: error });
}
};
setModal(
<GenericModal
variant='warning'
confirmText={t('API_Personal_Access_Tokens_Regenerate_It')}
onConfirm={onConfirm}
onCancel={closeModal}
onClose={closeModal}
>
{t('API_Personal_Access_Tokens_Regenerate_Modal')}
</GenericModal>,
);
},
[closeModal, dispatchToastMessage, queryClient, regenerateToken, setModal, t, userId],
);
const handleRemove = useCallback(
(name: string) => {
const onConfirm: () => Promise<void> = async () => {
try {
await removeToken({ tokenName: name });
dispatchToastMessage({ type: 'success', message: t('Token_has_been_removed') });
queryClient.invalidateQueries({ queryKey: miscQueryKeys.personalAccessTokens });
closeModal();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
setModal(
<GenericModal variant='danger' confirmText={t('Remove')} onConfirm={onConfirm} onCancel={closeModal} onClose={closeModal}>
{t('API_Personal_Access_Tokens_Remove_Modal')}
</GenericModal>,
);
},
[closeModal, dispatchToastMessage, queryClient, removeToken, setModal, t],
);
if (isError) {
return (
<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>
<StatesActions>
<StatesAction onClick={() => queryClient.invalidateQueries({ queryKey: miscQueryKeys.personalAccessTokens })}>
{t('Retry')}
</StatesAction>
</StatesActions>
</States>
</Box>
);
}
return (
<>
<AddToken reload={() => queryClient.invalidateQueries({ queryKey: miscQueryKeys.personalAccessTokens })} />
{isPending && (
<GenericTable aria-busy>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={5} />
</GenericTableBody>
</GenericTable>
)}
{filteredTokens && filteredTokens?.length > 0 && isSuccess && (
<>
<GenericTable ref={ref}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{isSuccess &&
filteredTokens &&
filteredTokens.map((filteredToken) => (
<AccountTokensRow
key={filteredToken.lastTokenPart}
onRegenerate={handleRegenerate}
onRemove={handleRemove}
isMedium={isMedium}
{...filteredToken}
/>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.tokens.length || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
)}
{isSuccess && filteredTokens?.length === 0 && <GenericNoResults />}
</>
);
};
export default AccountTokensTable;

View File

@@ -0,0 +1,109 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, TextInput, Button, Margins, Select, FieldError, FieldGroup, Field, FieldRow } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useUserId, useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useId, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
type AddTokenFormData = {
name: string;
bypassTwoFactor: string;
};
export type AddTokenProps = {
reload: () => void;
};
const AddToken = ({ reload }: AddTokenProps) => {
const { t } = useTranslation();
const userId = useUserId();
const setModal = useSetModal();
const createTokenFn = useEndpoint('POST', '/v1/users.generatePersonalAccessToken');
const dispatchToastMessage = useToastMessageDispatch();
const initialValues = useMemo(() => ({ name: '', bypassTwoFactor: 'require' }), []);
const {
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<AddTokenFormData>({ defaultValues: initialValues });
const twoFactorAuthOptions: SelectOption[] = useMemo(
() => [
['require', t('Require_Two_Factor_Authentication')],
['bypass', t('Ignore_Two_Factor_Authentication')],
],
[t],
);
const handleAddToken = useCallback(
async ({ name: tokenName, bypassTwoFactor }: AddTokenFormData) => {
try {
const { token } = await createTokenFn({ tokenName, bypassTwoFactor: bypassTwoFactor === 'bypass' });
const handleDismissModal = () => {
setModal(null);
reload();
reset();
};
setModal(
<GenericModal title={t('API_Personal_Access_Token_Generated')} onConfirm={handleDismissModal} onClose={handleDismissModal}>
<Box>
<Trans i18nKey='API_Personal_Access_Token_Generated_Text_Token_s_UserId_s' values={{ token, userId }} />
</Box>
</GenericModal>,
);
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[createTokenFn, dispatchToastMessage, reload, reset, setModal, t, userId],
);
const nameErrorId = useId();
return (
<FieldGroup is='form' onSubmit={handleSubmit(handleAddToken)} marginBlock={8}>
<Field>
<FieldRow>
<Margins inlineEnd={4}>
<Controller
name='name'
control={control}
rules={{ validate: (value) => (value.trim() ? undefined : t('Please_provide_a_name_for_your_token')) }}
render={({ field }) => (
<TextInput
aria-describedby={nameErrorId}
data-qa='PersonalTokenField'
{...field}
placeholder={t('API_Add_Personal_Access_Token')}
/>
)}
/>
<Box>
<Controller
name='bypassTwoFactor'
control={control}
render={({ field }) => <Select {...field} options={twoFactorAuthOptions} />}
/>
</Box>
</Margins>
<Button primary type='submit'>
{t('Add')}
</Button>
</FieldRow>
{errors?.name && (
<FieldError id={nameErrorId} role='alert'>
{errors.name.message}
</FieldError>
)}
</Field>
</FieldGroup>
);
};
export default AddToken;

View File

@@ -0,0 +1 @@
export { default } from './AccountTokensTable';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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>
);
},
],
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
`;

View 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;

View File

@@ -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',
});
});
});
});

View File

@@ -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;

View 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;

View File

@@ -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();
});
});

View File

@@ -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 = {};

View File

@@ -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;

View File

@@ -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());
});
});

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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 = {};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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;

View File

@@ -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 }]);
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,
},
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
`;

View 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;

View 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);

View 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;

View File

@@ -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'),
};
};

View File

@@ -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,
})),
};
},
});
};

View File

@@ -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',
});
});
});
});

View File

@@ -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(),
},
];
};

View File

@@ -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();
});
});
});

View File

@@ -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)} />);
};
};

Some files were not shown because too many files have changed in this diff Show More