import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog'; import { ConnectionStatusBadge } from '@/components/open-banking/connection-status-badge'; import { DisconnectDialog } from '@/components/open-banking/disconnect-dialog'; import { UpdateCredentialsDialog } from '@/components/open-banking/update-credentials-dialog'; import { UpgradeDialog } from '@/components/subscription/upgrade-dialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { CreateButton } from '@/components/ui/create-button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Spinner } from '@/components/ui/spinner'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { CONNECT_PROVIDERS } from '@/lib/connect-providers'; import { getCsrfToken } from '@/lib/csrf'; import type { SharedData } from '@/types'; import type { BankingConnection } from '@/types/banking'; import { __ } from '@/utils/i18n'; import { Head, router, usePage, usePoll } from '@inertiajs/react'; import { AlertCircle, ArrowRight, KeyRound, MoreHorizontal, RefreshCw, RotateCcw, Unplug, Wallet, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; interface Props { connections: BankingConnection[]; } export default function ConnectionsPage({ connections }: Props) { const { auth, flash, subscriptionsEnabled } = usePage().props; const isDemoAccount = auth?.isDemoAccount ?? false; const isFreePlan = subscriptionsEnabled && !auth?.hasProPlan; const [connectDialogOpen, setConnectDialogOpen] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); const [disconnectConnection, setDisconnectConnection] = useState(null); const [updateCredentialsConnection, setUpdateCredentialsConnection] = useState(null); const [reconnectingId, setReconnectingId] = useState(null); const hasSyncing = connections.some( (c) => c.status === 'active' && !c.last_synced_at, ); const { start, stop } = usePoll(5000, {}, { autoStart: false }); useEffect(() => { if (flash?.error) { toast.error(flash.error); } if (flash?.success) { toast.success(flash.success); } }, [flash?.error, flash?.success]); useEffect(() => { if (hasSyncing) { start(); } else { stop(); } }, [hasSyncing, start, stop]); function handleSync(connection: BankingConnection) { router.post(`/settings/connections/${connection.id}/sync`); } async function handleReconnect(connection: BankingConnection) { setReconnectingId(connection.id); try { const response = await fetch( `/open-banking/connections/${connection.id}/reauthorize`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-XSRF-TOKEN': getCsrfToken(), }, }, ); if (!response.ok) { const data = await response.json().catch(() => ({})); if (typeof data.redirect === 'string') { window.location.href = data.redirect; return; } throw new Error( data.error || __('Failed to start re-authorization.'), ); } const data = await response.json(); window.location.href = data.redirect_url; } catch (e) { toast.error( e instanceof Error ? e.message : __('Failed to reconnect. Please try again.'), ); setReconnectingId(null); } } function isApiKeyProvider(connection: BankingConnection): boolean { return CONNECT_PROVIDERS.some( (provider) => provider.providerKey === connection.provider, ); } function hasAuthError(connection: BankingConnection): boolean { return ( connection.status === 'error' && isApiKeyProvider(connection) && (connection.error_message?.includes('Authentication failed') ?? false) ); } function isEnableBankingAuthError(connection: BankingConnection): boolean { return ( connection.status === 'error' && connection.provider === 'enablebanking' && (connection.error_message?.includes('Authentication failed') ?? false) ); } function canReconnect(connection: BankingConnection): boolean { return ( connection.provider === 'enablebanking' && (connection.status === 'expired' || isEnableBankingAuthError(connection)) ); } function canManageAccounts(connection: BankingConnection): boolean { return ( connection.provider === 'enablebanking' && ['active', 'expired', 'error'].includes(connection.status) ); } function formatDate(dateString: string | null): string { if (!dateString) return __('Never'); return new Date(dateString).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } return (

{__('Bank Connections')}

{__( 'Manage your connected bank accounts for automatic transaction syncing.', )}

isFreePlan ? setUpgradeDialogOpen(true) : setConnectDialogOpen(true) } disabled={isDemoAccount} > {__('Connect Bank')}
{connections.length === 0 ? (

{__( 'No bank connections yet. Connect a bank to automatically sync your transactions.', )}

) : (
{connections.map((connection) => (
{connection.aspsp_name} {connection.aspsp_country}{' '} ·{' '} {connection.accounts_count}{' '} {connection.accounts_count === 1 ? __('account') : __('accounts')}
{connection.status === 'expired' && canReconnect(connection) && ( )} {connection.status === 'awaiting_mapping' && ( router.visit( `/open-banking/connections/${connection.id}/map-accounts`, ) } > {__('Map Accounts')} )} {canManageAccounts( connection, ) && ( router.visit( `/open-banking/connections/${connection.id}/accounts`, ) } > {__( 'Manage Accounts', )} )} {hasAuthError( connection, ) && ( setUpdateCredentialsConnection( connection, ) } > {__( 'Update Credentials', )} )} {canReconnect( connection, ) && ( handleReconnect( connection, ) } disabled={ reconnectingId === connection.id } > {__('Reconnect')} )} {(connection.status === 'active' || (connection.status === 'error' && !isEnableBankingAuthError( connection, ))) && ( handleSync( connection, ) } > {connection.status === 'error' ? __('Retry') : __( 'Sync Now', )} )} setDisconnectConnection( connection, ) } className="text-destructive" > {__('Disconnect')}
{connection.status === 'awaiting_mapping' ? ( {__( 'Accounts need to be mapped before syncing can begin.', )} ) : connection.status === 'active' && !connection.last_synced_at ? ( {[ 'indexacapital', 'interactivebrokers', ].includes( connection.provider, ) ? __( 'Syncing balances…', ) : __( 'Syncing transactions and balances…', )} ) : ( {__('Last synced')}:{' '} {formatDate( connection.last_synced_at, )} )} {connection.valid_until && ( {__('Expires')}:{' '} {formatDate( connection.valid_until, )} )}
{connection.status === 'error' && (

{connection.error_message ?? __( 'An unexpected error occurred during sync.', )}

{hasAuthError( connection, ) && ( )} {isEnableBankingAuthError( connection, ) ? ( ) : ( )} {__( 'Need help? Join our Discord', )}
)}
))}
)}
{disconnectConnection && ( { if (!open) setDisconnectConnection(null); }} /> )} {updateCredentialsConnection && ( { if (!open) setUpdateCredentialsConnection(null); }} /> )}
); }