diff --git a/resources/js/components/open-banking/connect-account-dialog.test.tsx b/resources/js/components/open-banking/connect-account-dialog.test.tsx index 223aefdc..b9436e8c 100644 --- a/resources/js/components/open-banking/connect-account-dialog.test.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.test.tsx @@ -161,6 +161,26 @@ describe('ConnectAccountDialog', () => { expect(connect).toBeEnabled(); }); + it('requires every provider credential before connecting', async () => { + await reachBankStep([]); + + fireEvent.click(screen.getByRole('button', { name: /Binance/ })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const connect = screen.getByRole('button', { name: 'Connect' }); + expect(connect).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('API Key'), { + target: { value: 'key' }, + }); + expect(connect).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('API Secret'), { + target: { value: 'secret' }, + }); + expect(connect).toBeEnabled(); + }); + it('does not warn when connecting a fresh bank', async () => { await reachBankStep([liveBbvaConnection()]); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index bf5d9130..217134ec 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -19,11 +19,17 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; import { alreadyConnectedBankNames, hasLiveConnectionForProvider, } from '@/lib/banking-connections'; +import { + CONNECT_PROVIDERS, + connectProviderForBank, + credentialPayload, + isProviderComplete, + ProviderCredentialFields, +} from '@/lib/connect-providers'; import { getCsrfToken } from '@/lib/csrf'; import type { SharedData } from '@/types'; import type { @@ -55,48 +61,6 @@ const COUNTRIES = [ { code: 'GB', name: 'United Kingdom' }, ] as const; -const INDEXA_CAPITAL_INSTITUTION: EnableBankingInstitution = { - name: 'Indexa Capital', - country: 'ES', - logo: '/images/banks/logos/indexa-capital.jpg', - maximum_consent_validity: null, -}; - -const BINANCE_INSTITUTION: EnableBankingInstitution = { - name: 'Binance', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png', - maximum_consent_validity: null, -}; - -const BITPANDA_INSTITUTION: EnableBankingInstitution = { - name: 'Bitpanda', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png', - maximum_consent_validity: null, -}; - -const COINBASE_INSTITUTION: EnableBankingInstitution = { - name: 'Coinbase', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/coinbase.png', - maximum_consent_validity: null, -}; - -const WISE_INSTITUTION: EnableBankingInstitution = { - name: 'Wise', - country: 'ALL', - logo: '/images/banks/logos/wise.png', - maximum_consent_validity: null, -}; - -const INTERACTIVE_BROKERS_INSTITUTION: EnableBankingInstitution = { - name: 'Interactive Brokers', - country: 'ALL', - logo: '/images/banks/logos/interactive-brokers.png', - maximum_consent_validity: null, -}; - interface ConnectAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -111,7 +75,6 @@ export function ConnectAccountDialog({ connections = [], }: ConnectAccountDialogProps) { const { features } = usePage().props; - const interactiveBrokersEnabled = features.interactiveBrokers; const [step, setStep] = useState('country'); const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false); const [country, setCountry] = useState(''); @@ -127,41 +90,11 @@ export function ConnectAccountDialog({ const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); - const [apiToken, setApiToken] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [apiSecret, setApiSecret] = useState(''); - const [bitpandaApiKey, setBitpandaApiKey] = useState(''); - const [coinbaseKeyName, setCoinbaseKeyName] = useState(''); - const [coinbasePrivateKey, setCoinbasePrivateKey] = useState(''); - const [wiseApiToken, setWiseApiToken] = useState(''); - const [ibToken, setIbToken] = useState(''); - const [ibQueryId, setIbQueryId] = useState(''); + const [credentials, setCredentials] = useState>({}); const [acknowledgedReplace, setAcknowledgedReplace] = useState(false); - const isIndexaCapital = useMemo( - () => selectedBank?.name === 'Indexa Capital', - [selectedBank], - ); - - const isBinance = useMemo( - () => selectedBank?.name === 'Binance', - [selectedBank], - ); - - const isBitpanda = useMemo( - () => selectedBank?.name === 'Bitpanda', - [selectedBank], - ); - - const isCoinbase = useMemo( - () => selectedBank?.name === 'Coinbase', - [selectedBank], - ); - - const isWise = useMemo(() => selectedBank?.name === 'Wise', [selectedBank]); - - const isInteractiveBrokers = useMemo( - () => selectedBank?.name === 'Interactive Brokers', + const provider = useMemo( + () => connectProviderForBank(selectedBank?.name), [selectedBank], ); @@ -175,6 +108,10 @@ export function ConnectAccountDialog({ [selectedBank, connectedBankNames], ); + const setCredential = useCallback((key: string, value: string) => { + setCredentials((current) => ({ ...current, [key]: value })); + }, []); + useEffect(() => { setAcknowledgedReplace(false); }, [selectedBank]); @@ -189,15 +126,7 @@ export function ConnectAccountDialog({ setIsLoading(false); setIsSubmitting(false); setError(null); - setApiToken(''); - setApiKey(''); - setApiSecret(''); - setBitpandaApiKey(''); - setCoinbaseKeyName(''); - setCoinbasePrivateKey(''); - setWiseApiToken(''); - setIbToken(''); - setIbQueryId(''); + setCredentials({}); setAcknowledgedReplace(false); }, []); @@ -240,42 +169,16 @@ export function ConnectAccountDialog({ const data = await response.json(); - const hasProvider = (provider: string) => - hasLiveConnectionForProvider(connections, provider); + const extraInstitutions = CONNECT_PROVIDERS.filter( + (p) => + (!p.feature || features[p.feature]) && + (!p.onlyCountry || p.onlyCountry === countryCode) && + !hasLiveConnectionForProvider(connections, p.providerKey), + ).map((p) => p.institution); - const extraInstitutions = [ - BINANCE_INSTITUTION, - BITPANDA_INSTITUTION, - COINBASE_INSTITUTION, - WISE_INSTITUTION, - ]; - if (interactiveBrokersEnabled) { - extraInstitutions.push(INTERACTIVE_BROKERS_INSTITUTION); - } - if (countryCode === 'ES') { - extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION); - } - - const allInstitutions = [...extraInstitutions, ...data] - .filter((institution) => { - if (institution.name === 'Binance') { - return !hasProvider('binance'); - } - if (institution.name === 'Bitpanda') { - return !hasProvider('bitpanda'); - } - if (institution.name === 'Coinbase') { - return !hasProvider('coinbase'); - } - if (institution.name === 'Indexa Capital') { - return !hasProvider('indexacapital'); - } - if (institution.name === 'Interactive Brokers') { - return !hasProvider('interactivebrokers'); - } - return true; - }) - .sort((a, b) => a.name.localeCompare(b.name)); + const allInstitutions = [...extraInstitutions, ...data].sort( + (a, b) => a.name.localeCompare(b.name), + ); setInstitutions(allInstitutions); setFilteredInstitutions(allInstitutions); @@ -294,41 +197,20 @@ export function ConnectAccountDialog({ setError(null); try { - const url = isBitpanda - ? '/open-banking/bitpanda/connect' - : isBinance - ? '/open-banking/binance/connect' - : isIndexaCapital - ? '/open-banking/indexa-capital/connect' - : isCoinbase - ? '/open-banking/coinbase/connect' - : isWise - ? '/open-banking/wise/connect' - : isInteractiveBrokers - ? '/open-banking/interactive-brokers/connect' - : '/open-banking/authorize'; + const url = provider + ? provider.endpoint + : '/open-banking/authorize'; - const body = isBitpanda - ? { api_key: bitpandaApiKey, country: country } - : isBinance - ? { api_key: apiKey, api_secret: apiSecret, country: country } - : isIndexaCapital - ? { api_token: apiToken } - : isCoinbase - ? { - api_key_name: coinbaseKeyName, - private_key: coinbasePrivateKey, - country: country, - } - : isWise - ? { api_token: wiseApiToken } - : isInteractiveBrokers - ? { token: ibToken, query_id: ibQueryId } - : { - aspsp_name: selectedBank.name, - country: country, - logo: selectedBank.logo, - }; + const body = provider + ? { + ...credentialPayload(provider, credentials), + ...(provider.sendsCountry ? { country } : {}), + } + : { + aspsp_name: selectedBank.name, + country, + logo: selectedBank.logo, + }; const response = await fetch(url, { method: 'POST', @@ -359,6 +241,11 @@ export function ConnectAccountDialog({ } } + const canSubmit = + !isSubmitting && + !(isAlreadyConnected && !acknowledgedReplace) && + (!provider || isProviderComplete(provider, credentials)); + return ( <> @@ -372,45 +259,11 @@ export function ConnectAccountDialog({ )} {step === 'bank' && __('Select your bank.')} {step === 'confirm' && - isWise && - __( - 'Enter your Wise Personal API token to connect your account.', - )} - {step === 'confirm' && - !isIndexaCapital && - !isBinance && - !isBitpanda && - !isCoinbase && - !isWise && - !isInteractiveBrokers && - __( - 'You will be redirected to your bank to authorize access.', - )} - {step === 'confirm' && - isInteractiveBrokers && - __( - 'Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.', - )} - {step === 'confirm' && - isIndexaCapital && - __( - 'Enter your API token to connect your Indexa Capital account.', - )} - {step === 'confirm' && - isBinance && - __( - 'Enter your API Key and Secret to connect your Binance account.', - )} - {step === 'confirm' && - isBitpanda && - __( - 'Enter your API Key to connect your Bitpanda account.', - )} - {step === 'confirm' && - isCoinbase && - __( - 'Enter your CDP App Key ID and Secret to connect your Coinbase account.', - )} + (provider + ? __(provider.headerDescription) + : __( + 'You will be redirected to your bank to authorize access.', + ))} @@ -554,33 +407,11 @@ export function ConnectAccountDialog({ {selectedBank.name}

- {isBitpanda - ? __( - 'Connect your Bitpanda account using your API Key.', - ) - : isBinance - ? __( - 'Connect your Binance account using your API Key and Secret.', - ) - : isIndexaCapital - ? __( - 'Connect your Indexa Capital account using your API token.', - ) - : isCoinbase - ? __( - 'Connect your Coinbase account using a CDP API key.', - ) - : isWise - ? __( - 'Connect your Wise account using a Personal API token.', - ) - : isInteractiveBrokers - ? __( - 'Connect your Interactive Brokers account using a Flex Web Service token and Query ID.', - ) - : __( - 'You will be redirected to authorize access to your account data.', - )} + {provider + ? __(provider.cardDescription) + : __( + 'You will be redirected to authorize access to your account data.', + )}

@@ -595,267 +426,12 @@ export function ConnectAccountDialog({ /> )} - {isIndexaCapital && ( -
- - - setApiToken(e.target.value) - } - placeholder={__( - 'Paste your Indexa Capital API token', - )} - className="my-2" - /> -

- {__( - 'You can generate your API token from your Indexa Capital dashboard under', - )}{' '} - - {__('Settings > Applications')} - - . -

-
- )} - - {isBinance && ( -
-
- - - setApiKey(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Binance API Key', - )} - /> -
-
- - - setApiSecret(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Binance API Secret', - )} - /> -
-

- {__( - 'You can create API keys from your Binance account under', - )}{' '} - - {__('API Management')} - - . -

-
- )} - - {isBitpanda && ( -
- - - setBitpandaApiKey(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Bitpanda API Key', - )} - /> -

- {__( - 'You can create API keys from your Bitpanda account under', - )}{' '} - - {__('API Key Management')} - - . -

-
- )} - - {isWise && ( -
- - - setWiseApiToken(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Wise API token', - )} - /> -

- {__('Generate a token in Wise under')}{' '} - - {__( - 'Settings → Developer Tools → API tokens', - )} - - . -

-
- )} - - {isInteractiveBrokers && ( -
-
- - - setIbToken(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Flex Web Service token', - )} - /> -
-
- - - setIbQueryId(e.target.value) - } - className="mt-1 font-mono" - placeholder="123456" - /> -
-

- {__( - 'In Client Portal, create an Activity Flex Query including the "Net Asset Value (NAV)" and "Open Positions" sections, then generate a Flex Web Service token under', - )}{' '} - - {__( - 'Performance & Reports → Flex Queries', - )} - - . -

-
- )} - - {isCoinbase && ( -
-
- - - setCoinbaseKeyName( - e.target.value, - ) - } - className="mt-1 font-mono text-xs" - placeholder="00000000-0000-0000-0000-000000000000" - /> -
-
- -