diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index e66cee81..30c3143b 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -54,6 +54,43 @@ class AuthorizationController extends Controller ]); } + /** + * Re-authorize an existing EnableBanking connection whose session has been revoked. + */ + public function reauthorize(Request $request, BankingConnection $connection, BankingProviderInterface $provider): JsonResponse + { + if ($connection->user_id !== auth()->id()) { + abort(403); + } + + if (! $connection->isEnableBanking()) { + return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422); + } + + if ($connection->status !== BankingConnectionStatus::Error && ! $connection->isExpired()) { + return response()->json(['error' => 'Only connections with an error or expired status can be re-authorized.'], 422); + } + + $redirectUrl = config('services.enablebanking.redirect_url'); + + $result = $provider->startAuthorization( + $connection->aspsp_name, + $connection->aspsp_country, + $redirectUrl, + ); + + $connection->update([ + 'authorization_id' => $result['authorization_id'], + 'status' => BankingConnectionStatus::Pending, + 'error_message' => null, + ]); + + return response()->json([ + 'redirect_url' => $result['url'], + 'connection_id' => $connection->id, + ]); + } + /** * Handle the callback from bank authorization. */ @@ -104,6 +141,22 @@ class AuthorizationController extends Controller ->with('error', 'No pending connection found.'); } + $isReconnect = $connection->accounts()->exists(); + + if ($isReconnect) { + $connection->update([ + 'session_id' => $sessionData['session_id'], + 'status' => BankingConnectionStatus::Active, + 'valid_until' => $sessionData['access']['valid_until'] ?? null, + 'error_message' => null, + ]); + + SyncBankingConnectionJob::dispatch($connection); + + return redirect()->route('settings.connections.index') + ->with('success', __('Bank account reconnected successfully.')); + } + if (Feature::for($user)->active('account-mapping')) { $connection->update([ 'session_id' => $sessionData['session_id'], diff --git a/lang/es.json b/lang/es.json index 9d3a71b8..d5459d9c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -174,6 +174,7 @@ "As the founders, your feedback directly shapes what we build next. We personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping us continue building something we're passionate about.": "Como fundadores, tus comentarios moldean directamente lo que construimos a continuación. Leemos personalmente cada respuesta y tomamos tus sugerencias en serio. Cuando te suscribes, no estás apoyando a una gran corporación, estás ayudándonos a seguir construyendo algo que nos apasiona.", "Assign a new category": "Asignar una nueva categoría", "At least one action is required": "Se requiere al menos una acción", + "Authentication failed. Your credentials may have expired or been revoked.": "Autenticación fallida. Tus credenciales pueden haber expirado o sido revocadas.", "Auto-detect": "Detectar automáticamente", "Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco", "Automate over time": "Automatiza con el tiempo", @@ -198,6 +199,7 @@ "Balance evolution": "Evolución del balance", "Bank": "Banco", "Bank Account": "Cuenta Bancaria", + "Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.", "Bank Connections": "Conexiones Bancarias", "Bank Name": "Nombre del Banco", "Bank accounts": "Cuentas bancarias", @@ -496,8 +498,10 @@ "Failed to create transaction": "Error al crear la transacción", "Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.", "Failed to load banks. Please try again.": "Error al cargar los bancos. Por favor, inténtalo de nuevo.", + "Failed to reconnect. Please try again.": "Error al reconectar. Por favor, inténtalo de nuevo.", "Failed to set balance. Please try again.": "Error al establecer el balance. Por favor, inténtalo de nuevo.", "Failed to setup encryption. Please try again.": "Error al configurar la encriptación. Por favor, inténtalo de nuevo.", + "Failed to start re-authorization.": "Error al iniciar la reautorización.", "Failed to update credentials. Please try again.": "Error al actualizar las credenciales. Por favor, inténtalo de nuevo.", "Failed to update transaction": "Error al actualizar la transacción", "Fair": "Aceptable", @@ -902,6 +906,7 @@ "Re-evaluate rules": "Reevaluar reglas", "Reactivate Your Subscription": "Reactiva Tu Suscripción", "Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?", + "Reconnect": "Reconectar", "Recovery Codes": "Códigos de Recuperación", "Recovery codes": "Códigos de recuperación", "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.", diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx index 1cb17d91..78a501d0 100644 --- a/resources/js/pages/settings/connections.tsx +++ b/resources/js/pages/settings/connections.tsx @@ -31,11 +31,17 @@ import { KeyRound, MoreHorizontal, RefreshCw, + RotateCcw, Unplug, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +function getCsrfToken(): string { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + return match ? decodeURIComponent(match[1]) : ''; +} + interface Props { connections: BankingConnection[]; } @@ -50,6 +56,7 @@ export default function ConnectionsPage({ connections }: Props) { useState(null); const [updateCredentialsConnection, setUpdateCredentialsConnection] = useState(null); + const [reconnectingId, setReconnectingId] = useState(null); const hasSyncing = connections.some( (c) => c.status === 'active' && !c.last_synced_at, @@ -78,6 +85,40 @@ export default function ConnectionsPage({ connections }: Props) { 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(() => ({})); + 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 ['indexacapital', 'binance', 'bitpanda'].includes( connection.provider, @@ -93,6 +134,15 @@ export default function ConnectionsPage({ connections }: Props) { ); } + function isEnableBankingAuthError(connection: BankingConnection): boolean { + return ( + connection.status === 'error' && + connection.provider === 'enablebanking' && + (connection.error_message?.includes('Authentication failed') ?? + false) + ); + } + function formatDate(dateString: string | null): string { if (!dateString) return __('Never'); return new Date(dateString).toLocaleDateString(undefined, { @@ -207,10 +257,31 @@ export default function ConnectionsPage({ connections }: Props) { )} )} + {isEnableBankingAuthError( + connection, + ) && ( + + handleReconnect( + connection, + ) + } + disabled={ + reconnectingId === + connection.id + } + > + + {__('Reconnect')} + + )} {(connection.status === 'active' || - connection.status === - 'error') && ( + (connection.status === + 'error' && + !isEnableBankingAuthError( + connection, + ))) && ( handleSync( @@ -313,19 +384,50 @@ export default function ConnectionsPage({ connections }: Props) { )} )} - + {isEnableBankingAuthError( + connection, + ) ? ( + + ) : ( + + )} group(functi Route::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () { Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions'); Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize'); + Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize'); Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback'); Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts'); Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store'); diff --git a/tests/Feature/OpenBanking/AuthorizationControllerTest.php b/tests/Feature/OpenBanking/AuthorizationControllerTest.php index ca00a79c..f57c7b1c 100644 --- a/tests/Feature/OpenBanking/AuthorizationControllerTest.php +++ b/tests/Feature/OpenBanking/AuthorizationControllerTest.php @@ -3,6 +3,7 @@ use App\Contracts\BankingProviderInterface; use App\Enums\BankingConnectionStatus; use App\Jobs\SyncBankingConnectionJob; +use App\Models\Account; use App\Models\BankingConnection; use App\Models\User; use Illuminate\Support\Facades\Queue; @@ -236,3 +237,214 @@ test('callback with valid code stores pending accounts and redirects to mapping Queue::assertNothingPushed(); }); + +// Reauthorize tests + +test('reauthorize returns 403 when user does not own the connection', function () { + $owner = User::factory()->onboarded()->create(); + $other = User::factory()->onboarded()->create(); + Feature::for($other)->activate('open-banking'); + + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $owner->id, + ]); + + $response = $this->actingAs($other)->postJson("/open-banking/connections/{$connection->id}/reauthorize"); + + $response->assertForbidden(); +}); + +test('reauthorize returns 422 for non-EnableBanking connections', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->indexaCapital()->error()->create([ + 'user_id' => $user->id, + ]); + + $response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize"); + + $response->assertUnprocessable(); + $response->assertJson(['error' => 'Only EnableBanking connections can be re-authorized.']); +}); + +test('reauthorize returns 422 for active connections', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'status' => BankingConnectionStatus::Active, + ]); + + $response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize"); + + $response->assertUnprocessable(); + $response->assertJson(['error' => 'Only connections with an error or expired status can be re-authorized.']); +}); + +test('reauthorize starts new authorization and sets connection to pending for error connections', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->error()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'CaixaBank', + 'aspsp_country' => 'ES', + 'error_message' => 'Authentication failed. Your credentials may have expired or been revoked.', + ]); + + $originalAuthorizationId = $connection->authorization_id; + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('startAuthorization') + ->with('CaixaBank', 'ES', config('services.enablebanking.redirect_url')) + ->once() + ->andReturn([ + 'url' => 'https://bank.example.com/reauthorize', + 'authorization_id' => 'new-auth-id-456', + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize"); + + $response->assertOk(); + $response->assertJsonStructure(['redirect_url', 'connection_id']); + $response->assertJson([ + 'redirect_url' => 'https://bank.example.com/reauthorize', + 'connection_id' => $connection->id, + ]); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Pending); + expect($connection->authorization_id)->toBe('new-auth-id-456'); + expect($connection->authorization_id)->not->toBe($originalAuthorizationId); + expect($connection->error_message)->toBeNull(); +}); + +test('reauthorize starts new authorization for expired connections', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->expired()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Santander', + 'aspsp_country' => 'ES', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('startAuthorization') + ->once() + ->andReturn([ + 'url' => 'https://bank.example.com/reauthorize', + 'authorization_id' => 'new-auth-id-789', + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->postJson("/open-banking/connections/{$connection->id}/reauthorize"); + + $response->assertOk(); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Pending); + expect($connection->authorization_id)->toBe('new-auth-id-789'); +}); + +// Reconnect callback tests + +test('callback with existing accounts updates session without creating new accounts', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'CaixaBank', + 'aspsp_country' => 'ES', + 'last_synced_at' => now()->subWeek(), + ]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'existing-ext-account-1', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->with('test-code') + ->once() + ->andReturn([ + 'session_id' => 'new-session-456', + 'accounts' => [ + [ + 'uid' => 'existing-ext-account-1', + 'currency' => 'EUR', + 'name' => 'CaixaBank Account', + 'account_id' => ['iban' => 'ES1234567890123456789012'], + ], + ], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test-code'); + + $response->assertRedirect(route('settings.connections.index')); + $response->assertSessionHas('success', 'Bank account reconnected successfully.'); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + expect($connection->session_id)->toBe('new-session-456'); + expect($connection->error_message)->toBeNull(); + + // No duplicate accounts should have been created + $this->assertDatabaseCount('accounts', 1); + + Queue::assertPushed(SyncBankingConnectionJob::class); +}); + +test('callback with existing accounts skips account-mapping even when feature flag is enabled', function () { + Queue::fake(); + + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate('open-banking'); + Feature::for($user)->activate('account-mapping'); + + $connection = BankingConnection::factory()->pending()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'CaixaBank', + 'aspsp_country' => 'ES', + ]); + + Account::factory()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'existing-ext-account-1', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('createSession') + ->once() + ->andReturn([ + 'session_id' => 'new-session-789', + 'accounts' => [], + 'access' => ['valid_until' => now()->addDays(90)->toIso8601String()], + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get('/open-banking/callback?code=test-code'); + + // Must redirect to connections page, NOT to the account-mapping route + $response->assertRedirect(route('settings.connections.index')); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Active); + + Queue::assertPushed(SyncBankingConnectionJob::class); +});