Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add iOS and Android view for SAML Login #29526

Merged
merged 39 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
400156a
add native view for SAML SignInPage
NikkiWines Oct 13, 2023
ca0d065
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Oct 13, 2023
8991e7e
log in with shortlivedtoken when one is returned
NikkiWines Oct 13, 2023
ad6c373
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Oct 23, 2023
0168e72
include const
NikkiWines Oct 23, 2023
9e4b7ae
style and clean up
NikkiWines Oct 24, 2023
1866096
add SAMLLOadingINdicator
NikkiWines Oct 24, 2023
89dc792
style
NikkiWines Oct 24, 2023
526cf8f
prettier
NikkiWines Oct 24, 2023
c386ce4
include platform
NikkiWines Oct 24, 2023
ef12fe6
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Oct 25, 2023
5172f95
pull in changes from infinitered:cdanwards/saml-webview
NikkiWines Oct 25, 2023
544ff27
fix imports
NikkiWines Oct 26, 2023
7073d93
fix redirect flickering when pressing go back after choosing magic code
NikkiWines Oct 26, 2023
4586fee
reset sign in data if going back from SSO provider page
NikkiWines Oct 26, 2023
46a336f
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Oct 26, 2023
4dd5f55
style
NikkiWines Oct 27, 2023
8ff4c64
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Oct 30, 2023
14818b7
add in error handling from App/pull/30027
NikkiWines Oct 30, 2023
ed66009
update imports
NikkiWines Oct 30, 2023
b247558
prettier
NikkiWines Oct 30, 2023
380fa26
remove navigation after callback
NikkiWines Oct 30, 2023
dc9d918
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 2, 2023
eaa2438
style
NikkiWines Nov 2, 2023
1c3ad08
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 7, 2023
b011750
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 7, 2023
efa391d
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 10, 2023
dab32d8
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 14, 2023
769d880
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 16, 2023
fd12887
update error handing to set account error message
NikkiWines Nov 17, 2023
6b5236d
Merge branch 'nikki-saml-newdot-ios' of https://github.com/Expensify/…
NikkiWines Nov 17, 2023
79e89c2
return early when we've already initiated saml login
NikkiWines Nov 17, 2023
6bbce78
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 26, 2023
1a62ccc
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Nov 28, 2023
0ebfd25
showLoginForm when error is returned during SAML login
NikkiWines Nov 28, 2023
4a020de
clear signin data after recieving error
NikkiWines Nov 29, 2023
5c8519f
remove unused onyxkey import
NikkiWines Dec 1, 2023
43d7439
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Dec 1, 2023
01c4029
Merge branch 'main' of https://github.com/Expensify/App into nikki-sa…
NikkiWines Dec 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/components/SAMLLoadingIndicator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import styles from '@styles/styles';
import themeColors from '@styles/themes/default';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import * as Illustrations from './Icon/Illustrations';
import Text from './Text';

function SAMLLoadingIndicator() {
const {translate} = useLocalize();
return (
<View style={[StyleSheet.absoluteFillObject, styles.deeplinkWrapperContainer]}>
<View style={styles.deeplinkWrapperMessage}>
<View style={styles.mb2}>
<Icon
width={200}
height={164}
src={Illustrations.RocketBlue}
/>
</View>
<Text style={[styles.textHeadline, styles.textXXLarge, styles.textAlignCenter]}>{translate('samlSignIn.launching')}</Text>
<View style={[styles.mt2, styles.mh2, styles.fontSizeNormal, styles.textAlignCenter]}>
<Text style={[styles.textAlignCenter]}>{translate('samlSignIn.oneMoment')}</Text>
</View>
</View>
<View style={styles.deeplinkWrapperFooter}>
<Icon
width={154}
height={34}
fill={themeColors.success}
src={Expensicons.ExpensifyWordmark}
/>
</View>
</View>
);
}

SAMLLoadingIndicator.displayName = 'SAMLLoadingIndicator';

export default SAMLLoadingIndicator;
39 changes: 2 additions & 37 deletions src/pages/signin/SAMLSignInPage/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import PropTypes from 'prop-types';
import React, {useEffect} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator';
import CONFIG from '@src/CONFIG';
import ONYXKEYS from '@src/ONYXKEYS';

Expand All @@ -25,39 +18,11 @@ const defaultProps = {
};

function SAMLSignInPage({credentials}) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();

useEffect(() => {
window.open(`${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}`, '_self');
}, [credentials.login]);

