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:
Mattsson
2026-08-17 11:32:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1bb423b2b3
commit f8db38f989
74 changed files with 366 additions and 224 deletions
@@ -148,7 +148,7 @@ export function ActivateAccountsDialog({
onClick={() => onCreateUnknown(r.account_number)}
>
<Plus className="mr-1 h-3 w-3" />
Skapa {r.account_number}
Skapa <span data-ph-mask="">{r.account_number}</span>
</Button>
))}
</div>
@@ -130,7 +130,8 @@ export default function CorrectOpeningBalanceDialog({
<DialogTitle>Korrigera ingående balanser</DialogTitle>
<DialogDescription>
Ändra beloppen nedan och spara. Den befintliga IB-verifikationen (
{formatVoucher(entry)}) makuleras och en ny bokförs med de korrigerade beloppen.
<span data-ph-mask="">{formatVoucher(entry)}</span>) makuleras och en ny bokförs med
de korrigerade beloppen.
</DialogDescription>
</DialogHeader>
@@ -303,6 +303,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={autoCorrectionDescription(entry.description)}
// ph-no-capture: the placeholder echoes the posted entry's
// description, and replay masking covers values, not attributes.
className="ph-no-capture"
/>
<p className="text-xs text-muted-foreground">
Texten på den nya verifikationen. Ändra den om originalets beskrivning inte längre
+1 -1
View File
@@ -299,7 +299,7 @@ export function EditAccountDialog({ open, onOpenChange, account, onSaved }: Edit
<DialogContent className="max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Redigera konto {account.account_number}
Redigera konto <span data-ph-mask="">{account.account_number}</span>
</DialogTitle>
</DialogHeader>
@@ -268,9 +268,10 @@ export default function InboxDocumentPicker({ open, onClose, journalEntryId, onL
<Dialog open={previewItem !== null} onOpenChange={(o) => !o && setPreviewItem(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate pr-6">{previewItem?.file_name}</DialogTitle>
{/* data-ph-mask: the file name and supplier details are user data */}
<DialogTitle data-ph-mask="" className="truncate pr-6">{previewItem?.file_name}</DialogTitle>
{previewItem && (previewItem.supplier_name || previewItem.amount != null) && (
<DialogDescription className="flex items-center gap-2 tabular-nums">
<DialogDescription data-ph-mask="" className="flex items-center gap-2 tabular-nums">
{previewItem.supplier_name && <span>{previewItem.supplier_name}</span>}
{previewItem.amount != null && (
<span>{formatCurrency(previewItem.amount, previewItem.currency ?? 'SEK')}</span>
+1 -1
View File
@@ -1713,7 +1713,7 @@ export default function JournalEntryForm({
{line.dimensions &&
Object.keys(line.dimensions).length > 0 &&
(line.account_number || line.debit_amount || line.credit_amount) && (
<Badge variant="outline" className="mt-1 font-mono text-[11px] font-normal">
<Badge data-ph-mask="" variant="outline" className="mt-1 font-mono text-[11px] font-normal">
{compactDims(line.dimensions)}
</Badge>
)}
@@ -170,7 +170,8 @@ export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Pr
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="sm:min-w-[460px] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{/* data-ph-mask: the template name is user data */}
<DialogTitle data-ph-mask="" className="font-display text-lg tracking-tight">
{selected ? selected.name : t('tpl_dialog_title')}
</DialogTitle>
</DialogHeader>
@@ -679,9 +679,11 @@ export default function BulkTagWorkbench() {
<DataListMetaSeparator />
<span>{voucher.lines.length} rader</span>
{(bags.length > 0 || partial) && <DataListMetaSeparator />}
{/* data-ph-mask: dimension codes are user data */}
{bags.map((bag) => (
<Badge
key={mapKey(bag)}
data-ph-mask=""
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
@@ -745,6 +747,7 @@ export default function BulkTagWorkbench() {
dimEntries.map(([dimNo, code]) => (
<Badge
key={dimNo}
data-ph-mask=""
variant="outline"
className="px-1.5 py-0 text-[10px] font-normal"
>
+10 -5
View File
@@ -379,12 +379,14 @@ export default function DimensionsManager() {
options={dimensions.map((dim) => ({
value: dim.id,
label: (
<>
// data-ph-mask: dimension names are user data; the wrapper
// keeps the segment button's inline gap between name and count.
<span data-ph-mask="" className="inline-flex items-center gap-1.5">
{dim.name}
{dim.values.length > 0 && (
<span className="text-muted-foreground tabular-nums">{dim.values.length}</span>
)}
</>
</span>
),
}))}
/>
@@ -427,13 +429,15 @@ export default function DimensionsManager() {
<EmptyState
icon={Search}
title={t('no_search_results_title')}
description={t('no_search_results_description', { term: searchTerm })}
/* data-ph-mask: the search term is user data */
description={<span data-ph-mask="">{t('no_search_results_description', { term: searchTerm })}</span>}
/>
) : (
<EmptyState
icon={Tags}
title={t('empty_title')}
description={t('empty_description', { dimension: activeDim?.name ?? '' })}
/* data-ph-mask: the dimension name is user data */
description={<span data-ph-mask="">{t('empty_description', { dimension: activeDim?.name ?? '' })}</span>}
actionLabel={canWrite ? t('new_value') : undefined}
onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined}
/>
@@ -497,7 +501,8 @@ export default function DimensionsManager() {
<Dialog open={dialog !== null} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{/* data-ph-mask: the dimension name is user data */}
<DialogTitle data-ph-mask="">
{dialog?.mode === 'edit'
? t('edit_value_title')
: t('new_value_title', { dimension: activeDim?.name ?? '' })}
@@ -2373,7 +2373,7 @@ function PurchaseRow({
{/* A chip only when the row deviates: here, when we can actually
tell the user where to go. */}
{purchase.portal && (
<Badge variant="outline" className="text-[10px] font-normal">
<Badge data-ph-mask="" variant="outline" className="text-[10px] font-normal">
{purchase.portal.vendor}
</Badge>
)}
@@ -328,10 +328,13 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building2 className="h-4 w-4" />
{profile.companyName}
{/* data-ph-mask: the looked-up company name is user data */}
<span data-ph-mask="">{profile.companyName}</span>
</CardTitle>
<CardDescription>
{profile.orgNumber} &middot; {profile.legalEntityType}
{/* data-ph-mask: TIC serves the orgnr in unnormalized format, so
the separator-based pattern scrub cannot be relied on */}
<span data-ph-mask="">{profile.orgNumber}</span> &middot; {profile.legalEntityType}
{!isActive && (
<span className="ml-2 text-destructive">&middot; {t('deregistered')}</span>
)}
@@ -477,7 +480,8 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
{profile.statuses.slice(0, 6).map((status, i) => (
<li key={i} className="flex items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant={statusColorToVariant(status.color)}>
{/* data-ph-mask: the Bolagsverket status text is user data */}
<Badge variant={statusColorToVariant(status.color)} data-ph-mask="">
{status.description ?? status.code ?? '-'}
</Badge>
{status.isCeased && (
@@ -179,7 +179,8 @@ export default function CsvImportWizard({
<TableHeader>
<TableRow>
{headers.map(h => (
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
/* data-ph-mask: CSV headers are user data */
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
))}
</TableRow>
</TableHeader>
@@ -341,7 +341,8 @@ export default function MockDataImportDialog<T>({
<TableHeader>
<TableRow>
{csvHeaders.map(h => (
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
/* data-ph-mask: CSV headers are user data */
<TableHead key={h} data-ph-mask="" className="text-xs whitespace-nowrap">{h}</TableHead>
))}
</TableRow>
</TableHeader>
@@ -200,7 +200,8 @@ export default function OpeningBalanceColumnMappingStep({
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
/* data-ph-mask: CSV headers are user data */
<th key={i} data-ph-mask="" className="px-3 py-2 text-left whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
@@ -91,7 +91,8 @@ export default function RegisterColumnMappingStep<K extends string>({
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
{headers.map((h, i) => (
<th key={i} className="px-3 py-2 text-left whitespace-nowrap">
/* data-ph-mask: CSV headers are user data */
<th key={i} data-ph-mask="" className="px-3 py-2 text-left whitespace-nowrap">
{h || `Kolumn ${i + 1}`}
</th>
))}
+2 -1
View File
@@ -2690,7 +2690,8 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<DialogContent>
<DialogHeader>
<DialogTitle>{t('send_now_dialog_title')}</DialogTitle>
<DialogDescription>
{/* data-ph-mask: the customer email is user data */}
<DialogDescription data-ph-mask="">
{t('send_now_dialog_description', { email: selectedCustomer?.email ?? '' })}
</DialogDescription>
</DialogHeader>
+4 -1
View File
@@ -321,7 +321,10 @@ export default function PaymentBookingDialog({
<DialogContent className="sm:max-w-[680px]">
<DialogHeader>
<DialogTitle>
{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
{/* data-ph-mask: the invoice number is user data */}
{t('title')}{invoice.invoice_number ? (
<span data-ph-mask="">{t('title_suffix', { number: invoice.invoice_number })}</span>
) : ''}
{nextVoucher && (
<span className="ml-1 text-muted-foreground tabular-nums">
({nextVoucher.series}{nextVoucher.next})
+6 -2
View File
@@ -455,9 +455,13 @@ export default function SendInvoiceDialog({
? 'title_email'
: 'title_manual',
)}
{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
{/* data-ph-mask: the invoice number is user data */}
{invoice.invoice_number ? (
<span data-ph-mask="">{t('title_suffix', { number: invoice.invoice_number })}</span>
) : ''}
</DialogTitle>
<DialogDescription>
{/* data-ph-mask: amount and customer email are user data */}
<DialogDescription data-ph-mask="">
{formatCurrency(invoice.total, invoice.currency)}
{invoice.currency !== 'SEK' && invoice.total_sek && (
<>{t('description_sek_suffix', { amount: formatCurrency(invoice.total_sek) })}</>
@@ -78,8 +78,9 @@ export default function CreateInvoiceFromOrderDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('invoice_title', { number: order.order_number })}</DialogTitle>
<DialogDescription>
{/* data-ph-mask: order number and customer name are user data */}
<DialogTitle data-ph-mask="">{t('invoice_title', { number: order.order_number })}</DialogTitle>
<DialogDescription data-ph-mask="">
{t('invoice_description', { customer: customerLabel })}
</DialogDescription>
</DialogHeader>
+2 -1
View File
@@ -114,7 +114,8 @@ export default function OrderBookingDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{/* data-ph-mask: the order number is user data */}
<DialogTitle data-ph-mask="">
{isRefund
? t('book_refund_title', { number: order.order_number })
: t('book_title', { number: order.order_number })}
@@ -167,7 +167,7 @@ export function PaymentMethodMappingForm({
const mode = policy?.mode ?? 'book'
const account = policy?.mode === 'book' ? policy.account : ''
return (
<SettingsRow key={method} label={title || method}>
<SettingsRow key={method} label={<span data-ph-mask="">{title || method}</span>}>
<SettingsRowEnd>
<select
value={policy ? mode : 'unmapped'}
+2 -1
View File
@@ -117,7 +117,8 @@ export function DimensionFilter({ value, onChange }: Props) {
</div>
{value && (
<Badge variant="warning" className="w-fit">
Filtrerad: {activeDim?.name ?? `Dim ${value.dimNo}`} {value.code}, ej fullständig rapport
{/* data-ph-mask: dimension names and codes are user data */}
Filtrerad: <span data-ph-mask="">{activeDim?.name ?? `Dim ${value.dimNo}`} {value.code}</span>, ej fullständig rapport
</Badge>
)}
</div>
+6 -2
View File
@@ -2803,7 +2803,8 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
<Card key={account.account_number}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{/* data-ph-mask: account number and name are user chart data */}
<CardTitle data-ph-mask="" className="text-base">
<AccountNumber number={account.account_number} name={account.account_name} showName />
</CardTitle>
<span className="text-sm text-muted-foreground">
@@ -3531,7 +3532,10 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
{data.columns.map((c, i) => (
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
/* data-ph-mask: the pivot column header is a user dimension
value. No title attribute: replay masking covers text
nodes, not attributes. */
<th key={i} data-ph-mask="" className="text-right font-medium px-4 py-2 w-32 tabular-nums">
{columnLabel(c)}
</th>
))}
+5 -3
View File
@@ -165,7 +165,9 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
// ph-no-capture: the placeholder is the employee's effective
// amount, and replay masking covers values, not attributes.
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
@@ -183,7 +185,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
@@ -201,7 +203,7 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
+1 -1
View File
@@ -160,7 +160,7 @@ export function RunEmployeesTable({
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge variant="secondary">{dimLabel}</Badge>
<Badge data-ph-mask="" variant="secondary">{dimLabel}</Badge>
)}
{showDiff && diffNode(sre)}
</div>
@@ -269,6 +269,9 @@ export function AccountDangerZone() {
onChange={(e) => setConfirmText(e.target.value)}
placeholder={email ?? ''}
autoComplete="off"
// ph-no-capture: the placeholder is the user's email, and
// replay masking covers values, not attributes.
className="ph-no-capture"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
+5 -1
View File
@@ -120,7 +120,8 @@ export function CompanyDangerZone() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
{/* data-ph-mask: the title interpolates the company name */}
<DialogTitle data-ph-mask="">{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
@@ -139,6 +140,9 @@ export function CompanyDangerZone() {
onChange={(e) => setConfirmText(e.target.value)}
placeholder={company.name}
autoComplete="off"
// ph-no-capture: the placeholder is the company name, and
// replay masking covers values, not attributes.
className="ph-no-capture"
/>
</div>
<DialogFooter>
+12 -7
View File
@@ -36,19 +36,23 @@ export function SettingsSectionHeader({ title, intro, action, mark }: SettingsSe
<div className="flex items-baseline justify-between gap-4">
<div className="flex items-center gap-2.5">
{mark ? <span className="shrink-0 leading-none">{mark}</span> : null}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
{/* data-ph-unmask: settings chrome (titles, labels) is static i18n
text in session replays; values and controls stay masked. */}
<h2 data-ph-unmask="" className="font-display text-xl tracking-tight">{title}</h2>
</div>
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
</div>
{intro ? (
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
<p data-ph-unmask="" className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
) : null}
</header>
)
}
interface SettingsGroupProps {
label?: string
/** Chrome in session replays (data-ph-unmask): wrap any user data (e.g. a
team name) in a data-ph-mask element. */
label?: React.ReactNode
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
help?: React.ReactNode
children: React.ReactNode
@@ -60,7 +64,7 @@ export function SettingsGroup({ label, help, children, className }: SettingsGrou
<section className={cn('pt-8 first:pt-6', className)}>
{label ? (
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{label}</span>
<span data-ph-unmask="">{label}</span>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</p>
) : null}
@@ -104,11 +108,11 @@ export function SettingsRow({
>
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
{htmlFor ? (
<label htmlFor={htmlFor} className={labelClass}>
<label htmlFor={htmlFor} data-ph-unmask="" className={labelClass}>
{label}
</label>
) : (
<span className={labelClass}>{label}</span>
<span data-ph-unmask="" className={labelClass}>{label}</span>
)}
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</div>
@@ -241,6 +245,7 @@ export function SettingsSeg<T extends string>({
disabled={disabled}
aria-pressed={o.value === value}
onClick={() => onChange(o.value)}
data-ph-unmask=""
className={cn(
'rounded-sm px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
o.value === value
@@ -259,7 +264,7 @@ export function SettingsSeg<T extends string>({
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-t border-destructive/30 pt-3">
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
<p data-ph-unmask="" className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
{label}
</p>
<div>{children}</div>
+1 -1
View File
@@ -116,7 +116,7 @@ export function TeamPanel() {
}
return (
<SettingsGroup label={teamName || t('team_fallback')}>
<SettingsGroup label={<span data-ph-mask="">{teamName || t('team_fallback')}</span>}>
{/* Read-only member roster: flat hairline rows, no cards. */}
{members.map((member) => (
<div
@@ -147,7 +147,8 @@ export function SkattekontoMatchDialog({
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>
{/* data-ph-mask: transaction text and amount are user data */}
<DialogDescription data-ph-mask="">
{row && (
<>
{formatDate(row.transaktionsdatum)} • {row.transaktionstext} •{' '}
@@ -2598,7 +2598,8 @@ export default function NewSupplierInvoiceForm({
<AlertCircle className="h-5 w-5 text-destructive" />
{t('duplicate_dialog_title')}
</DialogTitle>
<DialogDescription>{conflict?.message}</DialogDescription>
{/* data-ph-mask: the conflict message carries the invoice number */}
<DialogDescription data-ph-mask="">{conflict?.message}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{conflict?.existing && (
+1 -1
View File
@@ -798,7 +798,7 @@ export default function BulkBookDialog({
{t('preview_label', { count: previewLines.length })}
</Label>
{dimensionsEnabled && dimsSummary && (
<Badge variant="secondary" className="font-mono tabular-nums">
<Badge data-ph-mask="" variant="secondary" className="font-mono tabular-nums">
{dimsSummary}
</Badge>
)}
@@ -449,7 +449,7 @@ export default function QuickReviewDialog({
{template ? template.name_sv : categoryLabel}
</span>
{patternDimsLabel && (
<Badge variant="secondary" className="font-mono tabular-nums">
<Badge data-ph-mask="" variant="secondary" className="font-mono tabular-nums">
{patternDimsLabel}
</Badge>
)}
@@ -326,7 +326,7 @@ function BankHistoryRow({
</span>
)}
{hasInvoiceMatch && (
<Badge variant="secondary" className="hidden shrink-0 gap-1 font-normal md:inline-flex">
<Badge data-ph-mask="" variant="secondary" className="hidden shrink-0 gap-1 font-normal md:inline-flex">
<FileText className="h-3 w-3" />
{t('possible_match_invoice', {
number: transaction.potential_invoice!.invoice_number ?? '',
+3 -1
View File
@@ -35,8 +35,10 @@ export interface BadgeProps
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
// data-ph-unmask: status chips are static i18n chrome in session replays;
// a badge carrying user data adds data-ph-mask at the call site.
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div data-ph-unmask="" className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
+6
View File
@@ -52,8 +52,14 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
// data-ph-unmask: button labels are static i18n chrome in session
// replays. Combobox-style triggers render a selected VALUE (user data),
// so they stay masked; a call site whose label carries user data adds
// data-ph-mask, which wins over unmask on the same element.
const phUnmask = props.role === "combobox" ? {} : { "data-ph-unmask": "" }
return (
<Comp
{...phUnmask}
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
+4
View File
@@ -32,8 +32,11 @@ const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
// data-ph-unmask: card titles are static i18n chrome in session replays;
// a title carrying user data adds data-ph-mask at the call site.
<h3
ref={ref}
data-ph-unmask=""
className={cn(
"font-display text-xl leading-none tracking-tight",
className
@@ -49,6 +52,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
+6 -2
View File
@@ -66,11 +66,15 @@ export function ConfirmDialog({
<Dialog open={open} onOpenChange={(next) => !pending && onOpenChange(next)}>
<DialogContent className="sm:min-w-[460px] sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-lg tracking-tight">
{/* data-ph-mask: confirm dialogs describe the object being acted
on (convention 10), so title and description are user data in
session replays, not chrome. Mask wins over the primitives'
own data-ph-unmask. */}
<DialogTitle data-ph-mask="" className="font-display text-lg tracking-tight">
{title}
</DialogTitle>
{description && (
<DialogDescription className="text-[13px] leading-relaxed">
<DialogDescription data-ph-mask="" className="text-[13px] leading-relaxed">
{description}
</DialogDescription>
)}
+5 -1
View File
@@ -59,7 +59,11 @@ export function ConfirmationDialog({
<ClipboardCheck className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle className="text-lg sm:text-xl">{title}</DialogTitle>
{/* data-ph-mask on the title: confirm dialogs describe the
object being acted on (convention 10), so the title is user
data in session replays. The description is a static
sentence and stays readable. */}
<DialogTitle data-ph-mask="" className="text-lg sm:text-xl">{title}</DialogTitle>
<DialogDescription>Granska uppgifterna innan du bekräftar</DialogDescription>
</div>
</div>
+6 -2
View File
@@ -147,8 +147,10 @@ DataListMetaSeparator.displayName = "DataListMetaSeparator"
interface DataListEmptyProps {
icon?: React.ReactNode
title: string
description?: string
/** Chrome in session replays (data-ph-unmask): wrap any user data (e.g. a
search term) in a data-ph-mask element. */
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
className?: string
}
@@ -160,7 +162,9 @@ const DataListEmpty = ({
action,
className,
}: DataListEmptyProps) => (
// data-ph-unmask: list empty states are static i18n chrome in session replays.
<div
data-ph-unmask=""
className={cn(
"flex flex-col items-center justify-center px-6 py-16 text-center",
className
+5 -2
View File
@@ -68,11 +68,14 @@ export function DestructiveConfirmDialog({
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<DialogTitle>{title}</DialogTitle>
{/* data-ph-mask: confirm dialogs describe the object being
acted on (convention 10), so title and description are user
data in session replays, not chrome. */}
<DialogTitle data-ph-mask="">{title}</DialogTitle>
{/* pre-line so callers can pass newline-separated paragraphs
(e.g. the salary unapprove confirm assembles its copy
dynamically); single-line descriptions render unchanged. */}
<DialogDescription className="whitespace-pre-line">
<DialogDescription data-ph-mask="" className="whitespace-pre-line">
{description}
</DialogDescription>
</div>
+5 -2
View File
@@ -24,7 +24,8 @@ export function DetailSection({
return (
<section className={className}>
<div className="flex items-baseline justify-between gap-4 border-b border-border pb-2">
<h2 className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{/* data-ph-unmask: kickers are static i18n chrome in session replays. */}
<h2 data-ph-unmask="" className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{kicker}
</h2>
{aside}
@@ -50,7 +51,9 @@ export function DefRow({
className,
)}
>
<div className="text-muted-foreground">{label}</div>
{/* data-ph-unmask on the label only: values (children) are user data
and stay masked in session replays. */}
<div data-ph-unmask="" className="text-muted-foreground">{label}</div>
<div className="min-w-0">{children}</div>
</div>
)
+4
View File
@@ -100,8 +100,11 @@ const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
// data-ph-unmask: dialog titles are static i18n chrome in session replays;
// a title carrying user data adds data-ph-mask at the call site.
<DialogPrimitive.Title
ref={ref}
data-ph-unmask=""
className={cn(
"text-lg leading-none tracking-tight",
className
@@ -117,6 +120,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
+9 -3
View File
@@ -19,8 +19,13 @@ import { SupportLink } from '@/components/ui/support-link'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description: string
/**
* Usually static i18n strings. The empty state is data-ph-unmask chrome in
* session replays, so a title or description carrying user data (e.g. an
* interpolated search term) must wrap that part in a data-ph-mask element.
*/
title: React.ReactNode
description: React.ReactNode
actionLabel?: string
actionHref?: string
onAction?: () => void
@@ -49,7 +54,8 @@ export function EmptyState({
}: EmptyStateProps) {
const t = useTranslations('empty')
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
// data-ph-unmask: empty states are static i18n chrome in session replays.
<div data-ph-unmask="" className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
{Icon && (
<div className="mb-6">
<div className="p-4 rounded-full bg-muted">
+2
View File
@@ -86,6 +86,8 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
ref={panelRef}
role="note"
data-help-popover=""
// data-ph-unmask: page help is static i18n chrome in session replays.
data-ph-unmask=""
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
style={{ top: pos.top, left: pos.left }}
>
+3
View File
@@ -19,9 +19,12 @@ const TooltipContent = React.forwardRef<
// ancestor with overflow (e.g. a scrollable DialogContent: the send-invoice
// and journal-review dialogs use overflow-y-auto, which otherwise crops it).
<TooltipPrimitive.Portal>
{/* data-ph-unmask: tooltip help text is static i18n chrome in session
replays; content carrying user data adds data-ph-mask at the call site. */}
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
data-ph-unmask=""
className={cn(
'z-50 overflow-hidden rounded-lg border border-border/60 bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
+2 -2
View File
@@ -14,8 +14,8 @@ const Label = React.forwardRef<
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
// data-ph-unmask: form field labels are static i18n chrome, exempt from
// the pattern-based replay masking (see lib/analytics/replay-masking.ts).
// data-ph-unmask: form field labels are static i18n chrome, readable under
// the deny-by-default replay masking (see lib/analytics/replay-masking.ts).
// A call site whose label text is user data must add data-ph-mask, which
// wins over this default.
<LabelPrimitive.Root
+13 -5
View File
@@ -1,8 +1,13 @@
import * as React from 'react'
interface PageHeaderProps {
title: string
description?: string
/**
* Usually a static i18n string. The header is data-ph-unmask chrome in
* session replays, so a title or description that carries user data must
* wrap that part in a data-ph-mask element (hence ReactNode).
*/
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
/**
* Page help content, rendered as a small "?" popover right after the H1
@@ -16,12 +21,15 @@ export function PageHeader({ title, description, action, help }: PageHeaderProps
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<div className="flex items-center gap-2">
{/* Locked at exactly 24px/32px (UI-migration convention 2) */}
<h1 className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
{/* Locked at exactly 24px/32px (UI-migration convention 2).
data-ph-unmask: page titles are static i18n chrome in session
replays; a page whose title carries user data must wrap it in
data-ph-mask at the call site. */}
<h1 data-ph-unmask="" className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
{help}
</div>
{description && (
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
<p data-ph-unmask="" className="text-muted-foreground mt-1 text-balance">{description}</p>
)}
</div>
{action && <div className="w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto">{action}</div>}
+5 -1
View File
@@ -42,12 +42,16 @@ export function SegmentedControl<T extends string>({
aria-label={aria['aria-label']}
>
{options.map((opt) => (
// data-ph-unmask: segment labels are static i18n chrome in session
// replays; the count chip inside is data and carries data-ph-mask
// (nearest tag wins), matching the nav count bubbles.
<button
key={opt.value}
type="button"
role="tab"
aria-selected={value === opt.value}
onClick={() => onChange(opt.value)}
data-ph-unmask=""
className={cn(
'inline-flex h-full items-center gap-1.5 rounded-full px-3.5 text-[12.5px] transition-colors duration-150',
value === opt.value
@@ -57,7 +61,7 @@ export function SegmentedControl<T extends string>({
>
{opt.label}
{typeof opt.count === 'number' && opt.count > 0 && (
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
<span data-ph-mask="" className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
{opt.count}
</span>
)}
+4
View File
@@ -100,8 +100,11 @@ const SheetTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
// data-ph-unmask: sheet titles are static i18n chrome in session replays;
// a title carrying user data adds data-ph-mask at the call site.
<DialogPrimitive.Title
ref={ref}
data-ph-unmask=""
className={cn("text-base tracking-tight", className)}
{...props}
/>
@@ -114,6 +117,7 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
+2
View File
@@ -25,8 +25,10 @@ const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
// data-ph-unmask: tab labels are static i18n chrome in session replays.
<TabsPrimitive.Trigger
ref={ref}
data-ph-unmask=""
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
+4
View File
@@ -93,6 +93,10 @@ const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
// Deliberately NOT data-ph-unmask: toast titles and descriptions
// interpolate user data (deadline titles, account names) at too many call
// sites to audit, so toasts stay masked in session replays. The variant
// styling still shows success/failure in the replay.
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}