diff --git a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts new file mode 100644 index 0000000000000..e21a711963371 --- /dev/null +++ b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts @@ -0,0 +1,5 @@ +export const buildDeepLinkURL = (resumeToken: string, userId: string) => { + const url = new URL(window.location.href); + const { host } = url; + return `rocketchat://auth?host=http://${host}&token=${resumeToken}&userId=${userId}`; +}; diff --git a/apps/meteor/client/lib/sdk/ddpSdk.ts b/apps/meteor/client/lib/sdk/ddpSdk.ts index 91ea1afe0df2f..20b6462becbc1 100644 --- a/apps/meteor/client/lib/sdk/ddpSdk.ts +++ b/apps/meteor/client/lib/sdk/ddpSdk.ts @@ -66,7 +66,8 @@ export const getDdpSdk = (): DDPSDK => { return instance; }; -const readStoredLoginToken = (): string | null => (typeof window !== 'undefined' ? window.localStorage.getItem('Meteor.loginToken') : null); +export const readStoredLoginToken = (): string | null => + typeof window !== 'undefined' ? window.localStorage.getItem('Meteor.loginToken') : null; let inflightLogin: Promise | undefined; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index b355d9c7b44f2..250690e98d78f 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -27,11 +27,13 @@ import { useKeyboardShortcutsHotkey } from './hooks/useKeyboardShortcutsHotkey'; import { useLivechatEnterprise } from './hooks/useLivechatEnterprise'; import { useLoadMissedMessages } from './hooks/useLoadMissedMessages'; import { useLoadRoomForAllowedAnonymousRead } from './hooks/useLoadRoomForAllowedAnonymousRead'; +import { useLoginOtherClients } from './hooks/useLoginOtherClients'; import { useLoginViaQuery } from './hooks/useLoginViaQuery'; import { useMessageLinkClicks } from './hooks/useMessageLinkClicks'; import { useNotificationPermission } from './hooks/useNotificationPermission'; import { useRedirectToSetupWizard } from './hooks/useRedirectToSetupWizard'; import { useSettingsOnLoadSiteUrl } from './hooks/useSettingsOnLoadSiteUrl'; +import { useShareSessionWithOtherClients } from './hooks/useShareSessionWithOtherClients'; import { useStartupEvent } from './hooks/useStartupEvent'; import { appLayout } from '../../lib/appLayout'; @@ -70,6 +72,8 @@ const AppLayout = () => { useAutoupdate(); useCodeHighlight(); useLoginViaQuery(); + useLoginOtherClients(); + useShareSessionWithOtherClients(); useLoadMissedMessages(); useDesktopFavicon(); useDesktopTitle(); diff --git a/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts new file mode 100644 index 0000000000000..beefab27e06a8 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useLoginOtherClients.ts @@ -0,0 +1,29 @@ +import { useRouter } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; + +export const useLoginOtherClients = () => { + const router = useRouter(); + + useEffect(() => { + const { resumeToken, loginClient, userId } = router.getSearchParameters(); + + if (!resumeToken || !userId) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(resumeToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 0); + + return () => clearTimeout(timeout); + }, [router]); +}; diff --git a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts index a67739eb7b025..7bc3b2b93ecb0 100644 --- a/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts +++ b/apps/meteor/client/views/root/hooks/useLoginViaQuery.ts @@ -7,12 +7,17 @@ export const useLoginViaQuery = () => { useEffect(() => { const handleLogin = async () => { - const { resumeToken } = router.getSearchParameters(); + const { resumeToken, loginClient } = router.getSearchParameters(); if (!resumeToken) { return; } + //Case handled by useLoginOtherClients, we don't want to login here. + if (loginClient) { + return; + } + try { await loginWithToken(resumeToken); diff --git a/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts new file mode 100644 index 0000000000000..f879fa7bd3cb1 --- /dev/null +++ b/apps/meteor/client/views/root/hooks/useShareSessionWithOtherClients.ts @@ -0,0 +1,41 @@ +import { useRouter, useUserId } from '@rocket.chat/ui-contexts'; +import { useEffect } from 'react'; + +import { buildDeepLinkURL } from '../../../lib/buildAuthDeeplinkURL'; +import { readStoredLoginToken } from '../../../lib/sdk/ddpSdk'; + +export const useShareSessionWithOtherClients = () => { + const router = useRouter(); + const userId = useUserId(); + + useEffect(() => { + if (!userId) { + return; + } + + const loginToken = readStoredLoginToken(); + + if (!loginToken) { + return; + } + + const { resumeToken, loginClient } = router.getSearchParameters(); + + if (resumeToken) { + return; + } + + if (loginClient !== 'desktop' && loginClient !== 'mobile') { + return; + } + + const loginURL = buildDeepLinkURL(loginToken, userId); + window.location.href = loginURL; + + const timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 100); + + return () => clearTimeout(timeout); + }, [router, userId]); +}; diff --git a/apps/meteor/definition/externals/express-session.d.ts b/apps/meteor/definition/externals/express-session.d.ts new file mode 100644 index 0000000000000..6a59655d241a7 --- /dev/null +++ b/apps/meteor/definition/externals/express-session.d.ts @@ -0,0 +1,7 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + loginClient?: string; + } +} diff --git a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts index d81ee8f8e336f..894ea9545295e 100644 --- a/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts +++ b/apps/meteor/server/lib/oauth/addPassportCustomOAuth.ts @@ -6,6 +6,7 @@ import type { DoneCallback, Profile } from 'passport'; import { verifyFunction } from './verifyFunction'; import { CustomOAuthStrategy } from '../../../app/custom-oauth/server/customOAuth'; +import { settings } from '../../../app/settings/server'; import { oAuthRouter } from '../../configuration/configurePassport'; interface IOAuthRequest extends Request { @@ -25,14 +26,27 @@ export const addPassportCustomOAuth = (serviceName: string, config: Partial('Site_Url'); + oAuthRouter.get( `/oauth/${serviceName}`, - passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login' }), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + next(); + } + }, + passport.authenticate(serviceName, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), ); oAuthRouter.get( `/oauth/${serviceName}/callback`, - passport.authenticate(serviceName, { failureRedirect: '/login', failureFlash: true, failWithError: true }), + passport.authenticate(serviceName, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), async (req: IOAuthRequest, res: Response) => { const oAuthUser = req.user as IUser; @@ -40,10 +54,21 @@ export const addPassportCustomOAuth = (serviceName: string, config: Partial { if (err) { diff --git a/apps/meteor/server/lib/oauth/configureOAuthServices.ts b/apps/meteor/server/lib/oauth/configureOAuthServices.ts index 23b087dcddaa1..6fae927444167 100644 --- a/apps/meteor/server/lib/oauth/configureOAuthServices.ts +++ b/apps/meteor/server/lib/oauth/configureOAuthServices.ts @@ -9,10 +9,6 @@ import type { OAuthServiceConfig } from './createOAuthServiceConfig'; import type { ICachedSettings } from '../../../app/settings/server/CachedSettings'; import { oAuthRouter } from '../../configuration/configurePassport'; -interface IOAuthRequest extends Request { - user?: IUser; -} - export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], settings: ICachedSettings) => { oauthServiceConfig.forEach((config) => { const Strategy = config.strategy; @@ -36,7 +32,6 @@ export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], const profileWithRaw = profile as Profile & { _json?: Record; _raw?: string }; const { _json, _raw, ...restProfile } = profileWithRaw; - // eslint-disable-next-line @typescript-eslint/await-thenable const user = await Accounts.updateOrCreateUserFromExternalService( config.provider, { @@ -67,23 +62,44 @@ export const configureOAuthServices = (oauthServiceConfig: OAuthServiceConfig[], oAuthRouter.get( `/oauth/${config.provider}`, - passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login' }), + (req, _res, next) => { + const { loginClient } = req.query; + if (loginClient === 'mobile' || loginClient === 'desktop') { + req.session.loginClient = loginClient; + req.session.save(() => { + next(); + }); + } else { + next(); + } + }, + passport.authenticate(config.provider, { scope: config.scope, prompt: 'consent', failureRedirect: '/login', keepSessionInfo: true }), ); oAuthRouter.get( `/oauth/${config.provider}/callback`, - passport.authenticate(config.provider, { failureRedirect: '/login', failureFlash: true, failWithError: true }), - async (req: IOAuthRequest, res: Response) => { + passport.authenticate(config.provider, { failureRedirect: '/login', failureFlash: true, failWithError: true, keepSessionInfo: true }), + async (req: Request, res: Response) => { const oAuthUser = req.user as IUser; if (!oAuthUser) { - // return res.redirect('/login'); - return res.redirect('/noOauthUser'); + return res.redirect('/login'); } + const { loginClient } = req.session; + const stampedToken = Accounts._generateStampedLoginToken(); await Accounts._insertLoginToken(oAuthUser._id, stampedToken); - res.redirect(`/home?resumeToken=${stampedToken.token}`); + const redirectUrl = new URL(`/home`, siteUrl); + + redirectUrl.searchParams.set('resumeToken', stampedToken.token); + redirectUrl.searchParams.set('userId', oAuthUser._id); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + res.redirect(redirectUrl.toString()); req.session.destroy((err) => { if (err) { diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 9d10c1f89d1c5..5344ad5d18005 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -63,4 +63,5 @@ export interface IRocketChatDesktop { setUserToken: (token: string, userId: string) => void; openDocumentViewer: (url: string, format: string, options: any) => void; reloadServer: () => void; + openInBrowser: (url: string) => void; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 4bf33527ac06a..e46b9f53543be 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -6810,6 +6810,7 @@ "registration.component.form.usernameAlreadyInUse": "Username already in use", "registration.component.form.usernameContainsInvalidChars": "Username contains invalid characters", "registration.component.login": "Login", + "registration.component.login.onWeb": "Login on web", "registration.component.login.incorrectPassword": "Incorrect password", "registration.component.login.userNotFound": "User not found", "registration.component.resetPassword": "Reset password", diff --git a/packages/web-ui-registration/global.d.ts b/packages/web-ui-registration/global.d.ts new file mode 100644 index 0000000000000..51c30e263ccad --- /dev/null +++ b/packages/web-ui-registration/global.d.ts @@ -0,0 +1,7 @@ +import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; + +declare global { + interface Window { + RocketChatDesktop?: IRocketChatDesktop; + } +} diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index e36c032e0436f..b89b866fbabf4 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -1,4 +1,4 @@ -import { ButtonGroup, Divider } from '@rocket.chat/fuselage'; +import { Button, ButtonGroup, Divider } from '@rocket.chat/fuselage'; import { useLoginServices, useSetting } from '@rocket.chat/ui-contexts'; import type { Dispatch, ReactElement, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; @@ -21,6 +21,16 @@ const LoginServices = ({ return null; } + const isDesktopApp = !!window.RocketChatDesktop?.openInBrowser; + + const handleLoginOnWeb = () => { + if (!isDesktopApp) { + return; + } + + window.RocketChatDesktop?.openInBrowser(`${window.location.href}?loginClient=desktop`); + }; + return ( <> {showFormLogin && ( @@ -28,11 +38,18 @@ const LoginServices = ({ {t('registration.component.form.divider')} )} - - {services.map((service) => ( - - ))} - + {!isDesktopApp && ( + + {services.map((service) => ( + + ))} + + )} + {isDesktopApp && ( + + )} ); }; diff --git a/packages/web-ui-registration/src/LoginServicesButton.tsx b/packages/web-ui-registration/src/LoginServicesButton.tsx index e5488a18ace07..dbc9db53d491f 100644 --- a/packages/web-ui-registration/src/LoginServicesButton.tsx +++ b/packages/web-ui-registration/src/LoginServicesButton.tsx @@ -32,7 +32,17 @@ const LoginServicesButton = ({ const handleOnClick = useCallback(() => { if (!isLegacyOAuthEnabled) { - window.location.href = `/oauth/${service}`; + const url = new URL(window.location.href); + const queryParams = url.searchParams; + const loginClient = queryParams.get('loginClient'); + + const redirectUrl = new URL(`/oauth/${service}`, window.location.origin); + + if (loginClient) { + redirectUrl.searchParams.set('loginClient', loginClient); + } + + window.location.href = redirectUrl.toString(); return; } diff --git a/packages/web-ui-registration/tsconfig.build.json b/packages/web-ui-registration/tsconfig.build.json index 73f50b10a9645..78b00e08930a3 100644 --- a/packages/web-ui-registration/tsconfig.build.json +++ b/packages/web-ui-registration/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["./src/**/*"], + "include": ["./src/**/*", "./global.d.ts"], "exclude": ["./src/**/*.spec.ts", "./src/**/*.stories.tsx"] } diff --git a/packages/web-ui-registration/tsconfig.json b/packages/web-ui-registration/tsconfig.json index e1348ec0e2e74..46506c90ec74c 100644 --- a/packages/web-ui-registration/tsconfig.json +++ b/packages/web-ui-registration/tsconfig.json @@ -4,5 +4,5 @@ "rootDirs": ["./src","./.storybook"], "outDir": "./dist" }, - "include": ["./src", "./.storybook"], + "include": ["./src", "./.storybook", "./global.d.ts"], }