return (
<View style={styles.deeplinkWrapperContainer}>
<View style={styles.deeplinkWrapperMessage}>
<View style={styles.mb2}>
<Icon
width={200}
height={164}
src={Illustrations.RocketBlue}
/>
</View>
<Text style={[styles.textHeadline, styles.textXXLarge, styles.textAlignCenter]}>{translate('samlSignIn.launching')}</Text>
<View style={[styles.mt2, styles.mh2, styles.fontSizeNormal, styles.textAlignCenter]}>
<Text style={[styles.textAlignCenter]}>{translate('samlSignIn.oneMoment')}</Text>
</View>
</View>
<View style={styles.deeplinkWrapperFooter}>
<Icon
width={154}
height={34}
fill={theme.success}
src={Expensicons.ExpensifyWordmark}
/>
</View>
</View>
);
return <SAMLLoadingIndicator />;
}

SAMLSignInPage.propTypes = propTypes;
Expand Down
94 changes: 94 additions & 0 deletions src/pages/signin/SAMLSignInPage/index.native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import PropTypes from 'prop-types';
import React, {useCallback, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import WebView from 'react-native-webview';
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator';
import ScreenWrapper from '@components/ScreenWrapper';
import getPlatform from '@libs/getPlatform';
import Navigation from '@libs/Navigation/Navigation';
import * as Session from '@userActions/Session';
import CONFIG from '@src/CONFIG';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';

const propTypes = {
/** The credentials of the logged in person */
credentials: PropTypes.shape({
/** The email/phone the user logged in with */
login: PropTypes.string,
}),
};

const defaultProps = {
credentials: {},
};

function SAMLSignInPage({credentials}) {
const samlLoginURL = `${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}&platform=${getPlatform()}`;
const [showNavigation, shouldShowNavigation] = useState(true);

/**
* Handles in-app navigation once we get a response back from Expensify
*
* @param {String} params.url
*/
const handleNavigationStateChange = useCallback(
({url}) => {
// If we've gotten a callback then remove the option to navigate back to the sign in page
if (url.includes('loginCallback')) {
shouldShowNavigation(false);
}

const searchParams = new URLSearchParams(new URL(url).search);
if (searchParams.has('shortLivedAuthToken')) {
const shortLivedAuthToken = searchParams.get('shortLivedAuthToken');
Session.signInWithShortLivedAuthToken(credentials.login, shortLivedAuthToken);
}

// If the login attempt is unsuccessful, set the error message for the account and redirect to sign in page
if (searchParams.has('error')) {
Session.setAccountError(searchParams.get('error'));
Navigation.navigate(ROUTES.HOME);
}
},
[credentials.login, shouldShowNavigation],
);

return (
<ScreenWrapper
shouldShowOfflineIndicator={false}
includeSafeAreaPaddingBottom={false}
testID={SAMLSignInPage.displayName}
>
{showNavigation && (
<HeaderWithBackButton
title=""
onBackButtonPress={() => {
Session.clearSignInData();
Navigation.navigate(ROUTES.HOME);
}}
/>
)}
<FullPageOfflineBlockingView>
<WebView
originWhitelist={['https://*']}
source={{uri: samlLoginURL}}
incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352
startInLoadingState
renderLoading={() => <SAMLLoadingIndicator />}
onNavigationStateChange={handleNavigationStateChange}
/>
</FullPageOfflineBlockingView>
</ScreenWrapper>
);
}

SAMLSignInPage.propTypes = propTypes;
SAMLSignInPage.defaultProps = defaultProps;
SAMLSignInPage.displayName = 'SAMLSignInPage';

export default withOnyx({
credentials: {key: ONYXKEYS.CREDENTIALS},
})(SAMLSignInPage);
30 changes: 19 additions & 11 deletions src/pages/signin/SignInPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as ActiveClientManager from '@libs/ActiveClientManager';
import getPlatform from '@libs/getPlatform';
import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
Expand Down Expand Up @@ -99,15 +98,9 @@ function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, i
const isSAMLRequired = Boolean(account.isSAMLRequired);
const hasEmailDeliveryFailure = Boolean(account.hasEmailDeliveryFailure);

// SAML is temporarily restricted to users on the beta or to users signing in on web and mweb
let shouldShowChooseSSOOrMagicCode = false;
let shouldInitiateSAMLLogin = false;
const platform = getPlatform();
if (platform === CONST.PLATFORM.WEB || platform === CONST.PLATFORM.DESKTOP) {
// True if the user has SAML required and we haven't already initiated SAML for their account
shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && account.isLoading;
shouldShowChooseSSOOrMagicCode = hasAccount && hasLogin && isSAMLEnabled && !isSAMLRequired && !isUsingMagicCode;
}
// True if the user has SAML required and we haven't already initiated SAML for their account
const shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && account.isLoading;
const shouldShowChooseSSOOrMagicCode = hasAccount && hasLogin && isSAMLEnabled && !isSAMLRequired && !isUsingMagicCode;

// SAML required users may reload the login page after having already entered their login details, in which
// case we want to clear their sign in data so they don't end up in an infinite loop redirecting back to their
Expand Down Expand Up @@ -163,6 +156,19 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
}
App.setLocale(Localize.getDevicePreferredLocale());
}, [preferredLocale]);
useEffect(() => {
if (credentials.login) {
return;
}

// If we don't have a login set, reset the user's SAML login preferences
if (isUsingMagicCode) {
setIsUsingMagicCode(false);
}
if (hasInitiatedSAMLLogin) {
setHasInitiatedSAMLLogin(false);
}
}, [credentials.login, isUsingMagicCode, setIsUsingMagicCode, hasInitiatedSAMLLogin, setHasInitiatedSAMLLogin]);

