From a341ef89813ebf36b3dfb12c8fbdece8232f12fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 20 Nov 2025 12:38:06 +0100 Subject: [PATCH] Easy Login (#9261) * Login screen for easy login * Deep link changes * Fix login flow * Address feedback * Rebranding * Add deactivated flow * Apply changes to the get type API * Address feedback --- app/actions/remote/session.ts | 73 +++++++++++++ app/client/rest/users.ts | 28 +++++ app/constants/deep_linking.ts | 1 + app/init/launch.ts | 14 ++- app/screens/invite/index.ts | 2 + app/screens/login/form.tsx | 174 +++++++++++++++++++++++-------- app/screens/login/index.tsx | 36 ++++++- app/screens/login/link_sent.tsx | 177 ++++++++++++++++++++++++++++++++ app/utils/deep_link/index.ts | 49 ++++++++- app/utils/url/path.ts | 2 + assets/base/i18n/en.json | 7 ++ types/api/config.d.ts | 1 + types/api/users.d.ts | 2 + types/launch/index.ts | 7 +- 14 files changed, 522 insertions(+), 51 deletions(-) create mode 100644 app/screens/login/link_sent.tsx diff --git a/app/actions/remote/session.ts b/app/actions/remote/session.ts index ef3e33744..dc0485a31 100644 --- a/app/actions/remote/session.ts +++ b/app/actions/remote/session.ts @@ -15,12 +15,14 @@ import {getDeviceToken} from '@queries/app/global'; import {getServerDisplayName} from '@queries/app/servers'; import {getCurrentUserId, getExpiredSession} from '@queries/servers/system'; import {getCurrentUser} from '@queries/servers/user'; +import {resetToHome} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; import {getFullErrorMessage, isErrorWithStatusCode, isErrorWithUrl} from '@utils/errors'; import {logWarning, logError, logDebug} from '@utils/log'; import {scheduleExpiredNotification} from '@utils/notification'; import {type SAMLChallenge} from '@utils/saml_challenge'; import {getCSRFFromCookie} from '@utils/security'; +import {getServerUrlAfterRedirect} from '@utils/url'; import {loginEntry} from './entry'; @@ -433,3 +435,74 @@ export async function findSession(serverUrl: string, sessions: Session[]) { // At this point we did not find the session return undefined; } + +export const getUserLoginType = async (serverUrl: string, loginId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + return await client.getUserLoginType(loginId); + } catch (error) { + logError('error on getUserLoginType', getFullErrorMessage(error)); + return {error}; + } +}; + +export const magicLinkLogin = async (serverUrl: string, token: string): Promise => { + const httpsHeadRequest = await getServerUrlAfterRedirect(serverUrl); + let serverUrlToUse; + if (httpsHeadRequest.error || !httpsHeadRequest.url) { + // Retry with HTTP + const httpHeadRequest = await getServerUrlAfterRedirect(serverUrl, true); + if (httpHeadRequest.error || !httpHeadRequest.url) { + return {error: httpsHeadRequest.error || httpHeadRequest.error || 'empty server url', failed: true}; + } + serverUrlToUse = httpHeadRequest.url; + } else { + serverUrlToUse = httpsHeadRequest.url; + } + + const database = DatabaseManager.appDatabase?.database; + if (!database) { + return {error: 'App database not found', failed: true}; + } + + try { + const client = await NetworkManager.createClient(serverUrlToUse, undefined); + const config = await client.getClientConfigOld(); + const deviceId = await getDeviceToken(); + const serverDisplayName = config.SiteName; + + const user = await client.loginByMagicLinkLogin(token, deviceId); + + const server = await DatabaseManager.createServerDatabase({ + config: { + dbName: serverUrlToUse, + serverUrl: serverUrlToUse, + identifier: config.DiagnosticId, + displayName: serverDisplayName, + }, + }); + + await server?.operator.handleUsers({users: [user], prepareRecordsOnly: false}); + await server?.operator.handleSystem({ + systems: [{ + id: Database.SYSTEM_IDENTIFIERS.CURRENT_USER_ID, + value: user.id, + }], + prepareRecordsOnly: false, + }); + const csrfToken = await getCSRFFromCookie(serverUrlToUse); + client.setCSRFToken(csrfToken); + } catch (error) { + return {error, failed: true}; + } + + try { + await addPushProxyVerificationStateFromLogin(serverUrlToUse); + const {error} = await loginEntry({serverUrl: serverUrlToUse}); + await DatabaseManager.setActiveServerDatabase(serverUrlToUse); + await resetToHome(); + return {error, failed: false}; + } catch (error) { + return {error, failed: false}; + } +}; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index edc55ef77..f9c0e5ea6 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -19,6 +19,7 @@ export interface ClientUsersMix { setDefaultProfileImage: (userId: string) => Promise; login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise; loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise; + loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise; logout: () => Promise; getProfiles: (page?: number, perPage?: number, options?: Record) => Promise; getProfilesByIds: (userIds: string[], options?: Record, groupLabel?: RequestGroupLabel) => Promise; @@ -47,6 +48,7 @@ export interface ClientUsersMix { unsetCustomStatus: () => Promise<{status: string}>; removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; exchangeSsoLoginCode: (loginCode: string, codeVerifier: string, state: string) => Promise<{token: string; csrf: string}>; + getUserLoginType: (loginId: string, deviceId?: string) => Promise<{auth_service: LoginType; is_deactivated: boolean}>; } const ClientUsers = >(superclass: TBase) => class extends superclass { @@ -162,6 +164,25 @@ const ClientUsers = >(superclass: TBase) = return resp?.data; }; + loginByMagicLinkLogin = async (token: string, deviceId = '') => { + const body = { + magic_link_token: token, + device_id: deviceId, + }; + + const resp = await this.doFetch( + `${this.getUsersRoute()}/login`, + { + method: 'post', + body, + headers: {'Cache-Control': 'no-store'}, + }, + false, + ); + + return resp?.data; + }; + logout = async () => { const response = await this.doFetch( `${this.getUsersRoute()}/logout`, @@ -171,6 +192,13 @@ const ClientUsers = >(superclass: TBase) = return response; }; + getUserLoginType = async (loginId: string, deviceId?: string) => { + return this.doFetch( + `${this.getUsersRoute()}/login/type`, + {method: 'post', body: {login_id: loginId, device_id: deviceId}}, + ); + }; + getProfiles = async (page = 0, perPage = PER_PAGE_DEFAULT, options = {}) => { return this.doFetch( `${this.getUsersRoute()}${buildQueryString({page, per_page: perPage, ...options})}`, diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index f1a30c913..1b4a62bce 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -12,6 +12,7 @@ const DeepLinkType = { PlaybookRunsRetrospective: 'playbook_runs_retrospective', Redirect: '_redirect', Server: 'server', + MagicLink: 'magic_link', } as const; export default DeepLinkType; diff --git a/app/init/launch.ts b/app/init/launch.ts index cf2404902..ca791b861 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -23,7 +23,7 @@ import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; import {resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; -import {getLaunchPropsFromDeepLink} from '@utils/deep_link'; +import {getLaunchPropsFromDeepLink, handleDeepLink} from '@utils/deep_link'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; import {removeProtocol} from '@utils/url'; @@ -85,10 +85,16 @@ export const launchApp = async (props: LaunchProps) => { const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl); serverUrl = existingServer; props.serverUrl = serverUrl || extra.data?.serverUrl; - if (!serverUrl && extra.type !== DeepLink.Server) { + if (extra.type === DeepLink.MagicLink && extra.data && 'token' in extra.data) { + const result = await handleDeepLink(extra); + if (result.error) { + props.launchError = true; + } else { + return ''; + } + } else if (!serverUrl && extra.type !== DeepLink.Server) { props.launchError = true; - } - if (extra.type === DeepLink.Server) { + } else if (extra.type === DeepLink.Server) { if (removeProtocol(serverUrl) === extra.data?.serverUrl) { props.extra = undefined; props.launchType = Launch.Normal; diff --git a/app/screens/invite/index.ts b/app/screens/invite/index.ts index c8c479c55..dbf39ceb9 100644 --- a/app/screens/invite/index.ts +++ b/app/screens/invite/index.ts @@ -5,6 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$} from 'rxjs'; import {switchMap, distinctUntilChanged, map} from 'rxjs/operators'; +import {observeConfigValue} from '@queries/servers/system'; import {observeCurrentTeam} from '@queries/servers/team'; import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user'; import {isSystemAdmin} from '@utils/user'; @@ -34,6 +35,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { map((user) => isSystemAdmin(user?.roles || '')), distinctUntilChanged(), ), + allowPasswordlessInvites: observeConfigValue(database, 'EnableGuestMagicLink'), }; }); diff --git a/app/screens/login/form.tsx b/app/screens/login/form.tsx index 8dda0531d..9c670ac24 100644 --- a/app/screens/login/form.tsx +++ b/app/screens/login/form.tsx @@ -7,7 +7,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState, type RefObject import {defineMessages, useIntl} from 'react-intl'; import {Keyboard, TextInput, TouchableOpacity, View} from 'react-native'; -import {login} from '@actions/remote/session'; +import {getUserLoginType, login} from '@actions/remote/session'; import Button from '@components/button'; import CompassIcon from '@components/compass_icon'; import FloatingTextInput from '@components/floating_input/floating_text_input_label'; @@ -17,6 +17,7 @@ import {useAvoidKeyboard} from '@hooks/device'; import {usePreventDoubleTap} from '@hooks/utils'; import {goToScreen, loginAnimationOptions, resetToHome} from '@screens/navigation'; import {getFullErrorMessage, getServerError, isErrorWithMessage, isServerError} from '@utils/errors'; +import {logError} from '@utils/log'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; @@ -29,11 +30,32 @@ interface LoginProps extends LaunchProps { keyboardAwareRef: RefObject; serverDisplayName: string; theme: Theme; + setMagicLinkSent: (linkSent: boolean) => void; } export const MFA_EXPECTED_ERRORS = ['mfa.validate_token.authenticate.app_error', 'ent.mfa.validate_token.authenticate.app_error']; const hitSlop = {top: 8, right: 8, bottom: 8, left: 8}; +function getButtonDisabled(loginId: string, password: string, userLoginType: LoginType | undefined, isDeactivated: boolean, magicLinkEnabled: boolean) { + if (!loginId) { + return true; + } + + if (isDeactivated) { + return true; + } + + if (magicLinkEnabled && (userLoginType === 'guest_magic_link' || userLoginType === undefined)) { + return false; + } + + if (!password) { + return true; + } + + return false; +} + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { marginBottom: 24, @@ -79,7 +101,18 @@ const isMFAError = (loginError: unknown): boolean => { return false; }; -const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchError, launchType, license, serverUrl, theme}: LoginProps) => { +const LoginForm = ({ + config, + extra, + keyboardAwareRef, + serverDisplayName, + launchError, + launchType, + license, + serverUrl, + theme, + setMagicLinkSent, +}: LoginProps) => { const styles = getStyleSheet(theme); const loginRef = useRef(null); const passwordRef = useRef(null); @@ -89,12 +122,15 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr const [error, setError] = useState(); const [loginId, setLoginId] = useState(''); const [password, setPassword] = useState(''); - const [buttonDisabled, setButtonDisabled] = useState(true); const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isDeactivated, setIsDeactivated] = useState(false); const emailEnabled = config.EnableSignInWithEmail === 'true'; const usernameEnabled = config.EnableSignInWithUsername === 'true'; const ldapEnabled = license.IsLicensed === 'true' && config.EnableLdap === 'true' && license.LDAP === 'true'; + const [userLoginType, setUserLoginType] = useState(undefined); + const magicLinkEnabled = config.EnableGuestMagicLink === 'true'; + useAvoidKeyboard(keyboardAwareRef); const goToHome = useCallback((loginError?: unknown) => { @@ -106,6 +142,24 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr goToScreen(MFA, '', {goToHome, loginId, password, config, serverDisplayName, license, serverUrl, theme}, loginAnimationOptions()); }, [config, goToHome, license, loginId, password, serverDisplayName, serverUrl, theme]); + const checkUserLoginType = useCallback(async () => { + if (!serverUrl) { + setUserLoginType(''); + logError('error on checkUserLoginType', 'serverUrl is required'); + setError(intl.formatMessage({id: 'login.magic_link.request.error', defaultMessage: 'Failed to check user login type'})); + return ''; + } + const response = await getUserLoginType(serverUrl, loginId); + if ('error' in response) { + logError('error on checkUserLoginType', getFullErrorMessage(response?.error)); + setError(intl.formatMessage({id: 'login.magic_link.request.error', defaultMessage: 'Failed to check user login type'})); + return ''; + } + setUserLoginType(response.auth_service); + setIsDeactivated(response.is_deactivated ?? false); + return (response.auth_service); + }, [serverUrl, loginId, intl]); + const getLoginErrorMessage = useCallback((loginError: unknown) => { if (isServerError(loginError)) { const errorId = loginError.server_error_id; @@ -190,17 +244,34 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr passwordRef?.current?.focus(); }, []); - const onLogin = useCallback(() => { + const onLogin = useCallback(async () => { Keyboard.dismiss(); + if (magicLinkEnabled && userLoginType === undefined) { + const receivedUserLoginType = await checkUserLoginType(); + if (receivedUserLoginType === 'guest_magic_link') { + setMagicLinkSent(true); + } + if (isDeactivated) { + setError(intl.formatMessage({id: 'login.deactivated', defaultMessage: 'This account is deactivated'})); + return; + } + return; + } + preSignIn(); - }, [preSignIn]); + }, [checkUserLoginType, intl, isDeactivated, magicLinkEnabled, preSignIn, setMagicLinkSent, userLoginType]); const onLoginChange = useCallback((text: string) => { setLoginId(text); if (error) { setError(undefined); } - }, [error]); + if (userLoginType !== undefined) { + setPassword(''); + setUserLoginType(undefined); + setIsDeactivated(false); + } + }, [error, userLoginType]); const onPasswordChange = useCallback((text: string) => { setPassword(text); @@ -238,13 +309,23 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr setEmmUsernameIfAvailable(); }, [managedConfig?.username]); - useEffect(() => { - if (loginId && password) { - setButtonDisabled(false); + const onIdInputSubmitting = useCallback(() => { + if (!magicLinkEnabled || (userLoginType !== 'guest_magic_link')) { + focusPassword(); return; } - setButtonDisabled(true); - }, [loginId, password]); + + onLogin(); + }, [focusPassword, onLogin, magicLinkEnabled, userLoginType]); + + const buttonDisabled = getButtonDisabled(loginId, password, userLoginType, isDeactivated, magicLinkEnabled); + const showPasswordInput = !magicLinkEnabled || (userLoginType !== 'guest_magic_link' && userLoginType !== undefined && !isDeactivated); + let userInputError = error; + if (showPasswordInput) { + // error is passed to the password input box, so we use this + // hack to make the input box also show the error border + userInputError = error ? ' ' : ''; + } const proceedButton = ( @@ -282,11 +363,11 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr autoComplete='email' disableFullscreenUI={true} enablesReturnKeyAutomatically={true} - error={error ? ' ' : ''} + error={userInputError} keyboardType='email-address' label={createLoginPlaceholder()} onChangeText={onLoginChange} - onSubmitEditing={focusPassword} + onSubmitEditing={onIdInputSubmitting} ref={loginRef} returnKeyType='next' hideErrorIcon={true} @@ -294,38 +375,43 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr theme={theme} value={loginId} /> - - - {(emailEnabled || usernameEnabled) && config.PasswordEnableForgotLink !== 'false' && ( - - + - + + {(emailEnabled || usernameEnabled) && config.PasswordEnableForgotLink !== 'false' && ( + + + + )} + )} {proceedButton} diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index e4fd6f89a..28afebfd8 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -2,7 +2,8 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {Platform, useWindowDimensions, View, type LayoutChangeEvent} from 'react-native'; +import {useIntl} from 'react-intl'; +import {Platform, Text, useWindowDimensions, View, type LayoutChangeEvent} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {Navigation} from 'react-native-navigation'; import Animated from 'react-native-reanimated'; @@ -24,6 +25,7 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import Form from './form'; +import LinkSent from './link_sent'; import LoginOptionsSeparator from './login_options_separator'; import SsoOptions from './sso_options'; @@ -73,6 +75,19 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ marginBottom: 12, ...typography('Body', 200, 'Regular'), }, + linkSentContainer: { + flex: 1, + justifyContent: 'center', + paddingHorizontal: 24, + }, + linkSentTitle: { + ...typography('Heading', 1000, 'SemiBold'), + color: theme.centerChannelColor, + }, + linkSentDescription: { + ...typography('Body', 200, 'Regular'), + color: changeOpacity(theme.centerChannelColor, 0.72), + }, })); const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView); @@ -91,6 +106,10 @@ const LoginOptions = ({ const numberSSOs = useMemo(() => { return Object.values(ssoOptions).filter((v) => v.enabled).length; }, [ssoOptions]); + const intl = useIntl(); + + const [magicLinkSent, setMagicLinkSent] = useState(false); + const description = useMemo(() => { if (hasLoginForm) { return ( @@ -187,6 +206,20 @@ const LoginOptions = ({ ); } + if (magicLinkSent) { + return ( + + + + {intl.formatMessage({id: 'login.magic_link.link.sent.title', defaultMessage: 'We sent you a link to login'})} + + + {intl.formatMessage({id: 'login.magic_link.link.sent.description', defaultMessage: 'Please check your email for the link to login. Your link will expire in 5 minutes.'})} + + + ); + } + return ( } {optionsSeparator} diff --git a/app/screens/login/link_sent.tsx b/app/screens/login/link_sent.tsx new file mode 100644 index 000000000..5e8a4d680 --- /dev/null +++ b/app/screens/login/link_sent.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import * as React from 'react'; +import Svg, { + G, + Circle, + Path, + Mask, + Rect, + Defs, + ClipPath, +} from 'react-native-svg'; + +import {useTheme} from '@context/theme'; + +const LinkSent = () => { + const theme = useTheme(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default LinkSent; diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index 80ec2eaf6..6c3f599f2 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -9,6 +9,7 @@ import urlParse from 'url-parse'; import {joinIfNeededAndSwitchToChannel, makeDirectChannel} from '@actions/remote/channel'; import {showPermalink} from '@actions/remote/permalink'; +import {magicLinkLogin} from '@actions/remote/session'; import {fetchUsersByUsernames} from '@actions/remote/user'; import {DeepLink, Launch, Screens} from '@constants'; import DeepLinkType from '@constants/deep_linking'; @@ -35,6 +36,7 @@ import { TEAM_NAME_PATH_PATTERN, IDENTIFIER_PATH_PATTERN, ID_PATH_PATTERN, + TOKEN_PATH_PATTERN, } from '@utils/url/path'; import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkPlaybookRuns, DeepLinkWithData, LaunchProps} from '@typings/launch'; @@ -54,6 +56,15 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int // After checking the server for http & https then we add it if (!existingServerUrl) { const theme = EphemeralStore.theme || getDefaultThemeByAppearance(); + + if (deepLink.type === DeepLink.MagicLink && 'token' in deepLink.data) { + const result = await magicLinkLogin(deepLink.data.serverUrl, deepLink.data.token); + if (result.error) { + logError('Failed to do magic link login', result.error); + return {error: true}; + } + return {error: false}; + } if (NavigationStore.getVisibleScreen() === Screens.SERVER) { Navigation.updateProps(Screens.SERVER, {serverUrl: deepLink.data.serverUrl}); } else if (!NavigationStore.getScreensInStack().includes(Screens.SERVER)) { @@ -167,6 +178,16 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int } break; } + case DeepLink.MagicLink: { + Alert.alert( + intl.formatMessage({id: 'magic_link.already_logged_in_error.title', defaultMessage: 'Already logged in'}), + intl.formatMessage({id: 'magic_link.already_logged_in_error.description', defaultMessage: 'You are already logged in to this server. Log out and follow the link again.'}), + [{ + text: intl.formatMessage({id: 'magic_link.already_logged_in_error.ok', defaultMessage: 'OK'}), + }], + ); + break; + } } return {error: false}; } catch (error) { @@ -210,6 +231,14 @@ export const matchPlaybookRunsDeeplink = match(PLAYBOOK_ const PLAYBOOK_RUNS_RETROSPECTIVE = '*serverUrl/playbooks/runs/:playbookRunId/retrospective'; export const matchPlaybookRunsRetrospectiveDeeplink = match(PLAYBOOK_RUNS_RETROSPECTIVE); +type MagicLinkPathParams = { + serverUrl: string[]; + token: string; +}; + +const MAGIC_LINK_PATH = '*serverUrl/login/one_time_link'; +export const matchMagicLinkDeeplink = match(MAGIC_LINK_PATH); + type PermalinkPathParams = { serverUrl: string[]; teamName: string; @@ -290,9 +319,17 @@ function isValidId(id: string): boolean { return regex.test(id); } +function isValidToken(token?: string): boolean { + if (!token) { + return false; + } + const regex = new RegExp(`^${TOKEN_PATH_PATTERN}$`); + return regex.test(token); +} + export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWithData { try { - const parsedUrl = urlParse(deepLinkUrl); + const parsedUrl = urlParse(deepLinkUrl, true); const urlWithoutQuery = stripTrailingSlashes(parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname); const url = removeProtocol(urlWithoutQuery); @@ -337,6 +374,16 @@ export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWi return {type: DeepLink.PlaybookRuns, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), playbookRunId}}; } + const magicLinkMatch = matchMagicLinkDeeplink(url); + if (magicLinkMatch) { + const token = parsedUrl.query.t; + const {params: {serverUrl}} = magicLinkMatch; + if (!isValidToken(token)) { + return {type: DeepLink.Invalid, url: deepLinkUrl}; + } + return {type: DeepLink.MagicLink, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), token}}; + } + if (asServer) { const serverMatch = extractServerUrl(url); if (serverMatch) { diff --git a/app/utils/url/path.ts b/app/utils/url/path.ts index 19795a9ac..def2c1628 100644 --- a/app/utils/url/path.ts +++ b/app/utils/url/path.ts @@ -3,6 +3,8 @@ export const ID_PATH_PATTERN = '[a-z0-9]{26}'; +export const TOKEN_PATH_PATTERN = '[a-z0-9]{64}'; + // This should cover: // - Team name (lowercase english characters, numbers or -) // - Two ids separated by __ (userID__userID) diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index fa2526f29..2382ab781 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -468,10 +468,14 @@ "login_mfa.enterToken": "To complete the sign in process, please enter the code from your mobile device's authenticator app.", "login_mfa.token": "Enter MFA Token", "login_mfa.tokenReq": "Please enter an MFA token", + "login.deactivated": "This account is deactivated", "login.email": "Email", "login.forgot": "Forgot your password?", "login.invalid_credentials": "The email and password combination is incorrect", "login.ldapUsername": "AD/LDAP Username", + "login.magic_link.link.sent.description": "Please check your email for the link to login. Your link will expire in 5 minutes.", + "login.magic_link.link.sent.title": "We sent you a link to login", + "login.magic_link.request.error": "Failed to check user login type", "login.or": "or", "login.password": "Password", "login.signIn": "Log In", @@ -483,6 +487,9 @@ "logout.fail.message.forced": "We could not log you out of the server. Some data may continue to be accessible to this device once the device goes back online.", "logout.fail.ok": "OK", "logout.fail.title": "Logout not complete", + "magic_link.already_logged_in_error.description": "You are already logged in to this server. Log out and follow the link again.", + "magic_link.already_logged_in_error.ok": "OK", + "magic_link.already_logged_in_error.title": "Already logged in", "markdown.latex.error": "Latex render error", "markdown.max_nodes.error": "This message is too long to by shown fully on a mobile device. Please view it on desktop or contact an admin to increase this limit.", "markdown.parse_error": "An error occurred while parsing this text", diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 60e951227..8bf017622 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -62,6 +62,7 @@ interface ClientConfig { EnableEmailBatching: string; EnableEmailInvitations: string; EnableEmojiPicker: string; + EnableGuestMagicLink: string; EnableFileAttachments: string; EnableGifPicker: string; EnableGuestAccounts: string; diff --git a/types/api/users.d.ts b/types/api/users.d.ts index 502bd8901..71be2c752 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -162,3 +162,5 @@ type GetUsersOptions = { channel_roles?: string; team_roles?: string; }; + +type LoginType = '' | 'guest_magic_link'; diff --git a/types/launch/index.ts b/types/launch/index.ts index 4a179208a..f654641b9 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -41,12 +41,17 @@ export interface DeepLinkPlaybookRuns extends DeepLink { playbookRunId: string; } +export interface DeepLinkMagicLink extends DeepLink { + serverUrl: string; + token: string; +} + export type DeepLinkType = typeof DeepLinkConstant[keyof typeof DeepLinkConstant]; export interface DeepLinkWithData { type: DeepLinkType; url: string; - data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin | DeepLinkServer | DeepLinkPlaybooks | DeepLinkPlaybookRuns; + data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin | DeepLinkServer | DeepLinkPlaybooks | DeepLinkPlaybookRuns | DeepLinkMagicLink; } export type LaunchType = typeof Launch[keyof typeof Launch];