feat(toolbar): Add PII masking functionality to toolbar (#40909)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Rafael Audibert
2025-11-05 17:31:04 -03:00
committed by GitHub
parent 444db7516d
commit 2653b2f5b4
4 changed files with 225 additions and 7 deletions

View File

@@ -39,13 +39,13 @@ export interface LemonMenuItemNode extends LemonMenuItemBase {
}
export interface LemonMenuItemLeafCallback extends LemonMenuItemBase {
onClick?: () => void
onClick?: (e: React.MouseEvent) => void
items?: never
placement?: never
keyboardShortcut?: KeyboardShortcut
}
export interface LemonMenuItemLeafLink extends LemonMenuItemBase {
onClick?: () => void
onClick?: (e: React.MouseEvent) => void
to: string
disableClientSideRouting?: boolean
targetBlank?: boolean

View File

@@ -10,6 +10,8 @@ import {
IconCheck,
IconCursorClick,
IconDay,
IconEye,
IconHide,
IconLive,
IconLogomark,
IconNight,
@@ -30,6 +32,7 @@ import { IconFlare, IconMenu } from 'lib/lemon-ui/icons'
import { inStorybook, inStorybookTestRunner } from 'lib/utils'
import { ActionsToolbarMenu } from '~/toolbar/actions/ActionsToolbarMenu'
import { PII_MASKING_PRESET_COLORS } from '~/toolbar/bar/piiMaskingStyles'
import { toolbarLogic } from '~/toolbar/bar/toolbarLogic'
import { EventDebugMenu } from '~/toolbar/debug/EventDebugMenu'
import { ExperimentsToolbarMenu } from '~/toolbar/experiments/ExperimentsToolbarMenu'
@@ -53,7 +56,11 @@ function EnabledStatusItem({ label, value }: { label: string; value: boolean }):
)
}
function postHogDebugInfo(posthog: PostHog | null, loadingSurveys: boolean, surveysCount: number): LemonMenuItem {
function postHogDebugInfoMenuItem(
posthog: PostHog | null,
loadingSurveys: boolean,
surveysCount: number
): LemonMenuItem {
const isAutocaptureEnabled = posthog?.autocapture?.isEnabled
return {
@@ -134,15 +141,65 @@ function postHogDebugInfo(posthog: PostHog | null, loadingSurveys: boolean, surv
}
}
function piiMaskingMenuItem(
piiMaskingEnabled: boolean,
piiMaskingColor: string,
togglePiiMasking: () => void,
setPiiMaskingColor: (color: string) => void
): LemonMenuItem[] {
return [
{
icon: piiMaskingEnabled ? <IconEye /> : <IconHide />,
label: piiMaskingEnabled ? 'Show PII' : 'Hide PII',
onClick: (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
togglePiiMasking()
},
custom: true,
},
piiMaskingEnabled
? {
icon: (
<div
className="w-4 h-4 rounded border"
// eslint-disable-next-line react/forbid-dom-props
style={{ backgroundColor: piiMaskingColor }}
/>
),
label: 'PII masking color',
placement: 'right',
disabled: !piiMaskingEnabled,
items: PII_MASKING_PRESET_COLORS.map((preset) => ({
icon: (
<div
className="w-4 h-4 rounded border"
// eslint-disable-next-line react/forbid-dom-props
style={{ backgroundColor: preset.value }}
/>
),
label: preset.label,
onClick: () => {
setPiiMaskingColor(preset.value)
},
active: piiMaskingColor === preset.value,
custom: true,
})),
}
: undefined,
].filter(Boolean) as LemonMenuItem[]
}
function MoreMenu(): JSX.Element {
const { hedgehogMode, theme, posthog } = useValues(toolbarLogic)
const { setHedgehogMode, toggleTheme, setVisibleMenu } = useActions(toolbarLogic)
const { hedgehogMode, theme, posthog, piiMaskingEnabled, piiMaskingColor } = useValues(toolbarLogic)
const { setHedgehogMode, toggleTheme, setVisibleMenu, togglePiiMasking, setPiiMaskingColor } =
useActions(toolbarLogic)
const [loadingSurveys, setLoadingSurveys] = useState(true)
const [surveysCount, setSurveysCount] = useState(0)
useEffect(() => {
posthog?.surveys?.getSurveys((surveys) => {
posthog?.surveys?.getSurveys((surveys: any[]) => {
setSurveysCount(surveys.length)
setLoadingSurveys(false)
}, false)
@@ -180,7 +237,8 @@ function MoreMenu(): JSX.Element {
label: `Switch to ${currentlyLightMode ? 'dark' : 'light'} mode`,
onClick: () => toggleTheme(),
},
postHogDebugInfo(posthog, loadingSurveys, surveysCount),
...piiMaskingMenuItem(piiMaskingEnabled, piiMaskingColor, togglePiiMasking, setPiiMaskingColor),
postHogDebugInfoMenuItem(posthog, loadingSurveys, surveysCount),
{
icon: <IconQuestion />,
label: 'Help',

View File

@@ -0,0 +1,96 @@
interface Color {
r: number
g: number
b: number
}
const lighten = (color: Color, amount: number): Color => {
const newR = Math.min(255, color.r + amount)
const newG = Math.min(255, color.g + amount)
const newB = Math.min(255, color.b + amount)
return { r: newR, g: newG, b: newB }
}
const darken = (color: Color, amount: number): Color => {
const newR = Math.max(0, color.r - amount)
const newG = Math.max(0, color.g - amount)
const newB = Math.max(0, color.b - amount)
return { r: newR, g: newG, b: newB }
}
const parseColorFromHex = (hex: string): Color => {
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
return { r, g, b }
}
const colorToHex = (color: Color): string => {
return `#${color.r.toString(16).padStart(2, '0')}${color.g.toString(16).padStart(2, '0')}${color.b.toString(16).padStart(2, '0')}`
}
const getColorPalette = (color: string): [Color, Color, Color, Color, Color] => {
const hex = color.replace('#', '')
const baseColor = parseColorFromHex(hex)
const color1 = darken(baseColor, 30)
const color2 = darken(baseColor, 15)
const color3 = baseColor
const color4 = lighten(baseColor, 15)
const color5 = darken(baseColor, 15)
return [color1, color2, color3, color4, color5]
}
export const generatePiiMaskingCSS = (baseColor: string): string => {
const [color1, color2, color3, color4, color5] = getColorPalette(baseColor).map(colorToHex)
return `
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.ph-no-capture {
position: relative !important;
}
.ph-no-capture::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
${color1} 0%,
${color2} 20%,
${color3} 40%,
${color4} 60%,
${color5} 80%,
${color1} 100%
);
background-size: 200% 100%;
animation: shimmer 6s ease-in-out infinite;
z-index: 999999;
pointer-events: none;
}
.ph-no-capture img,
.ph-no-capture video {
opacity: 0 !important;
}
.ph-no-capture * {
color: transparent !important;
text-shadow: none !important;
}
`
}
export const PII_MASKING_PRESET_COLORS = [
{ label: 'Grey', value: '#888888' },
{ label: 'Red', value: '#aa7777' },
{ label: 'Blue', value: '#7788aa' },
{ label: 'Green', value: '#88aa88' },
{ label: 'Purple', value: '#9988aa' },
{ label: 'Orange', value: '#aa9988' },
] as const

View File

@@ -12,9 +12,11 @@ import { experimentsTabLogic } from '~/toolbar/experiments/experimentsTabLogic'
import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic'
import { TOOLBAR_CONTAINER_CLASS, TOOLBAR_ID, inBounds, makeNavigateWrapper } from '~/toolbar/utils'
import { generatePiiMaskingCSS } from './piiMaskingStyles'
import type { toolbarLogicType } from './toolbarLogicType'
const MARGIN = 2
const PII_MASKING_STYLESHEET_ID = 'posthog-pii-masking-styles'
export type MenuState =
| 'none'
@@ -91,6 +93,8 @@ export const toolbarLogic = kea<toolbarLogicType>([
setFixedPosition: (position: ToolbarPositionType) => ({ position }),
setCurrentPathname: (pathname: string) => ({ pathname }),
maybeSendNavigationMessage: true,
togglePiiMasking: (enabled?: boolean) => ({ enabled }),
setPiiMaskingColor: (color: string) => ({ color }),
})),
windowValues(() => ({
windowHeight: (window: Window) => window.innerHeight,
@@ -189,6 +193,20 @@ export const toolbarLogic = kea<toolbarLogicType>([
setCurrentPathname: (_, { pathname }) => pathname,
},
],
piiMaskingEnabled: [
false,
{ persist: true },
{
togglePiiMasking: (state, { enabled }) => enabled ?? !state,
},
],
piiMaskingColor: [
'#888888' as string,
{ persist: true },
{
setPiiMaskingColor: (_, { color }) => color,
},
],
})),
selectors({
position: [
@@ -428,6 +446,31 @@ export const toolbarLogic = kea<toolbarLogicType>([
)
}
},
togglePiiMasking: () => {
const styleElement = document.getElementById(PII_MASKING_STYLESHEET_ID) as HTMLStyleElement | null
if (values.piiMaskingEnabled) {
if (!styleElement) {
const newStyleElement = document.createElement('style')
newStyleElement.id = PII_MASKING_STYLESHEET_ID
document.head.appendChild(newStyleElement)
newStyleElement.textContent = generatePiiMaskingCSS(values.piiMaskingColor)
} else {
styleElement.textContent = generatePiiMaskingCSS(values.piiMaskingColor)
}
} else {
if (styleElement) {
styleElement.remove()
}
}
},
setPiiMaskingColor: async ({ color }) => {
const styleElement = document.getElementById(PII_MASKING_STYLESHEET_ID) as HTMLStyleElement | null
if (styleElement && values.piiMaskingEnabled) {
styleElement.textContent = generatePiiMaskingCSS(color)
}
},
})),
afterMount(({ actions, values, cache }) => {
// Add window event listeners using disposables
@@ -454,6 +497,27 @@ export const toolbarLogic = kea<toolbarLogicType>([
'historyProxy'
)
// Initialize PII masking if already enabled
// Remove stylesheet on unmount
cache.disposables.add(() => {
if (values.piiMaskingEnabled) {
let styleElement = document.getElementById(PII_MASKING_STYLESHEET_ID) as HTMLStyleElement | null
if (!styleElement) {
styleElement = document.createElement('style')
styleElement.id = PII_MASKING_STYLESHEET_ID
document.head.appendChild(styleElement)
}
styleElement.textContent = generatePiiMaskingCSS(values.piiMaskingColor)
}
return () => {
const styleElement = document.getElementById(PII_MASKING_STYLESHEET_ID)
if (styleElement) {
styleElement.remove()
}
}
}, 'piiMasking')
// the toolbar can be run within the posthog parent app
// if it is then it listens to parent messages
const isInIframe = window !== window.parent