fix(banking): harden PSD2 loading, error, and refresh states (#934)
Fixes loading/error/refresh-state gaps across the Enable Banking (PSD2) surfaces: - Delete the dead post-OAuth bank_connected sync flow + 9 orphaned i18n keys. - Surface fetch failures instead of empty/false-healthy states (settings panel error card, BankSelector non-OK guard, chip hidden on error). - Stop the full-panel spinner flash on refetch; clear the spinner in finally. - Client-side sync/backfill timeouts + live elapsed counter with a grace-period unlock so a slow bank can't trap the modal. - Broadcast a bank-sync signal so the transactions chip refetches after a manual sync. - Consolidate the import page onto the shared banking panel (also removes the core -> @/extensions import-rule violation) and standardize spinners. - Surface previously silent chart/fiscal-year fetch failures in the account picker. - Review fixes: cancellation guard on the deferred bank_error microtask; key the sync-progress dialog per attempt to reset its elapsed timer.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
@@ -23,92 +23,36 @@ export function BankingSettingsContent() {
|
||||
const [failedBankName, setFailedBankName] = useState<string | null>(null)
|
||||
const [isAccessDenied, setIsAccessDenied] = useState(false)
|
||||
const [showHbPoaHint, setShowHbPoaHint] = useState(false)
|
||||
const syncInitiatedRef = useRef(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const unmountedRef = useRef(false)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
// Surface a bank connection/authorization failure that the OAuth callback
|
||||
// bounced back as `?bank_error=...`. The success path is handled by the
|
||||
// callback redirecting to `?select_accounts=<id>`, which the banking panel
|
||||
// picks up to open account selection: there is no `bank_connected` param.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unmountedRef.current = true
|
||||
if (abortControllerRef.current) abortControllerRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
if (!bankError) return
|
||||
|
||||
if (bankConnected === 'true' && !syncInitiatedRef.current) {
|
||||
syncInitiatedRef.current = true
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
router.replace('/settings/banking')
|
||||
|
||||
if (connectionId) {
|
||||
toast({
|
||||
title: t('sync_start_title'),
|
||||
description: t('sync_start_description'),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
const syncTimeout = setTimeout(() => controller.abort(), 120_000)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 120 }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(syncTimeout)
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (!unmountedRef.current) {
|
||||
toast({
|
||||
title: t('sync_success_title'),
|
||||
description: t('sync_success_description', { count: data.imported ?? 0 }),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.error || 'Sync failed')
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(syncTimeout)
|
||||
if (unmountedRef.current) return
|
||||
if (controller.signal.aborted) {
|
||||
toast({
|
||||
title: t('sync_timeout_title'),
|
||||
description: t('sync_timeout_description'),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('sync_failed_title'),
|
||||
description: err instanceof Error ? err.message : t('sync_failed_default'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
} else {
|
||||
toast({
|
||||
title: t('sync_success_title'),
|
||||
description: t('sync_success_no_id_description'),
|
||||
})
|
||||
}
|
||||
let errorMsg: string
|
||||
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
|
||||
const bankName = searchParams.get('bank_name')
|
||||
const errorCode = searchParams.get('bank_error_code')
|
||||
const psuType = searchParams.get('psu_type')
|
||||
// The bank often returns a bare "server_error" with no description: show a
|
||||
// human message instead of the raw OAuth error code.
|
||||
if (errorCode === 'server_error' && errorMsg === 'server_error') {
|
||||
errorMsg = t('bank_server_error')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
let errorMsg: string
|
||||
try { errorMsg = decodeURIComponent(bankError) } catch { errorMsg = bankError }
|
||||
const bankName = searchParams.get('bank_name')
|
||||
const errorCode = searchParams.get('bank_error_code')
|
||||
const psuType = searchParams.get('psu_type')
|
||||
// The bank often returns a bare "server_error" with no description —
|
||||
// show a human message instead of the raw OAuth error code.
|
||||
if (errorCode === 'server_error' && errorMsg === 'server_error') {
|
||||
errorMsg = t('bank_server_error')
|
||||
}
|
||||
// Consume the one-shot ?bank_error= param off the render path: a microtask
|
||||
// defers these updates out of the effect body (react-hooks/set-state-in-
|
||||
// effect) without a user-visible delay, since the param appears at most
|
||||
// once per OAuth bounce-back. The cancellation flag drops the deferred work
|
||||
// if the effect re-runs or the component unmounts before it flushes (also
|
||||
// suppresses a duplicate toast under StrictMode's dev double-invoke).
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
toast({
|
||||
title: t('connect_failed_title'),
|
||||
description: errorMsg,
|
||||
@@ -119,12 +63,13 @@ export function BankingSettingsContent() {
|
||||
if (errorCode === 'access_denied') setIsAccessDenied(true)
|
||||
// Handelsbanken rejects business connects with server_error when the
|
||||
// company hasn't registered the open banking fullmakt ("Internet
|
||||
// Företag – tilläggstjänst API Företag") — surface the fix steps.
|
||||
// Företag – tilläggstjänst API Företag"): surface the fix steps.
|
||||
if (bankName === 'Handelsbanken' && psuType === 'business' && errorCode === 'server_error') {
|
||||
setShowHbPoaHint(true)
|
||||
}
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [searchParams, router, toast, t])
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user