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
+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)}