feat(analytics): show form field labels in session replays (#1416)

* feat(analytics): show form field labels in session replays

Replays showed the sidebar after #1412 but form pages were still fully
masked, so you could not tell WHICH field a user was interacting with.
Tag the shared Label primitive (components/ui/label.tsx, used by every
form in the app) with data-ph-unmask: field labels are static i18n
chrome, and maskAllInputs keeps every typed value hidden.

The one Label whose text is user data, the user-defined dimension name
in LineDimensionFields, gets data-ph-mask, which wins even on the same
element because maskTextFn checks it first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): re-mask three Labels that render user data

Completing the audit the PR review asked for: a multiline sweep over
every Label child found three call sites whose label text is user
data, missed by the first single-line pass. Danger-zone confirm
labels interpolate the user's email (AccountDangerZone) and the
company name (CompanyDangerZone), and the periodisering auto-detect
row label is counterparty name + invoice number. All three now carry
data-ph-mask. Currency-code and row-count interpolations were
reviewed and left visible: categorical UI state, not books data.

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-05 15:18:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d1c411ad6f
commit f633349c8d
6 changed files with 21 additions and 4 deletions
@@ -677,7 +677,9 @@ function AutoStep({
/>
<div className="flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
{/* data-ph-mask: source_label is counterparty name + invoice number */}
<Label
data-ph-mask=""
htmlFor={`auto-${key}`}
className="text-sm font-medium cursor-pointer select-none"
>
@@ -74,7 +74,8 @@ export default function LineDimensionFields({
<div className={stacked ? 'space-y-3' : 'grid grid-cols-2 gap-3'}>
{fields.map((field) => (
<div key={field.sieDimNo}>
<Label className="text-xs text-muted-foreground">{field.label}</Label>
{/* data-ph-mask: the label is the user's own dimension name, not chrome */}
<Label data-ph-mask="" className="text-xs text-muted-foreground">{field.label}</Label>
<div className="mt-1">
<DimensionCombobox
sieDimNo={field.sieDimNo}
+2 -1
View File
@@ -255,7 +255,8 @@ export function AccountDangerZone() {
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">
{/* data-ph-mask: the label interpolates the user's email */}
<Label data-ph-mask="" htmlFor="delete-confirm">
{t.rich('confirm_label', {
email: email ?? '',
strong: (chunks) => <strong>{chunks}</strong>,
+2 -1
View File
@@ -126,7 +126,8 @@ export function CompanyDangerZone() {
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="company-delete-confirm">
{/* data-ph-mask: the label interpolates the company name */}
<Label data-ph-mask="" htmlFor="company-delete-confirm">
{t.rich('danger_confirm_label', {
companyName: company.name,
strong: (chunks) => <strong>{chunks}</strong>,
+5
View File
@@ -14,8 +14,13 @@ 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, so session
// replays show WHICH field is being filled in while the typed value stays
// masked (see instrumentation-client.ts). A call site whose label text is
// user data must add data-ph-mask, which wins over this default.
<LabelPrimitive.Root
ref={ref}
data-ph-unmask=""
className={cn(labelVariants(), className)}
{...props}
/>
+8 -1
View File
@@ -65,8 +65,15 @@ function tracingHosts(): string[] {
* `data-ph-unmask` is for static chrome only (nav labels, headings,
* button text from i18n). User data nested inside an unmasked container
* (active company name, user email, badge counts) gets `data-ph-mask`,
* which wins because `closest()` finds the NEAREST tagged ancestor.
* which wins because `closest()` finds the NEAREST tagged ancestor: when
* both attributes land on the same element, mask still wins.
* Anything untagged stays masked, so a forgotten tag fails safe.
*
* The shared form label primitive (`components/ui/label.tsx`) carries
* `data-ph-unmask` by default, so replays show WHICH field a user is
* filling in while `maskAllInputs` keeps the typed value hidden. A call
* site whose label text is user data (e.g. user-defined dimension names)
* must add `data-ph-mask`.
*/
function maskText(text: string): string {
return text.replace(/\S/g, '*')