feat(connections): add EnableBanking reconnect flow (#218)

## Summary

- Add `reauthorize` endpoint and reconnect detection in OAuth
`callback()` so users can re-authorize a revoked EnableBanking session
without losing their accounts or transaction history
- Replace "Retry" with "Reconnect" button (dropdown + error panel) for
EnableBanking authentication errors; catch-up sync of missed
transactions is handled automatically by the existing
`SyncBankingConnectionJob` via `last_synced_at`
- Add 5 missing Spanish translations (`Reconectar`, `Autenticación
fallida...`, `Error al iniciar la reautorización.`, `Error al
reconectar...`, `Cuenta bancaria reconectada exitosamente.`) and wrap
the reconnect flash message in `__()`
- 7 new Pest tests covering all reauthorize scenarios and the reconnect
callback path (15 total passing)
This commit is contained in:
Víctor Falcón 2026-03-11 12:58:29 +00:00 committed by GitHub
parent 1058904b14
commit 1f5e6ac450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 388 additions and 15 deletions

View File

@ -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'],

View File

@ -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.",

View File

@ -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<BankingConnection | null>(null);
const [updateCredentialsConnection, setUpdateCredentialsConnection] =
useState<BankingConnection | null>(null);
const [reconnectingId, setReconnectingId] = useState<string | null>(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) {
)}
</DropdownMenuItem>
)}
{isEnableBankingAuthError(
connection,
) && (
<DropdownMenuItem
onClick={() =>
handleReconnect(
connection,
)
}
disabled={
reconnectingId ===
connection.id
}
>
<RotateCcw className="mr-2 h-4 w-4" />
{__('Reconnect')}
</DropdownMenuItem>
)}
{(connection.status ===
'active' ||
connection.status ===
'error') && (
(connection.status ===
'error' &&
!isEnableBankingAuthError(
connection,
))) && (
<DropdownMenuItem
onClick={() =>
handleSync(
@ -313,19 +384,50 @@ export default function ConnectionsPage({ connections }: Props) {
)}
</Button>
)}
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() =>
handleSync(
connection,
)
}
>
<RefreshCw className="mr-1.5 h-3 w-3" />
{__('Retry')}
</Button>
{isEnableBankingAuthError(
connection,
) ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
disabled={
reconnectingId ===
connection.id
}
onClick={() =>
handleReconnect(
connection,
)
}
>
{reconnectingId ===
connection.id ? (
<Spinner className="mr-1.5 size-3" />
) : (
<RotateCcw className="mr-1.5 h-3 w-3" />
)}
{__(
'Reconnect',
)}
</Button>
) : (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() =>
handleSync(
connection,
)
}
>
<RefreshCw className="mr-1.5 h-3 w-3" />
{__(
'Retry',
)}
</Button>
)}
<a
href="https://discord.gg/2WZmDW9QZ8"
target="_blank"

View File

@ -113,6 +113,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->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');

View File

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