diff --git a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx index 76f26a8137..b4cce19e3c 100644 --- a/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx +++ b/frontend/src/lib/lemon-ui/LemonMenu/LemonMenu.tsx @@ -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 diff --git a/frontend/src/toolbar/bar/Toolbar.tsx b/frontend/src/toolbar/bar/Toolbar.tsx index ea44162606..7784705da7 100644 --- a/frontend/src/toolbar/bar/Toolbar.tsx +++ b/frontend/src/toolbar/bar/Toolbar.tsx @@ -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 ? : , + label: piiMaskingEnabled ? 'Show PII' : 'Hide PII', + onClick: (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + togglePiiMasking() + }, + custom: true, + }, + piiMaskingEnabled + ? { + icon: ( +
+ ), + label: 'PII masking color', + placement: 'right', + disabled: !piiMaskingEnabled, + items: PII_MASKING_PRESET_COLORS.map((preset) => ({ + icon: ( +
+ ), + 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: , label: 'Help', diff --git a/frontend/src/toolbar/bar/piiMaskingStyles.ts b/frontend/src/toolbar/bar/piiMaskingStyles.ts new file mode 100644 index 0000000000..59bd0f38d5 --- /dev/null +++ b/frontend/src/toolbar/bar/piiMaskingStyles.ts @@ -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 diff --git a/frontend/src/toolbar/bar/toolbarLogic.ts b/frontend/src/toolbar/bar/toolbarLogic.ts index 2fa9472ad6..c3f6ab2d94 100644 --- a/frontend/src/toolbar/bar/toolbarLogic.ts +++ b/frontend/src/toolbar/bar/toolbarLogic.ts @@ -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([ 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([ 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([ ) } }, + 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([ '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