fix(analytics): mask session replays by default, chrome-only unmask (#1639)
* fix(analytics): mask session replays by default, chrome-only unmask Invert PostHog session-replay masking from visible-by-default with pattern masking to deny-by-default: every input value is masked wholesale (rrweb maskAllInputs, no maskInputFn) and every text node is masked unless it sits under data-ph-unmask chrome or a table column header (th). Chrome tags live on the shared UI primitives (PageHeader, Label, Button except combobox triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help popovers, empty states, settings labels), and tagged chrome is still pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask beats data-ph-unmask, so call sites that interpolate user data into chrome stay masked; a very-thorough audit swept every unmasked primitive and each found site got a call-site mask. Confirm-dialog wrappers and toasts stay masked centrally: their copy describes user objects by design. Untagged new UI over-masks instead of leaking. Privacy policy, RoPA and decision log updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): tag detail-section chrome merged from main The register-detail primitives landed on main after the replay-masking audit ran: kickers and DefRow labels are static i18n chrome, values stay masked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): close skeptic and review findings on replay masking Explicit data-ph tags now resolve before the th chrome fallback, so a th nested inside a data-ph-mask container masks correctly (regression test added). Seven missed text-leak sites get call-site masks: delete-invoice and credit-page invoice numbers, IB-correction voucher reference, TIC orgnr (served unnormalized, so the separator-based scrub cannot be relied on), articles search-term empty state, dimension segment labels, and activate-account buttons. The attribute channel is closed with rrweb's blockClass: inputs whose placeholder carries an effective user value (salary overrides, correction description, danger-zone confirms, credit confirm) get ph-no-capture, removing the element from recordings while the prefill UX stays intact; the pivot-th title attribute is dropped. Privacy-policy effective date bumped to 2026-08-17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1bb423b2b3
commit
f8db38f989
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user