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
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:
@@ -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]);
|
||||
};
|
||||
Reference in New Issue
Block a user