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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s

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

View File

@@ -0,0 +1,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;

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,5 @@
import { lazy } from 'react';
const MainLayout = lazy(() => import('./MainLayout'));
export default MainLayout;

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