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:
+15
-9
@@ -605,9 +605,9 @@ processing_activities:
|
||||
data_categories:
|
||||
- user.contact.email # person property via identify(), aldrig i event-properties
|
||||
- user.name # profiles.full_name
|
||||
# Sessionsinspelningen visar synlig text och tangentbordsinmatning,
|
||||
# förutom maskerade fält (belopp, person-/orgnummer, lösenord samt
|
||||
# data-ph-mask-taggade element), se security_measures nedan.
|
||||
# Sessionsinspelningen är maskerad som standard: all tangentbords-
|
||||
# inmatning och all text maskeras, utom appens eget statiska gränssnitt
|
||||
# (rubriker, navigering, knappar, etiketter), se security_measures.
|
||||
- user.behavior # sidvisningar, klick, händelser, sessionsinspelning
|
||||
- user.device # user agent, skärmstorlek, IP (trunkeras av PostHog)
|
||||
# Supportärenden: fritext som användaren SJÄLV skriver till supporten via
|
||||
@@ -637,12 +637,18 @@ processing_activities:
|
||||
stored_in:
|
||||
- posthog_eu # externt hos biträdet; inget lagras i vår databas
|
||||
security_measures:
|
||||
# Mönsterbaserad maskering (2026-08-06, ersätter maskera-allt-läget):
|
||||
# belopp (valutaformaterad text), person-/organisationsnummer (text och
|
||||
# inmatning) och lösenord maskeras; övrig text och tangentbordsinmatning
|
||||
# syns i inspelningen för felsökning. data-ph-mask tvingar maskering av
|
||||
# utpekade PII-element; låst av test i lib/analytics/__tests__.
|
||||
- session_replay_masks_amounts_and_identity_numbers # lib/analytics/replay-masking.ts
|
||||
# Maskerat-som-standard (2026-08-17, ersätter det mönsterbaserade läget
|
||||
# från 2026-08-06): ALL tangentbordsinmatning maskeras (rrweb
|
||||
# maskAllInputs utan undantagsfunktion) och ALL text maskeras utom
|
||||
# element taggade data-ph-unmask (statiskt i18n-gränssnitt: rubriker,
|
||||
# navigering, etiketter, knappar) samt tabellkolumnrubriker. Även
|
||||
# otaggat gränssnitt maskeras, så felmoden för ny UI är övermaskering,
|
||||
# aldrig läckage. Taggat gränssnitt mönsterskrubbas dessutom på belopp
|
||||
# och person-/organisationsnummer. data-ph-mask tvingar maskering och
|
||||
# vinner över data-ph-unmask; låst av test i lib/analytics/__tests__.
|
||||
- session_replay_masked_by_default # lib/analytics/replay-masking.ts
|
||||
- session_replay_masks_all_input_values
|
||||
- session_replay_masks_amounts_and_identity_numbers_in_chrome
|
||||
- session_replay_masks_password_inputs
|
||||
- org_number_never_transmitted_as_group_property # låst av test i lib/analytics/__tests__
|
||||
- no_pii_in_event_properties # PII endast som person properties via identify()
|
||||
|
||||
@@ -1019,6 +1019,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-15] Confirmed intentional (Swedish-review note): with override=true and an unresolvable filename, the attach endpoint links a document to any same-company, same-declared-year, posted verifikat, migrated or not. This mirrors /api/documents/[id]/link, which imposes no filename check at all, so it introduces no new capability class; tenant, year and period-lock enforcement always apply.
|
||||
[2026-08-15] BankID tabs bind to a random non-secret `flowId` signed into the shared flow cookie and sent as a request header after start or explicit resume: mode pinning alone cannot distinguish two same-mode tabs, so an older tab could otherwise silently follow, cancel, or complete a newer person's identification after `/start` replaced the origin-wide cookie. This supersedes the 2026-08-15 decision that deliberately skipped mode matching on active polls.
|
||||
[2026-08-15] Did not apply BankID migration `20260815120000` to Supabase staging during PR #1625 follow-through: read-only reconciliation found 14 staging-only and 99 branch-only migration versions, so applying on top of that divergent ledger would violate the no-orphan rule. Production is reconciled with zero remote-only versions and exactly this PR migration local-only; hosted pg-real validates the migration until staging is reconciled.
|
||||
[2026-08-17] Session replay masking inverted to deny-by-default (founder-directed after user pushback on session recording): ALL input values are masked (maskAllInputs with no maskInputFn, so rrweb masks wholesale; placeholders are attributes and stay visible) and ALL text is masked unless it sits under data-ph-unmask chrome or a th (dry-table column headers are raw th per page, so the mask function treats th as chrome rather than tagging hundreds of sites). Chrome tags live on the shared primitives (PageHeader, Label, Button except role=combobox triggers which render selected values, TabsTrigger, Badge, Card/Dialog/Sheet titles and descriptions, tooltips, help popovers, empty states, settings labels); tagged chrome is still pattern-scrubbed for amounts and identity numbers, and data-ph-mask beats data-ph-unmask so call sites that interpolate user data into chrome stay masked. Toasts (title AND description) deliberately NOT tagged: they interpolate user data at too many call sites to audit, and an audit found live leaks (deadline titles, bank account names) in titles alone. Confirm-dialog wrappers (ConfirmDialog, ConfirmationDialog, DestructiveConfirmDialog) force data-ph-mask on their titles/descriptions centrally: convention 10 makes confirm copy describe the object being acted on, so it is user data by design; that one change closed 14+ audited leak sites. A very-thorough audit of user data flowing into unmasked primitives ran in the same change and every found site got a call-site data-ph-mask. Failure mode for untagged new UI is over-masking, never leakage. Supersedes the 2026-08-06 pattern-based default; privacy policy and RoPA updated in the same change.
|
||||
[2026-08-17] Betalfil missing-bankgiro UX: advisory warning in PaymentFilePanel (download stays enabled, route stays the authority) + click-to-prefill from tic_snapshot instead of auto-seeding company_settings.bankgiro: sender payment data must be user-confirmed, and the snapshot is unvalidated registry JSON.
|
||||
[2026-08-16] /transactions FyPicker double-fetch fixed by gating the initial fetch on FyPicker's existing onReady (fires after its restore onChange) instead of the analysis doc's literal "read the persisted period synchronously in initial state": localStorage only holds the period ID, not the FiscalPeriod bounds, so a synchronous read would suppress FyPicker's restore (value !== null) and leave the fetch permanently unscoped while the chip claimed a year. Same outcome (one scoped fetch per mount, background refetch on period change) without a stale-bounds cache or new FyPicker API.
|
||||
[2026-08-16] Row exit animation for dry-table <tr> rows collapses via td padding/line-height/font-size transitions plus a numeric max-height (.row-collapsible) on the fixed-height cell spans, not grid-template-rows 0fr (the AttGoraSection pattern): table cells cannot host the grid wrapper without restructuring every td, and max-height needs a numeric rest value because auto/none does not interpolate. prefers-reduced-motion hides the exiting row instantly (display: none) while the 350ms timer does the state cleanup.
|
||||
@@ -1027,3 +1028,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-17] Article picker now overwrites the line's ROT/RUT (deduction_type + work_type) from the article's housework_type, INCLUDING clearing it when the article has none: article-defines-the-row is the established applyArticle semantic (description/price/unit already overwrite), and keeping a RUT flag when switching a row to a material article would silently claim a deduction on material (HUSFL labor-only rule). Kundkort personnummer prefill is a server-side fallback in buildInvoiceWriteData (typed > stored draft > kundkort), never a client prefill: customers.personal_number reaches the browser only as ciphertext/mask by design, so the editor just relaxes the required-mark and says where the number will come from.
|
||||
[2026-08-17] Arcim's "610 bilagor i Hela historiken men 100 i räkenskapsåret" in the full-archive dialog is NOT a pagination bug: verified against prod, exactly 100 documents are linked to posted vouchers in the single (extended) fiscal year and 510 are unlinked inbox/receipt docs, which scope=all includes by design (same split as cloud backup's year-ZIPs vs Grunddata.zip). Kept the semantics, fixed two things instead: estimateArchiveSize's period branch ran one unpaginated read with one flat IN() over every entry id (undercounts past the PostgREST row cap, URL blowup past ~a few hundred ids) -> now CHILD_FK_CHUNK-chunked and fetchAllRows-paginated like writeDocuments already was; and the dialog now states per scope which document set is counted, so the gap reads as intent, not as a bug.
|
||||
[2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user.
|
||||
[2026-08-17] Replay-masking skeptic round (PR #1639): explicit data-ph tags now resolve BEFORE the th chrome fallback in replayMaskText (a single closest over tags-plus-th let a th nested in a masked container win on DOM proximity, CodeRabbit); seven missed text-leak sites got call-site masks (delete-invoice number, credit-page number, IB voucher ref, TIC orgnr since TIC serves it unnormalized so the separator scrub cannot be relied on, articles search term, dimension segment labels, activate-account buttons); the attribute channel (placeholders prefilled with effective values, title tooltips) is handled with rrweb's blockClass: user-data placeholders carry ph-no-capture, which removes the element from the recording while app UX keeps the founder-approved prefill-override pattern intact. Chose ph-no-capture over stripping the placeholders because the prefilled effective value IS the UX.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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} · {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> · {profile.legalEntityType}
|
||||
{!isActive && (
|
||||
<span className="ml-2 text-destructive">· {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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -579,7 +579,7 @@ export function AccountPickerDialog({
|
||||
<Dialog open={open && !progressOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Välj konton att synka: {bankName}</DialogTitle>
|
||||
<DialogTitle>Välj konton att synka: <span data-ph-mask="">{bankName}</span></DialogTitle>
|
||||
<DialogDescription>
|
||||
{isInitialSelection
|
||||
? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från, och välj vilket bokföringskonto varje konto ska bokföras mot. Inga transaktioner hämtas innan du sparar.'
|
||||
|
||||
@@ -135,7 +135,8 @@ export function BankSyncProgressDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{/* 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'}
|
||||
|
||||
@@ -759,7 +759,7 @@ export default function BankingSettingsPanel() {
|
||||
}
|
||||
>
|
||||
{reusableSessions.map((offer) => (
|
||||
<SettingsRow key={offer.connection_id} label={offer.bank_name ?? 'Bank'}>
|
||||
<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">
|
||||
|
||||
+15
-20
@@ -1,7 +1,7 @@
|
||||
import posthog from 'posthog-js'
|
||||
import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured } from '@/lib/analytics/enabled'
|
||||
import { purgeLegacyAnalyticsStorage } from '@/lib/analytics/purge-legacy-storage'
|
||||
import { replayMaskInput, replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
import { replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
|
||||
// Clear anything Recapt left on the device. Runs unconditionally, BEFORE the
|
||||
// analytics gate: a browser carrying `__recapt_record_engine` must get cleaned
|
||||
@@ -54,26 +54,22 @@ function tracingHosts(): string[] {
|
||||
* `seenSurvey_*` flags straight to localStorage, bypassing this setting:
|
||||
* that is functional UI state ("don't ask again"), not tracking.
|
||||
*
|
||||
* 3. Pattern-based replay masking (founder-approved 2026-08-06, supersedes
|
||||
* the 2026-07-27 mask-everything default that made replays wall-to-wall
|
||||
* asterisks). Replays exist so support can see WHERE a user gets stuck
|
||||
* and WHAT they typed while getting there; what stays unreadable is the
|
||||
* content of their books. `lib/analytics/replay-masking.ts` masks
|
||||
* currency-shaped text (every amount renders through `formatCurrency()`,
|
||||
* so one pattern covers all surfaces including future code), person-/
|
||||
* organisationsnummer (for an enskild firma the orgnr IS the owner's
|
||||
* personnummer) in both text and typed input, and password inputs.
|
||||
* Everything else, typed input included, is visible in the replay.
|
||||
* 3. Deny-by-default replay masking (founder-approved 2026-08-17, supersedes
|
||||
* the 2026-08-06 pattern-based default where user content was visible).
|
||||
* Replays exist so support can see WHERE a user gets stuck: layout,
|
||||
* clicks and static chrome (headers, nav, labels, placeholders), never
|
||||
* what the user typed or what their books say.
|
||||
*
|
||||
* `data-ph-mask` force-masks a subtree (deliberate PII spots: danger-zone
|
||||
* labels, user-defined dimension names, nav count bubbles) and
|
||||
* `data-ph-unmask` exempts one from pattern masking; the NEAREST tagged
|
||||
* Inputs: `maskAllInputs: true` with NO `maskInputFn` means rrweb masks
|
||||
* every input value to asterisks, no exceptions. Placeholder text is an
|
||||
* attribute, not an input value, so it stays visible.
|
||||
*
|
||||
* Text: `lib/analytics/replay-masking.ts` masks every text node unless
|
||||
* it sits under chrome (`data-ph-unmask`, tagged on the shared UI
|
||||
* primitives, or a `<th>`); chrome is still pattern-scrubbed for
|
||||
* currency-shaped spans and person-/organisationsnummer. `data-ph-mask`
|
||||
* force-masks a subtree and beats `data-ph-unmask`: the NEAREST tagged
|
||||
* ancestor wins, and mask wins when both land on the same element.
|
||||
*
|
||||
* rrweb only calls `maskInputFn` on inputs flagged by `maskInputOptions`,
|
||||
* so `maskAllInputs: true` stays set to flag every input and the function
|
||||
* decides per value. posthog-js force-merges `password: true` into
|
||||
* `maskInputOptions` on top of that.
|
||||
*/
|
||||
if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
|
||||
@@ -88,7 +84,6 @@ if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
|
||||
tracing_headers: tracingHosts(),
|
||||
session_recording: {
|
||||
maskAllInputs: true,
|
||||
maskInputFn: replayMaskInput,
|
||||
maskTextSelector: '*',
|
||||
maskTextFn: replayMaskText,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { maskSensitiveText, replayMaskInput, replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
import { maskSensitiveText, replayMaskText } from '@/lib/analytics/replay-masking'
|
||||
|
||||
// Repo test convention. eventBus.clear() is deliberately absent: these are
|
||||
// pure functions and importing the bus would only add module side effects.
|
||||
@@ -9,9 +9,13 @@ beforeEach(() => {
|
||||
|
||||
/**
|
||||
* Minimal stand-ins for the DOM elements rrweb hands to the masking
|
||||
* functions (tests run in the node environment, no jsdom).
|
||||
* functions (tests run in the node environment, no jsdom). `closest` is
|
||||
* called once with the explicit-tag selector and, when that misses, once
|
||||
* with 'th'; the fake answers each selector like a real DOM lookup would.
|
||||
*/
|
||||
function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both' | null } = {}): HTMLElement {
|
||||
function fakeElement(
|
||||
opts: { tagged?: 'mask' | 'unmask' | 'both' | null; th?: boolean } = {}
|
||||
): HTMLElement {
|
||||
const attrs =
|
||||
opts.tagged === 'mask'
|
||||
? ['data-ph-mask']
|
||||
@@ -19,18 +23,18 @@ function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both'
|
||||
? ['data-ph-unmask']
|
||||
: opts.tagged === 'both'
|
||||
? ['data-ph-mask', 'data-ph-unmask']
|
||||
: []
|
||||
const tagged = attrs.length > 0 ? { hasAttribute: (name: string) => attrs.includes(name) } : null
|
||||
: null
|
||||
const tagged = attrs ? { hasAttribute: (name: string) => attrs.includes(name) } : null
|
||||
const thAncestor = opts.th ? { hasAttribute: () => false } : null
|
||||
return {
|
||||
type: opts.type,
|
||||
closest: (_selector: string) => tagged,
|
||||
closest: (selector: string) => (selector.includes('data-ph') ? tagged : thAncestor),
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
describe('maskSensitiveText', () => {
|
||||
it('masks sv-SE formatted amounts, preserving length and whitespace', () => {
|
||||
// First variant groups thousands with U+00A0 (what Intl sv-SE emits), the second with a regular space.
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
|
||||
})
|
||||
|
||||
@@ -73,43 +77,44 @@ describe('maskSensitiveText', () => {
|
||||
})
|
||||
|
||||
describe('replayMaskText', () => {
|
||||
it('pattern-masks when the node has no tagged ancestor', () => {
|
||||
expect(replayMaskText('Saldo 1 234 kr', fakeElement())).toBe('Saldo * *** **')
|
||||
expect(replayMaskText('Saldo 1 234 kr', undefined)).toBe('Saldo * *** **')
|
||||
it('masks everything when the node has no chrome ancestor', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement())).toBe('**** **')
|
||||
expect(replayMaskText('Kaffe till kontoret', fakeElement())).toBe('***** **** ********')
|
||||
expect(replayMaskText('Acme AB', undefined)).toBe('**** **')
|
||||
})
|
||||
|
||||
it('masks everything when rrweb passes an element without closest (text node parents can be non-Element)', () => {
|
||||
expect(replayMaskText('Acme AB', {} as unknown as HTMLElement)).toBe('**** **')
|
||||
})
|
||||
|
||||
it('shows chrome text under data-ph-unmask', () => {
|
||||
expect(replayMaskText('Bokför och godkänn', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Bokför och godkänn'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows table column headers (th) without a tag', () => {
|
||||
expect(replayMaskText('Datum', fakeElement({ th: true }))).toBe('Datum')
|
||||
})
|
||||
|
||||
it('lets an explicit data-ph-mask beat the th fallback (th inside a masked container, or masked th)', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask', th: true }))).toBe('**** **')
|
||||
})
|
||||
|
||||
it('pattern-scrubs amounts and identity numbers even inside chrome', () => {
|
||||
expect(replayMaskText('Betala 1 234 kr nu', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Betala * *** ** nu'
|
||||
)
|
||||
expect(replayMaskText('Ta bort 556677-8899', fakeElement({ tagged: 'unmask' }))).toBe(
|
||||
'Ta bort ***********'
|
||||
)
|
||||
})
|
||||
|
||||
it('masks everything under data-ph-mask', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask' }))).toBe('**** **')
|
||||
})
|
||||
|
||||
it('passes everything through under data-ph-unmask', () => {
|
||||
expect(replayMaskText('Belopp i kr', fakeElement({ tagged: 'unmask' }))).toBe('Belopp i kr')
|
||||
})
|
||||
|
||||
it('lets mask win when both attributes land on the same element', () => {
|
||||
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'both' }))).toBe('**** **')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replayMaskInput', () => {
|
||||
it('always masks password inputs, even under data-ph-unmask', () => {
|
||||
expect(replayMaskInput('hunter2', fakeElement({ type: 'password' }))).toBe('*******')
|
||||
expect(replayMaskInput('hunter2', fakeElement({ type: 'password', tagged: 'unmask' }))).toBe('*******')
|
||||
})
|
||||
|
||||
it('masks identity-number-shaped values, including partial typing', () => {
|
||||
expect(replayMaskInput('556677-8899', fakeElement({ type: 'text' }))).toBe('***********')
|
||||
expect(replayMaskInput('19850101-1234', fakeElement({ type: 'text' }))).toBe('*************')
|
||||
expect(replayMaskInput('5566778', fakeElement({ type: 'text' }))).toBe('*******')
|
||||
})
|
||||
|
||||
it('passes ordinary typed values through', () => {
|
||||
for (const value of ['1234,56', 'Kaffe till kontoret', 'namn@exempel.se', '1930', 'Acme AB']) {
|
||||
expect(replayMaskInput(value, fakeElement({ type: 'text' }))).toBe(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors data-ph-mask on inputs', () => {
|
||||
expect(replayMaskInput('Acme AB', fakeElement({ type: 'text', tagged: 'mask' }))).toBe('**** **')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
/**
|
||||
* Pattern-based masking for PostHog session replay.
|
||||
* Deny-by-default masking for PostHog session replay.
|
||||
*
|
||||
* Replays are visible by default so support can see WHERE a user gets stuck
|
||||
* and WHAT they typed while getting there. What must never be readable in a
|
||||
* replay is the content of a user's books and identity numbers:
|
||||
* Everything is masked unless it is app chrome (founder-approved 2026-08-17,
|
||||
* supersedes the 2026-08-06 pattern-based default where user content was
|
||||
* visible). Replays show layout, clicks and static UI text: headers, nav,
|
||||
* form labels, placeholders, buttons. They never show user data: not what a
|
||||
* user typed (input values are masked wholesale by rrweb, see
|
||||
* instrumentation-client.ts) and not user content rendered as text
|
||||
* (counterparty names, descriptions, amounts, identity numbers).
|
||||
*
|
||||
* 1. Monetary amounts. Every amount in the app renders through
|
||||
* `formatCurrency()` (Intl sv-SE currency style, e.g. "1 234,56 kr"), so a
|
||||
* currency-shaped text pattern covers transactions, vouchers, reports,
|
||||
* invoices and dashboards in one place, including future code, without
|
||||
* tagging hundreds of render sites.
|
||||
* 2. Person- and organisationsnummer. For an enskild firma the orgnr IS the
|
||||
* owner's personnummer. Masked both as rendered text (formatOrgNumber()
|
||||
* output, "556677-8899") and as typed input values.
|
||||
* 3. Passwords. Always masked, never overridable.
|
||||
* What counts as chrome, i.e. renders readable in a replay:
|
||||
* - Any subtree tagged `data-ph-unmask`. Tags live on the shared UI
|
||||
* primitives (nav, PageHeader, Label, Button, tabs, dialog/sheet titles,
|
||||
* card titles, badges, tooltips, empty states), so page code gets readable
|
||||
* chrome without per-page tagging.
|
||||
* - `<th>` elements with NO explicit tag anywhere above them: table column
|
||||
* headers are static chrome, but the page-level dry-table pattern writes
|
||||
* raw `<th className={TH_CLASS}>` per page, so there is no shared
|
||||
* component to tag. An explicit data-ph-mask (on the th or any ancestor)
|
||||
* always wins over this fallback.
|
||||
*
|
||||
* Tag overrides (nearest tagged ancestor wins, mask wins on a tie):
|
||||
* - `data-ph-mask` force-masks an element's whole subtree (used on deliberate
|
||||
* PII spots: company name / email in danger-zone labels, user-defined
|
||||
* dimension names, nav count bubbles).
|
||||
* - `data-ph-unmask` exempts a subtree from pattern masking (static chrome
|
||||
* such as form labels and nav). It never unmasks a password input.
|
||||
* Chrome is still pattern-scrubbed (belt and braces): an i18n string that
|
||||
* interpolates an amount or a person-/organisationsnummer into a title or
|
||||
* button label gets that span masked even inside an unmasked subtree.
|
||||
*
|
||||
* Known limits, accepted deliberately: masking is length-preserving (star
|
||||
* count reveals magnitude, layout stays stable in the replay), bare numbers
|
||||
* without a currency marker stay visible, and an identity number rendered
|
||||
* WITHOUT its separator is only caught on the input side.
|
||||
* `data-ph-mask` force-masks a subtree and wins over `data-ph-unmask`: the
|
||||
* NEAREST tagged ancestor decides, and mask wins when both attributes land
|
||||
* on the same element. Use it where user data flows into a chrome primitive
|
||||
* (e.g. a Label interpolating the user's email, a dialog title carrying a
|
||||
* counterparty name).
|
||||
*
|
||||
* Untagged text is fully masked, so the failure mode for new UI is
|
||||
* over-masking (asterisks where chrome should be readable), never leaking
|
||||
* a user's books.
|
||||
*
|
||||
* Known limit: rrweb masks text nodes and input values, not ATTRIBUTES.
|
||||
* posthog-js exposes no attribute mask hook, so a title/aria-label/
|
||||
* placeholder attribute is recorded as-is. An element whose attributes
|
||||
* carry user data (e.g. a placeholder prefilled with an effective value)
|
||||
* must carry the `ph-no-capture` class instead: rrweb's blockClass removes
|
||||
* the whole element from the recording while the app UX is untouched. Do
|
||||
* not put user data in title or aria-label attributes.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -47,16 +62,6 @@ const AMOUNT_PATTERN = new RegExp(
|
||||
*/
|
||||
const IDENTITY_TEXT_PATTERN = /(?<!\d)\d{6}(?:\d{2})?[-+]\d{4}(?!\d)/g
|
||||
|
||||
/**
|
||||
* A typed input value that is (or is on its way to becoming) a person-/
|
||||
* organisationsnummer: 6 or more leading digits, optionally a separator and
|
||||
* up to 4 more. Matching from the 6th digit means intermediate keystroke
|
||||
* snapshots never ship the birthdate prefix of a personnummer. Accepted
|
||||
* over-masking: any bare 6-12 digit value (e.g. a raw amount over 99 999)
|
||||
* is masked too; amounts with decimals or thousand separators stay visible.
|
||||
*/
|
||||
const IDENTITY_INPUT_PATTERN = /^\s*\d{6,8}[-+ ]?\d{0,4}\s*$/
|
||||
|
||||
const TAG_SELECTOR = '[data-ph-mask],[data-ph-unmask]'
|
||||
|
||||
/** Length-preserving mask: whitespace survives so table layout stays legible. */
|
||||
@@ -70,7 +75,8 @@ function maskSpan(span: string): string {
|
||||
|
||||
/**
|
||||
* Masks currency amounts and separator-formatted identity numbers inside a
|
||||
* text node, leaving the surrounding text readable.
|
||||
* text node, leaving the surrounding text readable. Applied to CHROME text:
|
||||
* non-chrome text never gets here, it is masked wholesale.
|
||||
*/
|
||||
export function maskSensitiveText(text: string): string {
|
||||
return text.replace(AMOUNT_PATTERN, maskSpan).replace(IDENTITY_TEXT_PATTERN, maskSpan)
|
||||
@@ -79,31 +85,17 @@ export function maskSensitiveText(text: string): string {
|
||||
/**
|
||||
* `session_recording.maskTextFn`. Runs on EVERY text node because
|
||||
* `maskTextSelector: '*'` flags them all; this function then decides.
|
||||
* Default is masked; only chrome shows through, and even chrome is
|
||||
* pattern-scrubbed.
|
||||
*
|
||||
* Explicit tags are resolved FIRST, and only then the th fallback: a single
|
||||
* closest() over tags-plus-th would let a th nested inside a data-ph-mask
|
||||
* container win on DOM proximity and unmask it.
|
||||
*/
|
||||
export function replayMaskText(text: string, element?: HTMLElement): string {
|
||||
const tagged = element?.closest(TAG_SELECTOR)
|
||||
if (tagged) {
|
||||
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
|
||||
}
|
||||
return maskSensitiveText(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* `session_recording.maskInputFn`. rrweb only invokes this on inputs flagged
|
||||
* by `maskInputOptions`, so the config sets `maskAllInputs: true` to flag
|
||||
* every input and this function selectively passes values through. Password
|
||||
* checks come first: not even `data-ph-unmask` may reveal one.
|
||||
*/
|
||||
export function replayMaskInput(text: string, element?: HTMLElement): string {
|
||||
if ((element as HTMLInputElement | undefined)?.type === 'password') {
|
||||
return maskAll(text)
|
||||
}
|
||||
const tagged = element?.closest?.(TAG_SELECTOR)
|
||||
if (tagged) {
|
||||
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
|
||||
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : maskSensitiveText(text)
|
||||
}
|
||||
if (IDENTITY_INPUT_PATTERN.test(text)) {
|
||||
return maskAll(text)
|
||||
}
|
||||
return text
|
||||
return element?.closest?.('th') ? maskSensitiveText(text) : maskAll(text)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user