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:
Víctor Falcón 2026-06-22 15:12:11 +02:00
parent 81a2d5ac96
commit b40a856dbc
5 changed files with 539 additions and 1135 deletions

View File

@ -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()]);

View File

@ -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...')

View File

@ -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>

View File

@ -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

View File

@ -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>
);
}