diff --git a/src/ROUTES.js b/src/ROUTES.js index ca7d80b9388..f3b856a469d 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -15,7 +15,8 @@ const IOU_BILL_CURRENCY = `${IOU_BILL}/currency`; const IOU_SEND_CURRENCY = `${IOU_SEND}/currency`; export default { - BANK_ACCOUNT: 'bank-account/:stepToOpen?', + BANK_ACCOUNT: 'bank-account', + BANK_ACCOUNT_WITH_STEP_TO_OPEN: 'bank-account/:stepToOpen?', BANK_ACCOUNT_PERSONAL: 'bank-account/personal', getBankAccountRoute: (stepToOpen = '') => `bank-account/${stepToOpen}`, HOME: '', diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index b4b1fe23ff1..abd2f6b5b8c 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -72,6 +72,12 @@ const propTypes = { /** Additional text to display */ text: PropTypes.string, + + /** The OAuth URI + stateID needed to re-initialize the PlaidLink after the user logs into their bank */ + receivedRedirectURI: PropTypes.string, + + /** During the OAuth flow we need to use the plaidLink token that we initially connected with */ + plaidLinkOAuthToken: PropTypes.string, }; const defaultProps = { @@ -82,6 +88,8 @@ const defaultProps = { onExitPlaid: () => {}, onSubmit: () => {}, text: '', + receivedRedirectURI: null, + plaidLinkOAuthToken: '', }; class AddPlaidBankAccount extends React.Component { @@ -89,6 +97,7 @@ class AddPlaidBankAccount extends React.Component { super(props); this.selectAccount = this.selectAccount.bind(this); + this.getPlaidLinkToken = this.getPlaidLinkToken.bind(this); this.state = { selectedIndex: undefined, @@ -100,6 +109,12 @@ class AddPlaidBankAccount extends React.Component { } componentDidMount() { + // If we're coming from Plaid OAuth flow then we need to reuse the existing plaidLinkToken + // Otherwise, clear the existing token and fetch a new one + if (this.props.receivedRedirectURI && this.props.plaidLinkOAuthToken) { + return; + } + BankAccounts.clearPlaidBankAccountsAndToken(); BankAccounts.fetchPlaidLinkToken(); } @@ -113,6 +128,19 @@ class AddPlaidBankAccount extends React.Component { return lodashGet(this.props.plaidBankAccounts, 'accounts', []); } + /** + * @returns {String} + */ + getPlaidLinkToken() { + if (!_.isEmpty(this.props.plaidLinkToken)) { + return this.props.plaidLinkToken; + } + + if (this.props.receivedRedirectURI && this.props.plaidLinkOAuthToken) { + return this.props.plaidLinkOAuthToken; + } + } + /** * @returns {Boolean} */ @@ -136,27 +164,29 @@ class AddPlaidBankAccount extends React.Component { this.props.onSubmit({ bankName, account, - plaidLinkToken: this.props.plaidLinkToken, + plaidLinkToken: this.getPlaidLinkToken(), }); } render() { const accounts = this.getAccounts(); + const token = this.getPlaidLinkToken(); const options = _.map(accounts, (account, index) => ({ value: index, label: `${account.addressName} ${account.accountNumber}`, })); const {icon, iconSize} = getBankIcon(this.state.institution.name); + return ( <> - {(!this.props.plaidLinkToken || this.props.plaidBankAccounts.loading) - && ( - - - - )} - {!_.isEmpty(this.props.plaidLinkToken) && ( + {(!token || this.props.plaidBankAccounts.loading) + && ( + + + + )} + {token && ( { Log.info('[PlaidLink] Success!'); BankAccounts.fetchPlaidBankAccounts(publicToken, metadata.institution.name); @@ -169,6 +199,7 @@ class AddPlaidBankAccount extends React.Component { // User prematurely exited the Plaid flow // eslint-disable-next-line react/jsx-props-no-multi-spaces onExit={this.props.onExitPlaid} + receivedRedirectURI={this.props.receivedRedirectURI} /> )} {accounts.length > 0 && ( diff --git a/src/components/PlaidLink/index.js b/src/components/PlaidLink/index.js index 472e1f37387..8d51adb8a60 100644 --- a/src/components/PlaidLink/index.js +++ b/src/components/PlaidLink/index.js @@ -18,6 +18,10 @@ const PlaidLink = (props) => { onEvent: (event, metadata) => { Log.info('[PlaidLink] Event: ', false, {event, metadata}); }, + + // The redirect URI with an OAuth state ID. Needed to re-initialize the PlaidLink after directing the + // user to their respective bank platform + receivedRedirectUri: props.receivedRedirectURI, }); useEffect(() => { diff --git a/src/components/PlaidLink/plaidLinkPropTypes.js b/src/components/PlaidLink/plaidLinkPropTypes.js index 70af75dd083..b36dade2a78 100644 --- a/src/components/PlaidLink/plaidLinkPropTypes.js +++ b/src/components/PlaidLink/plaidLinkPropTypes.js @@ -12,12 +12,17 @@ const plaidLinkPropTypes = { // Callback to execute when the user leaves the Plaid widget flow without entering any information onExit: PropTypes.func, + + // The redirect URI with an OAuth state ID. Needed to re-initialize the PlaidLink after directing the + // user to their respective bank platform + receivedRedirectURI: PropTypes.string, }; const plaidLinkDefaultProps = { onSuccess: () => {}, onError: () => {}, onExit: () => {}, + receivedRedirectURI: null, }; export { diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index aca2831a3e9..4e89c80ec81 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -103,7 +103,7 @@ export default { path: ROUTES.WORKSPACE_INVITE, }, ReimbursementAccount: { - path: ROUTES.BANK_ACCOUNT, + path: ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN, exact: true, }, }, diff --git a/src/libs/getPlaidLinkTokenParameters/index.android.js b/src/libs/getPlaidLinkTokenParameters/index.android.js index 111d35dd1c7..4174e2b8905 100644 --- a/src/libs/getPlaidLinkTokenParameters/index.android.js +++ b/src/libs/getPlaidLinkTokenParameters/index.android.js @@ -1,3 +1,3 @@ import CONST from '../../CONST'; -export default () => ({android_name: CONST.ANDROID_PACKAGE_NAME}); +export default () => ({android_package: CONST.ANDROID_PACKAGE_NAME}); diff --git a/src/libs/getPlaidLinkTokenParameters/index.js b/src/libs/getPlaidLinkTokenParameters/index.js index 56bf55ff188..17a81fc9a24 100644 --- a/src/libs/getPlaidLinkTokenParameters/index.js +++ b/src/libs/getPlaidLinkTokenParameters/index.js @@ -1 +1,7 @@ -export default () => ({}); +import ROUTES from '../../ROUTES'; +import CONFIG from '../../CONFIG'; + +export default () => { + const bankAccountRoute = window.location.href.includes('personal') ? ROUTES.BANK_ACCOUNT_PERSONAL : ROUTES.BANK_ACCOUNT; + return {redirect_uri: `${CONFIG.EXPENSIFY.URL_EXPENSIFY_CASH}/${bankAccountRoute}`}; +}; diff --git a/src/libs/getPlaidOAuthReceivedRedirectURI/index.js b/src/libs/getPlaidOAuthReceivedRedirectURI/index.js new file mode 100644 index 00000000000..c53e78e5ea6 --- /dev/null +++ b/src/libs/getPlaidOAuthReceivedRedirectURI/index.js @@ -0,0 +1,17 @@ +/** + * After a user authenticates their bank in the Plaid OAuth flow, Plaid returns us to the redirectURI we + * gave them along with a stateID param. We hand off the receivedRedirectUri to PlaidLink to finish connecting + * the user's account. + * @returns {String | null} + */ +export default () => { + const receivedRedirectURI = window.location.href; + const receivedRedirectSearchParams = (new URL(window.location.href)).searchParams; + const oauthStateID = receivedRedirectSearchParams.get('oauth_state_id'); + + // If no stateID passed in then we are either not in OAuth flow or flow is broken + if (!oauthStateID) { + return null; + } + return receivedRedirectURI; +}; diff --git a/src/libs/getPlaidOAuthReceivedRedirectURI/index.native.js b/src/libs/getPlaidOAuthReceivedRedirectURI/index.native.js new file mode 100644 index 00000000000..461f67a0a4b --- /dev/null +++ b/src/libs/getPlaidOAuthReceivedRedirectURI/index.native.js @@ -0,0 +1 @@ +export default () => null; diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 1b66d9c0910..0736f19bca8 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -1,13 +1,25 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; import ScreenWrapper from '../components/ScreenWrapper'; import Navigation from '../libs/Navigation/Navigation'; import * as BankAccounts from '../libs/actions/BankAccounts'; import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import AddPlaidBankAccount from '../components/AddPlaidBankAccount'; +import getPlaidOAuthReceivedRedirectURI from '../libs/getPlaidOAuthReceivedRedirectURI'; +import compose from '../libs/compose'; +import ONYXKEYS from '../ONYXKEYS'; const propTypes = { ...withLocalizePropTypes, + + /** Plaid SDK token to use to initialize the widget */ + plaidLinkToken: PropTypes.string, +}; + +const defaultProps = { + plaidLinkToken: '', }; const AddPersonalBankAccountPage = props => ( @@ -21,10 +33,21 @@ const AddPersonalBankAccountPage = props => ( BankAccounts.addPersonalBankAccount(account, password, plaidLinkToken); }} onExitPlaid={Navigation.dismissModal} + receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} + plaidLinkOAuthToken={props.plaidLinkToken} /> ); AddPersonalBankAccountPage.propTypes = propTypes; +AddPersonalBankAccountPage.defaultProps = defaultProps; AddPersonalBankAccountPage.displayName = 'AddPersonalBankAccountPage'; -export default withLocalize(AddPersonalBankAccountPage); + +export default compose( + withLocalize, + withOnyx({ + plaidLinkToken: { + key: ONYXKEYS.PLAID_LINK_TOKEN, + }, + }), +)(AddPersonalBankAccountPage); diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 2b20ed07ed4..84f582cf280 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import React from 'react'; import {View, Image, ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import MenuItem from '../../components/MenuItem'; import * as Expensicons from '../../components/Icon/Expensicons'; @@ -32,9 +33,20 @@ const propTypes = { // eslint-disable-next-line react/no-unused-prop-types reimbursementAccount: reimbursementAccountPropTypes.isRequired, + /** The OAuth URI + stateID needed to re-initialize the PlaidLink after the user logs into their bank */ + receivedRedirectURI: PropTypes.string, + + /** During the OAuth flow we need to use the plaidLink token that we initially connected with */ + plaidLinkOAuthToken: PropTypes.string, + ...withLocalizePropTypes, }; +const defaultProps = { + receivedRedirectURI: null, + plaidLinkOAuthToken: '', +}; + class BankAccountStep extends React.Component { constructor(props) { super(props); @@ -159,7 +171,9 @@ class BankAccountStep extends React.Component { // Disable bank account fields once they've been added in db so they can't be changed const isFromPlaid = this.props.achData.setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; const shouldDisableInputs = Boolean(this.props.achData.bankAccountID) || isFromPlaid; - const subStep = this.props.achData.subStep; + const shouldReinitializePlaidLink = this.props.plaidLinkOAuthToken && this.props.receivedRedirectURI; + const subStep = shouldReinitializePlaidLink ? CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID : this.props.achData.subStep; + return ( BankAccounts.setBankAccountSubStep(null)} + receivedRedirectURI={this.props.receivedRedirectURI} + plaidLinkOAuthToken={this.props.plaidLinkOAuthToken} /> )} {subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL && ( @@ -292,6 +308,8 @@ class BankAccountStep extends React.Component { } BankAccountStep.propTypes = propTypes; +BankAccountStep.defaultProps = defaultProps; + export default compose( withLocalize, withOnyx({ diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index bda68be13f5..31ccd39170f 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -17,6 +17,7 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import compose from '../../libs/compose'; import styles from '../../styles/styles'; import KeyboardAvoidingView from '../../components/KeyboardAvoidingView'; +import getPlaidOAuthReceivedRedirectURI from '../../libs/getPlaidOAuthReceivedRedirectURI'; import ExpensifyText from '../../components/ExpensifyText'; // Steps @@ -203,7 +204,6 @@ class ReimbursementAccountPage extends React.Component { ); } - return ( @@ -211,6 +211,8 @@ class ReimbursementAccountPage extends React.Component { )} {currentStep === CONST.BANK_ACCOUNT.STEP.COMPANY && ( @@ -251,6 +253,9 @@ export default compose( betas: { key: ONYXKEYS.BETAS, }, + plaidLinkToken: { + key: ONYXKEYS.PLAID_LINK_TOKEN, + }, }), withLocalize, )(ReimbursementAccountPage);