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. * Handle the callback from bank authorization.
*/ */
@ -104,6 +141,22 @@ class AuthorizationController extends Controller
->with('error', 'No pending connection found.'); ->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')) { if (Feature::for($user)->active('account-mapping')) {
$connection->update([ $connection->update([
'session_id' => $sessionData['session_id'], '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.", "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", "Assign a new category": "Asignar una nueva categoría",
"At least one action is required": "Se requiere al menos una acción", "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-detect": "Detectar automáticamente",
"Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco", "Auto-sync transactions directly from your bank": "Sincroniza automáticamente las transacciones desde tu banco",
"Automate over time": "Automatiza con el tiempo", "Automate over time": "Automatiza con el tiempo",
@ -198,6 +199,7 @@
"Balance evolution": "Evolución del balance", "Balance evolution": "Evolución del balance",
"Bank": "Banco", "Bank": "Banco",
"Bank Account": "Cuenta Bancaria", "Bank Account": "Cuenta Bancaria",
"Bank account reconnected successfully.": "Cuenta bancaria reconectada exitosamente.",
"Bank Connections": "Conexiones Bancarias", "Bank Connections": "Conexiones Bancarias",
"Bank Name": "Nombre del Banco", "Bank Name": "Nombre del Banco",
"Bank accounts": "Cuentas bancarias", "Bank accounts": "Cuentas bancarias",
@ -496,8 +498,10 @@
"Failed to create transaction": "Error al crear la transacción", "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 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 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 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 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 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", "Failed to update transaction": "Error al actualizar la transacción",
"Fair": "Aceptable", "Fair": "Aceptable",
@ -902,6 +906,7 @@
"Re-evaluate rules": "Reevaluar reglas", "Re-evaluate rules": "Reevaluar reglas",
"Reactivate Your Subscription": "Reactiva Tu Suscripción", "Reactivate Your Subscription": "Reactiva Tu Suscripción",
"Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?", "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": "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.", "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, KeyRound,
MoreHorizontal, MoreHorizontal,
RefreshCw, RefreshCw,
RotateCcw,
Unplug, Unplug,
} from 'lucide-react'; } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
function getCsrfToken(): string {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
interface Props { interface Props {
connections: BankingConnection[]; connections: BankingConnection[];
} }
@ -50,6 +56,7 @@ export default function ConnectionsPage({ connections }: Props) {
useState<BankingConnection | null>(null); useState<BankingConnection | null>(null);
const [updateCredentialsConnection, setUpdateCredentialsConnection] = const [updateCredentialsConnection, setUpdateCredentialsConnection] =
useState<BankingConnection | null>(null); useState<BankingConnection | null>(null);
const [reconnectingId, setReconnectingId] = useState<string | null>(null);
const hasSyncing = connections.some( const hasSyncing = connections.some(
(c) => c.status === 'active' && !c.last_synced_at, (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`); 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 { function isApiKeyProvider(connection: BankingConnection): boolean {
return ['indexacapital', 'binance', 'bitpanda'].includes( return ['indexacapital', 'binance', 'bitpanda'].includes(
connection.provider, 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 { function formatDate(dateString: string | null): string {
if (!dateString) return __('Never'); if (!dateString) return __('Never');
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
@ -207,10 +257,31 @@ export default function ConnectionsPage({ connections }: Props) {
)} )}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isEnableBankingAuthError(
connection,
) && (
<DropdownMenuItem
onClick={() =>
handleReconnect(
connection,
)
}
disabled={
reconnectingId ===
connection.id
}
>
<RotateCcw className="mr-2 h-4 w-4" />
{__('Reconnect')}
</DropdownMenuItem>
)}
{(connection.status === {(connection.status ===
'active' || 'active' ||
connection.status === (connection.status ===
'error') && ( 'error' &&
!isEnableBankingAuthError(
connection,
))) && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
handleSync( handleSync(
@ -313,19 +384,50 @@ export default function ConnectionsPage({ connections }: Props) {
)} )}
</Button> </Button>
)} )}
<Button {isEnableBankingAuthError(
variant="secondary" connection,
size="sm" ) ? (
className="h-7 text-xs" <Button
onClick={() => variant="secondary"
handleSync( size="sm"
connection, className="h-7 text-xs"
) disabled={
} reconnectingId ===
> connection.id
<RefreshCw className="mr-1.5 h-3 w-3" /> }
{__('Retry')} onClick={() =>
</Button> 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 <a
href="https://discord.gg/2WZmDW9QZ8" href="https://discord.gg/2WZmDW9QZ8"
target="_blank" 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::middleware(['auth', 'verified', 'open-banking'])->prefix('open-banking')->group(function () {
Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions'); Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions');
Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize'); 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('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts'); 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'); 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\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus; use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob; use App\Jobs\SyncBankingConnectionJob;
use App\Models\Account;
use App\Models\BankingConnection; use App\Models\BankingConnection;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -236,3 +237,214 @@ test('callback with valid code stores pending accounts and redirects to mapping
Queue::assertNothingPushed(); 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);
});