feat(analytics): show static nav chrome in session replays (#1412)
* feat(analytics): show static nav chrome in session replays Replays previously masked every text node via maskTextSelector '*', which made them unreadable: even sidebar labels and buttons were asterisks. Add a fail-safe maskTextFn: text stays masked unless its nearest tagged ancestor is data-ph-unmask, and data-ph-mask re-masks user data nested inside an unmasked container. Untagged text stays masked, so a forgotten tag can never leak user data. Tags the four nav containers and the skip link; the notification count bubbles inside them are re-masked. User menu, company switcher and page titles stay masked on purpose: on detail pages the title is user data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(analytics): use English domain terms in masking comment CodeRabbit: comment used 'enskild firma' and 'personnummer'; repo guideline is English for all comments. 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
6dcd401fb5
commit
b1f71912a2
@@ -303,6 +303,7 @@ export default async function DashboardLayout({
|
||||
>
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
<a
|
||||
data-ph-unmask
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||
>
|
||||
|
||||
@@ -548,7 +548,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
: null
|
||||
|
||||
const countBubble = (badge: number) => (
|
||||
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
||||
<span data-ph-mask className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)
|
||||
@@ -651,7 +651,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
cn('h-[17px] w-[17px]', active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'),
|
||||
)}
|
||||
{badge !== null && (
|
||||
<span className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
||||
<span data-ph-mask className="absolute -top-2 -right-2.5 min-w-[15px] h-[15px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
||||
{badge > 99 ? '99' : badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -727,7 +727,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
the aside width animates: no DOM swap, one continuous motion.
|
||||
The inactive layer is absolute (no layout), faded, nudged
|
||||
sideways and inert. */}
|
||||
{/* data-ph-unmask: nav labels are static i18n chrome; count
|
||||
bubbles inside carry data-ph-mask (nearest tag wins). */}
|
||||
<nav
|
||||
data-ph-unmask
|
||||
aria-hidden={!collapsed}
|
||||
inert={!collapsed ? true : undefined}
|
||||
className={cn(
|
||||
@@ -766,6 +769,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
})}
|
||||
</nav>
|
||||
<nav
|
||||
data-ph-unmask
|
||||
aria-hidden={collapsed}
|
||||
inert={collapsed ? true : undefined}
|
||||
className={cn(
|
||||
@@ -899,7 +903,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
</aside>
|
||||
|
||||
{/* Mobile bottom navigation */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
||||
<nav data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
|
||||
<div className="flex items-center justify-around h-16 px-2">
|
||||
{mobileNavItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
@@ -913,7 +917,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
<div className="relative">
|
||||
{renderNavIcon(item, cn('h-5 w-5 mb-1', active && 'text-primary'))}
|
||||
{badge !== null && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
||||
<span data-ph-mask className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] flex items-center justify-center rounded-full bg-primary text-primary-foreground text-[9px] font-semibold px-0.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -1001,8 +1005,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="px-2">
|
||||
{/* Navigation. data-ph-unmask: static i18n labels only; the
|
||||
CompanySwitcher above stays outside so it remains masked,
|
||||
and count bubbles inside carry data-ph-mask. */}
|
||||
<div data-ph-unmask className="px-2">
|
||||
{/* Top items (Hem, Assistent) */}
|
||||
<div className="space-y-0.5">
|
||||
{topItems.map((item) => {
|
||||
@@ -1015,7 +1021,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
{renderNavIcon(item, cn('h-[18px] w-[18px] flex-shrink-0', active ? 'text-primary' : 'text-muted-foreground'))}
|
||||
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
||||
{decorBadge ? decorBadge : badge !== null && (
|
||||
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -1072,7 +1078,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
|
||||
<span className="text-sm flex-1">{tNav(item.labelKey)}</span>
|
||||
{decorBadge ? decorBadge : badge !== null && (
|
||||
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
<span data-ph-mask className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -53,13 +53,24 @@ function tracingHosts(): string[] {
|
||||
* `seenSurvey_*` flags straight to localStorage, bypassing this setting:
|
||||
* that is functional UI state ("don't ask again"), not tracking.
|
||||
*
|
||||
* 3. `maskTextSelector: '*'` (PostHog's documented way to mask ALL text) on
|
||||
* top of the default `maskAllInputs`. This is an accounting app: org
|
||||
* numbers (which for an enskild firma ARE the owner's personnummer),
|
||||
* customer names, balances and invoice amounts are rendered as ordinary
|
||||
* text, and PostHog masks inputs but NOT text by default. Replays are for
|
||||
* seeing WHERE a user gets stuck, never WHAT their books say.
|
||||
* 3. Mask-by-default text masking. `maskTextSelector: '*'` routes EVERY text
|
||||
* node through `maskTextFn`, which masks unless a `data-ph-unmask`
|
||||
* ancestor opts the node back in. This is an accounting app: org numbers
|
||||
* (which for a sole proprietorship ARE the owner's personal identity
|
||||
* number), customer names, balances and invoice amounts are rendered as
|
||||
* ordinary text, and PostHog masks inputs but NOT text by default.
|
||||
* Replays are for seeing WHERE a user gets stuck, never WHAT their books
|
||||
* say.
|
||||
*
|
||||
* `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.
|
||||
* Anything untagged stays masked, so a forgotten tag fails safe.
|
||||
*/
|
||||
function maskText(text: string): string {
|
||||
return text.replace(/\S/g, '*')
|
||||
}
|
||||
if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
|
||||
api_host: '/rl',
|
||||
@@ -74,6 +85,13 @@ if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) {
|
||||
session_recording: {
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: '*',
|
||||
maskTextFn: (text: string, element?: HTMLElement): string => {
|
||||
const tagged = element?.closest('[data-ph-unmask],[data-ph-mask]')
|
||||
if (!tagged || tagged.hasAttribute('data-ph-mask')) {
|
||||
return maskText(text)
|
||||
}
|
||||
return text
|
||||
},
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user