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
This commit is contained in:
Daniel Espino García 2025-11-20 12:38:06 +01:00 committed by GitHub
parent c653d7c665
commit a341ef8981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 522 additions and 51 deletions

View file

@ -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<LoginActionResponse> => {
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};
}
};

View file

@ -19,6 +19,7 @@ export interface ClientUsersMix {
setDefaultProfileImage: (userId: string) => Promise<any>;
login: (loginId: string, password: string, token?: string, deviceId?: string, ldapOnly?: boolean) => Promise<UserProfile>;
loginById: (id: string, password: string, token?: string, deviceId?: string) => Promise<UserProfile>;
loginByMagicLinkLogin: (token: string, deviceId?: string) => Promise<UserProfile>;
logout: () => Promise<any>;
getProfiles: (page?: number, perPage?: number, options?: Record<string, any>) => Promise<UserProfile[]>;
getProfilesByIds: (userIds: string[], options?: Record<string, any>, groupLabel?: RequestGroupLabel) => Promise<UserProfile[]>;
@ -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 = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -162,6 +164,25 @@ const ClientUsers = <TBase extends Constructor<ClientBase>>(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 = <TBase extends Constructor<ClientBase>>(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})}`,

View file

@ -12,6 +12,7 @@ const DeepLinkType = {
PlaybookRunsRetrospective: 'playbook_runs_retrospective',
Redirect: '_redirect',
Server: 'server',
MagicLink: 'magic_link',
} as const;
export default DeepLinkType;

View file

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

View file

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

View file

@ -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<KeyboardAwareScrollView>;
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<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
@ -89,12 +122,15 @@ const LoginForm = ({config, extra, keyboardAwareRef, serverDisplayName, launchEr
const [error, setError] = useState<string | undefined>();
const [loginId, setLoginId] = useState<string>('');
const [password, setPassword] = useState<string>('');
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<LoginType | undefined>(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 = (
<View style={styles.loginButtonContainer}>
@ -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}
/>
<FloatingTextInput
rawInput={true}
blurOnSubmit={false}
autoComplete='current-password'
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
error={error}
keyboardType={isPasswordVisible ? 'visible-password' : 'default'}
label={intl.formatMessage({id: 'login.password', defaultMessage: 'Password'})}
onChangeText={onPasswordChange}
onSubmitEditing={onLogin}
ref={passwordRef}
returnKeyType='join'
secureTextEntry={!isPasswordVisible}
testID='login_form.password.input'
theme={theme}
value={password}
endAdornment={endAdornment}
/>
{(emailEnabled || usernameEnabled) && config.PasswordEnableForgotLink !== 'false' && (
<RNEButton
onPress={onPressForgotPassword}
buttonStyle={styles.forgotPasswordBtn}
testID='login_form.forgot_password.button'
>
<FormattedText
id='login.forgot'
defaultMessage='Forgot your password?'
style={styles.forgotPasswordTxt}
{showPasswordInput && (
<>
<FloatingTextInput
rawInput={true}
blurOnSubmit={false}
autoComplete='current-password'
disableFullscreenUI={true}
enablesReturnKeyAutomatically={true}
error={error}
keyboardType={isPasswordVisible ? 'visible-password' : 'default'}
label={intl.formatMessage({id: 'login.password', defaultMessage: 'Password'})}
onChangeText={onPasswordChange}
onSubmitEditing={onLogin}
ref={passwordRef}
returnKeyType='join'
secureTextEntry={!isPasswordVisible}
testID='login_form.password.input'
theme={theme}
value={password}
endAdornment={endAdornment}
autoFocus={magicLinkEnabled}
/>
</RNEButton>
{(emailEnabled || usernameEnabled) && config.PasswordEnableForgotLink !== 'false' && (
<RNEButton
onPress={onPressForgotPassword}
buttonStyle={styles.forgotPasswordBtn}
testID='login_form.forgot_password.button'
>
<FormattedText
id='login.forgot'
defaultMessage='Forgot your password?'
style={styles.forgotPasswordTxt}
/>
</RNEButton>
)}
</>
)}
{proceedButton}
</View>

View file

@ -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 (
<View style={styles.linkSentContainer}>
<LinkSent/>
<Text style={styles.linkSentTitle}>
{intl.formatMessage({id: 'login.magic_link.link.sent.title', defaultMessage: 'We sent you a link to login'})}
</Text>
<Text style={styles.linkSentDescription}>
{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.'})}
</Text>
</View>
);
}
return (
<View
style={styles.flex}
@ -225,6 +258,7 @@ const LoginOptions = ({
theme={theme}
serverDisplayName={serverDisplayName}
serverUrl={serverUrl}
setMagicLinkSent={setMagicLinkSent}
/>
}
{optionsSeparator}

View file

@ -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 (
<Svg
width={184}
height={160}
fill='none'
>
<G clipPath='url(#a)'>
<Circle
cx={92}
cy={80}
r={80}
fill={theme.centerChannelColor}
fillOpacity={0.08}
/>
<Path
stroke={theme.centerChannelColor}
strokeLinecap='round'
strokeOpacity={0.32}
strokeWidth={1.345}
d='M2 66.8v12h154.4v12.8h22.8M32.4 109.6V88h21.2'
/>
<Circle
cx={2}
cy={2}
r={2}
fill={theme.centerChannelColor}
fillOpacity={0.56}
transform='matrix(1 0 0 -1 0 67.2)'
/>
<Circle
cx={2}
cy={2}
r={2}
fill={theme.centerChannelColor}
fillOpacity={0.56}
transform='matrix(1 0 0 -1 30.4 113.6)'
/>
<Circle
cx={2}
cy={2}
r={2}
fill={theme.centerChannelColor}
fillOpacity={0.56}
transform='matrix(1 0 0 -1 177.6 93.6)'
/>
<Path
fill={theme.centerChannelBg}
stroke={theme.centerChannelColor}
strokeLinejoin='round'
strokeWidth={1.345}
d='M43.197 58.4 92.6 20.8c19.772 15.048 48.996 37.6 48.996 37.6V128h-98.4V58.4Z'
/>
<Path
fill={theme.centerChannelColor}
fillOpacity={0.24}
d='M138.662 124.8H46.137V59.355L92.592 24c18.591 14.15 46.07 35.355 46.07 35.355V124.8Z'
/>
<Mask
id='b'
width={95}
height={66}
x={45}
y={30}
maskUnits='userSpaceOnUse'
style={{
maskType: 'alpha',
}}
>
<Path
fill={theme.centerChannelBg}
d='M92.594 95.2 45.6 60V30.4h93.6V60s-27.798 21.113-46.606 35.2Z'
/>
</Mask>
<G mask='url(#b)'>
<Path
fill={theme.centerChannelBg}
stroke={theme.centerChannelColor}
strokeWidth={1.345}
d='M123.328 34.272v89.055H61.473V34.272z'
/>
</G>
<Path
fill={theme.onlineIndicator}
d='M92.8 79.637c7.511 0 13.6-6.089 13.6-13.6 0-7.511-6.089-13.6-13.6-13.6-7.511 0-13.6 6.089-13.6 13.6 0 7.511 6.089 13.6 13.6 13.6Z'
/>
<Path
stroke={theme.centerChannelBg}
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.6}
d='m87.295 66.555 4.39 3.966 8.383-8.672'
/>
<Path
fill={theme.centerChannelBg}
d='M92.593 95.154 140 59.2v68h-95.2v-68l47.794 35.954Z'
/>
<Path
stroke={theme.centerChannelColor}
strokeLinejoin='round'
strokeWidth={1.345}
d='m45.6 60 46.993 35.2C111.401 81.113 139.2 60 139.2 60'
/>
<Path
stroke={theme.centerChannelColor}
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.345}
d='M116.303 131.765H91.43M87.396 131.765h-8.067M75.295 131.765h-2.689'
/>
<Path
fill='#CCC4AE'
d='m39.849 40.174 7.21 7.196V12.511a1.6 1.6 0 0 0-1.6-1.6h-32.37a1.6 1.6 0 0 0-1.6 1.6v26.063a1.6 1.6 0 0 0 1.6 1.6h26.76Z'
/>
<Path
stroke='#3F4350'
strokeLinecap='round'
strokeWidth={1.345}
d='M20.38 19.804h10.67M20.38 24.25h19.563M20.38 29.585h8.002M30.16 29.585h8.004'
/>
<Path
fill='#28427B'
d='M141.159 45.263 130.401 56V3.2a1.6 1.6 0 0 1 1.6-1.6h49.873a1.6 1.6 0 0 1 1.6 1.6v40.463a1.6 1.6 0 0 1-1.6 1.6h-40.715Z'
/>
<Path
stroke='#fff'
strokeLinecap='round'
strokeWidth={1.345}
d='M143.67 14.868h15.922M143.67 21.502h29.19M143.67 29.464h11.941M158.265 29.464h11.941'
/>
<Rect
width={26.218}
height={6.05}
x={79.328}
y={40.336}
fill={theme.centerChannelColor}
fillOpacity={0.16}
rx={2.017}
/>
<Path
stroke={theme.centerChannelColor}
strokeLinecap='round'
strokeLinejoin='round'
strokeOpacity={0.32}
strokeWidth={1.345}
d='M47.731 69.244v53.781h30.252m2.69 0h5.377m2.69 0h2.689'
/>
</G>
<Defs>
<ClipPath id='a'>
<Path
fill={theme.centerChannelBg}
d='M0 0h183.2v160H0z'
/>
</ClipPath>
</Defs>
</Svg>
);
};
export default LinkSent;

View file

@ -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<PlaybookRunsPathParams>(PLAYBOOK_
const PLAYBOOK_RUNS_RETROSPECTIVE = '*serverUrl/playbooks/runs/:playbookRunId/retrospective';
export const matchPlaybookRunsRetrospectiveDeeplink = match<PlaybookRunsPathParams>(PLAYBOOK_RUNS_RETROSPECTIVE);
type MagicLinkPathParams = {
serverUrl: string[];
token: string;
};
const MAGIC_LINK_PATH = '*serverUrl/login/one_time_link';
export const matchMagicLinkDeeplink = match<MagicLinkPathParams>(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) {

View file

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

View file

@ -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",

View file

@ -62,6 +62,7 @@ interface ClientConfig {
EnableEmailBatching: string;
EnableEmailInvitations: string;
EnableEmojiPicker: string;
EnableGuestMagicLink: string;
EnableFileAttachments: string;
EnableGifPicker: string;
EnableGuestAccounts: string;

View file

@ -162,3 +162,5 @@ type GetUsersOptions = {
channel_roles?: string;
team_roles?: string;
};
type LoginType = '' | 'guest_magic_link';

View file

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