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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bb423b2b3
commit
f8db38f989
@@ -360,12 +360,15 @@ function ArticlesPageInner() {
|
||||
// The currency scope is part of why nothing matched, so it has to
|
||||
// be named: otherwise the register reads as empty of the term
|
||||
// when it is only empty inside the active scope.
|
||||
currencyFilter === ALL_CURRENCIES
|
||||
? t('no_search_results_description', { term: searchTerm })
|
||||
: t('no_search_results_in_currency_description', {
|
||||
term: searchTerm,
|
||||
currency: currencyFilter,
|
||||
})
|
||||
// data-ph-mask: the search term is what the user typed.
|
||||
<span data-ph-mask="">
|
||||
{currencyFilter === ALL_CURRENCIES
|
||||
? t('no_search_results_description', { term: searchTerm })
|
||||
: t('no_search_results_in_currency_description', {
|
||||
term: searchTerm,
|
||||
currency: currencyFilter,
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -277,7 +277,8 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">{asset.name}</CardTitle></CardHeader>
|
||||
{/* data-ph-mask: the asset name is user data */}
|
||||
<CardHeader><CardTitle data-ph-mask="" className="text-base">{asset.name}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 p-6 pt-0 text-sm">
|
||||
<SummaryRow label={t('acquisition_cost')} value={formatCurrency(Number(asset.acquisition_cost))} />
|
||||
<SummaryRow label={t('acquired')} value={formatDate(asset.acquisition_date)} />
|
||||
|
||||
@@ -441,11 +441,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
|
||||
const prefix = DIM_BADGE_PREFIX[dimNo] ?? dim?.name ?? `Dim ${dimNo}`
|
||||
const hasName = !!value && value.name !== '' && value.name !== value.code
|
||||
return (
|
||||
// data-ph-mask: dimension names and codes are user data. No title
|
||||
// attribute: replay masking covers text nodes, not attributes, so
|
||||
// a title tooltip would ship the masked content in the clear.
|
||||
<Badge
|
||||
key={dimNo}
|
||||
data-ph-mask=""
|
||||
variant="outline"
|
||||
className="font-mono text-[11px] font-normal"
|
||||
title={`${dim?.name ?? prefix} ${code}${hasName ? `: ${value.name}` : ''}`}
|
||||
>
|
||||
{prefix}: {hasName ? value.name : code}
|
||||
</Badge>
|
||||
|
||||
@@ -514,9 +514,11 @@ export default function ArsredovisningPage() {
|
||||
<PageHeader
|
||||
title={`Årsredovisning ${data.fiscal_period.name}`}
|
||||
description={
|
||||
data.company.org_number
|
||||
? `${data.company.name} · ${data.company.org_number}`
|
||||
: data.company.name
|
||||
<span data-ph-mask="">
|
||||
{data.company.org_number
|
||||
? `${data.company.name} · ${data.company.org_number}`
|
||||
: data.company.name}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<Button variant="outline" asChild>
|
||||
|
||||
@@ -314,7 +314,7 @@ function CustomersPageInner() {
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function HelpPage() {
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('no_results_title')}
|
||||
description={t('no_results', { query: searchQuery })}
|
||||
description={<span data-ph-mask="">{t('no_results', { query: searchQuery })}</span>}
|
||||
/>
|
||||
) : (
|
||||
filteredTerms.map((term) => (
|
||||
|
||||
@@ -250,7 +250,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('preview_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{/* data-ph-mask: the invoice number is user data */}
|
||||
<CardDescription data-ph-mask="">
|
||||
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -346,7 +347,8 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
<CardTitle>{t('confirm_card_title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('confirm_card_description_1')}
|
||||
<span className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
|
||||
{/* data-ph-mask: the invoice number is user data */}
|
||||
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
|
||||
{t('confirm_card_description_2')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -357,6 +359,9 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
|
||||
placeholder={invoice.invoice_number ?? ''}
|
||||
disabled={!invoice.invoice_number}
|
||||
className={cn(
|
||||
// ph-no-capture: the placeholder carries the invoice number, and
|
||||
// replay masking covers input values, not attributes.
|
||||
'ph-no-capture',
|
||||
confirmText && confirmText !== invoice.invoice_number && 'border-destructive'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1856,7 +1856,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{t('delete_dialog_desc_with_number_1')}
|
||||
<strong>{t('delete_dialog_status_makulerad')}</strong>
|
||||
{t('delete_dialog_desc_with_number_2')}
|
||||
<span className="mt-2 block text-muted-foreground">
|
||||
{/* data-ph-mask: interpolates the invoice number */}
|
||||
<span data-ph-mask="" className="mt-2 block text-muted-foreground">
|
||||
{t('delete_dialog_number_kept', { number: invoice.invoice_number })}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -693,7 +693,7 @@ export default function InvoicesPage() {
|
||||
<DataListEmpty
|
||||
icon={<ReceiptText className="h-6 w-6" />}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
|
||||
/>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
@@ -1250,7 +1250,8 @@ export default function PendingOperationsPage() {
|
||||
? t('reject_bulk_title', { count: selectedCount })
|
||||
: 'Avvisa operation'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{/* data-ph-mask: the operation title carries counterparty and amount */}
|
||||
<DialogDescription data-ph-mask="">
|
||||
{rejectTarget === 'bulk'
|
||||
? t('reject_bulk_description')
|
||||
: rejectTarget?.title}
|
||||
|
||||
@@ -773,7 +773,8 @@ function SkattekontoRow({
|
||||
unbooked deviates; upcoming rows are unbooked by nature. */}
|
||||
{section === 'booked' && !isBooked && (
|
||||
row.match_suggestion ? (
|
||||
<Badge variant="warning" className="font-normal">
|
||||
/* data-ph-mask: the voucher reference is user data */
|
||||
<Badge variant="warning" className="font-normal" data-ph-mask="">
|
||||
{t('chip_possible_duplicate', {
|
||||
voucher:
|
||||
row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
|
||||
@@ -857,7 +858,8 @@ function MatchDialog({
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Matcha mot befintligt verifikat</DialogTitle>
|
||||
<DialogDescription>
|
||||
{/* data-ph-mask: transaction text and amount are user data */}
|
||||
<DialogDescription data-ph-mask="">
|
||||
{row && (
|
||||
<>
|
||||
{formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '}
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function SuppliersPage() {
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
|
||||
@@ -186,7 +186,8 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
|
||||
Fakturainformation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Till: {invoice.customerName}
|
||||
{/* data-ph-mask: the customer name is user data */}
|
||||
Till: <span data-ph-mask="">{invoice.customerName}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() {
|
||||
Integritetspolicy
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Senast uppdaterad: 2026-08-06
|
||||
Senast uppdaterad: 2026-08-17
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -158,11 +158,13 @@ export default function PrivacyPolicyPage() {
|
||||
användar-ID, e-postadress, namn och företagsnamn. Om du
|
||||
själv skriver till supporten i appen skickas även ditt
|
||||
meddelande dit som ett ärende, så att vi kan svara. I
|
||||
sessionsinspelningar maskeras belopp, person- och
|
||||
organisationsnummer samt lösenord; övrig text i
|
||||
gränssnittet och det du själv skriver kan ingå i
|
||||
inspelningen, så att vi kan felsöka problem du stöter
|
||||
på. Organisationsnummer skickas aldrig som analysdata.
|
||||
sessionsinspelningar maskeras allt du skriver och allt
|
||||
innehåll i din bokföring (namn, beskrivningar, belopp,
|
||||
person- och organisationsnummer); läsbart är endast
|
||||
appens eget gränssnitt, som rubriker, knappar, menyer
|
||||
och ledtexter, så att vi kan se var i appen du stöter
|
||||
på problem utan att se dina uppgifter.
|
||||
Organisationsnummer skickas aldrig som analysdata.
|
||||
Identifiering sker endast för inloggade användare (ej
|
||||
sandbox/demo). Inga kakor används, och själva analysdatan
|
||||
lagras inte på din enhet. Två små tekniska värden sparas
|
||||
|
||||
Reference in New Issue
Block a user