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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user