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 && (
-
-
- {__('API Token')}
-
-
- 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 && (
-
-
-
- {__('API Key')}
-
-
- setApiKey(e.target.value)
- }
- className="mt-1"
- placeholder={__(
- 'Paste your Binance API Key',
- )}
- />
-
-
-
- {__('API Secret')}
-
-
- 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 && (
-
-
- {__('API Key')}
-
-
- 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 && (
-
- )}
-
- {isInteractiveBrokers && (
-
-
-
- {__('Flex Web Service Token')}
-
-
- setIbToken(e.target.value)
- }
- className="mt-1"
- placeholder={__(
- 'Paste your Flex Web Service token',
- )}
- />
-
-
-
- {__('Flex Query ID')}
-
-
- 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 && (
-
-
-
- {__('App Key ID')}
-
-
- setCoinbaseKeyName(
- e.target.value,
- )
- }
- className="mt-1 font-mono text-xs"
- placeholder="00000000-0000-0000-0000-000000000000"
- />
-
-
-
- {__('Secret')}
-
-
-
- {__(
- 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
- )}{' '}
-
- {__('API Keys')}
-
- . {__('Use a view-only key.')}
-
-
+ {provider && (
+
)}
@@ -868,21 +444,7 @@ export function ConnectAccountDialog({
{isSubmitting
? __('Connecting...')
diff --git a/resources/js/components/open-banking/connect-account-inline.tsx b/resources/js/components/open-banking/connect-account-inline.tsx
index 6e6ac9b4..67437c55 100644
--- a/resources/js/components/open-banking/connect-account-inline.tsx
+++ b/resources/js/components/open-banking/connect-account-inline.tsx
@@ -11,12 +11,18 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { Textarea } from '@/components/ui/textarea';
import { useWebHaptics } from '@/hooks/use-web-haptics';
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 {
@@ -49,41 +55,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 INTERACTIVE_BROKERS_INSTITUTION: EnableBankingInstitution = {
- name: 'Interactive Brokers',
- country: 'ALL',
- logo: '/images/banks/logos/interactive-brokers.png',
- maximum_consent_validity: null,
-};
-
type Step = 'country' | 'bank' | 'confirm';
interface ConnectAccountInlineProps {
@@ -96,7 +67,6 @@ export function ConnectAccountInline({
connections = [],
}: ConnectAccountInlineProps) {
const { features } = usePage().props;
- const interactiveBrokersEnabled = features.interactiveBrokers;
const [step, setStep] = useState('country');
const { trigger } = useWebHaptics();
const [country, setCountry] = useState('');
@@ -112,34 +82,11 @@ export function ConnectAccountInline({
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 [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 isInteractiveBrokers = useMemo(
- () => selectedBank?.name === 'Interactive Brokers',
+ const provider = useMemo(
+ () => connectProviderForBank(selectedBank?.name),
[selectedBank],
);
@@ -153,6 +100,10 @@ export function ConnectAccountInline({
[selectedBank, connectedBankNames],
);
+ const setCredential = useCallback((key: string, value: string) => {
+ setCredentials((current) => ({ ...current, [key]: value }));
+ }, []);
+
useEffect(() => {
setAcknowledgedReplace(false);
}, [selectedBank]);
@@ -204,41 +155,16 @@ export function ConnectAccountInline({
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,
- ];
- 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);
@@ -259,37 +185,20 @@ export function ConnectAccountInline({
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'
- : isInteractiveBrokers
- ? '/open-banking/interactive-brokers/connect'
- : '/open-banking/authorize';
+ const url = provider
+ ? provider.endpoint
+ : '/open-banking/authorize';
- const body = isBitpanda
- ? { api_key: bitpandaApiKey, country }
- : isBinance
- ? { api_key: apiKey, api_secret: apiSecret, country }
- : isIndexaCapital
- ? { api_token: apiToken }
- : isCoinbase
- ? {
- api_key_name: coinbaseKeyName,
- private_key: coinbasePrivateKey,
- country,
- }
- : isInteractiveBrokers
- ? { token: ibToken, query_id: ibQueryId }
- : {
- aspsp_name: selectedBank.name,
- 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',
@@ -320,6 +229,11 @@ export function ConnectAccountInline({
}
}
+ const canSubmit =
+ !isSubmitting &&
+ !(isAlreadyConnected && !acknowledgedReplace) &&
+ (!provider || isProviderComplete(provider, credentials));
+
return (
{error && (
@@ -455,29 +369,11 @@ export function ConnectAccountInline({
{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.',
- )
- : 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.',
+ )}
@@ -490,226 +386,20 @@ export function ConnectAccountInline({
/>
)}
- {isIndexaCapital && (
-
-
{__('API Token')}
-
setApiToken(e.target.value)}
- placeholder={__(
- 'Paste your Indexa Capital API token',
- )}
- />
-
- {__(
- 'You can generate your API token from your Indexa Capital dashboard under',
- )}{' '}
-
- {__('Settings > Applications')}
-
- .
-
-
- )}
-
- {isBinance && (
-
-
- {__('API Key')}
- setApiKey(e.target.value)}
- placeholder={__(
- 'Paste your Binance API Key',
- )}
- />
-
-
-
- {__('API Secret')}
-
-
- setApiSecret(e.target.value)
- }
- placeholder={__(
- 'Paste your Binance API Secret',
- )}
- />
-
-
- {__(
- 'You can create API keys from your Binance account under',
- )}{' '}
-
- {__('API Management')}
-
- .
-
-
- )}
-
- {isBitpanda && (
-
-
- {__('API Key')}
-
-
- setBitpandaApiKey(e.target.value)
- }
- placeholder={__('Paste your Bitpanda API Key')}
- />
-
- {__(
- 'You can create API keys from your Bitpanda account under',
- )}{' '}
-
- {__('API Key Management')}
-
- .
-
-
- )}
-
- {isInteractiveBrokers && (
-
-
-
- {__('Flex Web Service Token')}
-
- setIbToken(e.target.value)}
- placeholder={__(
- 'Paste your Flex Web Service token',
- )}
- />
-
-
-
- {__('Flex Query ID')}
-
-
- setIbQueryId(e.target.value)
- }
- className="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 && (
-
-
-
- {__('App Key ID')}
-
-
- setCoinbaseKeyName(e.target.value)
- }
- className="font-mono text-xs"
- placeholder="00000000-0000-0000-0000-000000000000"
- />
-
-
-
- {__('Secret')}
-
-
-
- {__(
- 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
- )}{' '}
-
- {__('API Keys')}
-
- . {__('Use a view-only key.')}
-
-
+ {provider && (
+
)}
{isSubmitting ? __('Connecting...') : __('Connect')}
diff --git a/resources/js/components/open-banking/update-credentials-dialog.tsx b/resources/js/components/open-banking/update-credentials-dialog.tsx
index 38fac9fc..5ca9166a 100644
--- a/resources/js/components/open-banking/update-credentials-dialog.tsx
+++ b/resources/js/components/open-banking/update-credentials-dialog.tsx
@@ -7,13 +7,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Textarea } from '@/components/ui/textarea';
+import {
+ connectProviderByKey,
+ credentialPayload,
+ isProviderComplete,
+ ProviderCredentialFields,
+} from '@/lib/connect-providers';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
-import { useState } from 'react';
+import { useCallback, useState } from 'react';
interface UpdateCredentialsDialogProps {
connection: BankingConnection;
@@ -27,68 +30,54 @@ export function UpdateCredentialsDialog({
onOpenChange,
}: UpdateCredentialsDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
- const [apiToken, setApiToken] = useState('');
- const [apiKey, setApiKey] = useState('');
- const [apiSecret, setApiSecret] = useState('');
- const [coinbaseKeyName, setCoinbaseKeyName] = useState('');
- const [coinbasePrivateKey, setCoinbasePrivateKey] = useState('');
- const [ibToken, setIbToken] = useState('');
- const [ibQueryId, setIbQueryId] = useState('');
+ const [credentials, setCredentials] = useState>({});
const [error, setError] = useState(null);
- const isIndexaCapital = connection.provider === 'indexacapital';
- const isBinance = connection.provider === 'binance';
- const isBitpanda = connection.provider === 'bitpanda';
- const isCoinbase = connection.provider === 'coinbase';
- const isInteractiveBrokers = connection.provider === 'interactivebrokers';
+ const provider = connectProviderByKey(connection.provider);
+ // Only providers the backend exposes a credential-update path for (Wise has none).
+ const updatableProvider =
+ provider && provider.updatable !== false ? provider : undefined;
- const isValid = isIndexaCapital
- ? apiToken.length > 0
- : isBinance
- ? apiKey.length > 0 && apiSecret.length > 0
- : isBitpanda
- ? apiKey.length > 0
- : isCoinbase
- ? coinbaseKeyName.length > 0 && coinbasePrivateKey.length > 0
- : isInteractiveBrokers
- ? ibToken.length > 0 && ibQueryId.length > 0
- : false;
+ const setCredential = useCallback((key: string, value: string) => {
+ setCredentials((current) => ({ ...current, [key]: value }));
+ }, []);
+
+ function resetState() {
+ setCredentials({});
+ setError(null);
+ }
+
+ function handleOpenChange(value: boolean) {
+ if (!value) {
+ resetState();
+ }
+ onOpenChange(value);
+ }
function handleSubmit() {
+ if (!updatableProvider) {
+ return;
+ }
+
setIsSubmitting(true);
setError(null);
- const data = isIndexaCapital
- ? { api_token: apiToken }
- : isBinance
- ? { api_key: apiKey, api_secret: apiSecret }
- : isCoinbase
- ? {
- api_key_name: coinbaseKeyName,
- private_key: coinbasePrivateKey,
- }
- : isInteractiveBrokers
- ? { token: ibToken, query_id: ibQueryId }
- : { api_key: apiKey };
-
router.patch(
`/settings/connections/${connection.id}/credentials`,
- data,
+ credentialPayload(updatableProvider, credentials),
{
onSuccess: () => {
onOpenChange(false);
resetState();
},
onError: (errors) => {
+ const fieldError = updatableProvider.fields
+ .map((f) => errors[f.key])
+ .find(Boolean);
+
setError(
errors.credentials ??
- errors.api_token ??
- errors.api_key ??
- errors.api_secret ??
- errors.api_key_name ??
- errors.private_key ??
- errors.token ??
- errors.query_id ??
+ fieldError ??
__(
'Failed to update credentials. Please try again.',
),
@@ -101,23 +90,9 @@ export function UpdateCredentialsDialog({
);
}
- function resetState() {
- setApiToken('');
- setApiKey('');
- setApiSecret('');
- setCoinbaseKeyName('');
- setCoinbasePrivateKey('');
- setIbToken('');
- setIbQueryId('');
- setError(null);
- }
-
- function handleOpenChange(value: boolean) {
- if (!value) {
- resetState();
- }
- onOpenChange(value);
- }
+ const isValid = updatableProvider
+ ? isProviderComplete(updatableProvider, credentials)
+ : false;
return (
@@ -133,215 +108,14 @@ export function UpdateCredentialsDialog({
{error && {error}
}
-
- {isIndexaCapital && (
-
-
- {__('API Token')}
-
-
setApiToken(e.target.value)}
- placeholder={__(
- 'Paste your Indexa Capital API token',
- )}
- />
-
- {__(
- 'You can generate your API token from your Indexa Capital dashboard under',
- )}{' '}
-
- {__('Settings > Applications')}
-
- .
-
-
- )}
-
- {isBinance && (
- <>
-
-
- {__('API Key')}
-
- setApiKey(e.target.value)}
- placeholder={__(
- 'Paste your Binance API Key',
- )}
- />
-
-
-
- {__('API Secret')}
-
-
- setApiSecret(e.target.value)
- }
- placeholder={__(
- 'Paste your Binance API Secret',
- )}
- />
-
-
- {__(
- 'You can create API keys from your Binance account under',
- )}{' '}
-
- {__('API Management')}
-
- .
-
- >
- )}
-
- {isBitpanda && (
-
-
- {__('API Key')}
-
-
setApiKey(e.target.value)}
- placeholder={__('Paste your Bitpanda API Key')}
- />
-
- {__(
- 'You can create API keys from your Bitpanda account under',
- )}{' '}
-
- {__('API Key Management')}
-
- .
-
-
- )}
-
- {isInteractiveBrokers && (
-
-
-
- {__('Flex Web Service Token')}
-
- setIbToken(e.target.value)}
- placeholder={__(
- 'Paste your Flex Web Service token',
- )}
- />
-
-
-
- {__('Flex Query ID')}
-
-
- setIbQueryId(e.target.value)
- }
- className="font-mono"
- placeholder="123456"
- />
-
-
- {__(
- 'Generate a Flex Web Service token in Client Portal under',
- )}{' '}
-
- {__('Performance & Reports → Flex Queries')}
-
- .
-
-
- )}
-
- {isCoinbase && (
-
-
-
- {__('App Key ID')}
-
-
- setCoinbaseKeyName(e.target.value)
- }
- className="font-mono text-xs"
- placeholder="00000000-0000-0000-0000-000000000000"
- />
-
-
-
- {__('Secret')}
-
-
-
- {__(
- 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
- )}{' '}
-
- {__('API Keys')}
-
- .
-
-
- )}
-
+ {updatableProvider && (
+
+ )}
Applications',
+ },
+ },
+ {
+ providerKey: 'binance',
+ institution: {
+ name: 'Binance',
+ country: 'ALL',
+ logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
+ maximum_consent_validity: null,
+ },
+ endpoint: '/open-banking/binance/connect',
+ sendsCountry: true,
+ headerDescription:
+ 'Enter your API Key and Secret to connect your Binance account.',
+ cardDescription:
+ 'Connect your Binance account using your API Key and Secret.',
+ fields: [
+ {
+ key: 'api_key',
+ label: 'API Key',
+ type: 'password',
+ placeholder: 'Paste your Binance API Key',
+ },
+ {
+ key: 'api_secret',
+ label: 'API Secret',
+ type: 'password',
+ placeholder: 'Paste your Binance API Secret',
+ },
+ ],
+ help: {
+ before: 'You can create API keys from your Binance account under',
+ href: 'https://www.binance.com/es/my/settings/api-management',
+ link: 'API Management',
+ },
+ },
+ {
+ providerKey: 'bitpanda',
+ institution: {
+ name: 'Bitpanda',
+ country: 'ALL',
+ logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png',
+ maximum_consent_validity: null,
+ },
+ endpoint: '/open-banking/bitpanda/connect',
+ sendsCountry: true,
+ headerDescription:
+ 'Enter your API Key to connect your Bitpanda account.',
+ cardDescription: 'Connect your Bitpanda account using your API Key.',
+ fields: [
+ {
+ key: 'api_key',
+ label: 'API Key',
+ type: 'password',
+ placeholder: 'Paste your Bitpanda API Key',
+ },
+ ],
+ help: {
+ before: 'You can create API keys from your Bitpanda account under',
+ href: 'https://web.bitpanda.com/apikey',
+ link: 'API Key Management',
+ },
+ },
+ {
+ providerKey: 'coinbase',
+ institution: {
+ name: 'Coinbase',
+ country: 'ALL',
+ logo: 'https://whisper.money/storage/banks/logos/coinbase.png',
+ maximum_consent_validity: null,
+ },
+ endpoint: '/open-banking/coinbase/connect',
+ sendsCountry: true,
+ headerDescription:
+ 'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
+ cardDescription: 'Connect your Coinbase account using a CDP API key.',
+ fields: [
+ {
+ key: 'api_key_name',
+ label: 'App Key ID',
+ type: 'text',
+ placeholderExample: '00000000-0000-0000-0000-000000000000',
+ mono: true,
+ small: true,
+ },
+ {
+ key: 'private_key',
+ label: 'Secret',
+ type: 'textarea',
+ placeholderExample: 'Paste your CDP API key secret',
+ mono: true,
+ small: true,
+ },
+ ],
+ help: {
+ before: 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
+ href: 'https://portal.cdp.coinbase.com/access/api',
+ link: 'API Keys',
+ after: 'Use a view-only key.',
+ },
+ },
+ {
+ providerKey: 'wise',
+ institution: {
+ name: 'Wise',
+ country: 'ALL',
+ logo: '/images/banks/logos/wise.png',
+ maximum_consent_validity: null,
+ },
+ endpoint: '/open-banking/wise/connect',
+ updatable: false,
+ headerDescription:
+ 'Enter your Wise Personal API token to connect your account.',
+ cardDescription:
+ 'Connect your Wise account using a Personal API token.',
+ fields: [
+ {
+ key: 'api_token',
+ label: 'Personal API Token',
+ type: 'password',
+ placeholder: 'Paste your Wise API token',
+ },
+ ],
+ help: {
+ before: 'Generate a token in Wise under',
+ href: 'https://wise.com/user/account#/developer',
+ link: 'Settings → Developer Tools → API tokens',
+ },
+ },
+ {
+ providerKey: 'interactivebrokers',
+ institution: {
+ name: 'Interactive Brokers',
+ country: 'ALL',
+ logo: '/images/banks/logos/interactive-brokers.png',
+ maximum_consent_validity: null,
+ },
+ endpoint: '/open-banking/interactive-brokers/connect',
+ feature: 'interactiveBrokers',
+ headerDescription:
+ 'Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.',
+ cardDescription:
+ 'Connect your Interactive Brokers account using a Flex Web Service token and Query ID.',
+ fields: [
+ {
+ key: 'token',
+ label: 'Flex Web Service Token',
+ type: 'password',
+ placeholder: 'Paste your Flex Web Service token',
+ },
+ {
+ key: 'query_id',
+ label: 'Flex Query ID',
+ type: 'text',
+ placeholderExample: '123456',
+ mono: true,
+ },
+ ],
+ help: {
+ before: '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',
+ href: 'https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm',
+ link: 'Performance & Reports → Flex Queries',
+ },
+ },
+];
+
+/** Find a provider by the selected institution name. */
+export function connectProviderForBank(
+ name: string | undefined,
+): ConnectProvider | undefined {
+ return CONNECT_PROVIDERS.find((p) => p.institution.name === name);
+}
+
+/** Find a provider by its `banking_connections.provider` value. */
+export function connectProviderByKey(
+ providerKey: string,
+): ConnectProvider | undefined {
+ return CONNECT_PROVIDERS.find((p) => p.providerKey === providerKey);
+}
+
+/** The credential payload (field values), shared by connect and update. */
+export function credentialPayload(
+ provider: ConnectProvider,
+ values: Record,
+): Record {
+ return Object.fromEntries(
+ provider.fields.map((f) => [f.key, values[f.key] ?? '']),
+ );
+}
+
+/** Whether every credential field has been filled in. */
+export function isProviderComplete(
+ provider: ConnectProvider,
+ values: Record,
+): boolean {
+ return provider.fields.every((f) => (values[f.key] ?? '').length > 0);
+}
+
+export function ProviderHelp({ help }: { help: ConnectProvider['help'] }) {
+ return (
+
+ {__(help.before)}{' '}
+
+ {__(help.link)}
+
+ {help.after ? <>. {__(help.after)}> : '.'}
+
+ );
+}
+
+/**
+ * Renders a provider's credential inputs, bound to a flat values record.
+ * `idPrefix` keeps element ids unique between the connect and update dialogs.
+ */
+export function ProviderCredentialFields({
+ provider,
+ values,
+ onChange,
+ idPrefix = 'connect',
+}: {
+ provider: ConnectProvider;
+ values: Record;
+ onChange: (key: string, value: string) => void;
+ idPrefix?: string;
+}) {
+ return (
+
+ {provider.fields.map((field) => {
+ const id = `${idPrefix}-${provider.providerKey}-${field.key}`;
+ const placeholder =
+ field.placeholderExample ??
+ (field.placeholder ? __(field.placeholder) : undefined);
+ const className = cn(
+ 'mt-1',
+ field.mono && 'font-mono',
+ field.small && 'text-xs',
+ );
+
+ return (
+
+ {__(field.label)}
+ {field.type === 'textarea' ? (
+
+ );
+ })}
+
+
+ );
+}