Files
accounted/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx
T
Mattsson f8db38f989 fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* 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>
2026-08-17 11:32:45 +02:00

291 lines
10 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-react'
import { daysBetween } from '@/lib/company/fiscal-year'
import type { StoredAccount } from '../types'
export interface SyncProgressSummary {
imported: number
duplicates: number
/**
* Rows the post-backfill reconciliation sweep linked to existing verifikat
* (renewal over an already-bookkept period). Optional: responses from before
* the sweep existed omit it.
*/
auto_matched?: number
requested_from: string
returned_min_date: string | null
returned_max_date: string | null
}
export interface SyncProgressError {
message: string
}
export type SyncProgressState =
| { kind: 'syncing' }
| { kind: 'done'; summary: SyncProgressSummary }
| { kind: 'failed'; error: SyncProgressError }
interface BankSyncProgressDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
bankName: string
accounts: StoredAccount[]
state: SyncProgressState
}
// Past this point the sync has run longer than the promised "up to a minute".
// We stop hard-locking the modal so the user isn't trapped: the request keeps
// running server-side (idempotent) and completion still resolves the state.
const GRACE_SEC = 75
function formatElapsed(sec: number): string {
if (sec < 60) return `${sec}s`
return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`
}
export function BankSyncProgressDialog({
open,
onOpenChange,
bankName,
accounts,
state,
}: BankSyncProgressDialogProps) {
const enabledAccounts = accounts.filter((a) => a.enabled !== false)
// Tick a visible elapsed counter while syncing so a slow bank doesn't look
// frozen, and so we know when to release the close-lock (GRACE_SEC). State is
// only ever set from the timer callbacks (never synchronously in the effect
// body), and elapsed is never computed from Date.now() during render, so this
// stays clear of the react-hooks purity rules.
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!open || state.kind !== 'syncing') return
const started = Date.now()
const tick = () => setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000)))
// Reset to ~0 on the next tick (async, so not a synchronous effect setState).
const reset = setTimeout(tick, 0)
const id = setInterval(tick, 1000)
return () => {
clearTimeout(reset)
clearInterval(id)
}
}, [open, state.kind])
const overGrace = state.kind === 'syncing' && elapsedSec >= GRACE_SEC
// Only hard-block the close affordances during the expected window; after the
// grace period the user may background the (still-running) sync.
const blockClose = state.kind === 'syncing' && !overGrace
// The payoff: once the first sync lands, name the work now waiting so the
// moment points onward instead of stranding the user on the settings panel.
// Same authenticated endpoint the dashboard pane refetches; best-effort.
const [workCounts, setWorkCounts] = useState<{ book: number; matches: number } | null>(null)
useEffect(() => {
if (!open || state.kind !== 'done' || state.summary.imported === 0) {
// A later sync must not inherit the previous run's numbers.
setWorkCounts(null)
return
}
let cancelled = false
fetch('/api/worklist/counts')
.then((res) => (res.ok ? res.json() : null))
.then((json: { data?: { counts?: Record<string, number> } } | null) => {
if (cancelled || !json?.data?.counts) return
setWorkCounts({
book: json.data.counts.book_transaction ?? 0,
matches: json.data.counts.suggested_match ?? 0,
})
})
.catch(() => {
// The CTA degrades to a plain link; the numbers are a nicety.
})
return () => {
cancelled = true
}
}, [open, state])
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next && blockClose) return
onOpenChange(next)
}}
>
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => {
if (blockClose) e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (blockClose) e.preventDefault()
}}
>
<DialogHeader>
{/* data-ph-mask: the bank name reveals which bank the user uses */}
<DialogTitle data-ph-mask="">
{state.kind === 'syncing' && `Hämtar transaktioner från ${bankName}`}
{state.kind === 'done' && 'Klart'}
{state.kind === 'failed' && 'Synkningen misslyckades'}
</DialogTitle>
<DialogDescription>
{state.kind === 'syncing' && (
overGrace ? (
<>
Det tar längre tid än vanligt. Vi fortsätter i bakgrunden: du kan
stänga rutan och komma tillbaka senare.
</>
) : (
<>
Vi hämtar transaktioner från {enabledAccounts.length}{' '}
{enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret.
</>
)
)}
{state.kind === 'done' && (
<>
Vi hämtade {state.summary.imported}{' '}
{state.summary.imported === 1 ? 'transaktion' : 'transaktioner'}.
</>
)}
{state.kind === 'failed' && (
<>Vi försöker igen automatiskt i bakgrunden. Du kan stänga den här rutan.</>
)}
</DialogDescription>
</DialogHeader>
{state.kind === 'syncing' && (
<div className="space-y-3 py-2">
<div className="flex flex-col items-center justify-center gap-2 py-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
{formatElapsed(elapsedSec)}
</span>
</div>
<ul className="rounded-lg border border-border divide-y divide-border text-sm">
{enabledAccounts.map((a) => (
<li key={a.uid} className="flex items-center justify-between gap-3 px-3 py-2">
<span className="truncate">{a.name || a.iban || a.uid}</span>
<span className="text-xs text-muted-foreground">{a.currency}</span>
</li>
))}
</ul>
</div>
)}
{state.kind === 'done' && (
<DoneBody summary={state.summary} workCounts={workCounts} />
)}
{state.kind === 'failed' && (
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
{state.error.message}
</div>
)}
<DialogFooter>
{state.kind === 'done' && state.summary.imported > 0 ? (
<>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
Stäng
</Button>
<Button asChild>
<Link href="/transactions">
{workCounts && workCounts.book > 0
? `Visa ${workCounts.book} att bokföra`
: 'Öppna transaktionerna'}
</Link>
</Button>
</>
) : (
<Button
type="button"
onClick={() => onOpenChange(false)}
disabled={blockClose}
>
{state.kind === 'syncing'
? (overGrace ? 'Fortsätt i bakgrunden' : 'Hämtar…')
: 'Klar'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function DoneBody({
summary,
workCounts,
}: {
summary: SyncProgressSummary
workCounts: { book: number; matches: number } | null
}) {
const requestedDays = daysBetween(summary.requested_from)
const returnedDays =
summary.returned_min_date && summary.returned_max_date
? daysBetween(summary.returned_min_date, new Date(summary.returned_max_date))
: 0
const wasTruncated = requestedDays - returnedDays > 7
return (
<div className="space-y-3 py-2">
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-4">
<CheckCircle2 className="h-6 w-6 shrink-0 text-foreground" />
<div className="text-sm">
<p>
<span className="tabular-nums font-medium">{summary.imported}</span> nya transaktioner
importerade.
</p>
{summary.returned_min_date && summary.returned_max_date && (
<p className="text-xs text-muted-foreground">
Datum: {summary.returned_min_date} {summary.returned_max_date}
</p>
)}
{(summary.auto_matched ?? 0) > 0 && (
<p className="text-xs text-muted-foreground tabular-nums">
{summary.auto_matched} kopplades automatiskt till redan bokförda verifikat.
</p>
)}
{workCounts && workCounts.book > 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{workCounts.book} att bokföra
{workCounts.matches > 0 && <> · {workCounts.matches} matchar fakturor</>}
</p>
)}
</div>
</div>
{wasTruncated && (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Banken returnerade kortare historik än begärt. För äldre data, använd{' '}
<Link href="/import?mode=sie" className="text-foreground underline underline-offset-2">
SIE- eller bankfil-import
</Link>
.
</span>
</div>
)}
</div>
)
}