const {
shouldShowLoginForm,
Expand All @@ -186,6 +192,9 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
if (shouldInitiateSAMLLogin) {
setHasInitiatedSAMLLogin(true);
Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SAML_SIGN_IN));
} else if (hasInitiatedSAMLLogin) {
// Return early because we're already navigating to a different page
return;
Copy link
Contributor

@fedirjh fedirjh Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NikkiWines I think this is not required. With this code, on login failure, the App lands on an empty screen.

CleanShot.2023-11-17.at.23.11.13.mp4

I tried to fix it with this code. We need to verify whether the user has initiated the SAML login process and, if an error has occurred, display the login form to handle the error presentation.

diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index ce70e548df..2b88e2d346 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -97,6 +97,7 @@ function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, i
     const isSAMLEnabled = Boolean(account.isSAMLEnabled);
     const isSAMLRequired = Boolean(account.isSAMLRequired);
     const hasEmailDeliveryFailure = Boolean(account.hasEmailDeliveryFailure);
+    const hasLoginError = !_.isEmpty(account.errors);
 
     // True if the user has SAML required and we haven't already initiated SAML for their account
     const shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && account.isLoading;
@@ -109,7 +110,7 @@ function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, i
         Session.clearSignInData();
     }
 
-    const shouldShowLoginForm = isClientTheLeader && !hasLogin && !hasValidateCode;
+    const shouldShowLoginForm = isClientTheLeader && ((!hasLogin && !hasValidateCode) || hasInitiatedSAMLLogin && hasLoginError);
     const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !shouldInitiateSAMLLogin;
     const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !account.validated && !hasEmailDeliveryFailure;
     const shouldShowValidateCodeForm =
@@ -192,9 +193,6 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
     if (shouldInitiateSAMLLogin) {
         setHasInitiatedSAMLLogin(true);
         Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SAML_SIGN_IN));
-    } else if (hasInitiatedSAMLLogin) {
-        // Return early because we're already navigating to a different page
-        return;
     }
 
     let welcomeHeader = '';

And this is the result :

CleanShot.2023-11-17.at.23.09.11.mp4

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not getting that blank page while testing @fedirjh - are you experiencing that only on mobile or also on web? Also if we remove the hasInitiatedSAMLLogin logic we'll get the [warn] SignInPage in unexpected state! - "" error when the user signs in using the SAML required flow.

What about something like:

-function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, isUsingMagicCode, hasInitiatedSAMLLogin, isClientTheLeader}) {
+function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, isUsingMagicCode, hasInitiatedSAMLLogin, isClientTheLeader, hasAccountError}) {
     const hasAccount = !_.isEmpty(account);
     const isSAMLEnabled = Boolean(account.isSAMLEnabled);
     const isSAMLRequired = Boolean(account.isSAMLRequired);
@@ -109,7 +109,7 @@ function getRenderOptions({hasLogin, hasValidateCode, account, isPrimaryLogin, i
         Session.clearSignInData();
     }
 
-    const shouldShowLoginForm = isClientTheLeader && !hasLogin && !hasValidateCode;
+    const shouldShowLoginForm = hasAccountError || isClientTheLeader && !hasLogin && !hasValidateCode;
     const shouldShowEmailDeliveryFailurePage = hasLogin && hasEmailDeliveryFailure && !shouldShowChooseSSOOrMagicCode && !shouldInitiateSAMLLogin;
     const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !account.validated && !hasEmailDeliveryFailure;
     const shouldShowValidateCodeForm =
@@ -148,6 +148,7 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
     const [hasInitiatedSAMLLogin, setHasInitiatedSAMLLogin] = useState(false);
 
     const isClientTheLeader = activeClients && ActiveClientManager.isClientTheLeader();
+    const hasAccountError = !_.isEmpty(account.errors);
 
     useEffect(() => Performance.measureTTI(), []);
     useEffect(() => {
@@ -187,12 +188,13 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
         isUsingMagicCode,
         hasInitiatedSAMLLogin,
         isClientTheLeader,
+        hasAccountError,
     });
 
     if (shouldInitiateSAMLLogin) {
         setHasInitiatedSAMLLogin(true);
         Navigation.isNavigationReady().then(() => Navigation.navigate(ROUTES.SAML_SIGN_IN));
-    } else if (hasInitiatedSAMLLogin) {
+    } else if (hasInitiatedSAMLLogin && !hasAccountError) {
         // Return early because we're already navigating to a different page
         return;
     }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you experiencing that only on mobile or also on web?

@NikkiWines I have not encountered this problem on the web. On the web, it redirects me to the production app, indicating that the app state is not preserved. Instead, a new, fresh state is constructed with the error data.

On mobile, however, the situation is different. The app state is preserved, and a new component (webView) is pushed to the screen. When the web-view is closed, the app restores the old login view along with its state. Therefore, we need to implement failure catch-up logic in that view.

Also if we remove the hasInitiatedSAMLLogin logic we'll get the [warn] SignInPage in unexpected state! - "" error when the user signs in using the SAML required flow.

Aha I see. So, that logic will handle the sign-in success case. It makes sense; we need to address both success and failure cases within the same flow.

What about something like:

I tried this solution but it didn't work. It seems account.errors is rested at some point during the flow.

Screenshot 2023-11-23 at 12 12 28 AM

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if we remove the hasInitiatedSAMLLogin logic we'll get the [warn] SignInPage in unexpected state! - "" error when the user signs in using the SAML required flow.

@NikkiWines I guess this error comes from this line , I think we need to update that block to address the failure case:

} else if (!shouldInitiateSAMLLogin) {
Log.warn('SignInPage in unexpected state!');
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay here - had some simulator issues and then was out of office for the US holiday weekend. I'm unable to reproduce the behavior you're seeing with the blank screen, but I do see an issue where the user gets redirected to the oldDot error page instead. That's because of a re-routing issue on the backend, which I'll push a PR up shortly to fix.

Simulator.Screen.Recording.-.iPhone.15.Pro.-.2023-11-28.at.11.37.28.mp4

I'd rather get this PR out now so that the success flow works on production, and then do a follow up PR to more accurately handle failed sign-in cases. Given that, I can revert my commit that added the following logic:

    } else if (hasInitiatedSAMLLogin) {
        // Return early because we're already navigating to a different page
        return;

How does that sound?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That looks good to me. Let's address failed sign-in cases carefully in a follow-up PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I lied, I was able to reproduce it. Updated some logic and it should be working now 🤞 🍀

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found one small bug 🐛 adressing it now

}

let welcomeHeader = '';
Expand Down Expand Up @@ -257,7 +266,6 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
<ValidateCodeForm
isUsingRecoveryCode={isUsingRecoveryCode}
setIsUsingRecoveryCode={setIsUsingRecoveryCode}
setIsUsingMagicCode={setIsUsingMagicCode}
/>
)}
{shouldShowUnlinkLoginForm && <UnlinkLoginForm />}
Expand Down
5 changes: 0 additions & 5 deletions src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,6 @@ const propTypes = {
/** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
setIsUsingRecoveryCode: PropTypes.func.isRequired,

/** Function to change `isUsingMagicCode` state when the user goes back to the login page */
setIsUsingMagicCode: PropTypes.func.isRequired,

...withLocalizePropTypes,
};

Expand Down Expand Up @@ -210,8 +207,6 @@ function BaseValidateCodeForm(props) {
* Clears local and Onyx sign in states
*/
const clearSignInData = () => {
// Reset the user's preference for signing in with SAML versus magic codes
props.setIsUsingMagicCode(false);
clearLocalSignInData();
Session.clearSignInData();
};
Expand Down
Loading