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:
37
apps/meteor/client/views/root/AppErrorPage.tsx
Normal file
37
apps/meteor/client/views/root/AppErrorPage.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Box, PaletteStyleTag, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage';
|
||||
import { useThemeMode } from '@rocket.chat/ui-client';
|
||||
|
||||
const AppErrorPage = () => {
|
||||
const theme = useThemeMode();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaletteStyleTag theme={theme} tagId='app-error-palette' />
|
||||
<Box display='flex' justifyContent='center' height='full' backgroundColor='surface'>
|
||||
<States>
|
||||
<StatesIcon name='error-circle' />
|
||||
<StatesTitle>Application Error</StatesTitle>
|
||||
<StatesSubtitle>The application GUI just crashed.</StatesSubtitle>
|
||||
|
||||
<StatesActions>
|
||||
<StatesAction
|
||||
onClick={() => {
|
||||
const result = indexedDB.deleteDatabase('MeteorDynamicImportCache');
|
||||
const reload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
result.onsuccess = reload;
|
||||
result.onerror = reload;
|
||||
result.onblocked = reload;
|
||||
}}
|
||||
>
|
||||
Reload Application
|
||||
</StatesAction>
|
||||
</StatesActions>
|
||||
</States>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppErrorPage;
|
||||
98
apps/meteor/client/views/root/AppLayout.tsx
Normal file
98
apps/meteor/client/views/root/AppLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useEffect, Suspense, useSyncExternalStore } from 'react';
|
||||
|
||||
import DocumentTitleWrapper from './DocumentTitleWrapper';
|
||||
import PageLoading from './PageLoading';
|
||||
import { useAppleOAuth } from './hooks/customOAuth/useAppleOAuth';
|
||||
import { useCustomOAuth } from './hooks/customOAuth/useCustomOAuth';
|
||||
import { useDolphinOAuth } from './hooks/customOAuth/useDolphinOAuth';
|
||||
import { useDrupalOAuth } from './hooks/customOAuth/useDrupalOAuth';
|
||||
import { useGitHubEnterpriseOAuth } from './hooks/customOAuth/useGitHubEnterpriseOAuth';
|
||||
import { useGitLabOAuth } from './hooks/customOAuth/useGitLabOAuth';
|
||||
import { useGithubOAuth } from './hooks/customOAuth/useGithubOAuth';
|
||||
import { useNextcloudOAuth } from './hooks/customOAuth/useNextcloudOAuth';
|
||||
import { useWordPressOAuth } from './hooks/customOAuth/useWordPressOAuth';
|
||||
import { useAnalytics } from './hooks/useAnalytics';
|
||||
import { useAnalyticsEventTracking } from './hooks/useAnalyticsEventTracking';
|
||||
import { useAutoupdate } from './hooks/useAutoupdate';
|
||||
import { useCodeHighlight } from './hooks/useCodeHighlight';
|
||||
import { useCorsSSLConfig } from './hooks/useCorsSSLConfig';
|
||||
import { useDesktopFavicon } from './hooks/useDesktopFavicon';
|
||||
import { useDesktopTitle } from './hooks/useDesktopTitle';
|
||||
import { useDesktopUserRoles } from './hooks/useDesktopUserRoles';
|
||||
import { useEscapeKeyStroke } from './hooks/useEscapeKeyStroke';
|
||||
import { useGoogleTagManager } from './hooks/useGoogleTagManager';
|
||||
import { useIframeCommands } from './hooks/useIframeCommands';
|
||||
import { useIframeLoginListener } from './hooks/useIframeLoginListener';
|
||||
import { useKeyboardShortcutsHotkey } from './hooks/useKeyboardShortcutsHotkey';
|
||||
import { useLivechatEnterprise } from './hooks/useLivechatEnterprise';
|
||||
import { useLoadMissedMessages } from './hooks/useLoadMissedMessages';
|
||||
import { useLoadRoomForAllowedAnonymousRead } from './hooks/useLoadRoomForAllowedAnonymousRead';
|
||||
import { useLoginOtherClients } from './hooks/useLoginOtherClients';
|
||||
import { useLoginViaQuery } from './hooks/useLoginViaQuery';
|
||||
import { useMessageLinkClicks } from './hooks/useMessageLinkClicks';
|
||||
import { useNativeEmoji } from './hooks/useNativeEmoji';
|
||||
import { useNotificationPermission } from './hooks/useNotificationPermission';
|
||||
import { useOAuthLogin } from './hooks/useOAuthLogin';
|
||||
import { useOAuthPopupCommands } from './hooks/useOAuthPopupCommands';
|
||||
import { useRedirectToSetupWizard } from './hooks/useRedirectToSetupWizard';
|
||||
import { useSettingsOnLoadSiteUrl } from './hooks/useSettingsOnLoadSiteUrl';
|
||||
import { useShareSessionWithOtherClients } from './hooks/useShareSessionWithOtherClients';
|
||||
import { useStartupEvent } from './hooks/useStartupEvent';
|
||||
import { appLayout } from '../../lib/appLayout';
|
||||
|
||||
const AppLayout = () => {
|
||||
useEffect(() => {
|
||||
document.body.classList.add('color-primary-font-color', 'rcx-content--main');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('color-primary-font-color', 'rcx-content--main');
|
||||
};
|
||||
}, []);
|
||||
|
||||
useIframeLoginListener();
|
||||
useMessageLinkClicks();
|
||||
useGoogleTagManager();
|
||||
useAnalytics();
|
||||
useEscapeKeyStroke();
|
||||
useKeyboardShortcutsHotkey();
|
||||
useAnalyticsEventTracking();
|
||||
useLoadRoomForAllowedAnonymousRead();
|
||||
useNotificationPermission();
|
||||
useNativeEmoji();
|
||||
useRedirectToSetupWizard();
|
||||
useSettingsOnLoadSiteUrl();
|
||||
useLivechatEnterprise();
|
||||
useNextcloudOAuth();
|
||||
useGitLabOAuth();
|
||||
useGithubOAuth();
|
||||
useGitHubEnterpriseOAuth();
|
||||
useDrupalOAuth();
|
||||
useDolphinOAuth();
|
||||
useAppleOAuth();
|
||||
useWordPressOAuth();
|
||||
useCustomOAuth();
|
||||
useCorsSSLConfig();
|
||||
useAutoupdate();
|
||||
useCodeHighlight();
|
||||
useLoginViaQuery();
|
||||
useLoginOtherClients();
|
||||
useOAuthLogin();
|
||||
useOAuthPopupCommands();
|
||||
useShareSessionWithOtherClients();
|
||||
useLoadMissedMessages();
|
||||
useDesktopFavicon();
|
||||
useDesktopTitle();
|
||||
useDesktopUserRoles();
|
||||
useStartupEvent();
|
||||
useIframeCommands();
|
||||
|
||||
const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<DocumentTitleWrapper>{layout}</DocumentTitleWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
48
apps/meteor/client/views/root/AppRoot.tsx
Normal file
48
apps/meteor/client/views/root/AppRoot.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import OutermostErrorBoundary from './OutermostErrorBoundary';
|
||||
import PageLoading from './PageLoading';
|
||||
import { queryClient } from '../../lib/queryClient';
|
||||
|
||||
const MeteorProvider = lazy(() => import('../../providers/MeteorProvider'));
|
||||
const AppLayout = lazy(() => import('./AppLayout'));
|
||||
|
||||
const AppRoot = () => (
|
||||
<OutermostErrorBoundary>
|
||||
{createPortal(
|
||||
<>
|
||||
<meta charSet='utf-8' />
|
||||
<meta httpEquiv='content-type' content='text/html; charset=utf-8' />
|
||||
<meta httpEquiv='expires' content='-1' />
|
||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='fragment' content='!' />
|
||||
<meta name='distribution' content='global' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, interactive-widget=resizes-content' />
|
||||
<meta name='rating' content='general' />
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='msapplication-TileImage' content='assets/tile_144.png' />
|
||||
<meta name='msapplication-config' content='images/browserconfig.xml' />
|
||||
<meta property='og:image' content='assets/favicon_512.png' />
|
||||
<meta property='twitter:image' content='assets/favicon_512.png' />
|
||||
<link rel='manifest' href='images/manifest.json' />
|
||||
<link rel='chrome-webstore-item' href='https://chrome.google.com/webstore/detail/nocfbnnmjnndkbipkabodnheejiegccf' />
|
||||
<link rel='mask-icon' href='assets/safari_pinned.svg' color='#04436a' />
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='assets/touchicon_180.png' />
|
||||
<link rel='apple-touch-icon-precomposed' href='assets/touchicon_180_pre.png' />
|
||||
</>,
|
||||
document.head,
|
||||
)}
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MeteorProvider>
|
||||
<AppLayout />
|
||||
</MeteorProvider>
|
||||
</QueryClientProvider>
|
||||
</Suspense>
|
||||
</OutermostErrorBoundary>
|
||||
);
|
||||
|
||||
export default AppRoot;
|
||||
66
apps/meteor/client/views/root/DocumentTitleWrapper.tsx
Normal file
66
apps/meteor/client/views/root/DocumentTitleWrapper.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useDocumentTitle } from '@rocket.chat/ui-client';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
import { useUnreadMessages } from './hooks/useUnreadMessages';
|
||||
|
||||
const useRouteTitleFocus = () => {
|
||||
return useCallback((node: HTMLElement | null) => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the parent element has aria-hidden set to true, it means the element is not visible to screen readers.
|
||||
// This is a common practice to hide elements that are not currently in use or are not relevant to the user.
|
||||
// For example, a modal or popover might be open, and the main content is hidden to keep the user's focus on the modal.
|
||||
// Since the title is marked with `tabIndex={-1}`, it is not reachable by keyboard navigation.
|
||||
// https://accessibilityinsights.io/info-examples/web/aria-hidden-focus/
|
||||
if (node.parentElement?.ariaHidden !== 'true') {
|
||||
node.focus();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
export type DocumentTitleWrapperProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const DocumentTitleWrapper = ({ children }: DocumentTitleWrapperProps) => {
|
||||
useDocumentTitle(useSetting('Site_Name', ''), false);
|
||||
const { title, key } = useDocumentTitle(useUnreadMessages(), false);
|
||||
|
||||
const refocusRef = useRouteTitleFocus();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
tabIndex={-1}
|
||||
ref={refocusRef}
|
||||
key={key}
|
||||
className={css`
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
`}
|
||||
>
|
||||
{title}
|
||||
</Box>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentTitleWrapper;
|
||||
39
apps/meteor/client/views/root/IndexRoute.tsx
Normal file
39
apps/meteor/client/views/root/IndexRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { RouteName } from '@rocket.chat/ui-contexts';
|
||||
import { useRouter, useUser, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import PageLoading from './PageLoading';
|
||||
|
||||
const IndexRoute = () => {
|
||||
const router = useRouter();
|
||||
const uid = useUserId();
|
||||
const user = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!uid) {
|
||||
router.navigate('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
if (user?.defaultRoom) {
|
||||
const room = user.defaultRoom.split('/') as [routeName: RouteName, routeParam: string];
|
||||
router.navigate({
|
||||
name: room[0],
|
||||
params: { name: room[1] },
|
||||
search: router.getSearchParameters(),
|
||||
});
|
||||
} else {
|
||||
router.navigate('/home');
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [router, uid, user?.defaultRoom]);
|
||||
|
||||
return <PageLoading />;
|
||||
};
|
||||
|
||||
export default IndexRoute;
|
||||
18
apps/meteor/client/views/root/LoginTokenRoute.tsx
Normal file
18
apps/meteor/client/views/root/LoginTokenRoute.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useLoginWithTokenRoute, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const LoginTokenRoute = () => {
|
||||
const router = useRouter();
|
||||
const loginMethod = useLoginWithTokenRoute();
|
||||
|
||||
useEffect(() => {
|
||||
loginMethod(router.getRouteParameters().token, (error) => {
|
||||
console.error(error);
|
||||
router.navigate('/');
|
||||
});
|
||||
}, [loginMethod, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default LoginTokenRoute;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Button } from '@rocket.chat/fuselage';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AccessibilityShortcut = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const currentRoutePath = router.getLocationPathname();
|
||||
|
||||
const customButtonClass = css`
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
z-index: 99;
|
||||
&:not(:focus) {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
border: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<Button className={customButtonClass} is='a' href={`${currentRoutePath}#main-content`} primary>
|
||||
{t('Skip_to_main_content')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessibilityShortcut;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import type { AuthenticationContextValue, SessionContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { AuthenticationContext, SessionContext, UserContext } from '@rocket.chat/ui-contexts';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import AuthenticationCheck from './AuthenticationCheck';
|
||||
import { STORAGE_KEYS, removeStoredItem } from '../../../lib/sdk/storage';
|
||||
|
||||
// The chain below this gate reaches for a great deal of app; what this spec is about is which of the three
|
||||
// outcomes the gate picks, so stand the rest of it down.
|
||||
jest.mock('./LoggedInArea', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children}</> }));
|
||||
jest.mock('./UsernameCheck', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children}</> }));
|
||||
jest.mock('./LoginPage', () => ({ __esModule: true, default: () => <div>login-page</div> }));
|
||||
jest.mock('../../home/HomeSkeleton', () => ({ __esModule: true, default: () => <div>home-skeleton</div> }));
|
||||
|
||||
const renderGate = (root: ReturnType<typeof mockAppRoot>) =>
|
||||
render(<AuthenticationCheck>conference</AuthenticationCheck>, { wrapper: root.build() });
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders the route for a user who is logged in', () => {
|
||||
renderGate(mockAppRoot().withJohnDoe());
|
||||
|
||||
expect(screen.getByText('conference')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the login page to a visitor with no session', () => {
|
||||
renderGate(mockAppRoot().withAnonymous());
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The bug this guards: a window that opens with a session already stored — a call popout, or any plain reload —
|
||||
// has no user until the login is resumed from that token, and showed a login form in the meantime.
|
||||
describe('while the session is being resumed', () => {
|
||||
it('shows the skeleton instead of the login page when a stored token is about to be used', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
|
||||
|
||||
renderGate(mockAppRoot().withAnonymous());
|
||||
|
||||
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The stored token is read through a subscription, not per render, and this is why: a rejected resume clears
|
||||
// the credentials without anything else about the gate changing — no user arrives, the status stays healthy —
|
||||
// so a per-render read would sit on the stale token with no next render to correct it.
|
||||
it('falls through to the login page when the stored token is cleared under it', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-the-server-rejects');
|
||||
|
||||
renderGate(mockAppRoot().withAnonymous());
|
||||
|
||||
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
});
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The one way a stored token could strand someone: it is cleared when a server *rejects* it, so a server that
|
||||
// never answers at all clears nothing. Once the connection stops trying, the form has to be reachable.
|
||||
it.each(['waiting', 'failed'] as const)('shows the login page when the connection has given up (%s)', (status) => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
|
||||
|
||||
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status }));
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `offline` is not a give-up state, however much it reads like one: the DDP SDK starts every page load `idle`
|
||||
// and that is reported as `offline`, so counting it would show the form on healthy reloads — the very bug.
|
||||
it('keeps the skeleton up while the connection is merely idle', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
|
||||
|
||||
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'offline' }));
|
||||
|
||||
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ...but not while it is still the ordinary first connect of a page load, which is the flash this removes.
|
||||
it('keeps the skeleton up while the connection is still being made', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token');
|
||||
|
||||
renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'connecting' }));
|
||||
|
||||
expect(screen.getByText('home-skeleton')).toBeInTheDocument();
|
||||
expect(screen.queryByText('login-page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Someone who was logged out, or whose session the server rejected, must reach the form — otherwise a stale
|
||||
// token in storage would leave them staring at a skeleton forever. The mocked app root has no session
|
||||
// support, so this one supplies the context itself.
|
||||
it('shows the login page when a login is being forced', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stale-token');
|
||||
|
||||
const forcingLogin = {
|
||||
query: () => [() => () => undefined, () => true] as ReturnType<SessionContextValue['query']>,
|
||||
dispatch: () => undefined,
|
||||
};
|
||||
|
||||
renderGate(
|
||||
mockAppRoot()
|
||||
.withAnonymous()
|
||||
.wrap((children) => <SessionContext.Provider value={forcingLogin}>{children}</SessionContext.Provider>),
|
||||
);
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The regression that came with the guard above, and the reason it asks only about the stored token: `isLoggingIn`
|
||||
// is true of *any* login in flight, a person typing their password at the form included. Swapping the form for a
|
||||
// skeleton mid-attempt lost the rejection — the form came back blank, with neither field marked invalid — and
|
||||
// iframe login never appeared at all, since the flow that fetches its URL runs from inside `LoginPage`.
|
||||
it('keeps the login page up while someone is logging in at it', () => {
|
||||
const loggingIn = { isLoggingIn: true } as AuthenticationContextValue;
|
||||
|
||||
renderGate(
|
||||
mockAppRoot()
|
||||
.withAnonymous()
|
||||
.wrap((children) => <AuthenticationContext.Provider value={loggingIn}>{children}</AuthenticationContext.Provider>),
|
||||
);
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Overrides whatever user the mocked root supplies, without changing the shape of the tree — the gate has to keep
|
||||
// the same instance across the switch for the test to be about anything.
|
||||
const MaybeSessionEnded = ({ ended, children }: { ended: boolean; children: ReactNode }) => {
|
||||
const value = useContext(UserContext);
|
||||
|
||||
return <UserContext.Provider value={ended ? { ...value, userId: undefined, user: null } : value}>{children}</UserContext.Provider>;
|
||||
};
|
||||
|
||||
// Deleting your own account ends the session server-side but clears nothing locally: the stored token stays put,
|
||||
// and reading it alone the gate answered "a resume is in flight" and held the skeleton up for good. A user that
|
||||
// has already been seen going away is an ended session, whatever storage still says.
|
||||
it('shows the login page when the user goes away with a token still stored', () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-nobody-cleared');
|
||||
|
||||
const Root = mockAppRoot().withJohnDoe().build();
|
||||
|
||||
const { rerender } = render(
|
||||
<Root>
|
||||
<MaybeSessionEnded ended={false}>
|
||||
<AuthenticationCheck>conference</AuthenticationCheck>
|
||||
</MaybeSessionEnded>
|
||||
</Root>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('conference')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Root>
|
||||
<MaybeSessionEnded ended={true}>
|
||||
<AuthenticationCheck>conference</AuthenticationCheck>
|
||||
</MaybeSessionEnded>
|
||||
</Root>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('login-page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
118
apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx
Normal file
118
apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useConnectionStatus, useSession, useUser, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import RegistrationRoute from '@rocket.chat/web-ui-registration';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import LoggedInArea from './LoggedInArea';
|
||||
import LoginPage from './LoginPage';
|
||||
import UsernameCheck from './UsernameCheck';
|
||||
import { useStoredItem } from '../../../hooks/useStoredItem';
|
||||
import { STORAGE_KEYS } from '../../../lib/sdk/storage';
|
||||
import HomeSkeleton from '../../home/HomeSkeleton';
|
||||
|
||||
/*
|
||||
* Anonymous and guest are similar in some way
|
||||
*
|
||||
* Anonymous is an old feature that allows the user to navigate as an anonymus user
|
||||
* by default the user dont need to do anything its hadled by the system but by behind the scenes a new * user is registered
|
||||
*
|
||||
* Guest is only for certain locations, it shows a form asking if the user wants to stay as guest and if so
|
||||
* renders the page, without creating an user (not even an anonymous user)
|
||||
*/
|
||||
export type AuthenticationCheckProps = { children: ReactNode; guest?: boolean };
|
||||
|
||||
/**
|
||||
* The connection states that mean a server was reached for and lost, as opposed to not having answered yet.
|
||||
*
|
||||
* `offline` is deliberately not one of them, however much it sounds like the plainest case: the DDP SDK begins
|
||||
* every page load `idle`, and `sdkStatusToMeteor` reports `idle` as `offline`. Counting it would give up on the
|
||||
* connection before it had been attempted, on exactly the healthy reloads this exists to protect.
|
||||
*/
|
||||
const hasGivenUp = (status: ReturnType<typeof useConnectionStatus>['status']): boolean => status === 'waiting' || status === 'failed';
|
||||
|
||||
const AuthenticationCheck = ({ children, guest }: AuthenticationCheckProps) => {
|
||||
const user = useUser();
|
||||
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead');
|
||||
const forceLogin = useSession('forceLogin');
|
||||
|
||||
const { status } = useConnectionStatus();
|
||||
|
||||
/**
|
||||
* A resume needs a server to answer it, and waiting on one that never will is the one way this can strand
|
||||
* someone: the token is only cleared when a server *rejects* it, so an unreachable server — a dropped network,
|
||||
* a captive portal, a workspace that is down — clears nothing and would leave the skeleton up for good.
|
||||
*
|
||||
* Which states count is the whole of the care here — see `hasGivenUp`, and note that neither `connecting` nor
|
||||
* `offline` is one of them. Once a state that counts has been seen it stands: a retry flapping between
|
||||
* `waiting` and `connecting` must not flap the form back into a skeleton. A resume that succeeds later brings
|
||||
* a user with it, which wins anyway.
|
||||
*
|
||||
* Seeded from the status rather than latched from `false`, so a mount that is *already* offline picks the form
|
||||
* on its first render instead of showing a frame of skeleton on the way to it.
|
||||
*/
|
||||
const [unreachable, setUnreachable] = useState(() => hasGivenUp(status));
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGivenUp(status)) {
|
||||
setUnreachable(true);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
/**
|
||||
* A resume is something that happens *before* the first user of a mount, never after one. Once a user has been
|
||||
* seen, a user that goes away again is a session that ended — a logout, an account deleted from under itself,
|
||||
* a token the server revoked — and the form, not a skeleton, is the answer. Latching this is what keeps the
|
||||
* skeleton bounded by something the component owns, rather than by every path that drops a session
|
||||
* remembering to clear the stored token on its way out: deleting your own account does not, and left the
|
||||
* skeleton up for good.
|
||||
*/
|
||||
const hasSeenUser = useRef(false);
|
||||
if (user) {
|
||||
hasSeenUser.current = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A window that opens with a session already stored — a call popout, or any plain reload — has no user until
|
||||
* the login is resumed from that token. Treating "no user yet" as "not logged in" showed a login form for the
|
||||
* few hundred milliseconds it took, to someone who never asked for one.
|
||||
*
|
||||
* The stored token is the whole of the test, and deliberately so. It is written before the window loads and
|
||||
* removed on an explicit logout or a rejected resume, so it covers the resume from end to end. Asking
|
||||
* `isLoggingIn` as well looked like it covered the same ground more directly, but it is true of *any* login in
|
||||
* flight, including one someone is making at the form right now: that unmounted the form mid-attempt, so a
|
||||
* rejected password came back to a blank form with nothing marked invalid, and iframe login — which runs from
|
||||
* inside `LoginPage` — could never get as far as showing its own form at all.
|
||||
*
|
||||
* Subscribed to rather than read per render, because the writes that matter here are same-tab ones and those
|
||||
* announce themselves to nobody: `clearStoredCredentials()` ends by nulling a connection userId that is
|
||||
* *already* null on a resume that never got a user, so neither the Tracker dep nor the userId store fires and
|
||||
* there is no next render to fall through on. See `useStoredItem`.
|
||||
*/
|
||||
const loginToken = useStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
|
||||
const isResumingSession = !user && !hasSeenUser.current && !forceLogin && !unreachable && !!loginToken;
|
||||
|
||||
if (isResumingSession) {
|
||||
return <HomeSkeleton />;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<LoggedInArea>
|
||||
<UsernameCheck>{children}</UsernameCheck>
|
||||
</LoggedInArea>
|
||||
);
|
||||
}
|
||||
|
||||
if (!forceLogin && guest) {
|
||||
return <RegistrationRoute defaultRoute='guest'>{children}</RegistrationRoute>;
|
||||
}
|
||||
|
||||
if (!forceLogin && allowAnonymousRead) {
|
||||
return <UsernameCheck>{children}</UsernameCheck>;
|
||||
}
|
||||
|
||||
return <LoginPage />;
|
||||
};
|
||||
|
||||
export default AuthenticationCheck;
|
||||
92
apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx
Normal file
92
apps/meteor/client/views/root/MainLayout/EmbeddedPreload.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { getObjectKeys } from '@rocket.chat/tools';
|
||||
import { useEndpoint, useMethod, useRouter, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { roomFields } from '../../../../lib/publishFields';
|
||||
import { RoomsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
|
||||
import { roomsQueryKeys } from '../../../lib/queryKeys';
|
||||
import { roomCoordinator } from '../../../lib/rooms/roomCoordinator';
|
||||
import { mapSubscriptionFromApi } from '../../../lib/utils/mapSubscriptionFromApi';
|
||||
import { Rooms } from '../../../stores';
|
||||
import PageLoading from '../PageLoading';
|
||||
import { useMainReady } from '../hooks/useMainReady';
|
||||
|
||||
export type EmbeddedPreloadProps = { children: ReactNode };
|
||||
|
||||
const EmbeddedPreload = ({ children }: EmbeddedPreloadProps) => {
|
||||
const ready = useMainReady();
|
||||
const router = useRouter();
|
||||
const uid = useUserId();
|
||||
|
||||
const roomParams = useMemo(() => {
|
||||
const routeName = router.getRouteName();
|
||||
if (!routeName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const identifier = roomCoordinator.getRouteNameIdentifier(routeName);
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directives = roomCoordinator.getRoomDirectives(identifier);
|
||||
if (!directives?.extractOpenRoomParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return directives.extractOpenRoomParams(router.getRouteParameters());
|
||||
}, [router]);
|
||||
|
||||
const getRoomByTypeAndName = useMethod('getRoomByTypeAndName');
|
||||
const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne');
|
||||
|
||||
const shouldFetch = !!roomParams && !!uid;
|
||||
|
||||
const { isLoading, isSuccess, isError } = useQuery({
|
||||
queryKey: roomParams ? roomsQueryKeys.roomReference(roomParams.reference, roomParams.type, uid ?? undefined) : [],
|
||||
queryFn: async () => {
|
||||
if (!roomParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roomData = await getRoomByTypeAndName(roomParams.type, roomParams.reference);
|
||||
if (!roomData?._id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Populate Rooms store and return same shape as useOpenRoom so the shared
|
||||
// React Query cache entry is usable when RoomOpenerEmbedded mounts.
|
||||
const unsetKeys = getObjectKeys(roomData).filter((key) => !(key in roomFields));
|
||||
unsetKeys.forEach((key) => {
|
||||
delete roomData[key];
|
||||
});
|
||||
Rooms.state.store(roomData);
|
||||
|
||||
const subResult = await getSubscription({ roomId: roomData._id });
|
||||
if (subResult.subscription) {
|
||||
SubscriptionsCachedStore.upsertSubscription(mapSubscriptionFromApi(subResult.subscription));
|
||||
}
|
||||
|
||||
return { rid: roomData._id };
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetch || isSuccess || isError) {
|
||||
SubscriptionsCachedStore.setReady(true);
|
||||
RoomsCachedStore.setReady(true);
|
||||
}
|
||||
}, [shouldFetch, isSuccess, isError]);
|
||||
|
||||
if (!ready || (shouldFetch && isLoading)) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default EmbeddedPreload;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import LayoutWithSidebar from './LayoutWithSidebar';
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useCurrentRoutePath: jest.fn(),
|
||||
useRouter: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../navbar', () => () => <div>NavBar</div>);
|
||||
jest.mock('../../../sidebar', () => () => <div>Sidebar</div>);
|
||||
jest.mock('../../navigation', () => () => <div>NavigationRegion</div>);
|
||||
jest.mock('./AccessibilityShortcut', () => () => <div>AccessibilityShortcut</div>);
|
||||
jest.mock('../../navigation/providers/RoomsNavigationProvider', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@rocket.chat/ui-client', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-client'),
|
||||
FeaturePreview: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
FeaturePreviewOn: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
FeaturePreviewOff: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const mockedUseCurrentRoutePath = useCurrentRoutePath as jest.MockedFunction<typeof useCurrentRoutePath>;
|
||||
const mockedUseRouter = useRouter as jest.MockedFunction<typeof useRouter>;
|
||||
|
||||
describe('LayoutWithSidebar - First_Channel_After_Login navigation', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const setupRouterMock = () => {
|
||||
const navigate = jest.fn();
|
||||
mockedUseRouter.mockReturnValue({ navigate } as any);
|
||||
return navigate;
|
||||
};
|
||||
|
||||
it('redirects to First_Channel_After_Login on "/"', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', 'general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith({ name: '/channel/general' });
|
||||
});
|
||||
|
||||
it('strips leading "#" from First_Channel_After_Login before redirecting', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', '#general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith({ name: '/channel/general' });
|
||||
});
|
||||
|
||||
it('does NOT redirect if First_Channel_After_Login starts with "?"', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', '?general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT redirect if First_Channel_After_Login starts with "##"', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', '##general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects when route is "/home"', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/home');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', 'general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT redirect if First_Channel_After_Login is empty', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', '').build(),
|
||||
});
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT redirect on non-home routes (e.g. /admin)', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/admin' as any);
|
||||
|
||||
render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', 'general').build(),
|
||||
});
|
||||
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects only once even if component re-renders', () => {
|
||||
const navigate = setupRouterMock();
|
||||
mockedUseCurrentRoutePath.mockReturnValue('/');
|
||||
|
||||
const { rerender } = render(<LayoutWithSidebar>content</LayoutWithSidebar>, {
|
||||
wrapper: mockAppRoot().withSetting('First_Channel_After_Login', 'general').build(),
|
||||
});
|
||||
|
||||
rerender(<LayoutWithSidebar>content again</LayoutWithSidebar>);
|
||||
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).toHaveBeenCalledWith({ name: '/channel/general' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn } from '@rocket.chat/ui-client';
|
||||
import type { IRouterPaths } from '@rocket.chat/ui-contexts';
|
||||
import { useLayout, useSetting, useCurrentRoutePath, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import AccessibilityShortcut from './AccessibilityShortcut';
|
||||
import MainContent from './MainContent';
|
||||
import { MainLayoutStyleTags } from './MainLayoutStyleTags';
|
||||
import NavBar from '../../../navbar';
|
||||
import Sidebar from '../../../sidebar';
|
||||
import NavigationRegion from '../../navigation';
|
||||
import RoomsNavigationProvider from '../../navigation/providers/RoomsNavigationProvider';
|
||||
|
||||
const INVALID_ROOM_NAME_PREFIXES = ['#', '?'] as const;
|
||||
|
||||
export type LayoutWithSidebarProps = { children: ReactNode };
|
||||
|
||||
const LayoutWithSidebar = ({ children }: LayoutWithSidebarProps) => {
|
||||
const { isEmbedded: embeddedLayout } = useLayout();
|
||||
|
||||
const currentRoutePath = useCurrentRoutePath();
|
||||
const router = useRouter();
|
||||
const removeSidenav = embeddedLayout && !currentRoutePath?.startsWith('/admin');
|
||||
|
||||
const firstChannelAfterLogin = useSetting<string>('First_Channel_After_Login', '');
|
||||
const roomName = (firstChannelAfterLogin.startsWith('#') ? firstChannelAfterLogin.slice(1) : firstChannelAfterLogin).trim();
|
||||
|
||||
const redirected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const needToBeRedirect = currentRoutePath && ['/', '/home'].includes(currentRoutePath);
|
||||
|
||||
if (!needToBeRedirect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roomName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (INVALID_ROOM_NAME_PREFIXES.some((prefix) => roomName.startsWith(prefix))) {
|
||||
// Because this will break url routing. Eg: /channel/#roomName and /channel/?roomName which will route to path /channel
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirected.current) {
|
||||
return;
|
||||
}
|
||||
redirected.current = true;
|
||||
|
||||
router.navigate({ name: `/channel/${roomName}` as keyof IRouterPaths });
|
||||
}, [router, currentRoutePath, roomName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccessibilityShortcut />
|
||||
{!embeddedLayout && <NavBar />}
|
||||
<Box
|
||||
backgroundColor='surface-light'
|
||||
id='rocket-chat'
|
||||
className={[embeddedLayout ? 'embedded-view' : undefined, 'menu-nav'].filter(Boolean).join(' ')}
|
||||
>
|
||||
<MainLayoutStyleTags />
|
||||
{!removeSidenav && (
|
||||
<FeaturePreview feature='secondarySidebar'>
|
||||
<FeaturePreviewOn>
|
||||
<RoomsNavigationProvider>
|
||||
<NavigationRegion />
|
||||
</RoomsNavigationProvider>
|
||||
</FeaturePreviewOn>
|
||||
<FeaturePreviewOff>
|
||||
<Sidebar />
|
||||
</FeaturePreviewOff>
|
||||
</FeaturePreview>
|
||||
)}
|
||||
<MainContent>{children}</MainContent>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutWithSidebar;
|
||||
50
apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx
Normal file
50
apps/meteor/client/views/root/MainLayout/LoggedInArea.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useUser } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import MediaPlayerProvider from '../../../providers/MediaPlayerProvider';
|
||||
import { useCustomEmoji } from '../hooks/loggedIn/useCustomEmoji';
|
||||
import { useE2EEncryption } from '../hooks/loggedIn/useE2EEncryption';
|
||||
import { useFingerprintChange } from '../hooks/loggedIn/useFingerprintChange';
|
||||
import { useFontStylePreference } from '../hooks/loggedIn/useFontStylePreference';
|
||||
import { useForceLogout } from '../hooks/loggedIn/useForceLogout';
|
||||
import { useLogoutCleanup } from '../hooks/loggedIn/useLogoutCleanup';
|
||||
import { useNotificationUserCalendar } from '../hooks/loggedIn/useNotificationUserCalendar';
|
||||
import { useNotifyUser } from '../hooks/loggedIn/useNotifyUser';
|
||||
import { useRestrictedRoles } from '../hooks/loggedIn/useRestrictedRoles';
|
||||
import { useRootUrlChange } from '../hooks/loggedIn/useRootUrlChange';
|
||||
import { useStoreCookiesOnLogin } from '../hooks/loggedIn/useStoreCookiesOnLogin';
|
||||
import { useTwoFactorAuthSetupCheck } from '../hooks/loggedIn/useTwoFactorAuthSetupCheck';
|
||||
import { useUnread } from '../hooks/loggedIn/useUnread';
|
||||
import { useUpdateVideoConfUser } from '../hooks/loggedIn/useUpdateVideoConfUser';
|
||||
|
||||
export type LoggedInAreaProps = { children: ReactNode };
|
||||
|
||||
const LoggedInArea = ({ children }: LoggedInAreaProps) => {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not logged');
|
||||
}
|
||||
|
||||
useFontStylePreference();
|
||||
useUnread();
|
||||
useNotifyUser(user);
|
||||
useUpdateVideoConfUser(user._id);
|
||||
useNotificationUserCalendar(user);
|
||||
useForceLogout(user._id);
|
||||
useStoreCookiesOnLogin(user._id);
|
||||
useCustomEmoji();
|
||||
useRestrictedRoles();
|
||||
// These 3 hooks below need to be called in this order due to the way our `setModal` works.
|
||||
// TODO: reevaluate `useSetModal`
|
||||
useFingerprintChange();
|
||||
useRootUrlChange();
|
||||
useTwoFactorAuthSetupCheck();
|
||||
//
|
||||
useLogoutCleanup();
|
||||
useE2EEncryption();
|
||||
|
||||
return <MediaPlayerProvider>{children}</MediaPlayerProvider>;
|
||||
};
|
||||
|
||||
export default LoggedInArea;
|
||||
38
apps/meteor/client/views/root/MainLayout/LoginPage.tsx
Normal file
38
apps/meteor/client/views/root/MainLayout/LoginPage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSession } from '@rocket.chat/ui-contexts';
|
||||
import type { LoginRoutes } from '@rocket.chat/web-ui-registration';
|
||||
import RegistrationRoute from '@rocket.chat/web-ui-registration';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LoggedOutBanner from '../../../components/deviceManagement/LoggedOutBanner';
|
||||
import { useIframe } from '../hooks/useIframe';
|
||||
|
||||
export type LoginPageProps = { defaultRoute?: LoginRoutes; children?: ReactNode };
|
||||
|
||||
const LoginPage = ({ defaultRoute, children }: LoginPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const showForcedLogoutBanner = useSession('forceLogout') as boolean | undefined;
|
||||
const { iframeLoginUrl, tryLogin, enabled: iframeEnabled } = useIframe();
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
tryLogin();
|
||||
}, [tryLogin, iframeEnabled]);
|
||||
|
||||
if (iframeLoginUrl) {
|
||||
return <iframe title={t('Login')} src={iframeLoginUrl} style={{ height: '100%', width: '100%' }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showForcedLogoutBanner && <LoggedOutBanner />}
|
||||
<RegistrationRoute defaultRoute={defaultRoute}>{children}</RegistrationRoute>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
41
apps/meteor/client/views/root/MainLayout/MainContent.tsx
Normal file
41
apps/meteor/client/views/root/MainLayout/MainContent.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { AllHTMLAttributes } from 'react';
|
||||
|
||||
const MainContent = ({ className, ...props }: Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>) => {
|
||||
const customCSS = css`
|
||||
@media print {
|
||||
overflow: visible !important; /* 1 */
|
||||
height: auto !important; /* 1 */
|
||||
max-height: none !important; /* 1 */
|
||||
flex-shrink: 0 !important; /* 1 */
|
||||
}
|
||||
|
||||
@media (width <= 767px) {
|
||||
transition:
|
||||
right 0.25s cubic-bezier(0.5, 0, 0.1, 1),
|
||||
transform 0.1s linear;
|
||||
will-change: transform;
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
is='main'
|
||||
id='main-content'
|
||||
className={[customCSS, className]}
|
||||
position='relative'
|
||||
zIndex={0}
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
flexBasis='100%'
|
||||
width='1vw'
|
||||
height='100%'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainContent;
|
||||
38
apps/meteor/client/views/root/MainLayout/MainLayout.tsx
Normal file
38
apps/meteor/client/views/root/MainLayout/MainLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import AuthenticationCheck from './AuthenticationCheck';
|
||||
import EmbeddedPreload from './EmbeddedPreload';
|
||||
import Preload from './Preload';
|
||||
import { useCustomScript } from './useCustomScript';
|
||||
|
||||
export type MainLayoutProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const MainLayout = ({ children = null }: MainLayoutProps) => {
|
||||
useCustomScript();
|
||||
|
||||
const isEmbeddedLayout = useEmbeddedLayout();
|
||||
|
||||
if (isEmbeddedLayout) {
|
||||
return (
|
||||
<EmbeddedPreload>
|
||||
<AuthenticationCheck>
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
</AuthenticationCheck>
|
||||
</EmbeddedPreload>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Preload>
|
||||
<AuthenticationCheck>
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
</AuthenticationCheck>
|
||||
</Preload>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { render, queryByAttribute } from '@testing-library/react';
|
||||
|
||||
import { MainLayoutStyleTags } from './MainLayoutStyleTags';
|
||||
|
||||
describe('MainLayout style tags', () => {
|
||||
it('should create the Light theme style tag', () => {
|
||||
render(<MainLayoutStyleTags />, {
|
||||
wrapper: mockAppRoot().withUserPreference('themeAppearence', 'light').build(),
|
||||
});
|
||||
const tagLight = queryByAttribute('id', document.head, 'main-palette-light');
|
||||
expect(tagLight).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should create the Dark theme style tag', () => {
|
||||
render(<MainLayoutStyleTags />, {
|
||||
wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(),
|
||||
});
|
||||
const tagDark = queryByAttribute('id', document.head, 'main-palette-dark');
|
||||
expect(tagDark).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should create the codeBlock style tag when in dark mode', () => {
|
||||
render(<MainLayoutStyleTags />, {
|
||||
wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(),
|
||||
});
|
||||
const style = queryByAttribute('id', document.head, 'codeBlock-palette');
|
||||
expect(style).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should create the Dark theme style tag', () => {
|
||||
render(<MainLayoutStyleTags />, {
|
||||
wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(),
|
||||
});
|
||||
const tagDark = queryByAttribute('id', document.head, 'main-palette-dark');
|
||||
expect(tagDark).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should create the codeBlock style tag when in dark mode', () => {
|
||||
render(<MainLayoutStyleTags />, {
|
||||
wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(),
|
||||
});
|
||||
const style = queryByAttribute('id', document.head, 'codeBlock-palette');
|
||||
expect(style).not.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PaletteStyleTag } from '@rocket.chat/fuselage';
|
||||
import { useThemeMode } from '@rocket.chat/ui-client';
|
||||
|
||||
import { codeBlock } from '../lib/codeBlockStyles';
|
||||
|
||||
export const MainLayoutStyleTags = () => {
|
||||
const theme = useThemeMode();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaletteStyleTag theme={theme} selector='.rcx-content--main, .rcx-tile' tagId={`main-palette-${theme}`} />
|
||||
<PaletteStyleTag theme='dark' selector='.rcx-sidebar--main, .rcx-sidepanel, .rcx-navbar' tagId='sidebar-palette' />
|
||||
{theme === 'dark' && <PaletteStyleTag selector='.rcx-content--main' palette={codeBlock} tagId='codeBlock-palette' />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useUser } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import TwoFactorAuthSetupCheck from './TwoFactorAuthSetupCheck';
|
||||
|
||||
const ResetPasswordPage = lazy(() =>
|
||||
import('@rocket.chat/web-ui-registration').then(({ ResetPasswordPage }) => ({ default: ResetPasswordPage })),
|
||||
);
|
||||
|
||||
export type PasswordChangeCheckProps = { children: ReactNode };
|
||||
|
||||
const PasswordChangeCheck = ({ children }: PasswordChangeCheckProps) => {
|
||||
const requirePasswordChange = useUser()?.requirePasswordChange === true;
|
||||
|
||||
if (requirePasswordChange) {
|
||||
return <ResetPasswordPage />;
|
||||
}
|
||||
|
||||
return <TwoFactorAuthSetupCheck>{children}</TwoFactorAuthSetupCheck>;
|
||||
};
|
||||
|
||||
export default PasswordChangeCheck;
|
||||
25
apps/meteor/client/views/root/MainLayout/Preload.tsx
Normal file
25
apps/meteor/client/views/root/MainLayout/Preload.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { RoomsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
|
||||
import PageLoading from '../PageLoading';
|
||||
import { useMainReady } from '../hooks/useMainReady';
|
||||
|
||||
export type PreloadProps = { children: ReactNode };
|
||||
|
||||
const Preload = ({ children }: PreloadProps) => {
|
||||
const ready = useMainReady();
|
||||
|
||||
useEffect(() => {
|
||||
SubscriptionsCachedStore.listen();
|
||||
RoomsCachedStore.listen();
|
||||
}, []);
|
||||
|
||||
if (!ready) {
|
||||
return <PageLoading />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Preload;
|
||||
139
apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx
Normal file
139
apps/meteor/client/views/root/MainLayout/RegisterUsername.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { TextInput, ButtonGroup, Button, FieldGroup, Field, FieldLabel, FieldRow, FieldError, Box } from '@rocket.chat/fuselage';
|
||||
import { VerticalWizardLayout, Form, FormContainer, FormFooter, FormHeader, FormSubtitle, FormTitle } from '@rocket.chat/layout';
|
||||
import { CustomFieldsForm } from '@rocket.chat/ui-client';
|
||||
import {
|
||||
useSetting,
|
||||
useTranslation,
|
||||
useLogout,
|
||||
useEndpoint,
|
||||
useUserId,
|
||||
useToastMessageDispatch,
|
||||
useAssetWithDarkModePath,
|
||||
useAccountsCustomFields,
|
||||
} from '@rocket.chat/ui-contexts';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useId } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import MarkdownText from '../../../components/MarkdownText';
|
||||
|
||||
type RegisterUsernamePayload = {
|
||||
username: Exclude<IUser['username'], undefined>;
|
||||
} & IUser['customFields'];
|
||||
|
||||
const RegisterUsername = () => {
|
||||
const t = useTranslation();
|
||||
const uid = useUserId();
|
||||
const logout = useLogout();
|
||||
const formLabelId = useId();
|
||||
const hideLogo = useSetting('Layout_Login_Hide_Logo', false);
|
||||
const customLogo = useAssetWithDarkModePath('logo');
|
||||
const customBackground = useAssetWithDarkModePath('background');
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const customFields = useAccountsCustomFields();
|
||||
|
||||
if (!uid) {
|
||||
throw new Error('Invalid user');
|
||||
}
|
||||
|
||||
const setBasicInfo = useEndpoint('POST', '/v1/users.updateOwnBasicInfo');
|
||||
|
||||
const usernameSuggestion = useEndpoint('GET', '/v1/users.getUsernameSuggestion');
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['suggestion'],
|
||||
queryFn: async () => usernameSuggestion(),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
getValues,
|
||||
setError,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterUsernamePayload>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.result && getValues('username') === '') {
|
||||
setValue('username', data.result);
|
||||
}
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const registerUsernameMutation = useMutation({
|
||||
mutationFn: async (data: RegisterUsernamePayload) => {
|
||||
const { username, ...customFields } = data;
|
||||
return Promise.all([setBasicInfo({ data: { username }, customFields })]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
dispatchToastMessage({ type: 'success', message: t('Username_has_been_updated') });
|
||||
queryClient.invalidateQueries({ queryKey: ['users.info'] });
|
||||
// Reload to redo any onLogin request that may have failed without an username
|
||||
location.reload();
|
||||
},
|
||||
onError: (error: any, { username }) => {
|
||||
if ([error.error, error.errorType].includes('error-blocked-username')) {
|
||||
return setError('username', { type: 'error-blocked-username', message: t('error-blocked-username', { field: username }) });
|
||||
}
|
||||
|
||||
if ([error.errorType].includes('error-field-unavailable')) {
|
||||
return setError('username', { type: 'error-field-unavailable', message: t('error-field-unavailable', { field: username }) });
|
||||
}
|
||||
|
||||
if ([error.errorType].includes('')) {
|
||||
return setError('username', { type: 'username-invalid', message: t('Username_invalid', { username }) });
|
||||
}
|
||||
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalWizardLayout
|
||||
background={customBackground}
|
||||
logo={!hideLogo && customLogo ? <Box is='img' maxHeight='x40' marginInline='neg-x8' src={customLogo} alt='Logo' /> : <></>}
|
||||
>
|
||||
<Form aria-labelledby={formLabelId} onSubmit={handleSubmit((data) => registerUsernameMutation.mutate(data))}>
|
||||
<FormHeader>
|
||||
<FormTitle id={formLabelId}>{t('Username_title')}</FormTitle>
|
||||
<FormSubtitle>{t('Username_description')}</FormSubtitle>
|
||||
</FormHeader>
|
||||
<FormContainer>
|
||||
{!isLoading && (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel id='username-label'>{t('Username')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<TextInput
|
||||
aria-labelledby='username-label'
|
||||
{...register('username', { required: t('Required_field', { field: t('Username') }) })}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors.username && (
|
||||
<FieldError>
|
||||
<MarkdownText content={errors.username.message} />
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
{isLoading && t('Loading_suggestion')}
|
||||
<CustomFieldsForm formName='customFields' formControl={control} metadata={customFields} />
|
||||
</FormContainer>
|
||||
<FormFooter>
|
||||
<ButtonGroup stretch vertical>
|
||||
<Button disabled={isLoading} type='submit' primary>
|
||||
{t('Use_this_username')}
|
||||
</Button>
|
||||
<Button onClick={logout}>{t('Logout')}</Button>
|
||||
</ButtonGroup>
|
||||
</FormFooter>
|
||||
</Form>
|
||||
</VerticalWizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterUsername;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { useLayout } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import LayoutWithSidebar from './LayoutWithSidebar';
|
||||
import MainContent from './MainContent';
|
||||
import { useRequire2faSetup } from '../../hooks/useRequire2faSetup';
|
||||
|
||||
const AccountSecurityPage = lazy(() => import('../../account/security/AccountSecurityPage'));
|
||||
|
||||
export type TwoFactorAuthSetupCheckProps = { children: ReactNode };
|
||||
|
||||
const TwoFactorAuthSetupCheck = ({ children }: TwoFactorAuthSetupCheckProps) => {
|
||||
const { isEmbedded: embeddedLayout } = useLayout();
|
||||
const require2faSetup = useRequire2faSetup();
|
||||
|
||||
if (require2faSetup) {
|
||||
return (
|
||||
<Box backgroundColor='surface-light' id='rocket-chat' className={embeddedLayout ? 'embedded-view' : undefined}>
|
||||
<MainContent>
|
||||
<AccountSecurityPage />
|
||||
</MainContent>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <LayoutWithSidebar>{children}</LayoutWithSidebar>;
|
||||
};
|
||||
|
||||
export default TwoFactorAuthSetupCheck;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button, Modal, ModalContent, ModalFooter, ModalFooterControllers, ModalHeader, ModalTitle } from '@rocket.chat/fuselage';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TwoFactorRequiredModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setModal(null);
|
||||
}, [setModal]);
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t('Two-factor_authentication_required')}</ModalTitle>
|
||||
</ModalHeader>
|
||||
<ModalContent>{t('Enable_two-factor_authentication_callout_description')}</ModalContent>
|
||||
<ModalFooter>
|
||||
<ModalFooterControllers>
|
||||
<Button primary onClick={closeModal}>
|
||||
{t('Set_up_2FA')}
|
||||
</Button>
|
||||
</ModalFooterControllers>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorRequiredModal;
|
||||
44
apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx
Normal file
44
apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useUserId, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import PasswordChangeCheck from './PasswordChangeCheck';
|
||||
import RegisterUsername from './RegisterUsername';
|
||||
import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery';
|
||||
import HomeSkeleton from '../../home/HomeSkeleton';
|
||||
|
||||
export type UsernameCheckProps = { children: ReactNode };
|
||||
|
||||
const UsernameCheck = ({ children }: UsernameCheckProps) => {
|
||||
const userId = useUserId();
|
||||
const { data: userData, isLoading } = useUserInfoQuery({ userId: userId || '' }, { enabled: !!userId });
|
||||
|
||||
const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead', false);
|
||||
|
||||
const shouldRegisterUsername = useMemo(() => {
|
||||
const hasUserInCollection = !!userData?.user;
|
||||
const hasUsername = !!userData?.user?.username;
|
||||
|
||||
if (!userId) {
|
||||
return !allowAnonymousRead;
|
||||
}
|
||||
|
||||
if (!hasUserInCollection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !hasUsername;
|
||||
}, [userData?.user, userId, allowAnonymousRead]);
|
||||
|
||||
if (isLoading) {
|
||||
return <HomeSkeleton />;
|
||||
}
|
||||
|
||||
if (shouldRegisterUsername) {
|
||||
return <RegisterUsername />;
|
||||
}
|
||||
|
||||
return <PasswordChangeCheck>{children}</PasswordChangeCheck>;
|
||||
};
|
||||
|
||||
export default UsernameCheck;
|
||||
5
apps/meteor/client/views/root/MainLayout/index.ts
Normal file
5
apps/meteor/client/views/root/MainLayout/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
const MainLayout = lazy(() => import('./MainLayout'));
|
||||
|
||||
export default MainLayout;
|
||||
16
apps/meteor/client/views/root/MainLayout/useCustomScript.ts
Normal file
16
apps/meteor/client/views/root/MainLayout/useCustomScript.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fireGlobalEvent } from '../../../lib/utils/fireGlobalEvent';
|
||||
|
||||
export const useCustomScript = (): void => {
|
||||
const uid = useUserId();
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
fireGlobalEvent('Custom_Script_Logged_In');
|
||||
return;
|
||||
}
|
||||
|
||||
fireGlobalEvent('Custom_Script_Logged_Out');
|
||||
}, [uid]);
|
||||
};
|
||||
50
apps/meteor/client/views/root/OutermostErrorBoundary.tsx
Normal file
50
apps/meteor/client/views/root/OutermostErrorBoundary.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Bugsnag from '@bugsnag/js';
|
||||
import type { BugsnagErrorBoundary as BugsnagErrorBoundaryComponent } from '@bugsnag/plugin-react';
|
||||
import BugsnagPluginReact from '@bugsnag/plugin-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import AppErrorPage from './AppErrorPage';
|
||||
import { Info } from '../../../app/utils/rocketchat.info';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Window {
|
||||
__BUGSNAG_KEY__: string;
|
||||
}
|
||||
}
|
||||
|
||||
let BugsnagErrorBoundary: BugsnagErrorBoundaryComponent | undefined;
|
||||
|
||||
if (window.__BUGSNAG_KEY__) {
|
||||
Bugsnag.start({
|
||||
apiKey: window.__BUGSNAG_KEY__,
|
||||
appVersion: Info.version,
|
||||
plugins: [new BugsnagPluginReact()],
|
||||
onError(event) {
|
||||
// Skip the benign "ResizeObserver loop" browser warning (not a crash; emitted in bulk by
|
||||
// virtua during message-list scroll). See CORE-2209.
|
||||
const errorMessage = event.errors?.[0]?.errorMessage;
|
||||
if (typeof errorMessage === 'string' && errorMessage.startsWith('ResizeObserver loop')) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
BugsnagErrorBoundary = Bugsnag.getPlugin('react')?.createErrorBoundary(React);
|
||||
}
|
||||
|
||||
export type OutermostErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const OutermostErrorBoundary = ({ children }: OutermostErrorBoundaryProps) => {
|
||||
if (BugsnagErrorBoundary) {
|
||||
return <BugsnagErrorBoundary FallbackComponent={AppErrorPage}>{children}</BugsnagErrorBoundary>;
|
||||
}
|
||||
|
||||
return <ErrorBoundary fallbackRender={() => <AppErrorPage />}>{children}</ErrorBoundary>;
|
||||
};
|
||||
|
||||
export default OutermostErrorBoundary;
|
||||
9
apps/meteor/client/views/root/PageLoading.tsx
Normal file
9
apps/meteor/client/views/root/PageLoading.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import LoadingIndicator from '../../components/LoadingIndicator';
|
||||
|
||||
const PageLoading = () => (
|
||||
<div className='page-loading' role='alert' aria-busy='true' aria-live='polite' aria-label='loading'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PageLoading;
|
||||
154
apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx
Normal file
154
apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { MockedServerContext, MockedSettingsContext, MockedUserContext } from '@rocket.chat/mock-providers';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import SAMLLoginRoute from './SAMLLoginRoute';
|
||||
import RouterContextMock from '../../../tests/mocks/client/RouterContextMock';
|
||||
import { useSamlInviteToken } from '../invite/hooks/useSamlInviteToken';
|
||||
|
||||
jest.mock('../invite/hooks/useSamlInviteToken');
|
||||
const mockUseSamlInviteToken = jest.mocked(useSamlInviteToken);
|
||||
const navigateStub = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Storage.prototype, 'getItem');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
navigateStub.mockClear();
|
||||
(Meteor.loginWithSamlToken as jest.Mock<any>).mockClear();
|
||||
});
|
||||
|
||||
it('should redirect to /home', async () => {
|
||||
mockUseSamlInviteToken.mockReturnValue([null, () => ({})]);
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<MockedUserContext>
|
||||
<RouterContextMock navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedUserContext>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(navigateStub).toHaveBeenCalledTimes(1);
|
||||
expect(navigateStub).toHaveBeenLastCalledWith(expect.objectContaining({ pathname: '/home' }), expect.anything());
|
||||
});
|
||||
|
||||
it('should redirect to /home when userId is null and the stored invite token is not valid', async () => {
|
||||
mockUseSamlInviteToken.mockReturnValue([null, () => ({})]);
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<RouterContextMock searchParameters={{ redirectUrl: 'http://rocket.chat' }} navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(navigateStub).toHaveBeenCalledTimes(1);
|
||||
expect(navigateStub).toHaveBeenLastCalledWith(expect.objectContaining({ pathname: '/home' }), expect.anything());
|
||||
});
|
||||
|
||||
it('should redirect to the invite page with the stored invite token when it is valid', async () => {
|
||||
mockUseSamlInviteToken.mockReturnValue(['test', () => ({})]);
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<RouterContextMock navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(navigateStub).toHaveBeenCalledTimes(1);
|
||||
expect(navigateStub).toHaveBeenLastCalledWith(expect.objectContaining({ pathname: '/invite/test' }), expect.anything());
|
||||
});
|
||||
|
||||
it('should call loginWithSamlToken when component is mounted', async () => {
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<RouterContextMock navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1);
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenLastCalledWith(undefined, expect.any(Function));
|
||||
});
|
||||
|
||||
it('should call loginWithSamlToken with the token when it is present', async () => {
|
||||
render(
|
||||
<MockedUserContext>
|
||||
<RouterContextMock routeParameters={{ token: 'testToken' }} navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedUserContext>,
|
||||
);
|
||||
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1);
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenLastCalledWith('testToken', expect.any(Function));
|
||||
});
|
||||
|
||||
jest.mock('../../lib/buildAuthDeeplinkURL', () => ({ buildSamlDeepLinkURL: jest.fn() }));
|
||||
|
||||
describe('native client handoff', () => {
|
||||
const { buildSamlDeepLinkURL } = jest.requireMock('../../lib/buildAuthDeeplinkURL') as { buildSamlDeepLinkURL: jest.Mock };
|
||||
|
||||
const navigableDeepLink = `${window.location.origin}/#deep-link`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseSamlInviteToken.mockReturnValue([null, () => ({})]);
|
||||
buildSamlDeepLinkURL.mockReturnValue(navigableDeepLink);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it.each(['mobile', 'desktop'])('should hand the credential token to the %s client without logging in', async (loginClient) => {
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient }} navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
// The browser must never authenticate: the app redeems the credential on its own connection.
|
||||
expect(Meteor.loginWithSamlToken).not.toHaveBeenCalled();
|
||||
expect(navigateStub).not.toHaveBeenCalled();
|
||||
|
||||
expect(buildSamlDeepLinkURL).toHaveBeenCalledTimes(1);
|
||||
expect(buildSamlDeepLinkURL).toHaveBeenLastCalledWith('testToken');
|
||||
expect(window.location.href).toBe(navigableDeepLink);
|
||||
});
|
||||
|
||||
it('should log in normally for an unrecognized loginClient', async () => {
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient: 'web' }} navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(buildSamlDeepLinkURL).not.toHaveBeenCalled();
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ignore loginClient and log in normally when the modern flow is disabled', async () => {
|
||||
render(
|
||||
<MockedServerContext>
|
||||
<MockedSettingsContext settings={{ Accounts_OAuth_Use_Modern_Flow: false }}>
|
||||
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient: 'desktop' }} navigate={navigateStub}>
|
||||
<SAMLLoginRoute />
|
||||
</RouterContextMock>
|
||||
</MockedSettingsContext>
|
||||
</MockedServerContext>,
|
||||
);
|
||||
|
||||
expect(buildSamlDeepLinkURL).not.toHaveBeenCalled();
|
||||
expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
53
apps/meteor/client/views/root/SAMLLoginRoute.tsx
Normal file
53
apps/meteor/client/views/root/SAMLLoginRoute.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useRouter, useToastMessageDispatch, useSearchParameter, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { buildSamlDeepLinkURL } from '../../lib/buildAuthDeeplinkURL';
|
||||
import { useSamlInviteToken } from '../invite/hooks/useSamlInviteToken';
|
||||
|
||||
const SAMLLoginRoute = () => {
|
||||
const router = useRouter();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const [inviteToken] = useSamlInviteToken();
|
||||
const loginClient = useSearchParameter('loginClient');
|
||||
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
|
||||
|
||||
useEffect(() => {
|
||||
const { token } = router.getRouteParameters();
|
||||
|
||||
//SAML token handoff to the native client (mobile/desktop)
|
||||
if (enableModernOAuthFlow && (loginClient === 'desktop' || loginClient === 'mobile')) {
|
||||
window.location.href = buildSamlDeepLinkURL(token);
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate('/home', { replace: true });
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
|
||||
Meteor.loginWithSamlToken(token, (error?: unknown) => {
|
||||
if (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
|
||||
if (inviteToken) {
|
||||
router.navigate(
|
||||
{
|
||||
pathname: `/invite/${inviteToken}`,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
} else {
|
||||
router.navigate(
|
||||
{
|
||||
pathname: '/home',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [dispatchToastMessage, enableModernOAuthFlow, inviteToken, loginClient, router]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SAMLLoginRoute;
|
||||
5
apps/meteor/client/views/root/hooks/closeAllRooms.ts
Normal file
5
apps/meteor/client/views/root/hooks/closeAllRooms.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
|
||||
|
||||
export const closeAllRooms = () => {
|
||||
LegacyRoomManager.closeAllRooms();
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { config } from '../../../../../app/apple/lib/config';
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
/* const Apple =*/ CustomOAuth.configureOAuthService('apple', config);
|
||||
|
||||
export const useAppleOAuth = () => {
|
||||
// Here we would expect to handle changes in settings, updating the configuration
|
||||
// accordingly, but it was not implemented yet.
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
import { loginServices } from '../../../../lib/loginServices';
|
||||
|
||||
export const useCustomOAuth = () => {
|
||||
useEffect(
|
||||
() =>
|
||||
loginServices.onLoad((services) => {
|
||||
for (const service of services) {
|
||||
if (!('custom' in service && service.custom)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CustomOAuth.configureCustomOAuthService(service.service, {
|
||||
serverURL: service.serverURL,
|
||||
authorizePath: service.authorizePath,
|
||||
scope: service.scope,
|
||||
});
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
const config = {
|
||||
serverURL: '',
|
||||
authorizePath: '/m/oauth2/auth/',
|
||||
tokenPath: '/m/oauth2/token/',
|
||||
identityPath: '/m/oauth2/api/me/',
|
||||
scope: 'basic',
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.dolphin'],
|
||||
forOtherUsers: ['services.dolphin.name'],
|
||||
},
|
||||
accessTokenParam: 'access_token',
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const Dolphin = CustomOAuth.configureOAuthService('dolphin', config);
|
||||
|
||||
export const useDolphinOAuth = () => {
|
||||
const enabled = useSetting('Accounts_OAuth_Dolphin');
|
||||
const url = useSetting('Accounts_OAuth_Dolphin_URL') as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
Dolphin.configure({
|
||||
...config,
|
||||
serverURL: url,
|
||||
});
|
||||
}
|
||||
}, [enabled, url]);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
// Drupal Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/drupal
|
||||
// In RocketChat -> Administration the URL needs to be http(s)://{drupal.server}/
|
||||
|
||||
const config = {
|
||||
serverURL: '',
|
||||
identityPath: '/oauth2/UserInfo',
|
||||
authorizePath: '/oauth2/authorize',
|
||||
tokenPath: '/oauth2/token',
|
||||
scope: 'openid email profile offline_access',
|
||||
tokenSentVia: 'payload',
|
||||
usernameField: 'preferred_username',
|
||||
mergeUsers: true,
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.drupal'],
|
||||
forOtherUsers: ['services.drupal.name'],
|
||||
},
|
||||
accessTokenParam: 'access_token',
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const Drupal = CustomOAuth.configureOAuthService('drupal', config);
|
||||
|
||||
export const useDrupalOAuth = () => {
|
||||
const drupalUrl = useSetting('API_Drupal_URL') as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (drupalUrl) {
|
||||
Drupal.configure({
|
||||
...config,
|
||||
serverURL: drupalUrl,
|
||||
});
|
||||
}
|
||||
}, [drupalUrl]);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
// GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise
|
||||
// In RocketChat -> Administration the URL needs to be http(s)://{github.enterprise.server}/
|
||||
|
||||
const config = {
|
||||
serverURL: '',
|
||||
identityPath: '/api/v3/user',
|
||||
authorizePath: '/login/oauth/authorize',
|
||||
tokenPath: '/login/oauth/access_token',
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.github-enterprise'],
|
||||
forOtherUsers: ['services.github-enterprise.username'],
|
||||
},
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const GitHubEnterprise = CustomOAuth.configureOAuthService('github_enterprise', config);
|
||||
|
||||
export const useGitHubEnterpriseOAuth = () => {
|
||||
const githubApiUrl = useSetting('API_GitHub_Enterprise_URL') as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (githubApiUrl) {
|
||||
GitHubEnterprise.configure({
|
||||
...config,
|
||||
serverURL: githubApiUrl,
|
||||
});
|
||||
}
|
||||
}, [githubApiUrl]);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
const config = {
|
||||
serverURL: 'https://gitlab.com',
|
||||
identityPath: '/api/v4/user',
|
||||
scope: 'read_user',
|
||||
mergeUsers: false,
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.gitlab'],
|
||||
forOtherUsers: ['services.gitlab.username'],
|
||||
},
|
||||
accessTokenParam: 'access_token',
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const Gitlab = CustomOAuth.configureOAuthService('gitlab', config);
|
||||
|
||||
export const useGitLabOAuth = () => {
|
||||
const gitlabApiUrl = useSetting('API_Gitlab_URL') as string;
|
||||
const gitlabIdentiry = useSetting('Accounts_OAuth_Gitlab_identity_path') as string;
|
||||
const gitlabMergeUsers = useSetting('Accounts_OAuth_Gitlab_merge_users', false);
|
||||
|
||||
useEffect(() => {
|
||||
Gitlab.configure({
|
||||
...config,
|
||||
...(gitlabApiUrl && { serverURL: gitlabApiUrl.trim().replace(/\/*$/, '') }),
|
||||
...(gitlabIdentiry && { identityPath: gitlabIdentiry.trim() || config.identityPath }),
|
||||
...(gitlabMergeUsers && { mergeUsers: true }),
|
||||
});
|
||||
}, [gitlabApiUrl, gitlabIdentiry, gitlabMergeUsers]);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
const config: OauthConfig = {
|
||||
authorizePath: 'https://github.com/login/oauth/authorize',
|
||||
serverURL: 'https://github.com',
|
||||
identityPath: 'https://api.github.com/user',
|
||||
tokenPath: 'https://github.com/login/oauth/access_token',
|
||||
scope: 'user:email',
|
||||
mergeUsers: false,
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.github'],
|
||||
forOtherUsers: ['services.github.username'],
|
||||
},
|
||||
accessTokenParam: 'access_token',
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const Github = CustomOAuth.configureOAuthService('github', config);
|
||||
|
||||
export const useGithubOAuth = () => {
|
||||
const enabled = useSetting('Accounts_OAuth_Github');
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
Github.configure({
|
||||
...config,
|
||||
});
|
||||
}
|
||||
}, [enabled]);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
const config = {
|
||||
serverURL: '',
|
||||
tokenPath: '/index.php/apps/oauth2/api/v1/token',
|
||||
tokenSentVia: 'header',
|
||||
authorizePath: '/index.php/apps/oauth2/authorize',
|
||||
identityPath: '/ocs/v2.php/cloud/user?format=json',
|
||||
scope: 'openid',
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.nextcloud'],
|
||||
forOtherUsers: ['services.nextcloud.name'],
|
||||
},
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const Nextcloud = CustomOAuth.configureOAuthService('nextcloud', config);
|
||||
|
||||
export const useNextcloudOAuth = (): void => {
|
||||
const nextcloudURL = useSetting('Accounts_OAuth_Nextcloud_URL') as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (nextcloudURL) {
|
||||
Nextcloud.configure({
|
||||
...config,
|
||||
serverURL: nextcloudURL.trim().replace(/\/*$/, ''),
|
||||
});
|
||||
}
|
||||
}, [nextcloudURL]);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { OauthConfig } from '@rocket.chat/core-typings';
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CustomOAuth } from '../../../../lib/customOAuth/CustomOAuth';
|
||||
|
||||
const configDefault = {
|
||||
serverURL: '',
|
||||
addAutopublishFields: {
|
||||
forLoggedInUser: ['services.wordpress'],
|
||||
forOtherUsers: ['services.wordpress.user_login'],
|
||||
},
|
||||
accessTokenParam: 'access_token',
|
||||
} as const satisfies OauthConfig;
|
||||
|
||||
const WordPress = CustomOAuth.configureOAuthService('wordpress', configDefault);
|
||||
|
||||
const configureServerType = (
|
||||
serverType: string,
|
||||
identityPath?: string,
|
||||
identityTokenSentVia?: string,
|
||||
tokenPath?: string,
|
||||
authorizePath?: string,
|
||||
scope?: string,
|
||||
): OauthConfig => {
|
||||
switch (serverType) {
|
||||
case 'custom': {
|
||||
return {
|
||||
...configDefault,
|
||||
...(identityPath && { identityPath }),
|
||||
...(identityTokenSentVia && { identityTokenSentVia }),
|
||||
...(tokenPath && { tokenPath }),
|
||||
...(authorizePath && { authorizePath }),
|
||||
...(scope && { scope }),
|
||||
};
|
||||
}
|
||||
|
||||
case 'wordpress-com':
|
||||
return {
|
||||
...configDefault,
|
||||
identityPath: 'https://public-api.wordpress.com/rest/v1/me',
|
||||
identityTokenSentVia: 'header',
|
||||
authorizePath: 'https://public-api.wordpress.com/oauth2/authorize',
|
||||
tokenPath: 'https://public-api.wordpress.com/oauth2/token',
|
||||
scope: 'auth',
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
...configDefault,
|
||||
identityPath: '/oauth/me',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const useWordPressOAuth = (): void => {
|
||||
const wordpressURL = useSetting('API_Wordpress_URL') as string;
|
||||
const serverType = useSetting('Accounts_OAuth_Wordpress_server_type') as string;
|
||||
const identityPath = useSetting('Accounts_OAuth_Wordpress_identity_path') as string;
|
||||
const identityTokenSentVia = useSetting('Accounts_OAuth_Wordpress_identity_token_sent_via') as string;
|
||||
const tokenPath = useSetting('Accounts_OAuth_Wordpress_token_path') as string;
|
||||
const authorizePath = useSetting('Accounts_OAuth_Wordpress_authorize_path') as string;
|
||||
const scope = useSetting('Accounts_OAuth_Wordpress_scope') as string;
|
||||
|
||||
useEffect(() => {
|
||||
WordPress.configure({
|
||||
...configDefault,
|
||||
...configureServerType(serverType, identityPath, identityTokenSentVia, tokenPath, authorizePath, scope),
|
||||
serverURL: wordpressURL,
|
||||
});
|
||||
}, [authorizePath, identityPath, identityTokenSentVia, scope, serverType, tokenPath, wordpressURL]);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useCustomEmoji } from './useCustomEmoji';
|
||||
import { emoji } from '../../../../lib/emoji';
|
||||
|
||||
jest.mock('../../../../lib/customEmoji', () => ({ customRender: jest.fn((html: string) => html) }));
|
||||
|
||||
const pristinePackages = { ...emoji.packages };
|
||||
|
||||
beforeEach(() => {
|
||||
emoji.list = {};
|
||||
emoji.packages = { ...pristinePackages };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createCustomEmoji = () => ({
|
||||
_id: 'emojiId',
|
||||
_updatedAt: new Date().toISOString(),
|
||||
name: 'my-emoji',
|
||||
aliases: ['my-alias'],
|
||||
extension: 'png',
|
||||
});
|
||||
|
||||
it('should call emoji-custom.list without a query parameter', async () => {
|
||||
const getCustomEmojis = jest.fn().mockReturnValue({ emojis: { update: [], remove: [] } });
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot().withEndpoint('GET', '/v1/emoji-custom.list', getCustomEmojis).build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getCustomEmojis).toHaveBeenCalledWith({}));
|
||||
});
|
||||
|
||||
it('should populate emoji.packages.emojiCustom with the emojis and aliases returned by the endpoint', async () => {
|
||||
const customEmoji = createCustomEmoji();
|
||||
const dispatchUpdateSpy = jest.spyOn(emoji, 'dispatchUpdate');
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/emoji-custom.list', () => ({ emojis: { update: [customEmoji], remove: [] } }))
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(emoji.packages.emojiCustom.emojisByCategory.rocket).toContain('my-emoji'));
|
||||
|
||||
expect(emoji.packages.emojiCustom.list).toEqual(expect.arrayContaining([':my-emoji:', ':my-alias:']));
|
||||
expect(emoji.list[':my-emoji:']).toMatchObject({ name: 'my-emoji', emojiPackage: 'emojiCustom' });
|
||||
expect(emoji.list[':my-alias:']).toEqual({ emojiPackage: 'emojiCustom', aliasOf: 'my-emoji' });
|
||||
expect(dispatchUpdateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset emoji.packages.emojiCustom to an empty list when the request fails', async () => {
|
||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
renderHook(() => useCustomEmoji(), {
|
||||
wrapper: mockAppRoot()
|
||||
.withEndpoint('GET', '/v1/emoji-custom.list', () => {
|
||||
throw new Error('unexpected error');
|
||||
})
|
||||
.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(consoleErrorSpy).toHaveBeenCalledWith('Error getting custom emoji ', expect.any(Error)));
|
||||
|
||||
expect(emoji.packages.emojiCustom.list).toEqual([]);
|
||||
expect(emoji.packages.emojiCustom.emojisByCategory.rocket).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { customRender } from '../../../../lib/customEmoji';
|
||||
import { emoji } from '../../../../lib/emoji';
|
||||
|
||||
export const useCustomEmoji = () => {
|
||||
const getCustomEmojis = useEndpoint('GET', '/v1/emoji-custom.list');
|
||||
const result = useQuery({
|
||||
queryKey: ['emoji-custom.list'],
|
||||
queryFn: () => getCustomEmojis({}),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
emoji.packages.emojiCustom = {
|
||||
emojiCategories: [{ key: 'rocket', i18n: 'Custom' }],
|
||||
categoryIndex: 1,
|
||||
toneList: {},
|
||||
list: [],
|
||||
_regexpSignature: null,
|
||||
_regexp: null,
|
||||
emojisByCategory: { rocket: [] },
|
||||
render: customRender,
|
||||
renderPicker: customRender,
|
||||
};
|
||||
|
||||
if (result.isError) {
|
||||
console.error('Error getting custom emoji ', result.error);
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
const {
|
||||
emojis: { update: customEmojis },
|
||||
} = result.data;
|
||||
|
||||
const addCustomEmojis = () => {
|
||||
for (const currentEmoji of customEmojis) {
|
||||
emoji.packages.emojiCustom.emojisByCategory.rocket.push(currentEmoji.name);
|
||||
emoji.packages.emojiCustom.list?.push(`:${currentEmoji.name}:`);
|
||||
emoji.list[`:${currentEmoji.name}:`] = { ...currentEmoji, emojiPackage: 'emojiCustom' } as any;
|
||||
for (const alias of currentEmoji.aliases) {
|
||||
emoji.packages.emojiCustom.list?.push(`:${alias}:`);
|
||||
emoji.list[`:${alias}:`] = {
|
||||
emojiPackage: 'emojiCustom',
|
||||
aliasOf: currentEmoji.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
emoji.dispatchUpdate();
|
||||
};
|
||||
addCustomEmojis();
|
||||
}
|
||||
}, [result.data, result.error, result.isError, result.isSuccess]);
|
||||
};
|
||||
121
apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts
Normal file
121
apps/meteor/client/views/root/hooks/loggedIn/useE2EEncryption.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { isE2EEPinnedMessage, type IRoom, type IMessage } from '@rocket.chat/core-typings';
|
||||
import { useUserId, useSetting, useRouter, useLayout, useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { MentionsParser } from '../../../../../app/mentions/lib/MentionsParser';
|
||||
import { e2e } from '../../../../lib/e2ee';
|
||||
import { onClientBeforeSendMessage } from '../../../../lib/onClientBeforeSendMessage';
|
||||
import { onClientMessageReceived } from '../../../../lib/onClientMessageReceived';
|
||||
import { Rooms } from '../../../../stores';
|
||||
import { useE2EEState } from '../../../room/hooks/useE2EEState';
|
||||
|
||||
export const useE2EEncryption = () => {
|
||||
const userId = useUserId();
|
||||
const enabled = useSetting('E2E_Enable', false);
|
||||
const router = useRouter();
|
||||
// we don't care about the reactivity of this boolean
|
||||
const adminEmbedded = useLayout().isEmbedded && router.getLocationPathname().startsWith('/admin');
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled && !adminEmbedded) {
|
||||
e2e.startClient(userId);
|
||||
} else {
|
||||
e2e.setState('DISABLED');
|
||||
e2e.closeAlert();
|
||||
}
|
||||
}, [adminEmbedded, enabled, userId]);
|
||||
|
||||
const state = useE2EEState();
|
||||
const ready = state === 'READY' || state === 'SAVE_PASSWORD';
|
||||
const listenersAttachedRef = useRef(false);
|
||||
|
||||
const mentionsEnabled = useSetting('E2E_Enabled_Mentions', true);
|
||||
const me = useUser()?.username || '';
|
||||
const pattern = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
|
||||
const useRealName = useSetting('UI_Use_Real_Name', false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (listenersAttachedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offClientMessageReceived = onClientMessageReceived.use(async (msg) => {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(msg.rid);
|
||||
if (!e2eRoom?.shouldConvertReceivedMessages()) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (isE2EEPinnedMessage(msg)) {
|
||||
return e2e.decryptPinnedMessage(msg);
|
||||
}
|
||||
|
||||
return e2e.decryptMessage(msg);
|
||||
});
|
||||
|
||||
// Encrypt messages before sending
|
||||
const offClientBeforeSendMessage = onClientBeforeSendMessage.use(async (message) => {
|
||||
const e2eRoom = await e2e.getInstanceByRoomId(message.rid);
|
||||
|
||||
if (!e2eRoom) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// e2e.getInstanceByRoomId already waits for the room to be available which means this logic needs to be
|
||||
// refactored to avoid waiting for the room again
|
||||
const subscription = await new Promise<IRoom>((resolve) => {
|
||||
const room = Rooms.state.get(message.rid);
|
||||
|
||||
if (room) resolve(room);
|
||||
|
||||
const unsubscribe = Rooms.use.subscribe((state) => {
|
||||
const room = state.get(message.rid);
|
||||
if (room) {
|
||||
unsubscribe();
|
||||
resolve(room);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
subscription.encrypted ? e2eRoom.resume() : e2eRoom.pause();
|
||||
|
||||
const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages(message);
|
||||
|
||||
if (!shouldConvertSentMessages) return message;
|
||||
|
||||
if (mentionsEnabled) {
|
||||
const mentions = new MentionsParser({
|
||||
pattern: () => pattern,
|
||||
useRealName: () => useRealName,
|
||||
me: () => me,
|
||||
});
|
||||
|
||||
const e2eMentions: IMessage['e2eMentions'] = {
|
||||
e2eUserMentions: mentions.getUserMentions(message.msg),
|
||||
e2eChannelMentions: mentions.getChannelMentions(message.msg),
|
||||
};
|
||||
|
||||
message.e2eMentions = e2eMentions;
|
||||
}
|
||||
|
||||
// Should encrypt this message.
|
||||
const encryptedMessage = await e2eRoom.encryptMessage(message);
|
||||
return encryptedMessage;
|
||||
});
|
||||
|
||||
listenersAttachedRef.current = true;
|
||||
|
||||
return () => {
|
||||
offClientMessageReceived();
|
||||
offClientBeforeSendMessage();
|
||||
listenersAttachedRef.current = false;
|
||||
};
|
||||
}, [me, mentionsEnabled, pattern, ready, useRealName]);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEndpoint, useRole, useSetModal, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FingerprintChangeModal from '../../../../components/FingerprintChangeModal';
|
||||
import FingerprintChangeModalConfirmation from '../../../../components/FingerprintChangeModalConfirmation';
|
||||
|
||||
const reducer = (
|
||||
state: { openModal: boolean; openConfirmation: boolean; newWorkspace?: boolean },
|
||||
action: { type: 'openModal' | 'openConfirmation' | 'closeModal'; newWorkspace?: boolean },
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case 'openModal':
|
||||
return { openModal: true, openConfirmation: false, newWorkspace: undefined };
|
||||
case 'openConfirmation':
|
||||
return { openModal: false, openConfirmation: true, newWorkspace: action.newWorkspace };
|
||||
case 'closeModal':
|
||||
return { openModal: false, openConfirmation: false, newWorkspace: undefined };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const useFingerprintChange = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isAdmin = useRole('admin');
|
||||
const setModal = useSetModal();
|
||||
const deploymentFingerPrintVerified = useSetting('Deployment_FingerPrint_Verified', true);
|
||||
const fingerprintEndpoint = useEndpoint('POST', '/v1/fingerprint');
|
||||
|
||||
const [{ openConfirmation, openModal, newWorkspace }, dispatch] = useReducer(reducer, {
|
||||
openModal: false,
|
||||
openConfirmation: false,
|
||||
newWorkspace: undefined,
|
||||
});
|
||||
|
||||
const { mutate: fingerPrintMutation } = useMutation({
|
||||
mutationKey: ['settings', 'Deployment_FingerPrint_Verified'],
|
||||
mutationFn: async (setDeploymentAs: 'new-workspace' | 'updated-configuration') => {
|
||||
const result = await fingerprintEndpoint({ setDeploymentAs });
|
||||
return {
|
||||
...result,
|
||||
setDeploymentAs,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ setDeploymentAs }) => {
|
||||
if (setDeploymentAs === 'new-workspace') {
|
||||
return dispatchToastMessage({ type: 'success', message: t('New_workspace_confirmed') });
|
||||
}
|
||||
return dispatchToastMessage({ type: 'success', message: t('Configuration_update_confirmed') });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
if (deploymentFingerPrintVerified === null || deploymentFingerPrintVerified === true) {
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'openModal' });
|
||||
|
||||
return () => {
|
||||
dispatch({ type: 'closeModal' });
|
||||
};
|
||||
}, [deploymentFingerPrintVerified, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openModal && !openConfirmation) {
|
||||
setModal(null);
|
||||
}
|
||||
if (openModal) {
|
||||
setModal(
|
||||
<FingerprintChangeModal
|
||||
onConfirm={() => dispatch({ type: 'openConfirmation', newWorkspace: false })}
|
||||
onCancel={() => dispatch({ type: 'openConfirmation', newWorkspace: true })}
|
||||
onClose={() => dispatch({ type: 'closeModal' })}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (openConfirmation && newWorkspace !== undefined) {
|
||||
setModal(
|
||||
<FingerprintChangeModalConfirmation
|
||||
onConfirm={() => {
|
||||
fingerPrintMutation(newWorkspace ? 'new-workspace' : 'updated-configuration');
|
||||
dispatch({ type: 'closeModal' });
|
||||
}}
|
||||
onCancel={() => dispatch({ type: 'openModal' })}
|
||||
onClose={() => dispatch({ type: 'closeModal' })}
|
||||
newWorkspace={newWorkspace}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}, [fingerPrintMutation, setModal, openConfirmation, openModal, newWorkspace]);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FontSize } from '@rocket.chat/rest-typings';
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useCreateFontStyleElement } from '../../../account/accessibility/hooks/useCreateFontStyleElement';
|
||||
|
||||
export const useFontStylePreference = () => {
|
||||
const fontSize = useUserPreference<FontSize>('fontSize');
|
||||
const createFontStyleElement = useCreateFontStyleElement();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handle the edge case where the font-size preference ceases to exist: set to 100% (default)
|
||||
createFontStyleElement(fontSize || '100%');
|
||||
}, [fontSize, createFontStyleElement]);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useSessionDispatch, useStream, useWipeLocalAuth } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { isSdkTransportEnabled } from '../../../../lib/sdk/sdkTransportEnabled';
|
||||
|
||||
const sdkTransportEnabled = isSdkTransportEnabled();
|
||||
|
||||
export const useForceLogout = (userId: string) => {
|
||||
const getNotifyUserStream = useStream('notify-user');
|
||||
const setForceLogout = useSessionDispatch('forceLogout');
|
||||
const wipeLocalAuth = useWipeLocalAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setForceLogout(false);
|
||||
|
||||
const unsubscribe = getNotifyUserStream(`${userId}/force_logout`, () => {
|
||||
setForceLogout(true);
|
||||
|
||||
if (!sdkTransportEnabled) {
|
||||
// Flag off: develop's exact behaviour — only set the session flag.
|
||||
// The legacy WS-reconnect → loginWithToken-fails → accounts-base
|
||||
// chain handles the actual local cleanup.
|
||||
return;
|
||||
}
|
||||
|
||||
// With the SDK socket as the transport, that chain no longer fires
|
||||
// reliably: DDPSDK auto-retries loginWithToken on every `connected`
|
||||
// and swallows the rejection with `void`, so the navbar stays on
|
||||
// Home with stale credentials. Wipe the stored login token + userId
|
||||
// here so the router falls back to /login.
|
||||
wipeLocalAuth();
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [getNotifyUserStream, setForceLogout, userId, wipeLocalAuth]);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useOnLogout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent';
|
||||
import { closeAllRooms } from '../closeAllRooms';
|
||||
import { purgeAllDrafts } from '../purgeAllDrafts';
|
||||
|
||||
export const useLogoutCleanup = () => {
|
||||
const onLogout = useOnLogout();
|
||||
const logoutCustomScript = useSetting('Custom_Script_On_Logout');
|
||||
const { mutate: fireLogoutCustomScript } = useFireGlobalEvent('Custom_Script_On_Logout');
|
||||
|
||||
useEffect(() => {
|
||||
return onLogout(() => {
|
||||
purgeAllDrafts();
|
||||
closeAllRooms();
|
||||
fireLogoutCustomScript(logoutCustomScript);
|
||||
});
|
||||
}, [fireLogoutCustomScript, logoutCustomScript, onLogout]);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ICalendarNotification, IUser } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { imperativeModal } from '@rocket.chat/ui-client';
|
||||
import { useStream, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import OutlookCalendarEventModal from '../../../outlookCalendar/OutlookCalendarEventModal';
|
||||
|
||||
export const useNotificationUserCalendar = (user: IUser) => {
|
||||
const requireInteraction = useUserPreference('desktopNotificationRequireInteraction');
|
||||
const notifyUserStream = useStream('notify-user');
|
||||
|
||||
const notifyUserCalendar = useStableCallback(async (notification: ICalendarNotification) => {
|
||||
if (user.status === 'busy') {
|
||||
return;
|
||||
}
|
||||
|
||||
let body = notification.text;
|
||||
if (notification.payload?.startTimeUtc) {
|
||||
try {
|
||||
const time = new Date(notification.payload.startTimeUtc);
|
||||
const formattedTime = time.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
dayPeriod: 'narrow',
|
||||
});
|
||||
body = formattedTime;
|
||||
} catch (error) {
|
||||
console.error('Failed to format calendar notification time:', error);
|
||||
body = notification.text;
|
||||
}
|
||||
}
|
||||
|
||||
const n = new Notification(notification.title, {
|
||||
body,
|
||||
tag: notification.payload._id,
|
||||
silent: true,
|
||||
requireInteraction,
|
||||
} as NotificationOptions);
|
||||
|
||||
n.onclick = function () {
|
||||
this.close();
|
||||
window.focus();
|
||||
imperativeModal.open({
|
||||
component: OutlookCalendarEventModal,
|
||||
props: { id: notification.payload._id, onClose: imperativeModal.close, onCancel: imperativeModal.close },
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id || !user.settings?.calendar?.outlook?.Enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return notifyUserStream(`${user._id}/calendar`, notifyUserCalendar);
|
||||
}, [notifyUserCalendar, notifyUserStream, user.settings?.calendar?.outlook?.Enabled, user?._id]);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AtLeast, INotificationDesktop, ISubscription, IUser } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { useCustomSound, useRouter, useStream, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useDesktopNotification } from '../../../../hooks/notification/useDesktopNotification';
|
||||
import { useNewMessageNotification } from '../../../../hooks/notification/useNewMessageNotification';
|
||||
import { RoomManager } from '../../../../lib/RoomManager';
|
||||
import { fireGlobalEvent } from '../../../../lib/utils/fireGlobalEvent';
|
||||
|
||||
export const useNotifyUser = (user: IUser) => {
|
||||
const router = useRouter();
|
||||
const isLayoutEmbedded = useEmbeddedLayout();
|
||||
const notifyUserStream = useStream('notify-user');
|
||||
const muteFocusedConversations = useUserPreference('muteFocusedConversations');
|
||||
const { notificationSounds } = useCustomSound();
|
||||
const newMessageNotification = useNewMessageNotification();
|
||||
const showDesktopNotification = useDesktopNotification();
|
||||
|
||||
const notifyNewRoom = useStableCallback(async (sub: AtLeast<ISubscription, 'rid'>): Promise<void> => {
|
||||
if (user.status === 'busy') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((!router.getRouteParameters().name || router.getRouteParameters().name !== sub.name) && !sub.ls && sub.alert === true) {
|
||||
notificationSounds.playNewRoom();
|
||||
}
|
||||
});
|
||||
|
||||
const notifyNewMessageAudioAndDesktop = useStableCallback((notification: INotificationDesktop) => {
|
||||
const hasFocus = document.hasFocus();
|
||||
|
||||
const openedRoomId = ['channel', 'group', 'direct'].includes(router.getRouteName() || '') ? RoomManager.opened : undefined;
|
||||
|
||||
const { rid } = notification.payload;
|
||||
const messageIsInOpenedRoom = openedRoomId === rid;
|
||||
|
||||
fireGlobalEvent('notification', {
|
||||
notification,
|
||||
fromOpenedRoom: messageIsInOpenedRoom,
|
||||
hasFocus,
|
||||
});
|
||||
|
||||
if (isLayoutEmbedded) {
|
||||
if (!hasFocus && messageIsInOpenedRoom) {
|
||||
// Play a notification sound
|
||||
newMessageNotification(notification.payload);
|
||||
showDesktopNotification(notification);
|
||||
}
|
||||
} else if (!hasFocus || !messageIsInOpenedRoom || !muteFocusedConversations) {
|
||||
// Play a notification sound
|
||||
newMessageNotification(notification.payload);
|
||||
showDesktopNotification(notification);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubNotification = notifyUserStream(`${user._id}/notification`, notifyNewMessageAudioAndDesktop);
|
||||
|
||||
const unsubSubs = notifyUserStream(`${user._id}/subscriptions-changed`, (action, sub) => {
|
||||
if (action !== 'inserted') {
|
||||
return;
|
||||
}
|
||||
|
||||
void notifyNewRoom(sub);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubNotification();
|
||||
unsubSubs();
|
||||
};
|
||||
}, [notifyNewMessageAudioAndDesktop, notifyNewRoom, notifyUserStream, router, user._id]);
|
||||
|
||||
useEffect(() => () => notificationSounds.stopNewRoom(), [notificationSounds]);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { AuthorizationUtils } from '../../../../../app/authorization/lib';
|
||||
|
||||
export const useRestrictedRoles = (): void => {
|
||||
const getLicenseInfo = useEndpoint('GET', '/v1/licenses.info');
|
||||
|
||||
const { data: isEnterprise, isSuccess } = useQuery({
|
||||
queryKey: ['isEnterprise'],
|
||||
queryFn: async () => (await getLicenseInfo({})).license.hasValidLicense,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterprise) {
|
||||
AuthorizationUtils.addRolePermissionWhiteList('guest', [
|
||||
'view-d-room',
|
||||
'view-joined-room',
|
||||
'view-p-room',
|
||||
'start-discussion',
|
||||
'mobile-upload-file',
|
||||
]);
|
||||
}
|
||||
}, [isEnterprise, isSuccess]);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useRole, useSetModal, useSetting, useSettingSetValue, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import UrlChangeModal from '../../../../components/UrlChangeModal';
|
||||
import { getRootUrl, getRootUrlPathPrefix } from '../../../../lib/meteorRuntimeConfig';
|
||||
|
||||
export const useRootUrlChange = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const isAdmin = useRole('admin');
|
||||
const setModal = useSetModal();
|
||||
const closeModal = useStableCallback(() => setModal(null));
|
||||
|
||||
const currentUrl = location.origin + getRootUrlPathPrefix();
|
||||
const siteUrl = useSetting('Site_Url', '');
|
||||
const documentDomain = useSetting('Document_Domain', '');
|
||||
const setSiteUrl = useSettingSetValue('Site_Url');
|
||||
|
||||
const {
|
||||
mutate: siteUrlMutation,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationKey: ['settings', 'Site_Url'],
|
||||
mutationFn: async (url: string) => {
|
||||
await setSiteUrl(url);
|
||||
return { url };
|
||||
},
|
||||
onSuccess: ({ url }) => dispatchToastMessage({ type: 'success', message: t('Saved_new_url_site_is__url__', { url }) }),
|
||||
onError: () => dispatchToastMessage({ type: 'error', message: t('Something_went_wrong') }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
if (!siteUrl) {
|
||||
return;
|
||||
}
|
||||
if (isPending || isSuccess) {
|
||||
return;
|
||||
}
|
||||
const rootUrl = getRootUrl();
|
||||
if (rootUrl && rootUrl.replace(/\/$/, '') === currentUrl) {
|
||||
return;
|
||||
}
|
||||
const onConfirm = () => {
|
||||
closeModal();
|
||||
siteUrlMutation(currentUrl);
|
||||
};
|
||||
|
||||
setModal(<UrlChangeModal onClose={closeModal} onConfirm={onConfirm} siteUrl={siteUrl} currentUrl={currentUrl} />);
|
||||
|
||||
if (documentDomain) {
|
||||
window.document.domain = documentDomain;
|
||||
}
|
||||
|
||||
return closeModal;
|
||||
}, [currentUrl, documentDomain, siteUrlMutation, siteUrl, isAdmin, isPending, isSuccess, setModal, closeModal]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useIsLoggingIn, useLoginToken } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useStoreCookiesOnLogin = (userId: string) => {
|
||||
const isLoggingIn = useIsLoggingIn();
|
||||
const loginToken = useLoginToken();
|
||||
|
||||
useEffect(() => {
|
||||
// Check for isLoggingIn to be reactive and ensure it will process only after login finishes
|
||||
// preventing race condition setting the rc_token as null forever
|
||||
if (isLoggingIn === false) {
|
||||
const secure = location.protocol === 'https:' ? '; secure' : '';
|
||||
|
||||
document.cookie = `rc_uid=${encodeURI(userId)}; path=/${secure}`;
|
||||
document.cookie = `rc_token=${encodeURI(loginToken ?? '')}; path=/${secure}`;
|
||||
}
|
||||
}, [isLoggingIn, loginToken, userId]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRequire2faSetup } from '../../../hooks/useRequire2faSetup';
|
||||
import TwoFactorRequiredModal from '../../MainLayout/TwoFactorRequiredModal';
|
||||
|
||||
export const useTwoFactorAuthSetupCheck = () => {
|
||||
const setModal = useSetModal();
|
||||
const require2faSetup = useRequire2faSetup();
|
||||
|
||||
useEffect(() => {
|
||||
if (require2faSetup) {
|
||||
setModal(<TwoFactorRequiredModal />);
|
||||
}
|
||||
}, [setModal, require2faSetup]);
|
||||
};
|
||||
69
apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts
Normal file
69
apps/meteor/client/views/root/hooks/loggedIn/useUnread.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { manageFavicon } from '@rocket.chat/favicon';
|
||||
import { useSession, useSessionDispatch, useUserPreference, useUserSubscriptions } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useFireGlobalEvent } from '../../../../hooks/useFireGlobalEvent';
|
||||
|
||||
const query = { open: { $ne: false }, hideUnreadStatus: { $ne: true }, archived: { $ne: true } };
|
||||
const options = { fields: { unread: 1, alert: 1, rid: 1, t: 1, name: 1, ls: 1, unreadAlert: 1, fname: 1, prid: 1 } };
|
||||
const updateFavicon = manageFavicon();
|
||||
|
||||
type UnreadData = { unread: number; alert: boolean | undefined; unreadAlert: string | undefined };
|
||||
|
||||
export const useUnread = () => {
|
||||
const unreadAlertEnabled = useUserPreference('unreadAlert');
|
||||
const setUnread = useSessionDispatch('unread');
|
||||
const unread = useSession('unread') as string | number | null | undefined;
|
||||
|
||||
const { mutate: fireEventUnreadChangedBySubscription } = useFireGlobalEvent('unread-changed-by-subscription');
|
||||
const { mutate: fireEventUnreadChanged } = useFireGlobalEvent('unread-changed');
|
||||
|
||||
const subscriptions = useUserSubscriptions(query, options);
|
||||
|
||||
// We keep a lightweight snapshot of the last emitted per-subscription unread state so we only
|
||||
// fire "unread-changed-by-subscription" for subscriptions whose unread-relevant fields changed.
|
||||
// Previously we emitted one global event per subscription on ANY change, which scaled O(N)
|
||||
// with the user subscription count (thousands) for every single message event, dominating CPU.
|
||||
const prevSubsRef = useRef(new Map<string, UnreadData>());
|
||||
|
||||
useEffect(() => {
|
||||
let badgeIndicator: false | '•' = false;
|
||||
let unreadCount = 0;
|
||||
const nextSnapshot = new Map<string, UnreadData>();
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const { rid, unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert } = subscription;
|
||||
const prev = prevSubsRef.current.get(rid);
|
||||
// Emit per-sub event only if something that influences unread UI changed.
|
||||
if (!prev || prev.unread !== unreadValue || prev.alert !== alert || prev.unreadAlert !== subscriptionUnreadAlert) {
|
||||
fireEventUnreadChangedBySubscription(subscription);
|
||||
}
|
||||
nextSnapshot.set(rid, { unread: unreadValue, alert, unreadAlert: subscriptionUnreadAlert });
|
||||
|
||||
if (alert || unreadValue > 0) {
|
||||
if (alert === true && subscriptionUnreadAlert !== 'nothing') {
|
||||
if (subscriptionUnreadAlert === 'all' || unreadAlertEnabled !== false) {
|
||||
badgeIndicator = '•';
|
||||
}
|
||||
}
|
||||
unreadCount += unreadValue;
|
||||
}
|
||||
}
|
||||
|
||||
prevSubsRef.current = nextSnapshot; // swap snapshot
|
||||
|
||||
if (unreadCount > 0) {
|
||||
setUnread(unreadCount > 999 ? '999+' : unreadCount);
|
||||
} else if (badgeIndicator !== false) {
|
||||
setUnread(badgeIndicator);
|
||||
} else {
|
||||
setUnread('');
|
||||
}
|
||||
|
||||
fireEventUnreadChanged(unreadCount);
|
||||
}, [setUnread, subscriptions, unreadAlertEnabled, fireEventUnreadChangedBySubscription, fireEventUnreadChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFavicon(unread);
|
||||
}, [unread]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEmbeddedLayout } from '@rocket.chat/ui-client';
|
||||
import { useConnectionStatus, useIsLoggingIn } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { VideoConfManager } from '../../../../lib/VideoConfManager';
|
||||
|
||||
export const useUpdateVideoConfUser = (userId: string) => {
|
||||
const { connected } = useConnectionStatus();
|
||||
const isLoggingIn = useIsLoggingIn();
|
||||
const embeddedLayout = useEmbeddedLayout();
|
||||
|
||||
useEffect(() => {
|
||||
// Videconf should not be available in embedded layout
|
||||
VideoConfManager.updateUser(embeddedLayout ? null : userId, isLoggingIn, connected);
|
||||
}, [userId, isLoggingIn, connected, embeddedLayout]);
|
||||
};
|
||||
5
apps/meteor/client/views/root/hooks/purgeAllDrafts.ts
Normal file
5
apps/meteor/client/views/root/hooks/purgeAllDrafts.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const purgeAllDrafts = (): void => {
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.indexOf('messagebox_') === 0)
|
||||
.forEach((key) => localStorage.removeItem(key));
|
||||
};
|
||||
140
apps/meteor/client/views/root/hooks/useAnalytics.ts
Normal file
140
apps/meteor/client/views/root/hooks/useAnalytics.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Window {
|
||||
_paq?: [string, ...unknown[]][];
|
||||
GoogleAnalyticsObject: unknown;
|
||||
ga?: qa;
|
||||
}
|
||||
|
||||
type qa = {
|
||||
(...args: unknown[]): void;
|
||||
l?: number;
|
||||
q?: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useAnalytics = (): void => {
|
||||
const uid = useUserId();
|
||||
|
||||
const googleAnalyticsEnabled = useSetting('GoogleAnalytics_enabled', false);
|
||||
const googleId = useSetting('GoogleAnalytics_ID', '');
|
||||
|
||||
const piwiEnabled = useSetting('PiwikAnalytics_enabled', false);
|
||||
const piwikUrl = useSetting('PiwikAnalytics_url', '');
|
||||
|
||||
const piwikSiteId = useSetting('PiwikAnalytics_siteId', '');
|
||||
const piwikPrependDomain = useSetting('PiwikAnalytics_prependDomain', '');
|
||||
const piwikCookieDomain = useSetting('PiwikAnalytics_cookieDomain', '');
|
||||
const piwikDomains = useSetting('PiwikAnalytics_domains', '');
|
||||
const piwikAdditionalTracker = useSetting('PiwikAdditionalTrackers', '');
|
||||
|
||||
useEffect(() => {
|
||||
if (uid) {
|
||||
window._paq = window._paq || [];
|
||||
window._paq.push(['setUserId', uid]);
|
||||
}
|
||||
}, [uid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!googleAnalyticsEnabled || !googleId) {
|
||||
return;
|
||||
}
|
||||
if (googleId.startsWith('G-')) {
|
||||
// Google Analytics 4
|
||||
const f = document.getElementsByTagName('script')[0];
|
||||
const j = document.createElement('script');
|
||||
j.async = true;
|
||||
j.src = `//www.googletagmanager.com/gtag/js?id=${googleId}`;
|
||||
f.parentNode?.insertBefore(j, f);
|
||||
|
||||
// injecting the dataLayer into the windows global object
|
||||
const w: Window & { dataLayer?: any } = window;
|
||||
const dataLayer = w.dataLayer || [];
|
||||
const gtag = (key: string, value: any) => {
|
||||
dataLayer.push(key, value);
|
||||
};
|
||||
gtag('js', new Date());
|
||||
gtag('config', googleId);
|
||||
} else {
|
||||
// Google Analytics 3
|
||||
(function (i, s, o, g, r: 'ga', a?: any, m?: any) {
|
||||
i.GoogleAnalyticsObject = r;
|
||||
((i[r] =
|
||||
i[r] ||
|
||||
function (...args) {
|
||||
((i[r] as any).q = (i[r] as any).q || []).push(args);
|
||||
}),
|
||||
((i[r] as any).l = new Date().getTime()));
|
||||
|
||||
((a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]));
|
||||
a.async = 1;
|
||||
a.src = g;
|
||||
m.parentNode.insertBefore(a, m);
|
||||
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
|
||||
|
||||
window.ga?.('create', googleId, 'auto');
|
||||
window.ga?.('send', 'pageview');
|
||||
}
|
||||
}, [googleAnalyticsEnabled, googleId, uid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!piwiEnabled || !piwikUrl) {
|
||||
document.getElementById('piwik-analytics')?.remove();
|
||||
window._paq = [];
|
||||
return;
|
||||
}
|
||||
|
||||
window._paq = window._paq || [];
|
||||
|
||||
window._paq.push(['trackPageView']);
|
||||
window._paq.push(['enableLinkTracking']);
|
||||
if (piwikPrependDomain) {
|
||||
window._paq.push(['setDocumentTitle', `${window.location.hostname}/${document.title}`]);
|
||||
}
|
||||
const upperLevelDomain = `*.${window.location.hostname.split('.').slice(1).join('.')}`;
|
||||
if (piwikCookieDomain) {
|
||||
window._paq.push(['setCookieDomain', upperLevelDomain]);
|
||||
}
|
||||
if (piwikDomains) {
|
||||
// array
|
||||
const domainsArray = piwikDomains.split(/\n/);
|
||||
const domains = [];
|
||||
for (let i = 0; i < domainsArray.length; i++) {
|
||||
// only push domain if it contains a non whitespace character.
|
||||
if (/\S/.test(domainsArray[i])) {
|
||||
domains.push(`*.${domainsArray[i].trim()}`);
|
||||
}
|
||||
}
|
||||
window._paq.push(['setDomains', domains]);
|
||||
}
|
||||
(() => {
|
||||
try {
|
||||
if (/\S/.test(piwikAdditionalTracker)) {
|
||||
// piwikAdditionalTracker is not empty or whitespace only
|
||||
const addTrackers = JSON.parse(piwikAdditionalTracker);
|
||||
for (let i = 0; i < addTrackers.length; i++) {
|
||||
const tracker = addTrackers[i];
|
||||
window._paq.push(['addTracker', `${tracker.trackerURL}js/`, tracker.siteId]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// parsing JSON faild
|
||||
console.log('Error while parsing JSON value of "piwikAdditionalTracker": ', e);
|
||||
}
|
||||
window._paq.push(['setTrackerUrl', `${piwikUrl}js/`]);
|
||||
window._paq.push(['setSiteId', Number.parseInt(piwikSiteId)]);
|
||||
const d = document;
|
||||
const g = d.createElement('script');
|
||||
g.setAttribute('id', 'piwik-analytics');
|
||||
const s = d.getElementsByTagName('script')[0];
|
||||
g.type = 'text/javascript';
|
||||
g.async = true;
|
||||
g.defer = true;
|
||||
g.src = `${piwikUrl}js/`;
|
||||
s.parentNode?.insertBefore(g, s);
|
||||
})();
|
||||
}, [piwiEnabled, piwikAdditionalTracker, piwikCookieDomain, piwikDomains, piwikPrependDomain, piwikSiteId, piwikUrl]);
|
||||
};
|
||||
257
apps/meteor/client/views/root/hooks/useAnalyticsEventTracking.ts
Normal file
257
apps/meteor/client/views/root/hooks/useAnalyticsEventTracking.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { clientCallbacks } from '@rocket.chat/ui-client';
|
||||
import { useRouter, useSetting, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
function trackEvent(category: string, action: string, label?: unknown) {
|
||||
const { _paq, ga } = window;
|
||||
|
||||
_paq?.push(['trackEvent', category, action, label]);
|
||||
ga?.('send', 'event', category, action, label);
|
||||
}
|
||||
|
||||
export const useAnalyticsEventTracking = () => {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
router.subscribeToRouteChange(() => {
|
||||
const { _paq, ga } = window;
|
||||
|
||||
if (_paq) {
|
||||
const http = location.protocol;
|
||||
const slashes = http.concat('//');
|
||||
const host = slashes.concat(location.hostname);
|
||||
_paq.push(['setCustomUrl', host + router.getLocationPathname()]);
|
||||
_paq.push(['trackPageView']);
|
||||
}
|
||||
|
||||
ga?.('send', 'pageview', router.getLocationPathname());
|
||||
}),
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
clientCallbacks.add(
|
||||
'loginPageStateChange',
|
||||
(state) => {
|
||||
trackEvent('Navigation', 'Login Page State Change', state);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-login-state-change',
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('loginPageStateChange', 'analytics-login-state-change');
|
||||
};
|
||||
}, []);
|
||||
|
||||
const featuresMessages = useSetting('Analytics_features_messages', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!featuresMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientCallbacks.add(
|
||||
'afterSaveMessage',
|
||||
(_message, { room }) => {
|
||||
trackEvent('Message', 'Send', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.LOW,
|
||||
'trackEvents',
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('afterSaveMessage', 'trackEvents');
|
||||
};
|
||||
}, [featuresMessages]);
|
||||
|
||||
const featuresRooms = useSetting('Analytics_features_rooms', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!featuresRooms) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientCallbacks.add(
|
||||
'afterCreateChannel',
|
||||
(_owner, room) => {
|
||||
trackEvent('Room', 'Create', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-after-create-channel',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'roomNameChanged',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Changed Name', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-room-name-changed',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'roomTopicChanged',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Changed Topic', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-room-topic-changed',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'roomAnnouncementChanged',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Changed Announcement', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-room-announcement-changed',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'roomTypeChanged',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Changed Room Type', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-room-type-changed',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'archiveRoom',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Archived', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-archive-room',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'unarchiveRoom',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Unarchived', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-unarchive-room',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'roomAvatarChanged',
|
||||
(room) => {
|
||||
trackEvent('Room', 'Changed Avatar', `${room.name} (${room._id})`);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-room-avatar-changed',
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('afterCreateChannel', 'analytics-after-create-channel');
|
||||
clientCallbacks.remove('roomNameChanged', 'analytics-room-name-changed');
|
||||
clientCallbacks.remove('roomTopicChanged', 'analytics-room-topic-changed');
|
||||
clientCallbacks.remove('roomAnnouncementChanged', 'analytics-room-announcement-changed');
|
||||
clientCallbacks.remove('roomTypeChanged', 'analytics-room-type-changed');
|
||||
clientCallbacks.remove('archiveRoom', 'analytics-archive-room');
|
||||
clientCallbacks.remove('unarchiveRoom', 'analytics-unarchive-room');
|
||||
clientCallbacks.remove('roomAvatarChanged', 'analytics-room-avatar-changed');
|
||||
};
|
||||
}, [featuresRooms]);
|
||||
|
||||
const featuresUsers = useSetting('Analytics_features_users', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!featuresUsers) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientCallbacks.add(
|
||||
'userRegistered',
|
||||
() => {
|
||||
trackEvent('User', 'Registered');
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'piwik-user-resitered',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'usernameSet',
|
||||
() => {
|
||||
trackEvent('User', 'Username Set');
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'piweik-username-set',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'userPasswordReset',
|
||||
() => {
|
||||
trackEvent('User', 'Reset Password');
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'piwik-user-password-reset',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'userConfirmationEmailRequested',
|
||||
() => {
|
||||
trackEvent('User', 'Confirmation Email Requested');
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'piwik-user-confirmation-email-requested',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'userForgotPasswordEmailRequested',
|
||||
() => {
|
||||
trackEvent('User', 'Forgot Password Email Requested');
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'piwik-user-forgot-password-email-requested',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'userStatusManuallySet',
|
||||
(status) => {
|
||||
trackEvent('User', 'Status Manually Changed', status);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-user-status-manually-set',
|
||||
);
|
||||
|
||||
clientCallbacks.add(
|
||||
'userAvatarSet',
|
||||
(service) => {
|
||||
trackEvent('User', 'Avatar Changed', service);
|
||||
},
|
||||
clientCallbacks.priority.MEDIUM,
|
||||
'analytics-user-avatar-set',
|
||||
);
|
||||
|
||||
return () => {
|
||||
clientCallbacks.remove('userRegistered', 'piwik-user-resitered');
|
||||
clientCallbacks.remove('usernameSet', 'piweik-username-set');
|
||||
clientCallbacks.remove('userPasswordReset', 'piwik-user-password-reset');
|
||||
clientCallbacks.remove('userConfirmationEmailRequested', 'piwik-user-confirmation-email-requested');
|
||||
clientCallbacks.remove('userForgotPasswordEmailRequested', 'piwik-user-forgot-password-email-requested');
|
||||
clientCallbacks.remove('userStatusManuallySet', 'analytics-user-status-manually-set');
|
||||
clientCallbacks.remove('userAvatarSet', 'analytics-user-avatar-set');
|
||||
};
|
||||
}, [featuresUsers]);
|
||||
|
||||
const uid = useUserId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!featuresUsers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _paq } = window;
|
||||
|
||||
trackEvent('User', 'Login', uid);
|
||||
_paq?.push(['setUserId', uid]);
|
||||
|
||||
return () => {
|
||||
trackEvent('User', 'Logout', uid);
|
||||
};
|
||||
}, [featuresUsers, uid]);
|
||||
};
|
||||
54
apps/meteor/client/views/root/hooks/useAutoupdate.spec.ts
Normal file
54
apps/meteor/client/views/root/hooks/useAutoupdate.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useAutoupdate } from './useAutoupdate';
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
useToastMessageDispatch: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useAutoupdate', () => {
|
||||
it('should add event listener to document on mount', () => {
|
||||
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
|
||||
renderHook(() => useAutoupdate());
|
||||
expect(addEventListenerSpy).toHaveBeenCalled();
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove event listener on unmount', () => {
|
||||
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = renderHook(() => useAutoupdate());
|
||||
unmount();
|
||||
expect(removeEventListenerSpy).toHaveBeenCalled();
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('client_changed', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should call toast function when client_changed event is fired', () => {
|
||||
const toastMock = jest.fn();
|
||||
(useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock);
|
||||
renderHook(() => useAutoupdate());
|
||||
|
||||
const event = new Event('client_changed');
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(toastMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock).toHaveBeenCalledWith({
|
||||
type: 'info',
|
||||
message: expect.anything(),
|
||||
options: { isPersistent: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call toast function when client_changed event is not fired', () => {
|
||||
const toastMock = jest.fn();
|
||||
(useToastMessageDispatch as jest.Mock).mockImplementation(() => toastMock);
|
||||
renderHook(() => useAutoupdate());
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
32
apps/meteor/client/views/root/hooks/useAutoupdate.tsx
Normal file
32
apps/meteor/client/views/root/hooks/useAutoupdate.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AutoupdateToastMessage } from '../../../components/AutoupdateToastMessage';
|
||||
|
||||
export const useAutoupdate = () => {
|
||||
const toast = useToastMessageDispatch();
|
||||
const { t } = useTranslation();
|
||||
const isDevMode = process.env.NODE_ENV === 'development';
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
// To test this feature locally, comment the if statement below
|
||||
if (isDevMode) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
type: 'info',
|
||||
options: { isPersistent: true },
|
||||
message: <AutoupdateToastMessage />,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('client_changed', fn);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('client_changed', fn);
|
||||
};
|
||||
}, [isDevMode, t, toast]);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { Subscriptions } from '../../../stores';
|
||||
|
||||
export const useClearUnreadAllMessagesMutation = (options?: Omit<UseMutationOptions<void, unknown, void, unknown>, 'mutationFn'>) => {
|
||||
const readSubscription = useEndpoint('POST', '/v1/subscriptions.read');
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const promises = Subscriptions.state
|
||||
.filter((record) => record.open)
|
||||
.map((subscription) => {
|
||||
if (subscription.alert || subscription.unread > 0) {
|
||||
return readSubscription({ rid: subscription.rid, readThreads: true });
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
19
apps/meteor/client/views/root/hooks/useCodeHighlight.ts
Normal file
19
apps/meteor/client/views/root/hooks/useCodeHighlight.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { register } from '../../../../app/markdown/lib/hljs';
|
||||
|
||||
export const useCodeHighlight = (): void => {
|
||||
const codeHighlight = useSetting('Message_Code_highlight');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof codeHighlight === 'string') {
|
||||
codeHighlight.split(',').forEach((language: string) => {
|
||||
const trimmedLanguage = language.trim();
|
||||
if (trimmedLanguage) {
|
||||
register(trimmedLanguage);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [codeHighlight]);
|
||||
};
|
||||
15
apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts
Normal file
15
apps/meteor/client/views/root/hooks/useCorsSSLConfig.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// TODO: remove this hook together with the Meteor webapp/DDP layer — it only
|
||||
// patches Meteor.absoluteUrl's `secure` default, which has no consumers once
|
||||
// Meteor.absoluteUrl is gone.
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { absoluteUrl } from '../../../lib/absoluteUrl';
|
||||
|
||||
export const useCorsSSLConfig = () => {
|
||||
const forceSSlSetting = useSetting('Force_SSL');
|
||||
|
||||
useEffect(() => {
|
||||
absoluteUrl.defaultOptions.secure = Boolean(forceSSlSetting);
|
||||
}, [forceSSlSetting]);
|
||||
};
|
||||
19
apps/meteor/client/views/root/hooks/useDesktopFavicon.ts
Normal file
19
apps/meteor/client/views/root/hooks/useDesktopFavicon.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useAbsoluteUrl, useAssetPath } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useDesktopFavicon = () => {
|
||||
const absoluteUrl = useAbsoluteUrl();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.RocketChatDesktop?.setUrlResolver((relativePath?: string) => absoluteUrl(relativePath ?? '/'));
|
||||
}, [absoluteUrl]);
|
||||
|
||||
const faviconUrl = useAssetPath('favicon');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!faviconUrl) return;
|
||||
window.RocketChatDesktop?.setFavicon(faviconUrl);
|
||||
}, [faviconUrl]);
|
||||
};
|
||||
12
apps/meteor/client/views/root/hooks/useDesktopTitle.ts
Normal file
12
apps/meteor/client/views/root/hooks/useDesktopTitle.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useDesktopTitle = () => {
|
||||
const title = useSetting('Site_Name', 'Rocket.Chat');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!title) return;
|
||||
window.RocketChatDesktop?.setTitle(title);
|
||||
}, [title]);
|
||||
};
|
||||
23
apps/meteor/client/views/root/hooks/useDesktopUserRoles.ts
Normal file
23
apps/meteor/client/views/root/hooks/useDesktopUserRoles.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useUser } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Pushes the logged-in user's roles to the desktop app so it can decide which
|
||||
* supportedVersions messages to show (e.g. restricting version-expiration
|
||||
* warnings to admins). The desktop app falls back to its own role lookup when
|
||||
* this bridge is unavailable, so `setUserRoles` is called optionally.
|
||||
*/
|
||||
export const useDesktopUserRoles = () => {
|
||||
// A single canonical value derived from the reactive user. Logout, login and
|
||||
// account switch all surface as a change to this key, so the effect re-runs
|
||||
// and re-pushes only when the roles actually change — no userId branch and no
|
||||
// effect cleanup (which would fire on every deps change and flicker
|
||||
// role-targeted UI). Logged-out and "logged in with no roles" both resolve to
|
||||
// an empty list, which is the correct signal for the desktop either way.
|
||||
const rolesKey = (useUser()?.roles ?? []).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.RocketChatDesktop?.setUserRoles?.(rolesKey ? rolesKey.split(',') : []);
|
||||
}, [rolesKey]);
|
||||
};
|
||||
50
apps/meteor/client/views/root/hooks/useEscapeKeyStroke.ts
Normal file
50
apps/meteor/client/views/root/hooks/useEscapeKeyStroke.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { GenericModal, imperativeModal } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useClearUnreadAllMessagesMutation } from './useClearUnreadAllMessagesMutation';
|
||||
|
||||
export const useEscapeKeyStroke = () => {
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const clearUnreadAllMessagesMutation = useClearUnreadAllMessagesMutation({
|
||||
onError: (error) => {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
},
|
||||
onMutate: () => {
|
||||
imperativeModal.close();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== 'Escape' || !(event.shiftKey === true || event.ctrlKey === true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
imperativeModal.open({
|
||||
component: GenericModal,
|
||||
props: {
|
||||
children: t('Are_you_sure_you_want_to_clear_all_unread_messages'),
|
||||
variant: 'warning',
|
||||
title: t('Clear_all_unreads_question'),
|
||||
confirmText: t('Yes_clear_all'),
|
||||
onClose: imperativeModal.close,
|
||||
onCancel: imperativeModal.close,
|
||||
onConfirm: clearUnreadAllMessagesMutation.mutate,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
document.body.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [clearUnreadAllMessagesMutation.mutate, dispatchToastMessage, t]);
|
||||
};
|
||||
29
apps/meteor/client/views/root/hooks/useGoogleTagManager.ts
Normal file
29
apps/meteor/client/views/root/hooks/useGoogleTagManager.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useGoogleTagManager = () => {
|
||||
const i = useSetting('GoogleTagManager_id', '');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof i !== 'string' || i.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const w: Window & { dataLayer?: { 'gtm.start': number; 'event': string }[] } = window;
|
||||
|
||||
w.dataLayer = w.dataLayer || [];
|
||||
w.dataLayer.push({
|
||||
'gtm.start': new Date().getTime(),
|
||||
'event': 'gtm.js',
|
||||
});
|
||||
const f = document.getElementsByTagName('script')[0];
|
||||
const j = document.createElement('script');
|
||||
j.async = true;
|
||||
j.src = `//www.googletagmanager.com/gtm.js?id=${i}`;
|
||||
f.parentNode?.insertBefore(j, f);
|
||||
|
||||
return () => {
|
||||
f.parentNode?.removeChild(j);
|
||||
};
|
||||
}, [i]);
|
||||
};
|
||||
90
apps/meteor/client/views/root/hooks/useIframe.ts
Normal file
90
apps/meteor/client/views/root/hooks/useIframe.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useLoginWithIframe, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type CallbackError = Error & { error?: string | number; reason?: string; details?: unknown };
|
||||
|
||||
export const useIframe = () => {
|
||||
const [iframeLoginUrl, setIframeLoginUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
const iframeEnabled = useSetting('Accounts_iframe_enabled', false);
|
||||
const accountIframeUrl = useSetting('Accounts_iframe_url', '');
|
||||
const apiUrl = useSetting('Accounts_Iframe_api_url', '');
|
||||
const apiMethod = useSetting('Accounts_Iframe_api_method', '');
|
||||
|
||||
const iframeLogin = useLoginWithIframe();
|
||||
const tokenLogin = useLoginWithToken();
|
||||
|
||||
const enabled = Boolean(iframeEnabled && accountIframeUrl && apiUrl && apiMethod);
|
||||
|
||||
const loginWithToken = useCallback(
|
||||
(tokenData: string | { loginToken: string } | { token: string }, callback?: (error: Error | null | undefined) => void) => {
|
||||
if (typeof tokenData === 'string') {
|
||||
tokenData = {
|
||||
token: tokenData,
|
||||
};
|
||||
}
|
||||
if ('loginToken' in tokenData) {
|
||||
tokenLogin(tokenData.loginToken, callback);
|
||||
}
|
||||
if ('token' in tokenData) {
|
||||
iframeLogin(tokenData.token, callback);
|
||||
}
|
||||
},
|
||||
[iframeLogin, tokenLogin],
|
||||
);
|
||||
|
||||
const tryLogin = useStableCallback(async (callback?: (error: Error | null | undefined, result: unknown) => void) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = accountIframeUrl;
|
||||
let separator = '?';
|
||||
if (url.indexOf('?') > -1) {
|
||||
separator = '&';
|
||||
}
|
||||
|
||||
if (navigator.userAgent.indexOf('Electron') > -1) {
|
||||
url += `${separator}client=electron`;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetch(apiUrl, {
|
||||
method: apiMethod,
|
||||
headers: undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!result.ok || result.status !== 200) {
|
||||
setIframeLoginUrl(url);
|
||||
callback?.(new Error(), null);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await result.json();
|
||||
loginWithToken(body, async (error: CallbackError | Error | null | undefined) => {
|
||||
if (error) {
|
||||
setIframeLoginUrl(url);
|
||||
} else {
|
||||
setIframeLoginUrl(undefined);
|
||||
}
|
||||
callback?.(error, body);
|
||||
});
|
||||
} catch (error) {
|
||||
setIframeLoginUrl(url);
|
||||
callback?.(error instanceof Error ? error : undefined, null);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
tryLogin();
|
||||
}, [tryLogin]);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
tryLogin,
|
||||
loginWithToken,
|
||||
iframeLoginUrl,
|
||||
};
|
||||
};
|
||||
137
apps/meteor/client/views/root/hooks/useIframeCommands.ts
Normal file
137
apps/meteor/client/views/root/hooks/useIframeCommands.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { UserStatus } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { type LocationPathname, UserContext, useLoginWithCustomOauth, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import { capitalize, ltrim, rtrim } from '../../../../lib/utils/stringUtils';
|
||||
import { AccountBox } from '../../../lib/AccountBox';
|
||||
import { baseURI } from '../../../lib/baseURI';
|
||||
import { loginServices } from '../../../lib/loginServices';
|
||||
import { getRootUrlPathPrefix } from '../../../lib/meteorRuntimeConfig';
|
||||
import { settings } from '../../../lib/settings';
|
||||
import { router } from '../../../providers/RouterProvider';
|
||||
|
||||
export const useIframeCommands = () => {
|
||||
const iframeReceiveEnabled = useSetting('Iframe_Integration_receive_enable');
|
||||
const iframeReceiveOrigin = useSetting('Iframe_Integration_receive_origin', '*');
|
||||
const loginWithToken = useLoginWithToken();
|
||||
const loginWithCustomOauth = useLoginWithCustomOauth();
|
||||
const { logout } = useContext(UserContext);
|
||||
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeReceiveEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = {
|
||||
'go'(data: { path: string }) {
|
||||
if (typeof data.path !== 'string' || data.path.trim().length === 0) {
|
||||
return console.error('`path` not defined');
|
||||
}
|
||||
const newUrl = new URL(`${rtrim(baseURI, '/')}/${ltrim(data.path, '/')}`);
|
||||
|
||||
const newParams = Array.from(newUrl.searchParams.entries()).reduce(
|
||||
(ret, [key, value]) => {
|
||||
ret[key] = value;
|
||||
return ret;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
const newPath = newUrl.pathname.replace(new RegExp(`^${escapeRegExp(getRootUrlPathPrefix())}`), '') as LocationPathname;
|
||||
router.navigate({
|
||||
pathname: newPath,
|
||||
search: { ...router.getSearchParameters(), ...newParams },
|
||||
});
|
||||
},
|
||||
|
||||
'set-user-status'(data: { status: UserStatus }) {
|
||||
AccountBox.setStatus(data.status);
|
||||
},
|
||||
|
||||
'call-custom-oauth-login'(data: { service: string; redirectUrl?: string | null }, event: MessageEvent) {
|
||||
if (enableModernOAuthFlow) {
|
||||
const url = new URL(window.location.href);
|
||||
const queryParams = url.searchParams;
|
||||
const loginClient = queryParams.get('loginClient');
|
||||
|
||||
const redirectUrl = new URL(`/oauth/${data.service}`, window.location.origin);
|
||||
|
||||
if (loginClient) {
|
||||
redirectUrl.searchParams.set('loginClient', loginClient);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl.toString();
|
||||
return;
|
||||
}
|
||||
|
||||
const customOAuthCallback = (response: unknown) => {
|
||||
event.source?.postMessage(
|
||||
{
|
||||
event: 'custom-oauth-callback',
|
||||
response,
|
||||
},
|
||||
{ targetOrigin: event.origin },
|
||||
);
|
||||
};
|
||||
|
||||
const siteUrl = `${settings.peek('Site_Url') ?? ''}/`;
|
||||
if (typeof data.redirectUrl !== 'string' || !data.redirectUrl.startsWith(siteUrl)) {
|
||||
data.redirectUrl = null;
|
||||
}
|
||||
|
||||
if (window.ServiceConfiguration) {
|
||||
const customOauth = loginServices.getLoginService(data.service);
|
||||
|
||||
if (customOauth) {
|
||||
const customRedirectUri = data.redirectUrl || siteUrl;
|
||||
loginWithCustomOauth(capitalize(customOauth.service, true), { redirectUrl: customRedirectUri }, customOAuthCallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'login-with-token'(data: { token: string }) {
|
||||
if (typeof data.token === 'string') {
|
||||
void loginWithToken(data.token, () => {
|
||||
console.log('Iframe command [login-with-token]: result', data);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
'logout'() {
|
||||
void logout();
|
||||
router.navigate('/home');
|
||||
},
|
||||
} as const;
|
||||
|
||||
type CommandMessage<TCommandName extends keyof typeof commands = keyof typeof commands> = {
|
||||
externalCommand: TCommandName;
|
||||
} & Parameters<(typeof commands)[TCommandName]>[0];
|
||||
|
||||
const messageListener = (event: MessageEvent<CommandMessage>) => {
|
||||
if (typeof event.data !== 'object' || typeof event.data.externalCommand !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (iframeReceiveOrigin !== '*' && iframeReceiveOrigin.split(',').indexOf(event.origin) === -1) {
|
||||
console.error('Origin not allowed', event.origin);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.data.externalCommand in commands)) {
|
||||
console.error('Command not allowed', event.data.externalCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
const command: (data: MessageEvent['data'], event: MessageEvent) => void = commands[event.data.externalCommand];
|
||||
command(event.data, event);
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageListener);
|
||||
};
|
||||
}, [iframeReceiveEnabled, iframeReceiveOrigin, loginWithToken, loginWithCustomOauth, logout, enableModernOAuthFlow]);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useUnstoreLoginToken } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useIframe } from './useIframe';
|
||||
|
||||
export const useIframeLoginListener = () => {
|
||||
const { enabled: iframeEnabled, tryLogin, loginWithToken } = useIframe();
|
||||
const unstoreLoginToken = useUnstoreLoginToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeEnabled) {
|
||||
return;
|
||||
}
|
||||
tryLogin();
|
||||
}, [iframeEnabled, tryLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeEnabled) {
|
||||
return;
|
||||
}
|
||||
const messageListener = (e: MessageEvent) => {
|
||||
if (!(typeof e.data === 'function' || (typeof e.data === 'object' && !!e.data))) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.data.event) {
|
||||
case 'try-iframe-login':
|
||||
tryLogin((error) => {
|
||||
if (error) {
|
||||
e.source?.postMessage(
|
||||
{
|
||||
event: 'login-error',
|
||||
response: error.message,
|
||||
},
|
||||
{ targetOrigin: e.origin },
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'login-with-token':
|
||||
loginWithToken(e.data, (error) => {
|
||||
if (error) {
|
||||
e.source?.postMessage(
|
||||
{
|
||||
event: 'login-error',
|
||||
response: error.message,
|
||||
},
|
||||
{ targetOrigin: e.origin },
|
||||
);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageListener);
|
||||
return () => {
|
||||
window.removeEventListener('message', messageListener);
|
||||
};
|
||||
}, [iframeEnabled, loginWithToken, tryLogin]);
|
||||
|
||||
useEffect(() => unstoreLoginToken(tryLogin), [tryLogin, unstoreLoginToken]);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
import tinykeys from 'tinykeys';
|
||||
|
||||
import KeyboardShortcutsModal from '../../../navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal';
|
||||
|
||||
const shouldIgnoreKeyStroke = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLElement && target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { tagName } = target;
|
||||
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return target.closest('dialog[open]') !== null;
|
||||
};
|
||||
|
||||
export const useKeyboardShortcutsHotkey = () => {
|
||||
const setModal = useSetModal();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (shouldIgnoreKeyStroke(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const handleClose = () => setModal(null);
|
||||
setModal(<KeyboardShortcutsModal onClose={handleClose} />);
|
||||
};
|
||||
|
||||
return tinykeys(window, {
|
||||
'Shift+?': handler,
|
||||
});
|
||||
}, [setModal]);
|
||||
};
|
||||
23
apps/meteor/client/views/root/hooks/useLivechatEnterprise.ts
Normal file
23
apps/meteor/client/views/root/hooks/useLivechatEnterprise.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
import { MultipleBusinessHoursBehavior } from '../../../lib/omnichannel/businessHours/MultipleBusinessHoursBehavior';
|
||||
import { SingleBusinessHourBehavior } from '../../../lib/omnichannel/businessHours/SingleBusinessHourBehavior';
|
||||
import { businessHourManager } from '../../../lib/omnichannel/businessHours/businessHourManager';
|
||||
|
||||
const businessHours = {
|
||||
multiple: new MultipleBusinessHoursBehavior(),
|
||||
single: new SingleBusinessHourBehavior(),
|
||||
};
|
||||
|
||||
export const useLivechatEnterprise = () => {
|
||||
const businessHourType = useSetting<'Single' | 'Multiple'>('Livechat_business_hour_type', 'Single');
|
||||
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
|
||||
|
||||
useEffect(() => {
|
||||
if (businessHourType && hasLicense) {
|
||||
businessHourManager.registerBusinessHourBehavior(businessHours[businessHourType.toLowerCase() as 'single' | 'multiple']);
|
||||
}
|
||||
}, [businessHourType, hasLicense]);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useConnectionStatus } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useLoadMissedMessages } from './useLoadMissedMessages';
|
||||
import { upsertMessageBulk } from '../../../lib/RoomHistoryManager';
|
||||
import { sdk } from '../../../lib/SDKClient';
|
||||
import { Messages } from '../../../stores/Messages';
|
||||
import { Subscriptions } from '../../../stores/Subscriptions';
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
useConnectionStatus: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/LegacyRoomManager', () => ({
|
||||
LegacyRoomManager: { openedRooms: { 'room-id': { rid: 'room-id' } } },
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/RoomHistoryManager', () => ({
|
||||
upsertMessageBulk: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/SDKClient', () => ({
|
||||
sdk: { rest: { get: jest.fn() } },
|
||||
}));
|
||||
|
||||
const rid = 'room-id';
|
||||
|
||||
const createMessage = (_id: string, ts: Date, extra: Partial<IMessage & { temp: boolean }> = {}): IMessage =>
|
||||
({
|
||||
_id,
|
||||
rid,
|
||||
msg: _id,
|
||||
ts,
|
||||
_updatedAt: ts,
|
||||
u: { _id: 'user-id', username: 'user' },
|
||||
...extra,
|
||||
}) as IMessage;
|
||||
|
||||
/** Mimics what the API returns: the same shape as `createMessage`, but with dates serialized. */
|
||||
const createSerializedMessage = (_id: string, ts: Date, extra: Record<string, unknown> = {}) => ({
|
||||
_id,
|
||||
rid,
|
||||
msg: _id,
|
||||
ts: ts.toISOString(),
|
||||
_updatedAt: ts.toISOString(),
|
||||
u: { _id: 'user-id', username: 'user' },
|
||||
...extra,
|
||||
});
|
||||
|
||||
const mockSyncResponse = ({ updated = [], deleted = [] }: { updated?: unknown[]; deleted?: { _id: string }[] } = {}) => {
|
||||
(sdk.rest.get as jest.Mock).mockResolvedValue({ result: { updated, deleted } });
|
||||
};
|
||||
|
||||
/** Renders the hook while offline, then flips the connection back on — the only transition that triggers a sync. */
|
||||
const renderReconnection = () => {
|
||||
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: false });
|
||||
const { rerender } = renderHook(() => useLoadMissedMessages());
|
||||
|
||||
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
|
||||
rerender();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Messages.state.replaceAll([]);
|
||||
Subscriptions.state.replaceAll([]);
|
||||
mockSyncResponse();
|
||||
// stand-in for the real bulk upsert, which only stores the messages it is handed
|
||||
(upsertMessageBulk as jest.Mock).mockImplementation(async ({ msgs }: { msgs: IMessage[] }) => Messages.state.storeMany(msgs));
|
||||
});
|
||||
|
||||
describe('useLoadMissedMessages', () => {
|
||||
it('should bound the sync window by the oldest and newest loaded messages', async () => {
|
||||
const oldest = new Date(1_000);
|
||||
const newest = new Date(3_000);
|
||||
Messages.state.storeMany([createMessage('oldest', oldest), createMessage('newest', newest)]);
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
|
||||
roomId: rid,
|
||||
lastUpdate: newest.toISOString(),
|
||||
fromTs: oldest.toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should leave hidden and temp messages out of the sync window', async () => {
|
||||
const oldest = new Date(2_000);
|
||||
const newest = new Date(3_000);
|
||||
Messages.state.storeMany([
|
||||
createMessage('hidden', new Date(1_000), { _hidden: true }),
|
||||
createMessage('oldest', oldest),
|
||||
createMessage('newest', newest),
|
||||
createMessage('temp', new Date(4_000), { temp: true }),
|
||||
]);
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
|
||||
roomId: rid,
|
||||
lastUpdate: newest.toISOString(),
|
||||
fromTs: oldest.toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should leave messages from other rooms out of the sync window', async () => {
|
||||
const oldest = new Date(2_000);
|
||||
const newest = new Date(3_000);
|
||||
Messages.state.storeMany([
|
||||
createMessage('other-older', new Date(1_000), { rid: 'other-room-id' }),
|
||||
createMessage('oldest', oldest),
|
||||
createMessage('newest', newest),
|
||||
createMessage('other-newer', new Date(4_000), { rid: 'other-room-id' }),
|
||||
]);
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sdk.rest.get).toHaveBeenCalledWith('/v1/chat.syncMessages', {
|
||||
roomId: rid,
|
||||
lastUpdate: newest.toISOString(),
|
||||
fromTs: oldest.toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not sync a room holding only hidden and temp messages', async () => {
|
||||
Messages.state.storeMany([
|
||||
createMessage('hidden', new Date(1_000), { _hidden: true }),
|
||||
createMessage('temp', new Date(2_000), { temp: true }),
|
||||
]);
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should not sync while the connection stays online', async () => {
|
||||
Messages.state.store(createMessage('message', new Date(1_000)));
|
||||
|
||||
(useConnectionStatus as jest.Mock).mockReturnValue({ connected: true });
|
||||
const { rerender } = renderHook(() => useLoadMissedMessages());
|
||||
rerender();
|
||||
|
||||
await waitFor(() => expect(sdk.rest.get).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should merge the messages reported as updated into the store', async () => {
|
||||
const ts = new Date(1_000);
|
||||
const editedAt = new Date(2_000);
|
||||
Messages.state.storeMany([createMessage('edited', ts), createMessage('untouched', ts)]);
|
||||
mockSyncResponse({
|
||||
updated: [
|
||||
createSerializedMessage('edited', ts, { msg: 'edited text', editedAt: editedAt.toISOString() }),
|
||||
createSerializedMessage('added', editedAt),
|
||||
],
|
||||
});
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(Messages.state.get('edited')).toHaveProperty('msg', 'edited text'));
|
||||
expect(Messages.state.get('edited')).toHaveProperty('editedAt', editedAt);
|
||||
expect(Messages.state.get('added')).toHaveProperty('ts', editedAt);
|
||||
expect(Messages.state.get('untouched')).toHaveProperty('msg', 'untouched');
|
||||
});
|
||||
|
||||
it('should deserialize the dates of the messages reported as updated', async () => {
|
||||
const ts = new Date(1_000);
|
||||
const editedAt = new Date(2_000);
|
||||
Messages.state.store(createMessage('edited', ts));
|
||||
mockSyncResponse({ updated: [createSerializedMessage('edited', ts, { editedAt: editedAt.toISOString() })] });
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalled());
|
||||
expect(upsertMessageBulk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
msgs: [expect.objectContaining({ _id: 'edited', ts, _updatedAt: ts, editedAt })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass the room subscription along with the updated messages', async () => {
|
||||
const ts = new Date(1_000);
|
||||
const subscription = { _id: 'subscription-id', rid } as Parameters<typeof Subscriptions.state.store>[0];
|
||||
Messages.state.store(createMessage('edited', ts));
|
||||
Subscriptions.state.storeMany([subscription, { _id: 'other-subscription-id', rid: 'other-room-id' } as typeof subscription]);
|
||||
mockSyncResponse({ updated: [createSerializedMessage('edited', ts)] });
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(upsertMessageBulk).toHaveBeenCalledWith(expect.objectContaining({ subscription })));
|
||||
});
|
||||
|
||||
it('should remove the messages reported as deleted', async () => {
|
||||
Messages.state.storeMany([createMessage('kept', new Date(1_000)), createMessage('dropped', new Date(2_000))]);
|
||||
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
|
||||
expect(Messages.state.get('kept')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should clear "tmid" from replies whose thread parent was deleted', async () => {
|
||||
Messages.state.storeMany([createMessage('parent', new Date(1_000)), createMessage('reply', new Date(2_000), { tmid: 'parent' })]);
|
||||
mockSyncResponse({ deleted: [{ _id: 'parent' }] });
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(Messages.state.get('parent')).toBeUndefined());
|
||||
expect(Messages.state.get('reply')).toBeDefined();
|
||||
expect(Messages.state.get('reply')).not.toHaveProperty('tmid');
|
||||
});
|
||||
|
||||
it('should keep "tmid" on replies whose thread parent survived', async () => {
|
||||
Messages.state.storeMany([
|
||||
createMessage('parent', new Date(1_000)),
|
||||
createMessage('reply', new Date(2_000), { tmid: 'parent' }),
|
||||
createMessage('dropped', new Date(3_000)),
|
||||
]);
|
||||
mockSyncResponse({ deleted: [{ _id: 'dropped' }] });
|
||||
|
||||
renderReconnection();
|
||||
|
||||
await waitFor(() => expect(Messages.state.get('dropped')).toBeUndefined());
|
||||
expect(Messages.state.get('reply')).toHaveProperty('tmid', 'parent');
|
||||
});
|
||||
});
|
||||
82
apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts
Normal file
82
apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
|
||||
import { useConnectionStatus } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { LegacyRoomManager } from '../../../lib/LegacyRoomManager';
|
||||
import { upsertMessageBulk } from '../../../lib/RoomHistoryManager';
|
||||
import { sdk } from '../../../lib/SDKClient';
|
||||
import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi';
|
||||
import { Messages } from '../../../stores/Messages';
|
||||
import { Subscriptions } from '../../../stores/Subscriptions';
|
||||
|
||||
/**
|
||||
* Reconciles a room with the server after the connection drops.
|
||||
*
|
||||
* `lastUpdate` is the newest loaded message's timestamp, so anything created or edited since then comes
|
||||
* back. `fromTs` is the oldest loaded one, which bounds the query to the window the client actually holds
|
||||
* — without it the server has no usable index bounds and scans every message in the room. The trade-off
|
||||
* is that edits to messages older than `fromTs` go unreported, which is fine here: those messages are not
|
||||
* loaded, so they are fetched fresh if the user scrolls back to them.
|
||||
*
|
||||
* @param rid - Room ID
|
||||
*/
|
||||
const loadMissedMessages = async (rid: IRoom['_id']): Promise<void> => {
|
||||
const isLoaded = (record: IMessage & { temp?: boolean }) => record.rid === rid && record._hidden !== true && !record.temp;
|
||||
|
||||
const newestLoaded = Messages.state.findFirst(isLoaded, (a, b) => b.ts.getTime() - a.ts.getTime());
|
||||
const oldestLoaded = Messages.state.findFirst(isLoaded, (a, b) => a.ts.getTime() - b.ts.getTime());
|
||||
|
||||
if (!newestLoaded || !oldestLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { result } = await sdk.rest.get('/v1/chat.syncMessages', {
|
||||
roomId: rid,
|
||||
lastUpdate: newestLoaded.ts.toISOString(),
|
||||
fromTs: oldestLoaded.ts.toISOString(),
|
||||
});
|
||||
|
||||
const subscription = Subscriptions.state.find((record) => record.rid === rid);
|
||||
|
||||
await upsertMessageBulk({ msgs: result.updated.map((msg) => mapMessageFromApi(msg)), subscription });
|
||||
|
||||
const deletedIds = new Set<IMessage['_id']>();
|
||||
|
||||
for (const { _id } of result.deleted) {
|
||||
Messages.state.delete(_id);
|
||||
deletedIds.add(_id);
|
||||
}
|
||||
|
||||
if (deletedIds.size > 0) {
|
||||
// remove thread reference from deleted messages
|
||||
Messages.state.update(
|
||||
(record) => record.tmid !== undefined && deletedIds.has(record.tmid),
|
||||
({ tmid: _, ...record }) => record,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading missed messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook that loads missed messages when connection is restored
|
||||
*/
|
||||
export const useLoadMissedMessages = (): void => {
|
||||
const { connected } = useConnectionStatus();
|
||||
const connectionWasOnlineRef = useRef(connected);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected === true && connectionWasOnlineRef.current === false && LegacyRoomManager.openedRooms) {
|
||||
Object.keys(LegacyRoomManager.openedRooms).forEach((key) => {
|
||||
const value = LegacyRoomManager.openedRooms[key];
|
||||
if (value.rid) {
|
||||
loadMissedMessages(value.rid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
connectionWasOnlineRef.current = connected;
|
||||
}, [connected]);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { RoomsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
|
||||
|
||||
export const useLoadRoomForAllowedAnonymousRead = () => {
|
||||
const userId = useUserId();
|
||||
const accountsAllowAnonymousRead = useSetting('Accounts_AllowAnonymousRead');
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId && accountsAllowAnonymousRead === true) {
|
||||
RoomsCachedStore.init();
|
||||
SubscriptionsCachedStore.setReady(true);
|
||||
return () => {
|
||||
RoomsCachedStore.setReady(false);
|
||||
SubscriptionsCachedStore.setReady(false);
|
||||
};
|
||||
}
|
||||
}, [accountsAllowAnonymousRead, userId]);
|
||||
};
|
||||
30
apps/meteor/client/views/root/hooks/useLoginOtherClients.ts
Normal file
30
apps/meteor/client/views/root/hooks/useLoginOtherClients.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useRouter, useSearchParameter } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
|
||||
|
||||
export const useLoginOtherClients = () => {
|
||||
const router = useRouter();
|
||||
const resumeToken = useSearchParameter('resumeToken');
|
||||
const loginClient = useSearchParameter('loginClient');
|
||||
const userId = useSearchParameter('userId');
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeToken || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginClient !== 'desktop' && loginClient !== 'mobile') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginURL = buildDeepLinkURL(resumeToken, userId);
|
||||
window.location.href = loginURL;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate('/home', { replace: true });
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [resumeToken, userId, loginClient, router]);
|
||||
};
|
||||
41
apps/meteor/client/views/root/hooks/useLoginViaQuery.ts
Normal file
41
apps/meteor/client/views/root/hooks/useLoginViaQuery.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useRouter, useLoginWithToken } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useLoginViaQuery = () => {
|
||||
const router = useRouter();
|
||||
const loginWithToken = useLoginWithToken();
|
||||
|
||||
useEffect(() => {
|
||||
const handleLogin = async () => {
|
||||
const { resumeToken } = router.getSearchParameters();
|
||||
|
||||
if (!resumeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loginWithToken(resumeToken);
|
||||
|
||||
const routeName = router.getRouteName();
|
||||
|
||||
if (!routeName) {
|
||||
router.navigate('/home');
|
||||
}
|
||||
|
||||
const { resumeToken: _, userId: __, ...search } = router.getSearchParameters();
|
||||
|
||||
router.navigate(
|
||||
{
|
||||
pathname: router.getLocationPathname(),
|
||||
search,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to login with token', error);
|
||||
}
|
||||
};
|
||||
|
||||
handleLogin();
|
||||
}, [loginWithToken, router]);
|
||||
};
|
||||
13
apps/meteor/client/views/root/hooks/useMainReady.ts
Normal file
13
apps/meteor/client/views/root/hooks/useMainReady.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useUserId } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import { PublicSettingsCachedStore, SubscriptionsCachedStore } from '../../../cachedStores';
|
||||
import { useUserDataSyncReady } from '../../../lib/userData';
|
||||
|
||||
export const useMainReady = () => {
|
||||
const uid = useUserId();
|
||||
const subscriptionsReady = SubscriptionsCachedStore.useReady();
|
||||
const settingsReady = PublicSettingsCachedStore.useReady();
|
||||
const userDataReady = useUserDataSyncReady();
|
||||
|
||||
return !uid || (userDataReady && subscriptionsReady && settingsReady);
|
||||
};
|
||||
39
apps/meteor/client/views/root/hooks/useMessageLinkClicks.ts
Normal file
39
apps/meteor/client/views/root/hooks/useMessageLinkClicks.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useAbsoluteUrl, useLayout } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { fireGlobalEvent } from '../../../lib/utils/fireGlobalEvent';
|
||||
|
||||
export const useMessageLinkClicks = () => {
|
||||
const absoluteUrl = useAbsoluteUrl();
|
||||
const { isEmbedded: embeddedLayout } = useLayout();
|
||||
|
||||
useEffect(() => {
|
||||
if (!embeddedLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessageLinkClick = (event: Event) => {
|
||||
const element = event.currentTarget as Element | null;
|
||||
|
||||
if (!element || !(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(element instanceof HTMLAnchorElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.origin !== absoluteUrl('').replace(/\/+$/, '') || !/msg=([a-zA-Z0-9]+)/.test(element.search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireGlobalEvent('click-message-link', { link: element.pathname + element.search });
|
||||
};
|
||||
|
||||
document.body.addEventListener('click', handleMessageLinkClick);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('click', handleMessageLinkClick);
|
||||
};
|
||||
}, [absoluteUrl, embeddedLayout]);
|
||||
};
|
||||
62
apps/meteor/client/views/root/hooks/useNativeEmoji.ts
Normal file
62
apps/meteor/client/views/root/hooks/useNativeEmoji.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig';
|
||||
import { legacyEmojioneMap } from '../../../../app/emoji-native/lib/legacyEmojioneMap';
|
||||
import { emoji } from '../../../lib/emoji';
|
||||
|
||||
const config = getEmojiConfig(emoji);
|
||||
|
||||
export const useNativeEmoji = () => {
|
||||
const convertAsciiToEmoji = useUserPreference<boolean>('convertAsciiEmoji', true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
emoji.packages.native = {
|
||||
emojiCategories: config.emojiCategories as any,
|
||||
emojisByCategory: config.emojisByCategory,
|
||||
toneList: config.toneList,
|
||||
render: config.render,
|
||||
renderPicker: config.renderPicker,
|
||||
sprites: config.sprites,
|
||||
};
|
||||
|
||||
for (const [key, currentEmoji] of Object.entries(config.emojiList)) {
|
||||
currentEmoji.emojiPackage = 'native';
|
||||
emoji.list[key] = currentEmoji;
|
||||
|
||||
if (currentEmoji.shortnames) {
|
||||
currentEmoji.shortnames.forEach((shortname: string) => {
|
||||
emoji.list[shortname] = currentEmoji;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register legacy emojione shortcodes so old reactions and stored shortcodes resolve correctly
|
||||
for (const [shortcode, unicode] of Object.entries(legacyEmojioneMap)) {
|
||||
const key = `:${shortcode}:`;
|
||||
if (emoji.list[key]) continue;
|
||||
|
||||
emoji.list[key] = {
|
||||
uc_base: '',
|
||||
uc_output: '',
|
||||
uc_match: '',
|
||||
uc_greedy: '',
|
||||
shortnames: [],
|
||||
category: '',
|
||||
emojiPackage: 'native',
|
||||
unicode,
|
||||
};
|
||||
}
|
||||
|
||||
emoji.dispatchUpdate();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!emoji.packages.native) {
|
||||
return;
|
||||
}
|
||||
|
||||
emoji.packages.native.ascii = convertAsciiToEmoji ?? true;
|
||||
emoji.dispatchUpdate();
|
||||
}, [convertAsciiToEmoji]);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
|
||||
import { notificationManager } from '../../../lib/notificationManager';
|
||||
|
||||
export const useNotificationPermission = () => {
|
||||
const requestPermission = useStableCallback(async () => {
|
||||
const response = await Notification.requestPermission();
|
||||
notificationManager.allowed = response === 'granted';
|
||||
notificationManager.emit('change');
|
||||
|
||||
const notifications = await navigator.permissions.query({ name: 'notifications' });
|
||||
notifications.onchange = () => {
|
||||
notificationManager.allowed = notifications.state === 'granted';
|
||||
notificationManager.emit('change');
|
||||
};
|
||||
});
|
||||
|
||||
if ('Notification' in window) {
|
||||
requestPermission();
|
||||
}
|
||||
};
|
||||
46
apps/meteor/client/views/root/hooks/useOAuthLogin.ts
Normal file
46
apps/meteor/client/views/root/hooks/useOAuthLogin.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEndpoint, useRouter, useSearchParameter, useLoginWithToken } from '@rocket.chat/ui-contexts';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
|
||||
|
||||
export const useOAuthLogin = () => {
|
||||
const router = useRouter();
|
||||
const loginCode = useSearchParameter('loginCode');
|
||||
const loginClient = useSearchParameter('loginClient');
|
||||
const redeemLoginCode = useEndpoint('POST', '/v1/loginCode.redeem');
|
||||
const loginWithToken = useLoginWithToken();
|
||||
|
||||
const { mutate: redeemLoginCodeMutation } = useMutation({
|
||||
mutationFn: async (loginCode: string) => {
|
||||
const { loginToken, userId } = await redeemLoginCode({ code: loginCode });
|
||||
|
||||
if (!loginToken || !userId) {
|
||||
throw new Error('Invalid response from login code redemption');
|
||||
}
|
||||
|
||||
return { loginToken, userId };
|
||||
},
|
||||
onSuccess: async ({ loginToken, userId }) => {
|
||||
if (loginClient === 'desktop' || loginClient === 'mobile') {
|
||||
window.location.href = buildDeepLinkURL(loginToken, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await loginWithToken(loginToken);
|
||||
router.navigate('/home', { replace: true });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to redeem login code for client redirect', error);
|
||||
router.navigate('/login', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loginCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
redeemLoginCodeMutation(loginCode);
|
||||
}, [loginCode, redeemLoginCodeMutation]);
|
||||
};
|
||||
73
apps/meteor/client/views/root/hooks/useOAuthPopupCommands.ts
Normal file
73
apps/meteor/client/views/root/hooks/useOAuthPopupCommands.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { type LocationPathname, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { ltrim, rtrim } from '../../../../lib/utils/stringUtils';
|
||||
import { baseURI } from '../../../lib/baseURI';
|
||||
import { getRootUrlPathPrefix } from '../../../lib/meteorRuntimeConfig';
|
||||
import { router } from '../../../providers/RouterProvider';
|
||||
|
||||
export const useOAuthPopupCommands = () => {
|
||||
const loginWithToken = useLoginWithToken();
|
||||
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableModernOAuthFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = {
|
||||
//Used for the 2FA challenge and for handing the session over to the desktop/mobile clients.
|
||||
'redirect'(data: { path: string }) {
|
||||
if (typeof data.path !== 'string' || data.path.trim().length === 0) {
|
||||
return console.error('OAuth popup command [navigate]: `path` not defined');
|
||||
}
|
||||
|
||||
const newUrl = new URL(`${rtrim(baseURI, '/')}/${ltrim(data.path, '/')}`);
|
||||
|
||||
const newParams = Object.fromEntries(newUrl.searchParams.entries());
|
||||
|
||||
const newPath = newUrl.pathname.replace(new RegExp(`^${escapeRegExp(getRootUrlPathPrefix())}`), '') as LocationPathname;
|
||||
|
||||
router.navigate({
|
||||
pathname: newPath,
|
||||
search: { ...router.getSearchParameters(), ...newParams },
|
||||
});
|
||||
},
|
||||
|
||||
'oauth-login-with-token'(data: { token: string }) {
|
||||
if (typeof data.token !== 'string') {
|
||||
return console.error('OAuth popup command [login]: `token` not defined');
|
||||
}
|
||||
|
||||
void loginWithToken(data.token);
|
||||
},
|
||||
} as const;
|
||||
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
//The popup is same-origin, anything else is not ours to act on.
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.data !== 'object' || event.data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { oauthPopupCommand } = event.data as { oauthPopupCommand?: unknown };
|
||||
|
||||
if (typeof oauthPopupCommand !== 'string' || !Object.hasOwn(commands, oauthPopupCommand)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command: (data: MessageEvent['data']) => void = commands[oauthPopupCommand as keyof typeof commands];
|
||||
command(event.data);
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageListener);
|
||||
};
|
||||
}, [loginWithToken, enableModernOAuthFlow]);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useRole, useRouter, useSetting, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useRedirectToSetupWizard = (): void => {
|
||||
const userId = useUserId();
|
||||
const setupWizardState = useSetting('Show_Setup_Wizard');
|
||||
const router = useRouter();
|
||||
const isAdmin = useRole('admin');
|
||||
|
||||
const isWizardInProgress = userId && isAdmin && setupWizardState === 'in_progress';
|
||||
const mustRedirect = (!userId && setupWizardState === 'pending') || isWizardInProgress;
|
||||
useEffect(() => {
|
||||
if (mustRedirect) {
|
||||
router.navigate('/setup-wizard');
|
||||
}
|
||||
}, [mustRedirect, router]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { setRootUrl } from '../../../lib/meteorRuntimeConfig';
|
||||
|
||||
export const useSettingsOnLoadSiteUrl = () => {
|
||||
const siteUrl = useSetting('Site_Url') as string;
|
||||
|
||||
useEffect(() => {
|
||||
const value = siteUrl;
|
||||
if (value == null || value.trim() === '') {
|
||||
return;
|
||||
}
|
||||
setRootUrl(value);
|
||||
}, [siteUrl]);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useRouter, useSearchParameter, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL';
|
||||
import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk';
|
||||
|
||||
export const useShareSessionWithOtherClients = () => {
|
||||
const router = useRouter();
|
||||
const userId = useUserId();
|
||||
const resumeToken = useSearchParameter('resumeToken');
|
||||
const loginClient = useSearchParameter('loginClient');
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginToken = readStoredLoginToken();
|
||||
|
||||
if (!loginToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resumeToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loginClient !== 'desktop' && loginClient !== 'mobile') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginURL = buildDeepLinkURL(loginToken, userId);
|
||||
window.location.href = loginURL;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate('/home', { replace: true });
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [resumeToken, loginClient, router, userId]);
|
||||
};
|
||||
11
apps/meteor/client/views/root/hooks/useStartupEvent.ts
Normal file
11
apps/meteor/client/views/root/hooks/useStartupEvent.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useFireGlobalEvent } from '../../../hooks/useFireGlobalEvent';
|
||||
|
||||
export const useStartupEvent = () => {
|
||||
const { mutate: fireStartupEvent } = useFireGlobalEvent('startup', 'global');
|
||||
|
||||
useEffect(() => {
|
||||
fireStartupEvent(true);
|
||||
}, [fireStartupEvent]);
|
||||
};
|
||||
14
apps/meteor/client/views/root/hooks/useUnreadMessages.ts
Normal file
14
apps/meteor/client/views/root/hooks/useUnreadMessages.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useSession } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useUnreadMessages = (): string | undefined => {
|
||||
const { t } = useTranslation();
|
||||
const unreadMessages = useSession('unread') as number | '' | '999+' | '•';
|
||||
|
||||
// TODO: remove this when we have a better way to handle this
|
||||
if (typeof unreadMessages !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return t('unread_messages_counter', { count: unreadMessages });
|
||||
};
|
||||
76
apps/meteor/client/views/root/lib/codeBlockStyles.ts
Normal file
76
apps/meteor/client/views/root/lib/codeBlockStyles.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export const codeBlock = `pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
.hljs {
|
||||
background: #1d1f21;
|
||||
color: #c5c8c6;
|
||||
}
|
||||
.hljs span::selection,
|
||||
.hljs::selection {
|
||||
background: #373b41;
|
||||
}
|
||||
.hljs span::-moz-selection,
|
||||
.hljs::-moz-selection {
|
||||
background: #373b41;
|
||||
}
|
||||
|
||||
div.rcx-box span.hljs-name,
|
||||
div.rcx-box span.hljs-title.function_,
|
||||
div.rcx-box span.hljs-title {
|
||||
color: #f0c674;
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-meta,
|
||||
.hljs-meta .hljs-keyword {
|
||||
color: #707880;
|
||||
}
|
||||
.hljs-deletion,
|
||||
.hljs-link,
|
||||
.hljs-literal,
|
||||
.hljs-number,
|
||||
.hljs-symbol {
|
||||
color: #c66;
|
||||
}
|
||||
.hljs-addition,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-string {
|
||||
color: #b5bd68;
|
||||
}
|
||||
.hljs-attribute,
|
||||
.hljs-code,
|
||||
.hljs-selector-id {
|
||||
color: #b294bb;
|
||||
}
|
||||
.hljs-bullet,
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-tag {
|
||||
color: #81a2be;
|
||||
}
|
||||
.hljs-subst,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-variable {
|
||||
color: #8abeb7;
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-quote,
|
||||
.hljs-section,
|
||||
.hljs-selector-class,
|
||||
.hljs-type {
|
||||
color: #de935f;
|
||||
}
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}`;
|
||||
Reference in New Issue
Block a user