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:
parent
1058904b14
commit
1f5e6ac450
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue