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:
parent
c653d7c665
commit
a341ef8981
14 changed files with 522 additions and 51 deletions
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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})}`,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const DeepLinkType = {
|
|||
PlaybookRunsRetrospective: 'playbook_runs_retrospective',
|
||||
Redirect: '_redirect',
|
||||
Server: 'server',
|
||||
MagicLink: 'magic_link',
|
||||
} as const;
|
||||
|
||||
export default DeepLinkType;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
177
app/screens/login/link_sent.tsx
Normal file
177
app/screens/login/link_sent.tsx
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -62,6 +62,7 @@ interface ClientConfig {
|
|||
EnableEmailBatching: string;
|
||||
EnableEmailInvitations: string;
|
||||
EnableEmojiPicker: string;
|
||||
EnableGuestMagicLink: string;
|
||||
EnableFileAttachments: string;
|
||||
EnableGifPicker: string;
|
||||
EnableGuestAccounts: string;
|
||||
|
|
|
|||
2
types/api/users.d.ts
vendored
2
types/api/users.d.ts
vendored
|
|
@ -162,3 +162,5 @@ type GetUsersOptions = {
|
|||
channel_roles?: string;
|
||||
team_roles?: string;
|
||||
};
|
||||
|
||||
type LoginType = '' | 'guest_magic_link';
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue