diff --git a/frontend/app/layout/LangBanner.tsx b/frontend/app/layout/LangBanner.tsx
new file mode 100644
index 000000000..cff21589e
--- /dev/null
+++ b/frontend/app/layout/LangBanner.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {
+ Languages, X, Info
+} from 'lucide-react'
+import { Button } from 'antd';
+import { useHistory } from "react-router-dom";
+import { client } from 'App/routes'
+
+function LangBanner({ onClose }: { onClose: () => void }) {
+ const history = useHistory()
+
+ const onClick = () => {
+ history.push(client('account'))
+ }
+ return (
+
+
+
+ OpenReplay now supports French, Russian, Chinese, and Spanish 🎉. Update your language in settings.
+
+
+
} size={'small'} onClick={onClick}>
+ Change Language
+
+
} type={'text'} shape={'circle'} onClick={onClose} size={'small'} />
+
+ )
+}
+
+export default LangBanner
\ No newline at end of file
diff --git a/frontend/app/layout/TopHeader.tsx b/frontend/app/layout/TopHeader.tsx
index 07a41bd28..045098637 100644
--- a/frontend/app/layout/TopHeader.tsx
+++ b/frontend/app/layout/TopHeader.tsx
@@ -1,6 +1,7 @@
import { Layout, Space, Tooltip } from 'antd';
import { observer } from 'mobx-react-lite';
import React, { useEffect } from 'react';
+import LangBanner from './LangBanner';
import { INDEXES } from 'App/constants/zindex';
import Logo from 'App/layout/Logo';
@@ -11,14 +12,27 @@ import { useTranslation } from 'react-i18next';
const { Header } = Layout;
+const langBannerClosedKey = '__or__langBannerClosed';
+const getLangBannerClosed = () => localStorage.getItem(langBannerClosedKey) === '1'
function TopHeader() {
const { userStore, notificationStore, projectsStore, settingsStore } =
useStore();
const { account } = userStore;
const { siteId } = projectsStore;
const { initialDataFetched } = userStore;
+ const [langBannerClosed, setLangBannerClosed] = React.useState(getLangBannerClosed);
const { t } = useTranslation();
+ React.useEffect(() => {
+ const langBannerVal = localStorage.getItem(langBannerClosedKey);
+ if (langBannerVal === null) {
+ localStorage.setItem(langBannerClosedKey, '0')
+ }
+ if (langBannerVal === '0') {
+ localStorage.setItem(langBannerClosedKey, '1')
+ }
+ }, [])
+
useEffect(() => {
if (!account.id || initialDataFetched) return;
Promise.all([
@@ -29,51 +43,58 @@ function TopHeader() {
});
}, [account]);
+ const closeLangBanner = () => {
+ setLangBannerClosed(true);
+ localStorage.setItem(langBannerClosedKey, '1');
+ }
return (
-
-
- {
- settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed);
- }}
- style={{ paddingTop: '4px' }}
- className="cursor-pointer xl:block hidden"
- >
-
+ {langBannerClosed ? null : }
+
+
-
-
-
-
+
+
+
+
-
-
+
+
+ >
);
}
diff --git a/frontend/app/layout/TopRight.tsx b/frontend/app/layout/TopRight.tsx
index 313a960db..8c0c3da29 100644
--- a/frontend/app/layout/TopRight.tsx
+++ b/frontend/app/layout/TopRight.tsx
@@ -11,18 +11,13 @@ import ProjectDropdown from 'Shared/ProjectDropdown';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
-interface Props {
- account: any;
- spotOnly?: boolean;
-}
-
-function TopRight(props: Props) {
+function TopRight() {
const { userStore } = useStore();
const spotOnly = userStore.scopeState === 1;
const { account } = userStore;
return (
- {props.spotOnly ? null : (
+ {spotOnly ? null : (
<>
@@ -30,7 +25,6 @@ function TopRight(props: Props) {
{account.name ? : null}
-
>
)}
diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json
index 57035348f..80292d6be 100644
--- a/frontend/app/locales/en.json
+++ b/frontend/app/locales/en.json
@@ -1498,5 +1498,8 @@
"More attribute": "More attribute",
"More attributes": "More attributes",
"Account settings updated successfully": "Account settings updated successfully",
- "Include rage clicks": "Include rage clicks"
+ "Include rage clicks": "Include rage clicks",
+ "Interface Language": "Interface Language",
+ "Select the language in which OpenReplay will appear.": "Select the language in which OpenReplay will appear.",
+ "Language": "Language"
}
diff --git a/frontend/app/locales/es.json b/frontend/app/locales/es.json
index 65670ecd8..0d2c3c73b 100644
--- a/frontend/app/locales/es.json
+++ b/frontend/app/locales/es.json
@@ -1498,5 +1498,8 @@
"More attribute": "Más atributos",
"More attributes": "Más atributos",
"Account settings updated successfully": "Configuración de la cuenta actualizada correctamente",
- "Include rage clicks": "Incluir clics de ira"
+ "Include rage clicks": "Incluir clics de ira",
+ "Interface Language": "Idioma de la interfaz",
+ "Select the language in which OpenReplay will appear.": "Selecciona el idioma en el que aparecerá OpenReplay.",
+ "Language": "Idioma"
}
diff --git a/frontend/app/locales/fr.json b/frontend/app/locales/fr.json
index 3292c2423..91c535204 100644
--- a/frontend/app/locales/fr.json
+++ b/frontend/app/locales/fr.json
@@ -1498,5 +1498,8 @@
"More attribute": "Plus d'attributs",
"More attributes": "Plus d'attributs",
"Account settings updated successfully": "Paramètres du compte mis à jour avec succès",
- "Include rage clicks": "Inclure les clics de rage"
+ "Include rage clicks": "Inclure les clics de rage",
+ "Interface Language": "Langue de l'interface",
+ "Select the language in which OpenReplay will appear.": "Sélectionnez la langue dans laquelle OpenReplay apparaîtra.",
+ "Language": "Langue"
}
diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json
index 80c720f3b..d713686d1 100644
--- a/frontend/app/locales/ru.json
+++ b/frontend/app/locales/ru.json
@@ -1498,5 +1498,8 @@
"More attribute": "Еще атрибут",
"More attributes": "Еще атрибуты",
"Account settings updated successfully": "Настройки аккаунта успешно обновлены",
- "Include rage clicks": "Включить невыносимые клики"
+ "Include rage clicks": "Включить невыносимые клики",
+ "Interface Language": "Язык интерфейса",
+ "Select the language in which OpenReplay will appear.": "Выберите язык, на котором будет отображаться OpenReplay.",
+ "Language": "Язык"
}
diff --git a/frontend/app/locales/zh.json b/frontend/app/locales/zh.json
index f60b057f9..236164820 100644
--- a/frontend/app/locales/zh.json
+++ b/frontend/app/locales/zh.json
@@ -1498,5 +1498,8 @@
"More attributes": "更多属性",
"More attribute": "更多属性",
"Account settings updated successfully": "帐户设置已成功更新",
- "Include rage clicks": "包括点击狂怒"
+ "Include rage clicks": "包括点击狂怒",
+ "Interface Language": "界面语言",
+ "Select the language in which OpenReplay will appear.": "选择 OpenReplay 将显示的语言。",
+ "Language": "语言"
}
diff --git a/frontend/app/mstore/userStore.ts b/frontend/app/mstore/userStore.ts
index 1010b8361..b1ddc8b0f 100644
--- a/frontend/app/mstore/userStore.ts
+++ b/frontend/app/mstore/userStore.ts
@@ -240,18 +240,7 @@ class UserStore {
resolve(response);
})
.catch(async (e) => {
- const err = await e.response?.json();
- runInAction(() => {
- this.saving = false;
- });
- const errStr = err.errors[0]
- ? err.errors[0].includes('already exists')
- ? this.t(
- "This email is already linked to an account or team on OpenReplay and can't be used again.",
- )
- : err.errors[0]
- : this.t('Error saving user');
- toast.error(errStr);
+ toast.error(e.message || this.t("Failed to save user's data."));
reject(e);
})
.finally(() => {
diff --git a/frontend/app/utils/index.ts b/frontend/app/utils/index.ts
index bf2eb3ff9..d0aa1c07b 100644
--- a/frontend/app/utils/index.ts
+++ b/frontend/app/utils/index.ts
@@ -29,6 +29,15 @@ export function debounce(callback, wait, context = this) {
};
}
+export function debounceCall(func, wait) {
+ let timeout;
+ return function (...args) {
+ const context = this;
+ clearTimeout(timeout);
+ timeout = setTimeout(() => func.apply(context, args), wait);
+ };
+}
+
export function randomInt(a, b) {
const min = (b ? a : 0) - 0.5;
const max = b || a || Number.MAX_SAFE_INTEGER;
diff --git a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml
index 4ecc87dd7..a7eb11693 100644
--- a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml
+++ b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml
@@ -95,6 +95,8 @@ spec:
value: {{ .Values.global.jwtSecret }}
- name: JWT_SPOT_SECRET
value: {{ .Values.global.jwtSpotSecret }}
+ - name: TOKEN_SECRET
+ value: {{ .Values.global.tokenSecret }}
ports:
{{- range $key, $val := .Values.service.ports }}
- name: {{ $key }}
diff --git a/scripts/helmcharts/vars.yaml b/scripts/helmcharts/vars.yaml
index 43c1375dd..8872068d8 100644
--- a/scripts/helmcharts/vars.yaml
+++ b/scripts/helmcharts/vars.yaml
@@ -124,6 +124,7 @@ global:
assistJWTSecret: "{{ randAlphaNum 20}}"
jwtSecret: "{{ randAlphaNum 20}}"
jwtSpotSecret: "{{ randAlphaNum 20}}"
+ tokenSecret: "{{randAlphaNum 20}}"
# In case of multiple nodes in the kubernetes cluster,
# we'll have to create an RWX PVC for shared components.
# If it's a single node, we'll use hostVolume, which is the default for the community/oss edition.
diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md
index 80f804cdc..c28cbb080 100644
--- a/tracker/tracker/CHANGELOG.md
+++ b/tracker/tracker/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 16.1.0
+
+- new `privateMode` option to hide all possible data from tracking
+
## 16.0.3
- better handling for local svg spritemaps
diff --git a/tracker/tracker/jest.config.js b/tracker/tracker/jest.config.js
index 0f5bd56b9..da1a2b887 100644
--- a/tracker/tracker/jest.config.js
+++ b/tracker/tracker/jest.config.js
@@ -8,6 +8,14 @@ const config = {
moduleNameMapper: {
'(.+)\\.js': '$1',
},
+ globals: {
+ 'ts-jest': {
+ tsConfig: {
+ target: 'es2020',
+ lib: ['DOM', 'ES2022'],
+ },
+ },
+ },
}
export default config
diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json
index f40314129..9b2ca79f4 100644
--- a/tracker/tracker/package.json
+++ b/tracker/tracker/package.json
@@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
- "version": "16.0.3",
+ "version": "16.1.0",
"keywords": [
"logging",
"replay"
diff --git a/tracker/tracker/src/main/app/observer/observer.ts b/tracker/tracker/src/main/app/observer/observer.ts
index 664c6edc7..054159631 100644
--- a/tracker/tracker/src/main/app/observer/observer.ts
+++ b/tracker/tracker/src/main/app/observer/observer.ts
@@ -357,6 +357,9 @@ export default abstract class Observer {
if (name === 'href' || value.length > 1e5) {
value = ''
}
+ if (['alt', 'placeholder'].includes(name) && this.app.sanitizer.privateMode) {
+ value = value.replaceAll(/./g, '*')
+ }
this.app.attributeSender.sendSetAttribute(id, name, value)
}
@@ -389,7 +392,7 @@ export default abstract class Observer {
{
acceptNode: (node) => {
if (this.app.nodes.getID(node) !== undefined) {
- this.app.debug.warn('! Node is already bound', node)
+ this.app.debug.info('! Node is already bound', node)
}
return isIgnored(node) || this.app.nodes.getID(node) !== undefined
? NodeFilter.FILTER_REJECT
diff --git a/tracker/tracker/src/main/app/sanitizer.ts b/tracker/tracker/src/main/app/sanitizer.ts
index 32ec1468c..1d037a51b 100644
--- a/tracker/tracker/src/main/app/sanitizer.ts
+++ b/tracker/tracker/src/main/app/sanitizer.ts
@@ -1,6 +1,6 @@
import type App from './index.js'
import { stars, hasOpenreplayAttribute } from '../utils.js'
-import { isElementNode } from './guards.js'
+import { isElementNode, isTextNode } from './guards.js'
export enum SanitizeLevel {
Plain,
@@ -32,31 +32,46 @@ export interface Options {
*
* */
domSanitizer?: (node: Element) => SanitizeLevel
+ /**
+ * private by default mode that will mask all elements not marked by data-openreplay-unmask
+ * */
+ privateMode?: boolean
}
export const stringWiper = (input: string) =>
input
.trim()
- .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█')
+ .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*')
export default class Sanitizer {
private readonly obscured: Set = new Set()
private readonly hidden: Set = new Set()
private readonly options: Options
+ public readonly privateMode: boolean
private readonly app: App
constructor(params: { app: App; options?: Partial }) {
this.app = params.app
- this.options = Object.assign(
- {
- obscureTextEmails: true,
- obscureTextNumbers: false,
- },
- params.options,
- )
+ const defaultOptions: Options = {
+ obscureTextEmails: true,
+ obscureTextNumbers: false,
+ privateMode: false,
+ domSanitizer: undefined,
+ }
+ this.privateMode = params.options?.privateMode ?? false
+ this.options = Object.assign(defaultOptions, params.options)
}
handleNode(id: number, parentID: number, node: Node) {
+ if (this.options.privateMode) {
+ if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
+ return this.obscured.add(id)
+ }
+ if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode as Element, 'unmask')) {
+ return this.obscured.add(id)
+ }
+ }
+
if (
this.obscured.has(parentID) ||
(isElementNode(node) &&
diff --git a/tracker/tracker/src/main/modules/console.ts b/tracker/tracker/src/main/modules/console.ts
index efdd3be88..1a30ae5e8 100644
--- a/tracker/tracker/src/main/modules/console.ts
+++ b/tracker/tracker/src/main/modules/console.ts
@@ -108,9 +108,13 @@ export default function (app: App, opts: Partial): void {
return
}
- const sendConsoleLog = app.safe((level: string, args: unknown[]): void =>
- app.send(ConsoleLog(level, printf(args))),
- )
+ const sendConsoleLog = app.safe((level: string, args: unknown[]): void => {
+ let logMsg = printf(args)
+ if (app.sanitizer.privateMode) {
+ logMsg = logMsg.replaceAll(/./g, '*')
+ }
+ app.send(ConsoleLog(level, logMsg))
+ })
let n = 0
const reset = (): void => {
diff --git a/tracker/tracker/src/main/modules/input.ts b/tracker/tracker/src/main/modules/input.ts
index e8297f2b8..7fc3626ba 100644
--- a/tracker/tracker/src/main/modules/input.ts
+++ b/tracker/tracker/src/main/modules/input.ts
@@ -205,7 +205,10 @@ export default function (app: App, opts: Partial): void {
inputTime: number,
) {
const { value, mask } = getInputValue(id, node)
- const label = getInputLabel(node)
+ let label = getInputLabel(node)
+ if (app.sanitizer.privateMode) {
+ label = label.replaceAll(/./g, '*')
+ }
app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime))
}
diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts
index 262005757..ab15a2303 100644
--- a/tracker/tracker/src/main/modules/mouse.ts
+++ b/tracker/tracker/src/main/modules/mouse.ts
@@ -230,11 +230,12 @@ export default function (app: App, options?: MouseHandlerOptions): void {
const normalizedY = roundNumber(clickY / contentHeight)
sendMouseMove()
+ const label = getTargetLabel(target)
app.send(
MouseClick(
id,
mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0,
- getTargetLabel(target),
+ app.sanitizer.privateMode ? label.replaceAll(/./g, '*') : label,
isClickable(target) && !disableClickmaps ? getSelector(id, target, options) : '',
normalizedX,
normalizedY,
diff --git a/tracker/tracker/src/main/modules/network.ts b/tracker/tracker/src/main/modules/network.ts
index 135a14a75..453406dcf 100644
--- a/tracker/tracker/src/main/modules/network.ts
+++ b/tracker/tracker/src/main/modules/network.ts
@@ -101,7 +101,7 @@ export default function (app: App, opts: Partial = {}) {
}
function sanitize(reqResInfo: RequestResponseData) {
- if (!options.capturePayload) {
+ if (!options.capturePayload || app.sanitizer.privateMode) {
// @ts-ignore
delete reqResInfo.request.body
delete reqResInfo.response.body
@@ -136,18 +136,19 @@ export default function (app: App, opts: Partial = {}) {
if (options.useProxy) {
return createNetworkProxy(
context,
- options.ignoreHeaders,
+ app.sanitizer.privateMode ? true : options.ignoreHeaders,
setSessionTokenHeader,
sanitize,
(message) => {
if (options.failuresOnly && message.status < 400) {
return
}
+ const url = app.sanitizer.privateMode ? '************' : message.url
app.send(
NetworkRequest(
message.requestType,
message.method,
- message.url,
+ url,
message.request,
message.response,
message.status,
diff --git a/tracker/tracker/src/main/modules/timing.ts b/tracker/tracker/src/main/modules/timing.ts
index f8e7d63cf..ff3e4cfae 100644
--- a/tracker/tracker/src/main/modules/timing.ts
+++ b/tracker/tracker/src/main/modules/timing.ts
@@ -147,7 +147,7 @@ export default function (app: App, opts: Partial): void {
entry.transferSize > entry.encodedBodySize ? entry.transferSize - entry.encodedBodySize : 0,
entry.encodedBodySize || 0,
entry.decodedBodySize || 0,
- entry.name,
+ app.sanitizer.privateMode ? entry.name.replaceAll(/./g, '*') : entry.name,
entry.initiatorType,
entry.transferSize,
// @ts-ignore
diff --git a/tracker/tracker/src/main/modules/viewport.ts b/tracker/tracker/src/main/modules/viewport.ts
index a17332855..b43306aa1 100644
--- a/tracker/tracker/src/main/modules/viewport.ts
+++ b/tracker/tracker/src/main/modules/viewport.ts
@@ -1,6 +1,7 @@
import type App from '../app/index.js'
import { getTimeOrigin } from '../utils.js'
import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js'
+import { stringWiper } from '../app/sanitizer.js'
export default function (app: App): void {
let url: string | null, width: number, height: number
@@ -11,7 +12,10 @@ export default function (app: App): void {
const { URL } = document
if (URL !== url) {
url = URL
- app.send(SetPageLocation(url, referrer, navigationStart, document.title))
+ const safeTitle = app.sanitizer.privateMode ? stringWiper(document.title) : document.title
+ const safeUrl = app.sanitizer.privateMode ? stringWiper(url) : url
+ const safeReferrer = app.sanitizer.privateMode ? stringWiper(referrer) : referrer
+ app.send(SetPageLocation(safeUrl, safeReferrer, navigationStart, safeTitle))
navigationStart = 0
referrer = url
}
diff --git a/tracker/tracker/src/tests/console.test.ts b/tracker/tracker/src/tests/console.test.ts
index 085b008be..749da72e6 100644
--- a/tracker/tracker/src/tests/console.test.ts
+++ b/tracker/tracker/src/tests/console.test.ts
@@ -23,6 +23,9 @@ describe('Console logging module', () => {
safe: jest.fn((callback) => callback),
send: jest.fn(),
attachStartCallback: jest.fn(),
+ sanitizer: {
+ privateMode: false,
+ },
ticker: {
attach: jest.fn(),
},
diff --git a/tracker/tracker/src/tests/sanitizer.unit.test.ts b/tracker/tracker/src/tests/sanitizer.unit.test.ts
index 295a570a8..be41b61cc 100644
--- a/tracker/tracker/src/tests/sanitizer.unit.test.ts
+++ b/tracker/tracker/src/tests/sanitizer.unit.test.ts
@@ -2,8 +2,8 @@ import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globa
import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js'
describe('stringWiper', () => {
- test('should replace all characters with █', () => {
- expect(stringWiper('Sensitive Data')).toBe('██████████████')
+ test('should replace all characters with *', () => {
+ expect(stringWiper('Sensitive Data')).toBe('**************')
})
})
@@ -126,7 +126,7 @@ describe('Sanitizer', () => {
element.mockId = 1
element.innerText = 'Sensitive Data'
const sanitizedText = sanitizer.getInnerTextSecure(element)
- expect(sanitizedText).toEqual('██████████████')
+ expect(sanitizedText).toEqual('**************')
})
test('should return empty string if node element does not exist', () => {