feat(connections): filter already-connected institutions from connect bank dialog (#217)

## Summary

- When a user already has a connection to a bank/provider, that
institution is now hidden from the list when they open the \"Connect
Bank\" dialog
- Applies to all providers: EnableBanking institutions (matched by
`aspsp_name`), Binance, Bitpanda, and Indexa Capital (matched by
`provider`)
- `ConnectAccountDialog` accepts a new `connections` prop (passed from
the connections settings page where it's already available);
`ConnectAccountInline` (used in onboarding) accepts an optional
`connections` prop defaulting to `[]`

## Tests

- Added 2 backend tests to `ConnectionControllerTest` verifying the data
contract: the connections page sends `provider` and `aspsp_name` fields
for all provider types, which the frontend filtering logic depends on
This commit is contained in:
Víctor Falcón 2026-03-11 12:06:49 +00:00 committed by GitHub
parent 28c8df34d5
commit 1058904b14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 9 deletions

View File

@ -16,7 +16,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { EnableBankingInstitution } from '@/types/banking';
import type {
BankingConnection,
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -65,6 +68,7 @@ const BITPANDA_INSTITUTION: EnableBankingInstitution = {
interface ConnectAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
connections?: BankingConnection[];
}
type Step = 'country' | 'bank' | 'confirm';
@ -81,6 +85,7 @@ function getCsrfToken(): string {
export function ConnectAccountDialog({
open,
onOpenChange,
connections = [],
}: ConnectAccountDialogProps) {
const [step, setStep] = useState<Step>('country');
const [country, setCountry] = useState<string>('');
@ -171,6 +176,14 @@ export function ConnectAccountDialog({
const data = await response.json();
const connectedEnableBankingNames = new Set(
connections
.filter((c) => c.provider === 'enablebanking')
.map((c) => c.aspsp_name),
);
const hasProvider = (provider: string) =>
connections.some((c) => c.provider === provider);
const extraInstitutions = [
BINANCE_INSTITUTION,
BITPANDA_INSTITUTION,
@ -179,9 +192,20 @@ export function ConnectAccountDialog({
extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
}
const allInstitutions = [...extraInstitutions, ...data].sort(
(a, b) => a.name.localeCompare(b.name),
);
const allInstitutions = [...extraInstitutions, ...data]
.filter((institution) => {
if (institution.name === 'Binance') {
return !hasProvider('binance');
}
if (institution.name === 'Bitpanda') {
return !hasProvider('bitpanda');
}
if (institution.name === 'Indexa Capital') {
return !hasProvider('indexacapital');
}
return !connectedEnableBankingNames.has(institution.name);
})
.sort((a, b) => a.name.localeCompare(b.name));
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);

View File

@ -9,7 +9,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { EnableBankingInstitution } from '@/types/banking';
import type {
BankingConnection,
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -70,9 +73,13 @@ function getCsrfToken(): string {
interface ConnectAccountInlineProps {
onBack: () => void;
connections?: BankingConnection[];
}
export function ConnectAccountInline({ onBack }: ConnectAccountInlineProps) {
export function ConnectAccountInline({
onBack,
connections = [],
}: ConnectAccountInlineProps) {
const [step, setStep] = useState<Step>('country');
const { trigger } = useWebHaptics();
const [country, setCountry] = useState<string>('');
@ -153,6 +160,14 @@ export function ConnectAccountInline({ onBack }: ConnectAccountInlineProps) {
const data = await response.json();
const connectedEnableBankingNames = new Set(
connections
.filter((c) => c.provider === 'enablebanking')
.map((c) => c.aspsp_name),
);
const hasProvider = (provider: string) =>
connections.some((c) => c.provider === provider);
const extraInstitutions = [
BINANCE_INSTITUTION,
BITPANDA_INSTITUTION,
@ -161,9 +176,20 @@ export function ConnectAccountInline({ onBack }: ConnectAccountInlineProps) {
extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
}
const allInstitutions = [...extraInstitutions, ...data].sort(
(a, b) => a.name.localeCompare(b.name),
);
const allInstitutions = [...extraInstitutions, ...data]
.filter((institution) => {
if (institution.name === 'Binance') {
return !hasProvider('binance');
}
if (institution.name === 'Bitpanda') {
return !hasProvider('bitpanda');
}
if (institution.name === 'Indexa Capital') {
return !hasProvider('indexacapital');
}
return !connectedEnableBankingNames.has(institution.name);
})
.sort((a, b) => a.name.localeCompare(b.name));
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);

View File

@ -351,6 +351,7 @@ export default function ConnectionsPage({ connections }: Props) {
<ConnectAccountDialog
open={connectDialogOpen}
onOpenChange={setConnectDialogOpen}
connections={connections}
/>
<UpgradeConnectionDialog

View File

@ -397,3 +397,42 @@ test('credential update validates required fields for binance', function () {
$response->assertSessionHasErrors('api_secret');
});
test('connections page includes provider and aspsp_name fields needed for frontend duplicate filtering', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create([
'user_id' => $user->id,
'provider' => 'enablebanking',
'aspsp_name' => 'CaixaBank',
]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 1)
->where('connections.0.provider', 'enablebanking')
->where('connections.0.aspsp_name', 'CaixaBank')
);
});
test('connections page includes connections from all provider types for frontend filtering', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate('open-banking');
BankingConnection::factory()->create(['user_id' => $user->id, 'provider' => 'enablebanking', 'aspsp_name' => 'CaixaBank']);
BankingConnection::factory()->binance()->create(['user_id' => $user->id]);
BankingConnection::factory()->bitpanda()->create(['user_id' => $user->id]);
BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/connections');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('connections', 4)
->has('connections.0.provider')
->has('connections.0.aspsp_name')
);
});