f8db38f989
* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
870 lines
35 KiB
TypeScript
870 lines
35 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
|
import Link from 'next/link'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { Button } from '@/components/ui/button'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
|
import { CheckCircle, Loader2, Upload } from 'lucide-react'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
|
import { useCompany, useCapability } from '@/contexts/CompanyContext'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
|
import {
|
|
SettingsGroup,
|
|
SettingsRow,
|
|
SettingsRowEnd,
|
|
SettingsRowNote,
|
|
SettingsSeg,
|
|
} from '@/components/settings/SettingsRows'
|
|
import { BankSelector, type Bank } from './BankSelector'
|
|
import { BankConnectionStatus } from './BankConnectionStatus'
|
|
import { AccountPickerDialog } from './AccountPickerDialog'
|
|
import type { BankConnection } from '@/types'
|
|
import type { StoredAccount } from '../types'
|
|
|
|
/** One "reuse an existing connection" offer, as returned by /reusable-sessions. */
|
|
interface ReusableSessionOffer {
|
|
connection_id: string
|
|
company_id: string
|
|
company_name: string | null
|
|
bank_name: string | null
|
|
consent_expires: string | null
|
|
available_account_count: number
|
|
}
|
|
|
|
/**
|
|
* Self-contained banking settings panel for the enable-banking extension.
|
|
* Loaded dynamically by the settings panel registry.
|
|
*/
|
|
export default function BankingSettingsPanel() {
|
|
const { toast } = useToast()
|
|
// Stable across renders so effects can list it as a dependency without
|
|
// re-firing on every parent render (same reason as AccountPickerDialog).
|
|
const supabase = useMemo(() => createClient(), [])
|
|
// The OAuth callback lands here with ?select_accounts=<id> once a bank is
|
|
// successfully connected. Read via useSearchParams (SSR/hydration-safe) so
|
|
// the first-load spinner can say "bank connected, fetching accounts"
|
|
// instead of an anonymous spinner. The param itself is consumed and
|
|
// stripped by the auto-open effect below.
|
|
const searchParams = useSearchParams()
|
|
const arrivedFromBankCallback = !!searchParams?.get('select_accounts')
|
|
|
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
|
const { company, companies } = useCompany()
|
|
const hasBankSync = useCapability(CAPABILITY.bank_sync)
|
|
|
|
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
|
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
|
|
const [isConnecting, setIsConnecting] = useState(false)
|
|
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
|
|
const connectingRef = useRef(false)
|
|
const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [loadError, setLoadError] = useState(false)
|
|
const hasLoadedRef = useRef(false)
|
|
const [showCsvFallback, setShowCsvFallback] = useState(false)
|
|
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
|
|
const [pickerConnectionId, setPickerConnectionId] = useState<string | null>(null)
|
|
// Live connections the same user holds at the same banks in OTHER companies.
|
|
// Several ASPSPs allow only one active AIS session per PSU, so authorizing
|
|
// company B silently kills company A's connection. RLS scopes SELECT to
|
|
// user_company_ids(), so this read stays within the user's own companies.
|
|
// Live sessions in the user's OTHER companies that still have unclaimed
|
|
// accounts. Reusing one connects this company without a second BankID and
|
|
// without revoking the first, which is what kills feeds at one-session banks.
|
|
const [reusableSessions, setReusableSessions] = useState<ReusableSessionOffer[]>([])
|
|
const [attachingConnectionId, setAttachingConnectionId] = useState<string | null>(null)
|
|
const [otherCompanyConnections, setOtherCompanyConnections] = useState<
|
|
{ bank_name: string; company_id: string }[]
|
|
>([])
|
|
// Set when the OAuth callback pointed at a connection that belongs to a
|
|
// different company than the active one: without this the picker simply
|
|
// never opens and the connection looks like it vanished.
|
|
const [pickerCompanyMismatch, setPickerCompanyMismatch] = useState<string | null>(null)
|
|
|
|
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
|
|
const PENDING_LOCK_MS = 30 * 1000
|
|
|
|
useEffect(() => {
|
|
fetchConnections()
|
|
return () => {
|
|
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
|
|
}
|
|
}, [])
|
|
|
|
// Latest-ref so the visibility listener below (subscribed once) always
|
|
// calls the current render's fetchConnections, which closes over company
|
|
// context that may resolve after mount.
|
|
const fetchConnectionsRef = useRef(fetchConnections)
|
|
useEffect(() => {
|
|
fetchConnectionsRef.current = fetchConnections
|
|
})
|
|
|
|
// Safety net for completion signals that never reach this tab: a mobile
|
|
// BankID app-switch can land the bank's redirect in a different browser
|
|
// tab, the user can close the finalize page before its redirect, and a
|
|
// bfcache-restored page shows a pre-connection snapshot. Refetch when the
|
|
// tab regains visibility (background refresh, no spinner: fetchConnections
|
|
// only blanks the panel on first load), throttled so rapid tab toggling
|
|
// doesn't hammer the API.
|
|
const lastVisibilityFetchRef = useRef(0)
|
|
useEffect(() => {
|
|
function onVisible() {
|
|
if (document.visibilityState !== 'visible') return
|
|
const now = Date.now()
|
|
if (now - lastVisibilityFetchRef.current < 5_000) return
|
|
lastVisibilityFetchRef.current = now
|
|
void fetchConnectionsRef.current()
|
|
}
|
|
document.addEventListener('visibilitychange', onVisible)
|
|
return () => document.removeEventListener('visibilitychange', onVisible)
|
|
}, [])
|
|
|
|
// Auto-open the picker when the user lands here from the OAuth callback
|
|
// (URL: /settings/banking?select_accounts=<id>). The query param is stripped
|
|
// afterwards so a refresh doesn't keep reopening it.
|
|
useEffect(() => {
|
|
if (isLoading) return
|
|
if (typeof window === 'undefined') return
|
|
|
|
const params = new URLSearchParams(window.location.search)
|
|
const targetId = params.get('select_accounts')
|
|
if (!targetId) return
|
|
|
|
const match = bankConnections.find(c => c.id === targetId)
|
|
if (match) {
|
|
setPickerConnectionId(targetId)
|
|
setPickerCompanyMismatch(null)
|
|
} else {
|
|
// The callback finished, but the connection belongs to a company that
|
|
// isn't the active one (the user switched company during the bank
|
|
// round-trip, or authorized while another company was active). Name the
|
|
// owner instead of dropping the user on a panel that looks unchanged.
|
|
void (async () => {
|
|
const { data } = await supabase
|
|
.from('bank_connections')
|
|
.select('company_id')
|
|
.eq('id', targetId)
|
|
.maybeSingle()
|
|
const ownerId = (data as { company_id?: string } | null)?.company_id
|
|
if (!ownerId) return
|
|
const owner = companies.find((c) => c.company.id === ownerId)
|
|
setPickerCompanyMismatch(owner?.company.name ?? 'ett annat bolag')
|
|
})()
|
|
}
|
|
|
|
params.delete('select_accounts')
|
|
const newQuery = params.toString()
|
|
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`
|
|
window.history.replaceState({}, '', newUrl)
|
|
}, [isLoading, bankConnections, companies, supabase])
|
|
|
|
function releaseConnectingLock() {
|
|
connectingRef.current = false
|
|
setIsConnecting(false)
|
|
setConnectingBankName(null)
|
|
}
|
|
|
|
async function fetchConnections() {
|
|
// Only the first load blanks the panel to a spinner. Later refetches (after
|
|
// a sync, disconnect, or account save) refresh in the background so the
|
|
// panel doesn't flash back to a full-height spinner and lose scroll
|
|
// position on every action.
|
|
if (!hasLoadedRef.current) setIsLoading(true)
|
|
setLoadError(false)
|
|
try {
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user || !company) {
|
|
setBankConnections([])
|
|
return
|
|
}
|
|
|
|
const { data: connections, error } = await supabase
|
|
.from('bank_connections')
|
|
.select('*')
|
|
.eq('company_id', company.id)
|
|
.order('created_at', { ascending: false })
|
|
|
|
if (error) {
|
|
// Surface the failure instead of rendering an empty panel: an empty
|
|
// panel reads as "your bank got disconnected" when it's really a
|
|
// transient fetch/RLS error.
|
|
setLoadError(true)
|
|
return
|
|
}
|
|
|
|
setBankConnections(connections || [])
|
|
|
|
// Same-bank connections in the user's other companies. Only sessions
|
|
// that actually hold a consent count: a revoked or errored row is not
|
|
// competing for the bank's one-session-per-login slot.
|
|
const { data: allConnections } = await supabase
|
|
.from('bank_connections')
|
|
.select('bank_name, company_id, status')
|
|
.in('status', ['active', 'pending_selection'])
|
|
setOtherCompanyConnections(
|
|
((allConnections || []) as { bank_name: string; company_id: string }[]).filter(
|
|
(c) => c.company_id !== company.id
|
|
)
|
|
)
|
|
|
|
// Reuse offers. Best-effort: a failure here costs the shortcut, never the
|
|
// panel, so the normal connect flow stays available either way.
|
|
try {
|
|
const reuseResponse = await fetch('/api/extensions/ext/enable-banking/reusable-sessions')
|
|
if (reuseResponse.ok) {
|
|
const { sessions } = await reuseResponse.json()
|
|
setReusableSessions((sessions || []) as ReusableSessionOffer[])
|
|
} else {
|
|
setReusableSessions([])
|
|
}
|
|
} catch {
|
|
setReusableSessions([])
|
|
}
|
|
|
|
// If a pending connection exists from a recent attempt (e.g. user bounced back from
|
|
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
|
|
const freshPending = (connections || []).find((c) => c.status === 'pending')
|
|
if (freshPending) {
|
|
const age = Date.now() - new Date(freshPending.created_at).getTime()
|
|
const remaining = PENDING_LOCK_MS - age
|
|
if (remaining > 0) {
|
|
connectingRef.current = true
|
|
setIsConnecting(true)
|
|
setConnectingBankName(freshPending.bank_name)
|
|
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
|
|
releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining)
|
|
}
|
|
}
|
|
} finally {
|
|
// Always clear the spinner, even on the early `!user || !company` return,
|
|
// so an expired session can't leave the panel spinning forever.
|
|
hasLoadedRef.current = true
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Warn before authorizing a bank where the same user already holds live
|
|
* connections in other companies. Several ASPSPs bind one active AIS session
|
|
* per PSU, so the new authorization silently invalidates the existing ones,
|
|
* and nothing in the product tells the user until a sync fails days later.
|
|
* Advisory only: legitimate multi-company setups must still be able to
|
|
* proceed, so the dialog always offers a working "Fortsätt".
|
|
*/
|
|
async function confirmSameBankConnections(bankName: string): Promise<boolean> {
|
|
const clashes = otherCompanyConnections.filter((c) => c.bank_name === bankName)
|
|
if (clashes.length === 0) return true
|
|
|
|
const names = clashes
|
|
.map((c) => companies.find((entry) => entry.company.id === c.company_id)?.company.name)
|
|
.filter((name): name is string => !!name)
|
|
const companyList = names.length > 0 ? ` (${names.join(', ')})` : ''
|
|
const count = clashes.length
|
|
|
|
return confirm({
|
|
title: `Du har redan ${count} ${count === 1 ? 'anslutning' : 'anslutningar'} till ${bankName}`,
|
|
description:
|
|
`${bankName} är sedan tidigare ansluten i ${count === 1 ? 'ett annat bolag' : 'andra bolag'}${companyList}. ` +
|
|
'Vissa banker tillåter bara en aktiv anslutning per inloggning: när du slutför den här kan de andra sluta synka ' +
|
|
'och behöva förnyas. Fortsätt om du vet att din bank tillåter flera.',
|
|
confirmLabel: 'Fortsätt',
|
|
variant: 'warning',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reuse a session authorized for another of the user's companies. No bank
|
|
* round-trip: the server creates this company's connection against the same
|
|
* consent and parks it in 'pending_selection', so the account picker opens
|
|
* exactly as it does after a real authorization.
|
|
*/
|
|
async function handleReuseConnection(offer: ReusableSessionOffer) {
|
|
if (attachingConnectionId) return
|
|
setAttachingConnectionId(offer.connection_id)
|
|
try {
|
|
const response = await fetch('/api/extensions/ext/enable-banking/attach', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ connection_id: offer.connection_id }),
|
|
})
|
|
const result = await response.json()
|
|
|
|
if (!response.ok) {
|
|
toast({
|
|
title: 'Kunde inte återanvända anslutningen',
|
|
description: result?.error || 'Försök igen om en stund.',
|
|
variant: 'destructive',
|
|
})
|
|
return
|
|
}
|
|
|
|
await fetchConnections()
|
|
notifyBankSyncUpdated()
|
|
// Straight into account selection: the connection exists but syncs
|
|
// nothing until the user picks which accounts belong to this company.
|
|
setPickerConnectionId(result.connection_id)
|
|
} catch (error) {
|
|
console.error('[enable-banking] Reuse failed', error)
|
|
toast({
|
|
title: 'Kunde inte återanvända anslutningen',
|
|
description: 'Ett oväntat fel uppstod. Försök igen om en stund.',
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setAttachingConnectionId(null)
|
|
}
|
|
}
|
|
|
|
async function handleConnectBank(bank: Bank, psuTypeOverride?: 'personal' | 'business') {
|
|
if (connectingRef.current) return
|
|
// Claim the lock BEFORE the confirm await. The dialog can sit open
|
|
// indefinitely, and a second click in that window would otherwise sail
|
|
// past the guard above and start a concurrent connect flow.
|
|
connectingRef.current = true
|
|
if (!(await confirmSameBankConnections(bank.name))) {
|
|
connectingRef.current = false
|
|
return
|
|
}
|
|
setIsConnecting(true)
|
|
setConnectingBankName(bank.name)
|
|
|
|
try {
|
|
console.log('[enable-banking] Initiating bank connection', {
|
|
bankName: bank.name,
|
|
bankCountry: bank.country,
|
|
psuTypeOverride,
|
|
})
|
|
|
|
const body: Record<string, string> = { aspsp_name: bank.name, aspsp_country: bank.country }
|
|
if (psuTypeOverride) body.psu_type = psuTypeOverride
|
|
|
|
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
console.error('[enable-banking] Connect request failed', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: data.error,
|
|
bankName: bank.name,
|
|
})
|
|
throw new Error(data.error)
|
|
}
|
|
|
|
console.log('[enable-banking] Redirecting to bank authorization', {
|
|
connectionId: data.connection_id,
|
|
hasAuthUrl: !!data.authorization_url,
|
|
})
|
|
window.location.href = data.authorization_url
|
|
} catch (error) {
|
|
console.error('[enable-banking] Connect flow failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
bankName: bank.name,
|
|
})
|
|
toast({
|
|
title: 'Fel',
|
|
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
|
|
variant: 'destructive',
|
|
})
|
|
connectingRef.current = false
|
|
setIsConnecting(false)
|
|
setConnectingBankName(null)
|
|
setShowCsvFallback(true)
|
|
}
|
|
}
|
|
|
|
// Re-authorize an existing connection in place: no disconnect required.
|
|
// Posts to /connect with the existing connection_id so the server reuses the
|
|
// same row (revoking the dead session, issuing fresh authorization), then
|
|
// hands off to the bank's consent screen. The OAuth callback drives the row
|
|
// back through account selection to active.
|
|
async function handleReconnect(connection: BankConnection, psuTypeOverride?: 'personal' | 'business') {
|
|
if (connectingRef.current) return
|
|
// Lock before the confirm await, same reason as handleConnectBank.
|
|
connectingRef.current = true
|
|
if (!(await confirmSameBankConnections(connection.bank_name))) {
|
|
connectingRef.current = false
|
|
return
|
|
}
|
|
setIsConnecting(true)
|
|
setConnectingBankName(connection.bank_name)
|
|
|
|
try {
|
|
const country = (connection.provider as string)?.split('-').pop()?.toUpperCase() || 'SE'
|
|
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
connection_id: connection.id,
|
|
aspsp_name: connection.bank_name,
|
|
aspsp_country: country,
|
|
// Omitted → server reuses the connection's stored psu_type (falling
|
|
// back to entity_type). Set → switch account type in place.
|
|
...(psuTypeOverride ? { psu_type: psuTypeOverride } : {}),
|
|
}),
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.error)
|
|
}
|
|
|
|
window.location.href = data.authorization_url
|
|
} catch (error) {
|
|
console.error('[enable-banking] Reconnect flow failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
connectionId: connection.id,
|
|
})
|
|
toast({
|
|
title: 'Fel',
|
|
description: error instanceof Error ? error.message : 'Kunde inte förnya anslutningen',
|
|
variant: 'destructive',
|
|
})
|
|
connectingRef.current = false
|
|
setIsConnecting(false)
|
|
setConnectingBankName(null)
|
|
}
|
|
}
|
|
|
|
async function handleSyncTransactions(connectionId: string) {
|
|
setSyncingConnectionId(connectionId)
|
|
|
|
// A slow bank can hold the request open up to the route's 300s budget.
|
|
// Cap the client wait so the spinner can't hang indefinitely; the sync is
|
|
// idempotent (imports dedup), so a background completion or manual retry is
|
|
// safe.
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), 180_000)
|
|
|
|
try {
|
|
console.log('[enable-banking] Starting sync', { connectionId })
|
|
|
|
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ connection_id: connectionId }),
|
|
signal: controller.signal,
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (!response.ok) {
|
|
console.error('[enable-banking] Sync request failed', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: data.error,
|
|
connectionId,
|
|
})
|
|
throw new Error(data.error)
|
|
}
|
|
|
|
console.log('[enable-banking] Sync completed', {
|
|
connectionId,
|
|
imported: data.imported,
|
|
duplicates: data.duplicates,
|
|
})
|
|
|
|
toast({
|
|
title: 'Synkronisering klar',
|
|
description: `${data.imported} nya transaktioner importerade`,
|
|
})
|
|
|
|
setShowCsvFallback(false)
|
|
notifyBankSyncUpdated()
|
|
fetchConnections()
|
|
} catch (error) {
|
|
if (controller.signal.aborted) {
|
|
toast({
|
|
title: 'Synkronisering tar längre tid än vanligt',
|
|
description: 'Transaktionerna hämtas i bakgrunden. Uppdatera sidan om en stund.',
|
|
})
|
|
fetchConnections()
|
|
} else {
|
|
console.error('[enable-banking] Sync flow failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
connectionId,
|
|
})
|
|
toast({
|
|
title: 'Fel',
|
|
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
|
|
variant: 'destructive',
|
|
})
|
|
setShowCsvFallback(true)
|
|
// Refresh so a now-expired connection (e.g. closed PSD2 session) moves
|
|
// into "Åtgärd krävs" and surfaces the "Förnya anslutning" button.
|
|
fetchConnections()
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
setSyncingConnectionId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDisconnectBank(connectionId: string) {
|
|
const ok = await confirm({
|
|
title: 'Koppla bort bank?',
|
|
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
|
|
confirmLabel: 'Koppla bort',
|
|
variant: 'warning',
|
|
})
|
|
if (!ok) return
|
|
|
|
try {
|
|
console.log('[enable-banking] Disconnecting bank', { connectionId })
|
|
|
|
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ connection_id: connectionId }),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json()
|
|
console.error('[enable-banking] Disconnect request failed', {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
error: data.error,
|
|
connectionId,
|
|
})
|
|
throw new Error(data.error || 'Disconnect failed')
|
|
}
|
|
|
|
console.log('[enable-banking] Bank disconnected', { connectionId })
|
|
toast({
|
|
title: 'Bank bortkopplad',
|
|
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
|
|
})
|
|
fetchConnections()
|
|
} catch (error) {
|
|
console.error('[enable-banking] Disconnect flow failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
connectionId,
|
|
})
|
|
toast({
|
|
title: 'Fel',
|
|
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
|
|
variant: 'destructive',
|
|
})
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
// Coming back from the bank's consent flow the connection already exists,
|
|
// so tell the user that instead of showing an anonymous spinner: this is
|
|
// the last silent gap between "approved at the bank" and the account
|
|
// picker opening.
|
|
if (arrivedFromBankCallback) {
|
|
return (
|
|
<div className="flex h-32 flex-col items-center justify-center gap-3">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<CheckCircle className="h-4 w-4 text-success" />
|
|
<span>Banken är ansluten</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span>Hämtar dina konton…</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<div className="flex items-center justify-center h-32">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// First-load failure: show a recoverable error instead of an empty panel (a
|
|
// blank panel misreads as "no banks connected"). A background-refetch failure
|
|
// keeps the already-loaded connections visible instead of wiping them.
|
|
if (loadError && bankConnections.length === 0) {
|
|
return (
|
|
<div className="px-1 pt-8">
|
|
<p className="text-sm font-medium">Kunde inte ladda bankanslutningar</p>
|
|
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">
|
|
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
|
|
och transaktioner är oförändrade.
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
|
<Button variant="outline" size="sm" onClick={() => fetchConnections()}>
|
|
Försök igen
|
|
</Button>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href="/import?mode=bank">Importera bankfil istället</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const activeConnections = bankConnections.filter((c) => c.status === 'active')
|
|
const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection')
|
|
const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status))
|
|
|
|
const pickerConnection = pickerConnectionId
|
|
? bankConnections.find(c => c.id === pickerConnectionId)
|
|
: null
|
|
const pickerAccounts = pickerConnection
|
|
? ((pickerConnection.accounts_data as StoredAccount[] | null) || [])
|
|
: []
|
|
|
|
return (
|
|
<div>
|
|
<DestructiveConfirmDialog {...dialogProps} />
|
|
|
|
{pickerConnection && (
|
|
<AccountPickerDialog
|
|
open={!!pickerConnection}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPickerConnectionId(null)
|
|
}}
|
|
connectionId={pickerConnection.id}
|
|
bankName={pickerConnection.bank_name}
|
|
accounts={pickerAccounts}
|
|
isInitialSelection={pickerConnection.status === 'pending_selection'}
|
|
onSaved={() => fetchConnections()}
|
|
/>
|
|
)}
|
|
|
|
{/* The callback landed on a connection owned by another of the user's
|
|
companies: one ochre line naming where it went (convention 6). */}
|
|
{pickerCompanyMismatch && (
|
|
<p className="px-1 pt-6 text-[12.5px] leading-relaxed text-attn">
|
|
Bankanslutningen slutfördes för {pickerCompanyMismatch}, inte för det bolag som är aktivt
|
|
nu. Byt till {pickerCompanyMismatch} för att välja vilka konton som ska synka.
|
|
</p>
|
|
)}
|
|
|
|
{/* Persistent CSV fallback after connection/sync failure: a live hint,
|
|
kept visible as a compact line instead of a boxed strip. */}
|
|
{showCsvFallback && (
|
|
<div className="flex items-start gap-2 px-1 pt-6">
|
|
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
|
|
Har du problem med bankanslutningen? Du kan{' '}
|
|
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
|
|
importera transaktioner manuellt via bankfil
|
|
</Link>
|
|
.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending account selection: new connections waiting for the user to pick accounts */}
|
|
{pendingSelectionConnections.length > 0 && (
|
|
<SettingsGroup
|
|
label="Välj konton att synka"
|
|
help="Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas."
|
|
>
|
|
{pendingSelectionConnections.map((connection) => {
|
|
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
|
|
return (
|
|
<div
|
|
key={connection.id}
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3"
|
|
>
|
|
<span className="text-sm font-medium">{connection.bank_name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
|
|
</span>
|
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
|
<Button size="sm" onClick={() => setPickerConnectionId(connection.id)}>
|
|
Välj konton
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => handleDisconnectBank(connection.id)}
|
|
>
|
|
Avbryt
|
|
</Button>
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</SettingsGroup>
|
|
)}
|
|
|
|
{/* Action required: expired/error connections */}
|
|
{actionRequiredConnections.length > 0 && (
|
|
<SettingsGroup label="Åtgärd krävs" help="Dessa anslutningar behöver uppmärksamhet.">
|
|
{actionRequiredConnections.map((connection) => (
|
|
<BankConnectionStatus
|
|
key={connection.id}
|
|
connection={connection}
|
|
onSync={handleSyncTransactions}
|
|
onDisconnect={handleDisconnectBank}
|
|
onReconnect={handleReconnect}
|
|
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
|
isSyncing={syncingConnectionId === connection.id}
|
|
/>
|
|
))}
|
|
</SettingsGroup>
|
|
)}
|
|
|
|
{/* Connected banks */}
|
|
{activeConnections.length > 0 && (
|
|
<SettingsGroup label="Anslutna banker">
|
|
{activeConnections.map((connection) => (
|
|
<BankConnectionStatus
|
|
key={connection.id}
|
|
connection={connection}
|
|
onSync={handleSyncTransactions}
|
|
onDisconnect={handleDisconnectBank}
|
|
onManageAccounts={() => setPickerConnectionId(connection.id)}
|
|
isSyncing={syncingConnectionId === connection.id}
|
|
/>
|
|
))}
|
|
</SettingsGroup>
|
|
)}
|
|
|
|
{/* Reuse a session authorized for another of the user's companies. Sits
|
|
ABOVE the bank list deliberately: at a one-session-per-login bank,
|
|
choosing the bank below is the very action that kills the other
|
|
company's feed, so the cheaper and safer path has to be seen first.
|
|
Renders only when a live session actually has unclaimed accounts. */}
|
|
{hasBankSync && reusableSessions.length > 0 && (
|
|
<SettingsGroup
|
|
label="Återanvänd befintlig anslutning"
|
|
help={
|
|
<div className="space-y-2">
|
|
<p>
|
|
Du har redan en giltig bankanslutning i ett annat bolag, och den ser konton
|
|
som inget bolag använder ännu.
|
|
</p>
|
|
<p>
|
|
Vissa banker tillåter bara en aktiv anslutning per inloggning. Att återanvända
|
|
anslutningen i stället för att logga in på nytt låter bolagen dela samma
|
|
samtycke, så bolaget som redan är anslutet fortsätter att synka.
|
|
</p>
|
|
<p>
|
|
Bolagen delar bara samtycket. Konton, transaktioner och bokföring hålls isär,
|
|
och du väljer i nästa steg vilka konton som hör till det här bolaget.
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
{reusableSessions.map((offer) => (
|
|
<SettingsRow key={offer.connection_id} label={<span data-ph-mask="">{offer.bank_name ?? 'Bank'}</span>}>
|
|
<SettingsRowNote>
|
|
Ansluten för{' '}
|
|
<span className="font-medium text-foreground">
|
|
{offer.company_name ?? 'ett annat bolag'}
|
|
</span>
|
|
. {offer.available_account_count}{' '}
|
|
{offer.available_account_count === 1 ? 'ledigt konto' : 'lediga konton'} kan
|
|
kopplas till{' '}
|
|
<span className="font-medium text-foreground">
|
|
{company?.name ?? 'det här bolaget'}
|
|
</span>{' '}
|
|
utan nytt BankID.
|
|
</SettingsRowNote>
|
|
<SettingsRowEnd>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => handleReuseConnection(offer)}
|
|
disabled={!!attachingConnectionId}
|
|
>
|
|
{attachingConnectionId === offer.connection_id ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Kopplar
|
|
</>
|
|
) : (
|
|
'Återanvänd'
|
|
)}
|
|
</Button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
))}
|
|
</SettingsGroup>
|
|
)}
|
|
|
|
{/* Connect new bank. Non-payers keep seeing the group (conversion
|
|
surface) but the bank list is replaced by an upgrade note: the
|
|
server gate would 403 the connect anyway. The former "Om
|
|
bankintegration (PSD2)" card lives on as group-level help. */}
|
|
<SettingsGroup
|
|
label="Anslut ny bank"
|
|
help={
|
|
<div className="space-y-2">
|
|
<p>Välj din bank nedan för att koppla ditt konto via PSD2.</p>
|
|
<p>
|
|
Anslutningen görs för det bolag som är aktivt just nu. Byt bolag först om du vill
|
|
ansluta banken åt ett annat bolag.
|
|
</p>
|
|
<p className="font-medium">Om bankintegration (PSD2)</p>
|
|
<p>
|
|
Automatisk import av transaktioner via PSD2 open banking.
|
|
Samtycket gäller i 90 dagar och behöver sedan förnyas.
|
|
</p>
|
|
<p>
|
|
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
|
|
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
|
|
bankfiler på importsidan.
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
{!hasBankSync ? (
|
|
<div className="px-1 pt-3">
|
|
<UpgradeNote>
|
|
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
|
|
transaktioner manuellt via bankfiler på importsidan.
|
|
</UpgradeNote>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<SettingsRow
|
|
label="Kontotyp"
|
|
help="Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma)."
|
|
>
|
|
<SettingsSeg
|
|
value={psuType}
|
|
onChange={setPsuType}
|
|
aria-label="Kontotyp"
|
|
options={[
|
|
{ value: 'business', label: 'Företagskonto' },
|
|
{ value: 'personal', label: 'Privatkonto' },
|
|
]}
|
|
/>
|
|
</SettingsRow>
|
|
<div className="px-1 pt-4">
|
|
{/* Name the company on the surface itself, not only in the help
|
|
popover: the bank login that follows says nothing about which
|
|
set of books the accounts will land in. */}
|
|
{company?.name && (
|
|
<p className="mb-3 text-[12.5px] leading-relaxed text-muted-foreground">
|
|
Anslutningen görs för{' '}
|
|
<span className="font-medium text-foreground">{company.name}</span>.
|
|
</p>
|
|
)}
|
|
<BankSelector
|
|
onConnect={(bank) => handleConnectBank(bank, psuType)}
|
|
onPsuTypeDetected={setPsuType}
|
|
isConnecting={isConnecting}
|
|
connectingBankName={connectingBankName}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</SettingsGroup>
|
|
</div>
|
|
)
|
|
}
|