diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 507136706..36b781497 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,10 +625,10 @@ importers: version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)) + version: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) ts-jest: specifier: ^29.1.1 - version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)))(typescript@5.6.3) + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)))(typescript@5.6.3) tslib: specifier: 'catalog:' version: 2.8.1 @@ -940,6 +940,9 @@ importers: dayjs: specifier: ^1.11.10 version: 1.11.20 + dompurify: + specifier: ^3.4.12 + version: 3.4.12 firebase: specifier: ^11.4.0 version: 11.10.0 @@ -4139,6 +4142,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/web@0.0.152': resolution: {integrity: sha512-Iu1OJ3ahq6/mwSK6jRKj2revI+avfPRWZOADcKeuv1AGCtkwbDl12AkWAl4KjiW2l4s2rkST7WagdhWOVGx9qg==} @@ -5595,6 +5601,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -11992,6 +12001,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 18.19.61 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3))': dependencies: '@jest/console': 29.7.0 @@ -13894,6 +13938,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/web@0.0.152': {} '@types/webidl-conversions@7.0.3': {} @@ -15210,6 +15257,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + create-jest@29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)): dependencies: '@jest/types': 29.6.3 @@ -15671,6 +15733,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -15976,8 +16042,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.6 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -16003,37 +16069,37 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 enhanced-resolve: 5.17.1 eslint: 9.39.4(jiti@2.6.1) - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)) fast-glob: 3.3.2 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -16044,7 +16110,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -17528,6 +17594,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)) @@ -17578,6 +17663,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0(babel-plugin-macros@3.1.0) + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 18.19.61 + ts-node: 10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)): dependencies: '@babel/core': 7.29.7 @@ -17873,6 +17989,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)) @@ -21259,12 +21387,12 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.29.7) - ts-jest@29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)))(typescript@5.6.3): + ts-jest@29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)))(typescript@5.6.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 29.7.0(@types/node@26.0.0)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@26.0.0)(typescript@5.6.3)) + jest: 29.7.0(@types/node@18.19.61)(ts-node@10.9.2(@swc/core@1.15.2)(@types/node@18.19.61)(typescript@5.6.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx new file mode 100644 index 000000000..36f0be42c --- /dev/null +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -0,0 +1,285 @@ +import React, { useReducer } from "react"; +import DOMPurify from "dompurify"; +import { X } from "lucide-react"; +import { useRouter } from "next/router"; +import { useBilling, UseBillingResult } from "./BillingProvider"; +import { useEventsUsage, UseUsageRes } from "./use-events-usage"; +import { useWorkspace } from "../../lib/context"; +import { WJitsuButton } from "../JitsuButton/JitsuButton"; +import { BillingBanner } from "../../lib/schema"; + +/** + * In-app billing banners (JITSU-88). + * + * The billing/settings response provides parametrized banner payloads + * (severity, title, badge, body HTML with the quota progress bar, action, + * closeable); this component owns the card template and renders them. HTML + * fragments (`body`, `icon`, `action.subtitle`) are sanitized — a compromised + * or misbehaving billing response must not run script — and action locations + * are restricted to workspace-relative console paths. + * + * Two banners are still computed client-side (they replaced the old floating + * workspace alerts) and share the same template, with explicit priority rules: + * - active partial throttle (`throttle=N` in featuresEnabled, N < 100): shown + * INSTEAD of any server banners — the applied throttle is ground truth, + * server state may lag it. At throttle=100 with server banners present, the + * server wins: a full block is exactly what the server's blocked banner + * describes, with better copy (blocked-count); + * - usage projected to exceed the free plan: shown only when there is nothing + * else to show. + * TODO(JITSU-123): move both to the billing server (getWorkspaceBanners) and + * delete useEventsUsage from here. + * + * Dismissals are client-side, keyed by workspace + the banner's stable `id` — + * the id changes (e.g. new severity level or billing period) to re-show a + * previously dismissed banner. + */ + +const dismissKey = (workspaceId: string, bannerId: string) => `billing-banner-dismissed:${workspaceId}:${bannerId}`; + +// localStorage can throw in restricted contexts (privacy mode, blocked +// third-party storage) — a failed read/write must degrade to "not dismissed", +// never take down layout rendering. +const safeStorageGet = (key: string): string | null => { + try { + return typeof window !== "undefined" ? window.localStorage.getItem(key) : null; + } catch { + return null; + } +}; +const safeStorageSet = (key: string, value: string): void => { + try { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, value); + } + } catch {} +}; + +/** + * A safe navigation target: workspace-relative console path — starts with a + * single "/", and no dot segments (decoded), so it cannot escape the + * `/${workspace}` prefix WJitsuButton prepends or go off-origin. + */ +const isSafeLocation = (location: string): boolean => { + if (!location.startsWith("/") || location.startsWith("//")) { + return false; + } + let decoded: string; + try { + decoded = decodeURIComponent(location); + } catch { + return false; + } + return !decoded.split("/").some(segment => segment === ".." || segment === "."); +}; + +// Any surviving sanitization must satisfy the same rules as +// action.location — inline links must not become an off-origin escape hatch. +let dompurifyHookInstalled = false; +const installHook = () => { + if (!dompurifyHookInstalled && typeof window !== "undefined") { + DOMPurify.addHook("afterSanitizeAttributes", node => { + if (node.tagName === "A" && !isSafeLocation(node.getAttribute("href") || "")) { + node.removeAttribute("href"); + } + }); + dompurifyHookInstalled = true; + } +}; + +// Banner body/icon fragments are copy + widgets (progress bar, icon glyph): +// structural markup and inline styles, no script vectors. +const sanitize = (html: string) => { + installHook(); + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ["div", "span", "p", "a", "b", "i", "em", "strong", "u", "s", "code", "br"], + ALLOWED_ATTR: ["href", "style"], + }); +}; + +// Subtitles are one-line captions: inline formatting only — no styles, no +// structure, no links. +const sanitizeInline = (html: string) => { + installHook(); + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ["b", "i", "em", "strong", "u", "s", "code", "br"], + ALLOWED_ATTR: [], + }); +}; + +const Html: React.FC<{ html: string; className?: string; inline?: boolean }> = ({ html, className, inline }) => ( + +); + +function usageIsAboutToExceed(billing: UseBillingResult, usage: UseUsageRes): boolean { + return !!( + billing.enabled && + billing.settings && + !usage.isLoading && + !usage.error && + usage.usage?.usagePercentage && + usage.usage?.usagePercentage < 1 && + billing.settings.planId === "free" && + usage.usage?.projectionByTheEndOfPeriod && + usage.usage?.projectionByTheEndOfPeriod > usage.usage.maxAllowedDestinatonEvents + ); +} + +const themes: Record = { + info: { + card: "bg-blue-50 border-blue-200 border-l-blue-600", + iconBox: "bg-blue-100 text-blue-600", + badge: "text-blue-700 bg-blue-100 border-blue-200", + }, + warning: { + card: "bg-amber-50 border-amber-200 border-l-amber-600", + iconBox: "bg-amber-100 text-amber-600", + badge: "text-amber-700 bg-amber-100 border-amber-200", + }, + error: { + card: "bg-red-50 border-red-200 border-l-red-600", + iconBox: "bg-red-100 text-red-600", + badge: "text-red-700 bg-red-100 border-red-200", + }, +}; + +/** The banner card template (design: JITSU-88 mock) filled from a BillingBanner payload. */ +const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ banner, onClose }) => { + const t = themes[banner.severity]; + // Only workspace-relative console paths — a server bug/compromise must not + // be able to send users off-origin (WJitsuButton prefixes the workspace). + const action = banner.action && isSafeLocation(banner.action.location) ? banner.action : undefined; + // Fall back to the default severity icon when the override sanitizes to + // nothing (e.g. markup outside the allowlist) — never render a blank tile. + const iconHtml = banner.icon ? sanitize(banner.icon) : ""; + return ( +
+
+ {iconHtml.trim() ? ( + + ) : ( + ! + )} +
+
+
+ {banner.title} + + {banner.badge} + +
+ +
+ {action && ( +
+ + {action.text} + + {action.subtitle && } +
+ )} + {banner.closeable && onClose && ( + + )} +
+ ); +}; + +const BillingBannersInner: React.FC = () => { + const billing = useBilling(); + const workspace = useWorkspace(); + const router = useRouter(); + const usage = useEventsUsage({ skipSubscribed: true }); + const [, forceRerender] = useReducer(x => x + 1, 0); + + const serverBanners = (billing.enabled && billing.settings?.banners) || []; + // The billing page shows its own detailed alerts (BillingManager) — the + // client-computed banners would duplicate them there. Server banners render + // everywhere. + const onBillingPage = router.pathname.endsWith("/settings/billing"); + + let banners: BillingBanner[]; + if (usage.throttle && !onBillingPage && !(usage.throttle >= 100 && serverBanners.length > 0)) { + banners = [ + { + id: "client-throttle", + severity: "error", + title: "Workspace throttled", + badge: `${usage.throttle}% THROTTLED`, + body: `Part of your events are not being delivered — your workspace is throttled at ${usage.throttle}% due to exceeding the free plan quota.`, + action: { text: "See details", location: "/settings/billing", onBillingPage: false }, + closeable: false, + onBillingPage: false, + }, + ]; + } else if (serverBanners.length > 0) { + banners = serverBanners; + } else if (usageIsAboutToExceed(billing, usage) && !onBillingPage) { + banners = [ + { + id: `client-projection:${usage.usage?.periodEnd?.toISOString() ?? "period"}`, + severity: "warning", + title: "Projected to exceed your quota", + badge: "PROJECTION", + body: "At the current pace you'll exceed your monthly events quota before the period ends. Upgrade your plan to avoid service disruption.", + action: { text: "Upgrade", location: "/settings/billing", onBillingPage: false }, + closeable: true, + onBillingPage: false, + }, + ]; + } else { + banners = []; + } + + // Server-controlled visibility on the billing settings page: banners and + // actions carry an `onBillingPage` flag (missing = show). Warnings hide + // there entirely; the blocked banner shows but drops its action (which + // would navigate to the very page the user is on). + if (onBillingPage) { + banners = banners + .filter(banner => banner.onBillingPage !== false) + .map(banner => (banner.action?.onBillingPage === false ? { ...banner, action: undefined } : banner)); + } + + const isDismissed = (banner: BillingBanner) => + banner.closeable && !!safeStorageGet(dismissKey(workspace.id, banner.id)); + const visibleBanners = banners.filter(banner => !isDismissed(banner)); + if (visibleBanners.length === 0) { + return null; + } + + return ( + <> + {visibleBanners.map(banner => ( +
+ { + safeStorageSet(dismissKey(workspace.id, banner.id), "1"); + forceRerender(); + } + : undefined + } + /> +
+ ))} + + ); +}; + +export const BillingBanners: React.FC = () => { + const billing = useBilling(); + // Inner component gate: useEventsUsage asserts billing is enabled + loaded. + if (!billing.enabled || billing.loading) { + return null; + } + return ; +}; diff --git a/webapps/console/components/Billing/BillingProvider.tsx b/webapps/console/components/Billing/BillingProvider.tsx index 899a4d49a..ce1e73b91 100644 --- a/webapps/console/components/Billing/BillingProvider.tsx +++ b/webapps/console/components/Billing/BillingProvider.tsx @@ -1,4 +1,5 @@ -import { BillingSettings, noRestrictions } from "../../lib/schema"; +import { z } from "zod"; +import { BillingBanner, BillingSettings, noRestrictions } from "../../lib/schema"; import { useAppConfig, useUser, useWorkspace } from "../../lib/context"; import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from "react"; import { getLog } from "juava"; @@ -30,10 +31,19 @@ export function useBilling(): UseBillingResult { } export const parseBillingSettings = (settings: any): BillingSettings => { + // banners is a sibling of subscriptionStatus in the billing/settings + // response (JITSU-88) — attach it after parsing the subscription. Banners + // are decorative: a malformed payload drops to [] instead of failing the + // whole billing context. + const bannersParsed = z.array(BillingBanner).safeParse(settings.banners ?? []); + if (!bannersParsed.success) { + log.atWarn().log(`Ignoring malformed billing banners payload`, bannersParsed.error); + } + const banners = bannersParsed.success ? bannersParsed.data : []; if (settings.noRestrictions) { - return noRestrictions; + return { ...noRestrictions, banners }; } - return BillingSettings.parse(settings.subscriptionStatus); + return { ...BillingSettings.parse(settings.subscriptionStatus), banners }; }; export const BillingProvider: React.FC> = ({ diff --git a/webapps/console/components/PageLayout/WorkspacePageLayout.tsx b/webapps/console/components/PageLayout/WorkspacePageLayout.tsx index 95f7059e4..188a364f9 100644 --- a/webapps/console/components/PageLayout/WorkspacePageLayout.tsx +++ b/webapps/console/components/PageLayout/WorkspacePageLayout.tsx @@ -8,8 +8,6 @@ import { ButtonLabel } from "../ButtonLabel/ButtonLabel"; import styles from "./WorkspacePageLayout.module.css"; import { Activity, - AlertCircle, - ArrowRight, BellIcon, ChevronDown, ChevronUp, @@ -45,14 +43,14 @@ import { useApi } from "../../lib/useApi"; import { Overlay } from "../Overlay/Overlay"; import { WorkspaceNameAndSlugEditor } from "../WorkspaceNameAndSlugEditor/WorkspaceNameAndSlugEditor"; -import { assertDefined, assertTrue, getLog } from "juava"; +import { getLog } from "juava"; import classNames from "classnames"; import { BillingBlockingDialog } from "../Billing/BillingBlockingDialog"; +import { BillingBanners } from "../Billing/BillingBanners"; import { useJitsu } from "@jitsu/jitsu-react"; import { useSearchParams } from "next/navigation"; import omit from "lodash/omit"; -import { useBilling, UseBillingResult } from "../Billing/BillingProvider"; -import { useEventsUsage, UseUsageRes } from "../Billing/use-events-usage"; +import { useBilling } from "../Billing/BillingProvider"; import { MenuItemType } from "antd/lib/menu/interface"; import { FaGear } from "react-icons/fa6"; @@ -334,107 +332,6 @@ const UserProfileButton: React.FC<{}> = () => { ); }; -const AlertView: React.FC> = ({ children }) => { - const [show, setShow] = useState(false); - const workspaceAlertHiddenAt = "workspaceAlertHiddenAt"; - useEffect(() => { - const hiddenAt = localStorage.getItem(workspaceAlertHiddenAt); - const hideAlertSeconds = 60 * 60; // 1 hour - if (!hiddenAt || new Date().getTime() - new Date(hiddenAt).getTime() > 1000 * hideAlertSeconds) { - setTimeout(() => setShow(true), 1); // show after 1ms to avoid SSR issues and enable animation - } - }, []); - - return ( -
-
- -
{children}
-
- -
-
-
- ); -}; - -function usageIsAboutToExceed(billing: UseBillingResult, usage: UseUsageRes) { - return ( - billing.enabled && - billing.settings && - !usage.isLoading && - !usage.error && - usage.usage?.usagePercentage && - usage.usage?.usagePercentage < 1 && - billing.settings.planId === "free" && - usage.usage?.projectionByTheEndOfPeriod && - usage.usage?.projectionByTheEndOfPeriod > usage.usage.maxAllowedDestinatonEvents - ); -} - -const FreePlanQuotaAlert: React.FC<{}> = () => { - const workspace = useWorkspace(); - const usage = useEventsUsage({ skipSubscribed: true }); - const billing = useBilling(); - const router = useRouter(); - assertTrue(billing.enabled, "Billing should be enabled and loaded"); - assertDefined(billing.settings, "Billing settings should be loaded"); - - if (router.pathname.endsWith("/settings/billing")) { - //don't display second alert on billing page - return <>; - } - - if (usage.throttle) { - return ( - - Your workspace is throttled due to exceeding the free plan quota at rate of {usage.throttle}% - - Go to billing to see more details{" "} - - - - ); - } else if (usageIsAboutToExceed(billing, usage)) { - return ( - - You are projected to exceed your monthly events. Please upgrade your plan to avoid service disruption.{" "} - - Go to billing - - - ); - } - - return <>; -}; - -const WorkspaceAlert: React.FC<{}> = () => { - const billing = useBilling(); - if (billing.loading || !billing.enabled) { - return <>; - } - return ; -}; - function PageHeader() { const appConfig = useAppConfig(); const workspace = useWorkspace(); @@ -527,7 +424,6 @@ function PageHeader() { return (
-
@@ -706,6 +602,13 @@ export const WorkspacePageLayout: React.FC> = ) : ( pHeader )} + {!fullscreen && ( + + + + + + )} {fullscreen && (