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:
co-authored by
Claude Fable 5
parent
d1c411ad6f
commit
f633349c8d
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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, '*')
|
||||
|
||||
Reference in New Issue
Block a user