snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
40
apps/meteor/client/views/account/AccountRouter.tsx
Normal file
40
apps/meteor/client/views/account/AccountRouter.tsx
Normal 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;
|
||||
30
apps/meteor/client/views/account/AccountSidebar.tsx
Normal file
30
apps/meteor/client/views/account/AccountSidebar.tsx
Normal 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);
|
||||
@@ -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;
|
||||
12
apps/meteor/client/views/account/accessibility/fontSizes.ts
Normal file
12
apps/meteor/client/views/account/accessibility/fontSizes.ts
Normal 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')],
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}, []);
|
||||
};
|
||||
29
apps/meteor/client/views/account/accessibility/themeItems.ts
Normal file
29
apps/meteor/client/views/account/accessibility/themeItems.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './DeviceManagementAccountTable';
|
||||
@@ -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;
|
||||
2
apps/meteor/client/views/account/index.ts
Normal file
2
apps/meteor/client/views/account/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { registerAccountRoute } from './routes';
|
||||
export { registerAccountSidebarItem, unregisterSidebarItem } from './sidebarItems';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
48
apps/meteor/client/views/account/preferences/MyDataModal.tsx
Normal file
48
apps/meteor/client/views/account/preferences/MyDataModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
447
apps/meteor/client/views/account/profile/AccountProfileForm.tsx
Normal file
447
apps/meteor/client/views/account/profile/AccountProfileForm.tsx
Normal 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;
|
||||
154
apps/meteor/client/views/account/profile/AccountProfilePage.tsx
Normal file
154
apps/meteor/client/views/account/profile/AccountProfilePage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
90
apps/meteor/client/views/account/routes.tsx
Normal file
90
apps/meteor/client/views/account/routes.tsx
Normal 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')),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
201
apps/meteor/client/views/account/security/ChangePassphrase.tsx
Normal file
201
apps/meteor/client/views/account/security/ChangePassphrase.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
133
apps/meteor/client/views/account/security/ChangePassword.tsx
Normal file
133
apps/meteor/client/views/account/security/ChangePassword.tsx
Normal 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;
|
||||
16
apps/meteor/client/views/account/security/EndToEnd.tsx
Normal file
16
apps/meteor/client/views/account/security/EndToEnd.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
apps/meteor/client/views/account/security/TwoFactorEmail.tsx
Normal file
54
apps/meteor/client/views/account/security/TwoFactorEmail.tsx
Normal 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;
|
||||
187
apps/meteor/client/views/account/security/TwoFactorTOTP.tsx
Normal file
187
apps/meteor/client/views/account/security/TwoFactorTOTP.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
};
|
||||
63
apps/meteor/client/views/account/sidebarItems.tsx
Normal file
63
apps/meteor/client/views/account/sidebarItems.tsx
Normal 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',
|
||||
},
|
||||
]);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AccountTokensTable';
|
||||
Reference in New Issue
Block a user