diff --git a/.env.example b/.env.example index f32107eb4..4ae0ef539 100644 --- a/.env.example +++ b/.env.example @@ -63,4 +63,33 @@ GOTENBERG_DOCS_URL=http://server:3000/public/ EXCHANGE_RATE_SERVICE=open-exchange-rate # Open Exchange Rate -OPEN_EXCHANGE_RATE_APP_ID= \ No newline at end of file +OPEN_EXCHANGE_RATE_APP_ID= + +# The Plaid environment to use ('sandbox' or 'development'). +# https://plaid.com/docs/#api-host +PLAID_ENV=sandbox + +# Your Plaid keys, which can be found in the Plaid Dashboard. +# https://dashboard.plaid.com/account/keys +PLAID_CLIENT_ID= +PLAID_SECRET_DEVELOPMENT= +PLAID_SECRET_SANDBOX= + +# (Optional) Redirect URI settings section +# Only required for OAuth redirect URI testing (not common on desktop): +# Sandbox Mode: +# Set the PLAID_SANDBOX_REDIRECT_URI below to 'http://localhost:3001/oauth-link'. +# The OAuth redirect flow requires an endpoint on the developer's website +# that the bank website should redirect to. You will also need to configure +# this redirect URI for your client ID through the Plaid developer dashboard +# at https://dashboard.plaid.com/team/api. +# Development mode: +# When running in development mode, you must use an https:// url. +# You will need to configure this https:// redirect URI in the Plaid developer dashboard. +# Instructions to create a self-signed certificate for localhost can be found at +# https://github.com/plaid/pattern/blob/master/README.md#testing-oauth. +# If your system is not set up to run localhost with https://, you will be unable to test +# the OAuth in development and should leave the PLAID_DEVELOPMENT_REDIRECT_URI blank. + +PLAID_SANDBOX_REDIRECT_URI= +PLAID_DEVELOPMENT_REDIRECT_URI= diff --git a/packages/webapp/package.json b/packages/webapp/package.json index c1c2415aa..a11d565bd 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -69,6 +69,9 @@ "moment-timezone": "^0.5.33", "path-browserify": "^1.0.1", "prop-types": "15.8.1", + "plaid": "^9.3.0", + "plaid-threads": "^11.4.3", + "react-plaid-link": "^3.2.1", "query-string": "^7.1.1", "ramda": "^0.27.1", "react": "^18.2.0", diff --git a/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx new file mode 100644 index 000000000..dcc5f1893 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Plaid/PlaidLanchLink.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import { + usePlaidLink, + PlaidLinkOnSuccessMetadata, + PlaidLinkOnExitMetadata, + PlaidLinkError, + PlaidLinkOptionsWithLinkToken, + PlaidLinkOnEventMetadata, + PlaidLinkStableEvent, +} from 'react-plaid-link'; +import { useHistory } from 'react-router-dom'; +// import { exchangeToken, setItemState } from '../services/api'; +// import { useItems, useLink, useErrors } from '../services'; + +interface LaunchLinkProps { + isOauth?: boolean; + token: string; + userId: number; + itemId?: number | null; + children?: React.ReactNode; +} + +// Uses the usePlaidLink hook to manage the Plaid Link creation. See https://github.com/plaid/react-plaid-link for full usage instructions. +// The link token passed to usePlaidLink cannot be null. It must be generated outside of this component. In this sample app, the link token +// is generated in the link context in client/src/services/link.js. + +export function LaunchLink(props: LaunchLinkProps) { + const history = useHistory(); + // const { getItemsByUser, getItemById } = useItems(); + // const { generateLinkToken, deleteLinkToken } = useLink(); + // const { setError, resetError } = useErrors(); + + // define onSuccess, onExit and onEvent functions as configs for Plaid Link creation + const onSuccess = async ( + publicToken: string, + metadata: PlaidLinkOnSuccessMetadata, + ) => { + // log and save metatdata + // logSuccess(metadata, props.userId); + if (props.itemId != null) { + // update mode: no need to exchange public token + // await setItemState(props.itemId, 'good'); + // deleteLinkToken(null, props.itemId); + // getItemById(props.itemId, true); + // regular link mode: exchange public token for access token + } else { + // call to Plaid api endpoint: /item/public_token/exchange in order to obtain access_token which is then stored with the created item + // await exchangeToken( + // publicToken, + // metadata.institution, + // metadata.accounts, + // props.userId, + // ); + // getItemsByUser(props.userId, true); + } + // resetError(); + // deleteLinkToken(props.userId, null); + history.push(`/user/${props.userId}`); + }; + + const onExit = async ( + error: PlaidLinkError | null, + metadata: PlaidLinkOnExitMetadata, + ) => { + // log and save error and metatdata + // logExit(error, metadata, props.userId); + if (error != null && error.error_code === 'INVALID_LINK_TOKEN') { + // await generateLinkToken(props.userId, props.itemId); + } + if (error != null) { + // setError(error.error_code, error.display_message || error.error_message); + } + // to handle other error codes, see https://plaid.com/docs/errors/ + }; + + const onEvent = async ( + eventName: PlaidLinkStableEvent | string, + metadata: PlaidLinkOnEventMetadata, + ) => { + // handle errors in the event end-user does not exit with onExit function error enabled. + if (eventName === 'ERROR' && metadata.error_code != null) { + // setError(metadata.error_code, ' '); + } + // logEvent(eventName, metadata); + }; + + const config: PlaidLinkOptionsWithLinkToken = { + onSuccess, + onExit, + onEvent, + token: props.token, + }; + + if (props.isOauth) { + // add additional receivedRedirectUri config when handling an OAuth reidrect + config.receivedRedirectUri = window.location.href; + } + const { open, ready } = usePlaidLink(config); + + useEffect(() => { + // initiallizes Link automatically + if (props.isOauth && ready) { + open(); + } else if (ready) { + // regular, non-OAuth case: + // set link token, userId and itemId in local storage for use if needed later by OAuth + + localStorage.setItem( + 'oauthConfig', + JSON.stringify({ + userId: props.userId, + itemId: props.itemId, + token: props.token, + }), + ); + open(); + } + }, [ready, open, props.isOauth, props.userId, props.itemId, props.token]); + + return <>; +} diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx index 4c5c2d4c8..23f7a6df7 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React from 'react'; +import React, { useState } from 'react'; import { Button, NavbarGroup, @@ -14,7 +14,10 @@ import { Icon, FormattedMessage as T, } from '@/components'; -import { useRefreshCashflowAccounts } from '@/hooks/query'; +import { + useGetPlaidLinkToken, + useRefreshCashflowAccounts, +} from '@/hooks/query'; import { CashflowAction, AbilitySubject } from '@/constants/abilityOption'; import withDialogActions from '@/containers/Dialog/withDialogActions'; @@ -26,6 +29,7 @@ import { ACCOUNT_TYPE } from '@/constants'; import { DialogsName } from '@/constants/dialogs'; import { compose } from '@/utils'; +import { LaunchLink } from '@/containers/Banking/Plaid/PlaidLanchLink'; /** * Cash Flow accounts actions bar. @@ -63,8 +67,20 @@ function CashFlowAccountsActionsBar({ setCashflowAccountsTableState({ inactiveMode: checked }); }; + const { mutateAsync: getPlaidLinkToken } = useGetPlaidLinkToken(); + const [linkToken, setLinkToken] = useState(''); + + const handleConnectToBank = () => { + getPlaidLinkToken() + .then((res) => { + setLinkToken(res.data.link_token); + }) + .catch(() => {}); + }; + return ( +