diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php index 41be2bbb..7380fab9 100644 --- a/app/Http/Controllers/OpenBanking/ConnectionController.php +++ b/app/Http/Controllers/OpenBanking/ConnectionController.php @@ -6,8 +6,12 @@ use App\Contracts\BankingProviderInterface; use App\Enums\BankingConnectionStatus; use App\Http\Controllers\Controller; use App\Http\Requests\OpenBanking\DestroyConnectionRequest; +use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest; use App\Jobs\SyncBankingConnectionJob; use App\Models\BankingConnection; +use App\Services\Banking\BinanceClient; +use App\Services\Banking\BitpandaClient; +use App\Services\Banking\IndexaCapitalClient; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Log; @@ -60,6 +64,64 @@ class ConnectionController extends Controller return back()->with('success', 'Sync started. Transactions will be updated shortly.'); } + /** + * Update credentials for an API-key-based connection. + */ + public function updateCredentials(UpdateConnectionCredentialsRequest $request, BankingConnection $connection): RedirectResponse + { + $validated = $request->validated(); + + $validationError = $this->validateProviderCredentials($connection, $validated); + + if ($validationError) { + return back()->withErrors(['credentials' => $validationError]); + } + + $updateData = match ($connection->provider) { + 'indexacapital' => ['api_token' => $validated['api_token']], + 'binance' => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']], + 'bitpanda' => ['api_token' => $validated['api_key']], + default => [], + }; + + $connection->update([ + ...$updateData, + 'status' => BankingConnectionStatus::Active, + 'error_message' => null, + ]); + + SyncBankingConnectionJob::dispatch($connection); + + return back()->with('success', __('Credentials updated. Sync started.')); + } + + /** + * Validate credentials against the provider API. + */ + private function validateProviderCredentials(BankingConnection $connection, array $validated): ?string + { + try { + match ($connection->provider) { + 'indexacapital' => (new IndexaCapitalClient($validated['api_token']))->getUser(), + 'binance' => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(), + 'bitpanda' => (new BitpandaClient($validated['api_key']))->getCryptoWallets(), + default => throw new \InvalidArgumentException('Unsupported provider for credential update.'), + }; + } catch (\InvalidArgumentException $e) { + return $e->getMessage(); + } catch (\Throwable $e) { + Log::warning('Credential validation failed during update', [ + 'connection_id' => $connection->id, + 'provider' => $connection->provider, + 'error' => $e->getMessage(), + ]); + + return __('Invalid credentials. Please check and try again.'); + } + + return null; + } + /** * Revoke and delete a banking connection. */ diff --git a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php new file mode 100644 index 00000000..d6f566cf --- /dev/null +++ b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php @@ -0,0 +1,45 @@ +route('connection'); + + return Feature::for($this->user())->active('open-banking') + && $connection instanceof BankingConnection + && $connection->user_id === $this->user()->id; + } + + /** + * @return array> + */ + public function rules(): array + { + $connection = $this->route('connection'); + + if (! $connection instanceof BankingConnection) { + return []; + } + + return match ($connection->provider) { + 'indexacapital' => [ + 'api_token' => ['required', 'string', 'min:10'], + ], + 'binance' => [ + 'api_key' => ['required', 'string', 'min:10'], + 'api_secret' => ['required', 'string', 'min:10'], + ], + 'bitpanda' => [ + 'api_key' => ['required', 'string', 'min:10'], + ], + default => [], + }; + } +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 33e4d995..d3f5e3e4 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\BankingConnectionStatus; +use App\Mail\BankingConnectionAuthFailedEmail; use App\Mail\BankTransactionsSyncedEmail; use App\Models\BankingConnection; use App\Services\Banking\BalanceSyncService; @@ -84,6 +85,13 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue 'error_message' => $this->friendlyErrorMessage($e), ]); + if ($this->isAuthError($e) && $this->isApiKeyProvider($connection) && $this->attempts() >= $this->tries) { + Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail( + $connection->user, + $connection, + )); + } + throw $e; } } @@ -194,4 +202,17 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue return __('An unexpected error occurred during sync. Please try again later.'); } + + private function isAuthError(\Throwable $e): bool + { + return $e instanceof RequestException + && in_array($e->response->status(), [401, 403]); + } + + private function isApiKeyProvider(BankingConnection $connection): bool + { + return $connection->isIndexaCapital() + || $connection->isBinance() + || $connection->isBitpanda(); + } } diff --git a/app/Mail/BankingConnectionAuthFailedEmail.php b/app/Mail/BankingConnectionAuthFailedEmail.php new file mode 100644 index 00000000..bdabf3d0 --- /dev/null +++ b/app/Mail/BankingConnectionAuthFailedEmail.php @@ -0,0 +1,69 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct( + public User $user, + public BankingConnection $bankingConnection, + ) { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: __('Action required: :provider connection needs attention', [ + 'provider' => $this->bankingConnection->aspsp_name, + ]), + )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.banking-connection-auth-failed', + with: [ + 'userName' => $this->user->name, + 'providerName' => $this->bankingConnection->aspsp_name, + ], + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index abd79166..be8a2cc7 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -32,6 +32,14 @@ class BankingConnection extends Model 'api_secret', ]; + protected $hidden = [ + 'api_token', + 'api_secret', + 'pending_accounts_data', + 'authorization_id', + 'session_id', + ]; + protected function casts(): array { return [ diff --git a/resources/js/components/open-banking/update-credentials-dialog.tsx b/resources/js/components/open-banking/update-credentials-dialog.tsx new file mode 100644 index 00000000..dcee1825 --- /dev/null +++ b/resources/js/components/open-banking/update-credentials-dialog.tsx @@ -0,0 +1,242 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { BankingConnection } from '@/types/banking'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import { useState } from 'react'; + +interface UpdateCredentialsDialogProps { + connection: BankingConnection; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function UpdateCredentialsDialog({ + connection, + open, + onOpenChange, +}: UpdateCredentialsDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [apiToken, setApiToken] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [apiSecret, setApiSecret] = useState(''); + const [error, setError] = useState(null); + + const isIndexaCapital = connection.provider === 'indexacapital'; + const isBinance = connection.provider === 'binance'; + const isBitpanda = connection.provider === 'bitpanda'; + + const isValid = isIndexaCapital + ? apiToken.length > 0 + : isBinance + ? apiKey.length > 0 && apiSecret.length > 0 + : isBitpanda + ? apiKey.length > 0 + : false; + + function handleSubmit() { + setIsSubmitting(true); + setError(null); + + const data = isIndexaCapital + ? { api_token: apiToken } + : isBinance + ? { api_key: apiKey, api_secret: apiSecret } + : { api_key: apiKey }; + + router.patch( + `/settings/connections/${connection.id}/credentials`, + data, + { + onSuccess: () => { + onOpenChange(false); + resetState(); + }, + onError: (errors) => { + setError( + errors.credentials ?? + errors.api_token ?? + errors.api_key ?? + errors.api_secret ?? + __( + 'Failed to update credentials. Please try again.', + ), + ); + }, + onFinish: () => { + setIsSubmitting(false); + }, + }, + ); + } + + function resetState() { + setApiToken(''); + setApiKey(''); + setApiSecret(''); + setError(null); + } + + function handleOpenChange(value: boolean) { + if (!value) { + resetState(); + } + onOpenChange(value); + } + + return ( + + + + {__('Update Credentials')} + + {__('Enter your new API credentials for :provider.', { + provider: connection.aspsp_name, + })} + + + + {error &&

{error}

} + +
+ {isIndexaCapital && ( +
+ + setApiToken(e.target.value)} + placeholder={__( + 'Paste your Indexa Capital API token', + )} + /> +

+ {__( + 'You can generate your API token from your Indexa Capital dashboard under', + )}{' '} + + {__('Settings > Applications')} + + . +

+
+ )} + + {isBinance && ( + <> +
+ + setApiKey(e.target.value)} + placeholder={__( + 'Paste your Binance API Key', + )} + /> +
+
+ + + setApiSecret(e.target.value) + } + placeholder={__( + 'Paste your Binance API Secret', + )} + /> +
+

+ {__( + 'You can create API keys from your Binance account under', + )}{' '} + + {__('API Management')} + + . +

+ + )} + + {isBitpanda && ( +
+ + setApiKey(e.target.value)} + placeholder={__('Paste your Bitpanda API Key')} + /> +

+ {__( + 'You can create API keys from your Bitpanda account under', + )}{' '} + + {__('API Key Management')} + + . +

+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx index 33f7c563..00dbed40 100644 --- a/resources/js/pages/settings/connections.tsx +++ b/resources/js/pages/settings/connections.tsx @@ -1,6 +1,7 @@ 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 { Button } from '@/components/ui/button'; import { Card, @@ -26,6 +27,7 @@ import { Head, router, usePage, usePoll } from '@inertiajs/react'; import { AlertCircle, ArrowRight, + KeyRound, MoreHorizontal, RefreshCw, Unplug, @@ -43,6 +45,8 @@ export default function ConnectionsPage({ connections }: Props) { const [connectDialogOpen, setConnectDialogOpen] = useState(false); const [disconnectConnection, setDisconnectConnection] = useState(null); + const [updateCredentialsConnection, setUpdateCredentialsConnection] = + useState(null); const hasSyncing = connections.some( (c) => c.status === 'active' && !c.last_synced_at, @@ -71,6 +75,21 @@ export default function ConnectionsPage({ connections }: Props) { router.post(`/settings/connections/${connection.id}/sync`); } + function isApiKeyProvider(connection: BankingConnection): boolean { + return ['indexacapital', 'binance', 'bitpanda'].includes( + connection.provider, + ); + } + + function hasAuthError(connection: BankingConnection): boolean { + return ( + connection.status === 'error' && + isApiKeyProvider(connection) && + (connection.error_message?.includes('Authentication failed') ?? + false) + ); + } + function formatDate(dateString: string | null): string { if (!dateString) return __('Never'); return new Date(dateString).toLocaleDateString(undefined, { @@ -165,6 +184,22 @@ export default function ConnectionsPage({ connections }: Props) { {__('Map Accounts')} )} + {hasAuthError( + connection, + ) && ( + + setUpdateCredentialsConnection( + connection, + ) + } + > + + {__( + 'Update Credentials', + )} + + )} {(connection.status === 'active' || connection.status === @@ -252,6 +287,25 @@ export default function ConnectionsPage({ connections }: Props) { )}

+ {hasAuthError( + connection, + ) && ( + + )}