Skip to content

Commit

Permalink
Merge pull request #28372 from Expensify/nikki-saml-newdot-web
Browse files Browse the repository at this point in the history
[SAML NewDot] Add SAML flow for web, mweb, desktop
  • Loading branch information
MonilBhavsar authored Oct 16, 2023
2 parents aaf9c26 + b793275 commit febc3cf
Show file tree
Hide file tree
Showing 15 changed files with 336 additions and 37 deletions.
1 change: 1 addition & 0 deletions src/CONFIG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default {
CONCIERGE_URL_PATHNAME: 'concierge/',
DEVPORTAL_URL_PATHNAME: '_devportal/',
CONCIERGE_URL: `${expensifyURL}concierge/`,
SAML_URL: `${expensifyURL}authentication/saml/login`,
},
IS_IN_PRODUCTION: Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__,
IS_IN_STAGING: ENVIRONMENT === CONST.ENVIRONMENT.STAGING,
Expand Down
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const CONST = {
CUSTOM_STATUS: 'customStatus',
NEW_DOT_CATEGORIES: 'newDotCategories',
NEW_DOT_TAGS: 'newDotTags',
NEW_DOT_SAML: 'newDotSAML',
},
BUTTON_STATES: {
DEFAULT: 'default',
Expand Down
2 changes: 2 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default {
APPLE_SIGN_IN: 'sign-in-with-apple',
GOOGLE_SIGN_IN: 'sign-in-with-google',
DESKTOP_SIGN_IN_REDIRECT: 'desktop-signin-redirect',
SAML_SIGN_IN: 'sign-in-with-saml',

// This is a special validation URL that will take the user to /workspace/new after validation. This is used
// when linking users from e.com in order to share a session in this app.
ENABLE_PAYMENTS: 'enable-payments',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default {
SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop',
SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop',
DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect',
SAML_SIGN_IN: 'SAMLSignIn',
VALIDATE_LOGIN: 'ValidateLogin',

// Iframe screens from olddot
Expand Down
8 changes: 8 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,14 @@ export default {
termsOfService: 'Terms of Service',
privacy: 'Privacy',
},
samlSignIn: {
welcomeSAMLEnabled: 'Continue logging in with single sign-on:',
orContinueWithMagicCode: 'Or optionally, your company allows signing in with a magic code',
useSingleSignOn: 'Use single sign-on',
useMagicCode: 'Use magic code',
launching: 'Launching...',
oneMoment: "One moment while we redirect you to your company's single sign-on portal.",
},
reportActionCompose: {
addAction: 'Actions',
dropToUpload: 'Drop to upload',
Expand Down
8 changes: 8 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@ export default {
termsOfService: 'Términos de servicio',
privacy: 'Privacidad',
},
samlSignIn: {
welcomeSAMLEnabled: 'Continua iniciando sesión con el inicio de sesión único:',
orContinueWithMagicCode: 'O, opcionalmente, tu empresa te permite iniciar sesión con un código mágico',
useSingleSignOn: 'Usar el inicio de sesión único',
useMagicCode: 'Usar código mágico',
launching: 'Cargando...',
oneMoment: 'Un momento mientras te redirigimos al portal de inicio de sesión único de tu empresa.',
},
reportActionCompose: {
addAction: 'Acción',
dropToUpload: 'Suelta el archivo aquí para compartirlo',
Expand Down
6 changes: 6 additions & 0 deletions src/libs/Navigation/AppNavigator/PublicScreens.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import defaultScreenOptions from './defaultScreenOptions';
import UnlinkLoginPage from '../../../pages/UnlinkLoginPage';
import AppleSignInDesktopPage from '../../../pages/signin/AppleSignInDesktopPage';
import GoogleSignInDesktopPage from '../../../pages/signin/GoogleSignInDesktopPage';
import SAMLSignInPage from '../../../pages/signin/SAMLSignInPage';

const RootStack = createStackNavigator();

Expand Down Expand Up @@ -44,6 +45,11 @@ function PublicScreens() {
options={defaultScreenOptions}
component={GoogleSignInDesktopPage}
/>
<RootStack.Screen
name="SAMLSignIn"
options={defaultScreenOptions}
component={SAMLSignInPage}
/>
</RootStack.Navigator>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default {
[SCREENS.CONCIERGE]: ROUTES.CONCIERGE,
AppleSignInDesktop: ROUTES.APPLE_SIGN_IN,
GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN,
SAMLSignIn: ROUTES.SAML_SIGN_IN,
[SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT,
[SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route,

Expand Down
7 changes: 6 additions & 1 deletion src/libs/actions/Session/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ function signInWithShortLivedAuthToken(email, authToken) {
// If the user is signing in with a different account from the current app, should not pass the auto-generated login as it may be tied to the old account.
// scene 1: the user is transitioning to newDot from a different account on oldDot.
// scene 2: the user is transitioning to desktop app from a different account on web app.
const oldPartnerUserID = credentials.login === email ? credentials.autoGeneratedLogin : '';
const oldPartnerUserID = credentials.login === email && credentials.autoGeneratedLogin ? credentials.autoGeneratedLogin : '';
API.read('SignInWithShortLivedAuthToken', {authToken, oldPartnerUserID, skipReauthentication: true}, {optimisticData, successData, failureData});
}

Expand Down Expand Up @@ -541,6 +541,10 @@ function clearAccountMessages() {
});
}

function setAccountError(error) {
Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)});
}

// It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to
// reconnect each time when we only need to reconnect once. This way, if an authToken is expired and we try to
// subscribe to a bunch of channels at once we will only reauthenticate and force reconnect Pusher once.
Expand Down Expand Up @@ -807,6 +811,7 @@ export {
unlinkLogin,
clearSignInData,
clearAccountMessages,
setAccountError,
authenticatePusher,
reauthenticatePusher,
invalidateCredentials,
Expand Down
40 changes: 27 additions & 13 deletions src/pages/LogInWithShortLivedAuthTokenPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import themeColors from '../styles/themes/default';
import Icon from '../components/Icon';
import * as Expensicons from '../components/Icon/Expensicons';
import * as Illustrations from '../components/Icon/Illustrations';
import withLocalize, {withLocalizePropTypes} from '../components/withLocalize';
import compose from '../libs/compose';
import useLocalize from '../hooks/useLocalize';
import TextLink from '../components/TextLink';
import ONYXKEYS from '../ONYXKEYS';

Expand All @@ -33,8 +32,6 @@ const propTypes = {
}),
}).isRequired,

...withLocalizePropTypes,

/** The details about the account that the user is signing in with */
account: PropTypes.shape({
/** Whether a sign is loading */
Expand All @@ -49,15 +46,26 @@ const defaultProps = {
};

function LogInWithShortLivedAuthTokenPage(props) {
const {translate} = useLocalize();

useEffect(() => {
const email = lodashGet(props, 'route.params.email', '');

// We have to check for both shortLivedAuthToken and shortLivedToken, as the old mobile app uses shortLivedToken, and is not being actively updated.
const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', '') || lodashGet(props, 'route.params.shortLivedToken', '');
if (shortLivedAuthToken) {

// Try to authenticate using the shortLivedToken if we're not already trying to load the accounts
if (shortLivedAuthToken && !props.account.isLoading) {
Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken);
return;
}

// If an error is returned as part of the route, ensure we set it in the onyxData for the account
const error = lodashGet(props, 'route.params.error', '');
if (error) {
Session.setAccountError(error);
}

const exitTo = lodashGet(props, 'route.params.exitTo', '');
if (exitTo) {
Navigation.isNavigationReady().then(() => {
Expand All @@ -82,10 +90,18 @@ function LogInWithShortLivedAuthTokenPage(props) {
src={Illustrations.RocketBlue}
/>
</View>
<Text style={[styles.textHeadline, styles.textXXLarge]}>{props.translate('deeplinkWrapper.launching')}</Text>
<Text style={[styles.textHeadline, styles.textXXLarge]}>{translate('deeplinkWrapper.launching')}</Text>
<View style={styles.mt2}>
<Text style={[styles.fontSizeNormal, styles.textAlignCenter]}>
{props.translate('deeplinkWrapper.expired')} <TextLink onPress={() => Navigation.navigate()}>{props.translate('deeplinkWrapper.signIn')}</TextLink>
{translate('deeplinkWrapper.expired')}{' '}
<TextLink
onPress={() => {
Session.clearSignInData();
Navigation.navigate();
}}
>
{translate('deeplinkWrapper.signIn')}
</TextLink>
</Text>
</View>
</View>
Expand All @@ -105,9 +121,7 @@ LogInWithShortLivedAuthTokenPage.propTypes = propTypes;
LogInWithShortLivedAuthTokenPage.defaultProps = defaultProps;
LogInWithShortLivedAuthTokenPage.displayName = 'LogInWithShortLivedAuthTokenPage';

export default compose(
withLocalize,
withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
}),
)(LogInWithShortLivedAuthTokenPage);
export default withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
session: {key: ONYXKEYS.SESSION},
})(LogInWithShortLivedAuthTokenPage);
108 changes: 108 additions & 0 deletions src/pages/signin/ChooseSSOOrMagicCode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import React from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import _ from 'underscore';
import styles from '../../styles/styles';
import ONYXKEYS from '../../ONYXKEYS';
import Text from '../../components/Text';
import Button from '../../components/Button';
import * as Session from '../../libs/actions/Session';
import ChangeExpensifyLoginLink from './ChangeExpensifyLoginLink';
import Terms from './Terms';
import CONST from '../../CONST';
import ROUTES from '../../ROUTES';
import Navigation from '../../libs/Navigation/Navigation';
import * as ErrorUtils from '../../libs/ErrorUtils';
import useLocalize from '../../hooks/useLocalize';
import useNetwork from '../../hooks/useNetwork';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import FormHelpMessage from '../../components/FormHelpMessage';

const propTypes = {
/* Onyx Props */

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

/** The details about the account that the user is signing in with */
account: PropTypes.shape({
/** Whether or not a sign on form is loading (being submitted) */
isLoading: PropTypes.bool,

/** Form that is being loaded */
loadingForm: PropTypes.oneOf(_.values(CONST.FORMS)),

/** Whether this account has 2FA enabled or not */
requiresTwoFactorAuth: PropTypes.bool,

/** Server-side errors in the submitted authentication code */
errors: PropTypes.objectOf(PropTypes.string),
}),

/** Function that returns whether the user is using SAML or magic codes to log in */
setIsUsingMagicCode: PropTypes.func.isRequired,
};

const defaultProps = {
credentials: {},
account: {},
};

function ChooseSSOOrMagicCode({credentials, account, setIsUsingMagicCode}) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();

return (
<>
<View>
<Text style={[styles.loginHeroBody, styles.mb5, styles.textNormal, !isSmallScreenWidth ? styles.textAlignLeft : {}]}>{translate('samlSignIn.welcomeSAMLEnabled')}</Text>
<Button
isDisabled={isOffline}
success
style={[styles.mv3]}
text={translate('samlSignIn.useSingleSignOn')}
isLoading={account.isLoading}
onPress={() => {
Navigation.navigate(ROUTES.SAML_SIGN_IN);
}}
/>

<View style={[styles.mt5]}>
<Text style={[styles.loginHeroBody, styles.mb5, styles.textNormal, !isSmallScreenWidth ? styles.textAlignLeft : {}]}>
{translate('samlSignIn.orContinueWithMagicCode')}
</Text>
</View>

<Button
isDisabled={isOffline}
style={[styles.mv3]}
text={translate('samlSignIn.useMagicCode')}
isLoading={account.isLoading && account.loadingForm === (account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM)}
onPress={() => {
Session.resendValidateCode(credentials.login);
setIsUsingMagicCode(true);
}}
/>
{Boolean(account) && !_.isEmpty(account.errors) && <FormHelpMessage message={ErrorUtils.getLatestErrorMessage(account)} />}
<ChangeExpensifyLoginLink onPress={() => Session.clearSignInData()} />
</View>
<View style={[styles.mt5, styles.signInPageWelcomeTextContainer]}>
<Terms />
</View>
</>
);
}

ChooseSSOOrMagicCode.propTypes = propTypes;
ChooseSSOOrMagicCode.defaultProps = defaultProps;
ChooseSSOOrMagicCode.displayName = 'ChooseSSOOrMagicCode';

export default withOnyx({
credentials: {key: ONYXKEYS.CREDENTIALS},
account: {key: ONYXKEYS.ACCOUNT},
})(ChooseSSOOrMagicCode);
2 changes: 1 addition & 1 deletion src/pages/signin/LoginForm/BaseLoginForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ function LoginForm(props) {
useEffect(() => {
// Just call clearAccountMessages on the login page (home route), because when the user is in the transition route and not yet authenticated,
// this component will also be mounted, resetting account.isLoading will cause the app to briefly display the session expiration page.
if (props.isFocused) {
if (props.isFocused && props.isVisible) {
Session.clearAccountMessages();
}
if (!canFocusInputOnScreenFocus() || !input.current || !props.isVisible) {
Expand Down
66 changes: 66 additions & 0 deletions src/pages/signin/SAMLSignInPage/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import ONYXKEYS from '../../../ONYXKEYS';
import CONFIG from '../../../CONFIG';
import Icon from '../../../components/Icon';
import Text from '../../../components/Text';
import * as Expensicons from '../../../components/Icon/Expensicons';
import * as Illustrations from '../../../components/Icon/Illustrations';
import styles from '../../../styles/styles';
import themeColors from '../../../styles/themes/default';
import useLocalize from '../../../hooks/useLocalize';

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 {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={themeColors.success}
src={Expensicons.ExpensifyWordmark}
/>
</View>
</View>
);
}

SAMLSignInPage.propTypes = propTypes;
SAMLSignInPage.defaultProps = defaultProps;

export default withOnyx({
credentials: {key: ONYXKEYS.CREDENTIALS},
})(SAMLSignInPage);
Loading

0 comments on commit febc3cf

Please sign in to comment.