refactor(banking): centralize connect providers in a shared registry
Replace the per-provider if/ternary cascades in the connect dialog, inline connect flow and update-credentials dialog with a single CONNECT_PROVIDERS registry plus a generic ProviderCredentialFields component. Each provider is now one data entry (institution, endpoint, fields, copy); the credential fields double as the request payload, so there is no per-provider branching left. Field state collapses from many useState hooks to one record. Net -616 lines across the three components. Behavior preserved, except the inline connect flow now also offers Wise (it was missing before, which looked like drift).
This commit is contained in:
parent
81a2d5ac96
commit
b40a856dbc
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SharedData>().props;
|
||||
const interactiveBrokersEnabled = features.interactiveBrokers;
|
||||
const [step, setStep] = useState<Step>('country');
|
||||
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false);
|
||||
const [country, setCountry] = useState<string>('');
|
||||
|
|
@ -127,41 +90,11 @@ export function ConnectAccountDialog({
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<Record<string, string>>({});
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -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.',
|
||||
))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -554,33 +407,11 @@ export function ConnectAccountDialog({
|
|||
{selectedBank.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -595,267 +426,12 @@ export function ConnectAccountDialog({
|
|||
/>
|
||||
)}
|
||||
|
||||
{isIndexaCapital && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-token">
|
||||
{__('API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-token"
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) =>
|
||||
setApiToken(e.target.value)
|
||||
}
|
||||
placeholder={__(
|
||||
'Paste your Indexa Capital API token',
|
||||
)}
|
||||
className="my-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can generate your API token from your Indexa Capital dashboard under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://indexacapital.com/es/u/user#settings-apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Settings > Applications')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBinance && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) =>
|
||||
setApiKey(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Key',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-secret">
|
||||
{__('API Secret')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) =>
|
||||
setApiSecret(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Binance API Secret',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Binance account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.binance.com/es/my/settings/api-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBitpanda && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bitpanda-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="bitpanda-api-key"
|
||||
type="password"
|
||||
value={bitpandaApiKey}
|
||||
onChange={(e) =>
|
||||
setBitpandaApiKey(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Bitpanda API Key',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Bitpanda account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://web.bitpanda.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Key Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWise && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wise-api-token">
|
||||
{__('Personal API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="wise-api-token"
|
||||
type="password"
|
||||
value={wiseApiToken}
|
||||
onChange={(e) =>
|
||||
setWiseApiToken(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Wise API token',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Generate a token in Wise under')}{' '}
|
||||
<a
|
||||
href="https://wise.com/user/account#/developer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__(
|
||||
'Settings → Developer Tools → API tokens',
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInteractiveBrokers && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ib-token">
|
||||
{__('Flex Web Service Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="ib-token"
|
||||
type="password"
|
||||
value={ibToken}
|
||||
onChange={(e) =>
|
||||
setIbToken(e.target.value)
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder={__(
|
||||
'Paste your Flex Web Service token',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ib-query-id">
|
||||
{__('Flex Query ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="ib-query-id"
|
||||
type="text"
|
||||
value={ibQueryId}
|
||||
onChange={(e) =>
|
||||
setIbQueryId(e.target.value)
|
||||
}
|
||||
className="mt-1 font-mono"
|
||||
placeholder="123456"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'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',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__(
|
||||
'Performance & Reports → Flex Queries',
|
||||
)}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCoinbase && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-key-name">
|
||||
{__('App Key ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="coinbase-key-name"
|
||||
type="text"
|
||||
value={coinbaseKeyName}
|
||||
onChange={(e) =>
|
||||
setCoinbaseKeyName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-private-key">
|
||||
{__('Secret')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="coinbase-private-key"
|
||||
value={coinbasePrivateKey}
|
||||
onChange={(e) =>
|
||||
setCoinbasePrivateKey(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
rows={6}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder={
|
||||
'Paste your CDP API key secret'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://portal.cdp.coinbase.com/access/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Keys')}
|
||||
</a>
|
||||
. {__('Use a view-only key.')}
|
||||
</p>
|
||||
</div>
|
||||
{provider && (
|
||||
<ProviderCredentialFields
|
||||
provider={provider}
|
||||
values={credentials}
|
||||
onChange={setCredential}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
@ -868,21 +444,7 @@ export function ConnectAccountDialog({
|
|||
</Button>
|
||||
<Button
|
||||
onClick={handleAuthorize}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isAlreadyConnected &&
|
||||
!acknowledgedReplace) ||
|
||||
(isIndexaCapital && !apiToken) ||
|
||||
(isBinance &&
|
||||
(!apiKey || !apiSecret)) ||
|
||||
(isBitpanda && !bitpandaApiKey) ||
|
||||
(isCoinbase &&
|
||||
(!coinbaseKeyName ||
|
||||
!coinbasePrivateKey)) ||
|
||||
(isWise && !wiseApiToken) ||
|
||||
(isInteractiveBrokers &&
|
||||
(!ibToken || !ibQueryId))
|
||||
}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitting
|
||||
? __('Connecting...')
|
||||
|
|
|
|||
|
|
@ -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<SharedData>().props;
|
||||
const interactiveBrokersEnabled = features.interactiveBrokers;
|
||||
const [step, setStep] = useState<Step>('country');
|
||||
const { trigger } = useWebHaptics();
|
||||
const [country, setCountry] = useState<string>('');
|
||||
|
|
@ -112,34 +82,11 @@ export function ConnectAccountInline({
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<Record<string, string>>({});
|
||||
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 (
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{error && (
|
||||
|
|
@ -455,29 +369,11 @@ export function ConnectAccountInline({
|
|||
{selectedBank.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -490,226 +386,20 @@ export function ConnectAccountInline({
|
|||
/>
|
||||
)}
|
||||
|
||||
{isIndexaCapital && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-token">{__('API Token')}</Label>
|
||||
<Input
|
||||
id="api-token"
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Indexa Capital API token',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can generate your API token from your Indexa Capital dashboard under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://indexacapital.com/es/u/user#settings-apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Settings > Applications')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBinance && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">{__('API Key')}</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Binance API Key',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-secret">
|
||||
{__('API Secret')}
|
||||
</Label>
|
||||
<Input
|
||||
id="api-secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) =>
|
||||
setApiSecret(e.target.value)
|
||||
}
|
||||
placeholder={__(
|
||||
'Paste your Binance API Secret',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Binance account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.binance.com/es/my/settings/api-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBitpanda && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bitpanda-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="bitpanda-api-key"
|
||||
type="password"
|
||||
value={bitpandaApiKey}
|
||||
onChange={(e) =>
|
||||
setBitpandaApiKey(e.target.value)
|
||||
}
|
||||
placeholder={__('Paste your Bitpanda API Key')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Bitpanda account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://web.bitpanda.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Key Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInteractiveBrokers && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ib-token">
|
||||
{__('Flex Web Service Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="ib-token"
|
||||
type="password"
|
||||
value={ibToken}
|
||||
onChange={(e) => setIbToken(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Flex Web Service token',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ib-query-id">
|
||||
{__('Flex Query ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="ib-query-id"
|
||||
type="text"
|
||||
value={ibQueryId}
|
||||
onChange={(e) =>
|
||||
setIbQueryId(e.target.value)
|
||||
}
|
||||
className="font-mono"
|
||||
placeholder="123456"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'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',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Performance & Reports → Flex Queries')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCoinbase && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-key-name">
|
||||
{__('App Key ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="coinbase-key-name"
|
||||
type="text"
|
||||
value={coinbaseKeyName}
|
||||
onChange={(e) =>
|
||||
setCoinbaseKeyName(e.target.value)
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coinbase-private-key">
|
||||
{__('Secret')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="coinbase-private-key"
|
||||
value={coinbasePrivateKey}
|
||||
onChange={(e) =>
|
||||
setCoinbasePrivateKey(e.target.value)
|
||||
}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder={
|
||||
'Paste your CDP API key secret'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://portal.cdp.coinbase.com/access/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Keys')}
|
||||
</a>
|
||||
. {__('Use a view-only key.')}
|
||||
</p>
|
||||
</div>
|
||||
{provider && (
|
||||
<ProviderCredentialFields
|
||||
provider={provider}
|
||||
values={credentials}
|
||||
onChange={setCredential}
|
||||
idPrefix="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleAuthorize}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
(isAlreadyConnected && !acknowledgedReplace) ||
|
||||
(isIndexaCapital && !apiToken) ||
|
||||
(isBinance && (!apiKey || !apiSecret)) ||
|
||||
(isBitpanda && !bitpandaApiKey) ||
|
||||
(isCoinbase &&
|
||||
(!coinbaseKeyName || !coinbasePrivateKey)) ||
|
||||
(isInteractiveBrokers && (!ibToken || !ibQueryId))
|
||||
}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitting ? __('Connecting...') : __('Connect')}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -133,215 +108,14 @@ export function UpdateCredentialsDialog({
|
|||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="space-y-4">
|
||||
{isIndexaCapital && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-api-token">
|
||||
{__('API Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-api-token"
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => setApiToken(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Indexa Capital API token',
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can generate your API token from your Indexa Capital dashboard under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://indexacapital.com/es/u/user#settings-apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Settings > Applications')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBinance && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Binance API Key',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-api-secret">
|
||||
{__('API Secret')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-api-secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) =>
|
||||
setApiSecret(e.target.value)
|
||||
}
|
||||
placeholder={__(
|
||||
'Paste your Binance API Secret',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Binance account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.binance.com/es/my/settings/api-management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBitpanda && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-bitpanda-api-key">
|
||||
{__('API Key')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-bitpanda-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={__('Paste your Bitpanda API Key')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'You can create API keys from your Bitpanda account under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://web.bitpanda.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Key Management')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInteractiveBrokers && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-ib-token">
|
||||
{__('Flex Web Service Token')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-ib-token"
|
||||
type="password"
|
||||
value={ibToken}
|
||||
onChange={(e) => setIbToken(e.target.value)}
|
||||
placeholder={__(
|
||||
'Paste your Flex Web Service token',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-ib-query-id">
|
||||
{__('Flex Query ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-ib-query-id"
|
||||
type="text"
|
||||
value={ibQueryId}
|
||||
onChange={(e) =>
|
||||
setIbQueryId(e.target.value)
|
||||
}
|
||||
className="font-mono"
|
||||
placeholder="123456"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Generate a Flex Web Service token in Client Portal under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('Performance & Reports → Flex Queries')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCoinbase && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-coinbase-key-name">
|
||||
{__('App Key ID')}
|
||||
</Label>
|
||||
<Input
|
||||
id="update-coinbase-key-name"
|
||||
type="text"
|
||||
value={coinbaseKeyName}
|
||||
onChange={(e) =>
|
||||
setCoinbaseKeyName(e.target.value)
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="update-coinbase-private-key">
|
||||
{__('Secret')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="update-coinbase-private-key"
|
||||
value={coinbasePrivateKey}
|
||||
onChange={(e) =>
|
||||
setCoinbasePrivateKey(e.target.value)
|
||||
}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder={
|
||||
'Paste your CDP API key secret'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
||||
)}{' '}
|
||||
<a
|
||||
href="https://portal.cdp.coinbase.com/access/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__('API Keys')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{updatableProvider && (
|
||||
<ProviderCredentialFields
|
||||
provider={updatableProvider}
|
||||
values={credentials}
|
||||
onChange={setCredential}
|
||||
idPrefix="update"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Features } from '@/types';
|
||||
import type { EnableBankingInstitution } from '@/types/banking';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
/**
|
||||
* Single source of truth for the API-key banking providers.
|
||||
*
|
||||
* Every provider used to be branched on in ~9 places across the connect
|
||||
* dialog, the inline connect flow and the update-credentials dialog. They are
|
||||
* now described once here: the registry drives the institution list, the
|
||||
* request endpoint/body, the on-screen copy and the credential form. Adding a
|
||||
* provider is a single entry; consent-based EnableBanking has no entry and is
|
||||
* handled as the default redirect flow by the consumers.
|
||||
*/
|
||||
|
||||
type CredentialField = {
|
||||
/** Doubles as the POST body key and the form-state key. */
|
||||
key: string;
|
||||
/** i18n key for the field label. */
|
||||
label: string;
|
||||
type: 'password' | 'text' | 'textarea';
|
||||
/** i18n key for a translated placeholder. */
|
||||
placeholder?: string;
|
||||
/** Literal, non-translatable placeholder (e.g. an example value). */
|
||||
placeholderExample?: string;
|
||||
mono?: boolean;
|
||||
small?: boolean;
|
||||
};
|
||||
|
||||
export type ConnectProvider = {
|
||||
/** `banking_connections.provider` value. */
|
||||
providerKey: string;
|
||||
institution: EnableBankingInstitution;
|
||||
/** Connect endpoint (the update flow always PATCHes the connection). */
|
||||
endpoint: string;
|
||||
/** Whether the connect request also sends the selected `country`. */
|
||||
sendsCountry?: boolean;
|
||||
/** Only offered when connecting from this country (e.g. Indexa: ES). */
|
||||
onlyCountry?: string;
|
||||
/** Hidden unless this Pennant feature is active. */
|
||||
feature?: keyof Features;
|
||||
/** Backend exposes a credential-update path for this provider. */
|
||||
updatable?: boolean;
|
||||
/** Confirm-step header copy (i18n key). */
|
||||
headerDescription: string;
|
||||
/** Confirm-card copy (i18n key). */
|
||||
cardDescription: string;
|
||||
fields: CredentialField[];
|
||||
help: { before: string; href: string; link: string; after?: string };
|
||||
};
|
||||
|
||||
export const CONNECT_PROVIDERS: ConnectProvider[] = [
|
||||
{
|
||||
providerKey: 'indexacapital',
|
||||
institution: {
|
||||
name: 'Indexa Capital',
|
||||
country: 'ES',
|
||||
logo: '/images/banks/logos/indexa-capital.jpg',
|
||||
maximum_consent_validity: null,
|
||||
},
|
||||
endpoint: '/open-banking/indexa-capital/connect',
|
||||
onlyCountry: 'ES',
|
||||
headerDescription:
|
||||
'Enter your API token to connect your Indexa Capital account.',
|
||||
cardDescription:
|
||||
'Connect your Indexa Capital account using your API token.',
|
||||
fields: [
|
||||
{
|
||||
key: 'api_token',
|
||||
label: 'API Token',
|
||||
type: 'password',
|
||||
placeholder: 'Paste your Indexa Capital API token',
|
||||
},
|
||||
],
|
||||
help: {
|
||||
before: 'You can generate your API token from your Indexa Capital dashboard under',
|
||||
href: 'https://indexacapital.com/es/u/user#settings-apps',
|
||||
link: 'Settings > 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<string, string>,
|
||||
): Record<string, string> {
|
||||
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<string, string>,
|
||||
): boolean {
|
||||
return provider.fields.every((f) => (values[f.key] ?? '').length > 0);
|
||||
}
|
||||
|
||||
export function ProviderHelp({ help }: { help: ConnectProvider['help'] }) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(help.before)}{' '}
|
||||
<a
|
||||
href={help.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{__(help.link)}
|
||||
</a>
|
||||
{help.after ? <>. {__(help.after)}</> : '.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={id}>{__(field.label)}</Label>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
id={id}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(field.key, e.target.value)
|
||||
}
|
||||
rows={6}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
type={field.type}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(field.key, e.target.value)
|
||||
}
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ProviderHelp help={provider.help} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue