From f28c13c5d37a4a783307e07b432ac0dfb70171e9 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Mon, 20 Jul 2026 16:36:28 +0400 Subject: [PATCH 01/19] feat(console): usage-quota banner from billing quotaStatus (JITSU-88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the `quotaStatus` ee-api now returns in billing/settings as an in-app banner in the workspace layout: warning80 / warning90 — dismissible (per workspace + level + period) warning95 — persistent warning blocked — persistent error (delivery paused, resumes on reset) The console owns none of the thresholds or the block decision — it only renders what `level` says. quotaStatus is a sibling of subscriptionStatus in the billing/settings response, so parseBillingSettings attaches it onto the parsed BillingSettings (schema gains an optional QuotaStatus). The banner self-gates (null when billing disabled / no quotaStatus / level none) and sits between the workspace header and content, hidden in fullscreen data views. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingProvider.tsx | 7 +- .../components/Billing/QuotaBanner.tsx | 132 ++++++++++++++++++ .../PageLayout/WorkspacePageLayout.tsx | 2 + webapps/console/lib/schema/index.ts | 19 +++ 4 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 webapps/console/components/Billing/QuotaBanner.tsx diff --git a/webapps/console/components/Billing/BillingProvider.tsx b/webapps/console/components/Billing/BillingProvider.tsx index 899a4d49a..dfe1443db 100644 --- a/webapps/console/components/Billing/BillingProvider.tsx +++ b/webapps/console/components/Billing/BillingProvider.tsx @@ -30,10 +30,13 @@ export function useBilling(): UseBillingResult { } export const parseBillingSettings = (settings: any): BillingSettings => { + // quotaStatus is a sibling of subscriptionStatus in the billing/settings + // response (JITSU-88) — attach it after parsing the subscription. + const quotaStatus = settings.quotaStatus ?? null; if (settings.noRestrictions) { - return noRestrictions; + return { ...noRestrictions, quotaStatus }; } - return BillingSettings.parse(settings.subscriptionStatus); + return { ...BillingSettings.parse(settings.subscriptionStatus), quotaStatus }; }; export const BillingProvider: React.FC> = ({ diff --git a/webapps/console/components/Billing/QuotaBanner.tsx b/webapps/console/components/Billing/QuotaBanner.tsx new file mode 100644 index 000000000..3c7d9d4a2 --- /dev/null +++ b/webapps/console/components/Billing/QuotaBanner.tsx @@ -0,0 +1,132 @@ +import React, { useReducer } from "react"; +import { Alert } from "antd"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { useBilling } from "./BillingProvider"; +import { useWorkspace } from "../../lib/context"; +import { WJitsuButton } from "../JitsuButton/JitsuButton"; +import { QuotaStatus } from "../../lib/schema"; + +dayjs.extend(utc); + +/** + * In-app usage-quota banner (JITSU-88). Renders the `quotaStatus` computed by + * ee-api (billing/settings) — the console owns none of the thresholds or the + * copy decision, it only renders what `level` says: + * + * warning80 / warning90 — dismissible (per workspace + level + period) + * warning95 — persistent warning + * blocked — persistent error (delivery paused) + */ + +const fmt = (n: number) => n.toLocaleString("en-US", { maximumFractionDigits: 0 }); +const formatPeriodEnd = (iso: string) => dayjs(iso).utc().format("MMMM D, YYYY"); + +const DISMISSIBLE = new Set(["warning80", "warning90"]); + +function dismissKey(workspaceId: string, q: QuotaStatus): string { + // Keyed by level + period so an escalation (80→90) or a new billing period + // re-shows the banner even after a prior dismissal. + return `quota-banner-dismissed:${workspaceId}:${q.level}:${q.periodEnd}`; +} + +export const QuotaBanner: React.FC = () => { + const billing = useBilling(); + const workspace = useWorkspace(); + const [, forceRerender] = useReducer(x => x + 1, 0); + + const quota = billing.enabled && !billing.loading ? billing.settings.quotaStatus : undefined; + if (!quota || quota.level === "none") { + return null; + } + + const key = dismissKey(workspace.id, quota); + if (DISMISSIBLE.has(quota.level) && typeof window !== "undefined" && window.localStorage.getItem(key)) { + return null; + } + const onClose = () => { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, "1"); + } + forceRerender(); + }; + + const billingHref = "/settings/billing"; + const upgrade = (label: string) => ( + + {label} + + ); + + const usageText = `${fmt(quota.usage)} / ${fmt(quota.quota)}`; + const period = formatPeriodEnd(quota.periodEnd); + + switch (quota.level) { + case "warning80": + return ( + + ⚠️ You've used {quota.percent}% of your events quota ({usageText}). Period resets {period}. + + } + action={upgrade("View usage")} + /> + ); + case "warning90": + return ( + + ⚠️ {quota.percent}% of quota used. Event delivery pauses at 105% ({usageText}). Upgrade to avoid + interruption. + + } + action={upgrade("Upgrade now")} + /> + ); + case "warning95": + return ( + + 🚨 {quota.percent}% of quota used — delivery will pause at 105%. Upgrade now to keep events flowing + ({usageText}). + + } + action={upgrade("Upgrade")} + /> + ); + case "blocked": + return ( + + ⛔ Event delivery paused. You've exceeded your quota ({usageText}). + {quota.blockedCount > 0 ? ` ${fmt(quota.blockedCount)} events not delivered so far.` : ""} Delivery + resumes {period}. + + } + action={upgrade("Upgrade to resume")} + /> + ); + default: + return null; + } +}; diff --git a/webapps/console/components/PageLayout/WorkspacePageLayout.tsx b/webapps/console/components/PageLayout/WorkspacePageLayout.tsx index 95f7059e4..0f3027609 100644 --- a/webapps/console/components/PageLayout/WorkspacePageLayout.tsx +++ b/webapps/console/components/PageLayout/WorkspacePageLayout.tsx @@ -48,6 +48,7 @@ import { WorkspaceNameAndSlugEditor } from "../WorkspaceNameAndSlugEditor/Worksp import { assertDefined, assertTrue, getLog } from "juava"; import classNames from "classnames"; import { BillingBlockingDialog } from "../Billing/BillingBlockingDialog"; +import { QuotaBanner } from "../Billing/QuotaBanner"; import { useJitsu } from "@jitsu/jitsu-react"; import { useSearchParams } from "next/navigation"; import omit from "lodash/omit"; @@ -706,6 +707,7 @@ export const WorkspacePageLayout: React.FC> = ) : ( pHeader )} + {!fullscreen && } {fullscreen && ( - - - - ); -}; - -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(); @@ -528,7 +424,6 @@ function PageHeader() { return (
-
From 2e2958bad7f08efdb8bbb91e1ff24481b98c575c Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Tue, 21 Jul 2026 16:08:20 +0400 Subject: [PATCH 05/19] fix(console): at throttle=100 with server banners present, show the server banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full block is exactly what the server's blocked banner describes, with better copy (blocked-count) — the generic client throttle banner now only outranks server banners for partial throttles. Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 162c07106..f6adde590 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -20,9 +20,11 @@ import { BillingBanner } from "../../lib/schema"; * * Two banners are still computed client-side (they replaced the old floating * workspace alerts) with explicit priority rules: - * - active throttle (`throttle=N` in featuresEnabled): shown INSTEAD of any - * server banners — the applied throttle is ground truth, server state may - * lag it; + * - 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-88 follow-up): move both to the billing server @@ -71,7 +73,7 @@ const BillingBannersInner: React.FC = () => { const onBillingPage = router.pathname.endsWith("/settings/billing"); let banners: BillingBanner[]; - if (usage.throttle && !onBillingPage) { + if (usage.throttle && !onBillingPage && !(usage.throttle >= 100 && serverBanners.length > 0)) { banners = [ { id: "client-throttle", From c41b09456528e4652d2665bcd00ee4057c2ee4a4 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Tue, 21 Jul 2026 16:21:47 +0400 Subject: [PATCH 06/19] =?UTF-8?q?fix(console):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20pre-filter=20dismissed=20banners,=20restrict=20acti?= =?UTF-8?q?on=20hrefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dismissed banners are filtered before render, so the component returns null when nothing remains instead of a fragment of nulls. - banner.action.href is enforced to be a workspace-relative console path ("/..." but not "//...") at render time — a billing bug/compromise cannot redirect off-origin or to a javascript: URL. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingBanners.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index f6adde590..4c830fb23 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -99,17 +99,25 @@ const BillingBannersInner: React.FC = () => { banners = []; } - if (banners.length === 0) { + const isDismissed = (banner: BillingBanner) => + banner.dismissible && + typeof window !== "undefined" && + !!window.localStorage.getItem(dismissKey(workspace.id, banner.id)); + const visibleBanners = banners.filter(banner => !isDismissed(banner)); + if (visibleBanners.length === 0) { return null; } return ( <> - {banners.map(banner => { + {visibleBanners.map(banner => { const key = dismissKey(workspace.id, banner.id); - if (banner.dismissible && typeof window !== "undefined" && window.localStorage.getItem(key)) { - return null; - } + // Only workspace-relative console paths — a server bug/compromise must + // not be able to send users off-origin (or to a javascript: URL). + const action = + banner.action && banner.action.href.startsWith("/") && !banner.action.href.startsWith("//") + ? banner.action + : undefined; return ( { }} message={} action={ - banner.action ? ( - - {banner.action.label} + action ? ( + + {action.label} ) : undefined } From 26f2100e16ef8d06f1745aa91b0c0dc818f0be88 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Tue, 21 Jul 2026 16:55:12 +0400 Subject: [PATCH 07/19] fix(console): align billing banners with the page width limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banners rendered full-bleed while the header/content sit in a centered WidthControl (max-width 1500px) — wrap them in the same VerticalSection/WidthControl and restore the Alert's default rounding (rounded-none was for the full-bleed look). Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 2 +- .../console/components/PageLayout/WorkspacePageLayout.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 4c830fb23..de0364703 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -121,7 +121,7 @@ const BillingBannersInner: React.FC = () => { return ( > = ) : ( pHeader )} - {!fullscreen && } + {!fullscreen && ( + + + + + + )} {fullscreen && ( + )} +
+ ))} ); }; diff --git a/webapps/console/lib/schema/index.ts b/webapps/console/lib/schema/index.ts index d8c9a7e07..0338c573b 100644 --- a/webapps/console/lib/schema/index.ts +++ b/webapps/console/lib/schema/index.ts @@ -29,19 +29,18 @@ export const ContextApiResponse = z.object({ export type ContextApiResponse = z.infer; /** - * Ready-to-render in-app banner provided by the billing API (JITSU-88). All - * copy, severity and dismissal policy are decided server-side (billing repo); - * the console renders these verbatim. + * Ready-to-render in-app banner provided by the billing API (JITSU-88). + * `html` is the entire banner card (layout, icon, progress bar, action button) + * with self-contained inline styles; all copy, styling and dismissal policy + * are decided server-side (billing repo). The console sanitizes + renders it + * verbatim and only owns the dismiss control. */ export const BillingBanner = z.object({ /** Stable identity for client-side dismissal (a dismissed id stays hidden). */ id: z.string(), - severity: z.enum(["info", "warning", "error"]), dismissible: z.boolean(), - /** Banner body, HTML. Comes from our own billing server — trusted. */ + /** Complete banner card HTML from our own billing server. */ html: z.string(), - /** Optional action button; `href` is a workspace-relative console path. */ - action: z.object({ label: z.string(), href: z.string() }).optional(), }); export type BillingBanner = z.infer; From 2acfeb39da16be38f90f193cb7819b6faa108226 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 12:49:54 +0400 Subject: [PATCH 09/19] =?UTF-8?q?fix(console):=20dismiss=20via=20delegatio?= =?UTF-8?q?n=20on=20the=20server-rendered=20=E2=9C=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay dismiss button overlapped the card's action column. Billing now embeds a behavior-less ✕ (data-banner-dismiss) in the card layout; the console supplies the behavior with one delegated click handler on the wrapper (no ids, no listener lifecycle). The client-computed projection card gets a matching inline ✕ via onDismiss. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingBanners.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 224e82e41..488ce8f7c 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -55,8 +55,10 @@ const sanitize = (html: string) => { dompurifyHookInstalled = true; } return DOMPurify.sanitize(html, { - ALLOWED_TAGS: ["div", "span", "p", "a", "b", "i", "em", "strong", "u", "s", "code", "br"], - ALLOWED_ATTR: ["href", "style"], + ALLOWED_TAGS: ["div", "span", "p", "a", "b", "i", "em", "strong", "u", "s", "code", "br", "button"], + ALLOWED_ATTR: ["href", "style", "type", "aria-label"], + // keeps data-banner-dismiss — the delegation target for the dismiss ✕ + ALLOW_DATA_ATTR: true, }); }; @@ -90,7 +92,8 @@ const ClientBannerCard: React.FC<{ body: ReactNode; actionLabel: string; actionHref: string; -}> = ({ theme, title, badge, body, actionLabel, actionHref }) => { + onDismiss?: () => void; +}> = ({ theme, title, badge, body, actionLabel, actionHref, onDismiss }) => { const t = cardThemes[theme]; return (
@@ -120,6 +123,15 @@ const ClientBannerCard: React.FC<{ {actionLabel}
+ {onDismiss && ( + + )}
); }; @@ -140,6 +152,13 @@ const BillingBannersInner: React.FC = () => { const onBillingPage = router.pathname.endsWith("/settings/billing"); const billingHref = `/${workspace.slugOrId}/settings/billing`; + const dismiss = (bannerId: string) => { + if (typeof window !== "undefined") { + window.localStorage.setItem(dismissKey(workspace.id, bannerId), "1"); + } + forceRerender(); + }; + let banners: DisplayBanner[]; if (usage.throttle && !onBillingPage && !(usage.throttle >= 100 && serverBanners.length > 0)) { banners = [ @@ -167,12 +186,25 @@ const BillingBannersInner: React.FC = () => { banners = serverBanners.map(banner => ({ id: banner.id, dismissible: banner.dismissible, - node:
, + node: ( + // Dismissible server cards contain a `data-banner-dismiss` ✕ rendered + // by billing as part of the card layout; it carries no behavior of its + // own — this delegated click handler supplies it. +
{ + if ((e.target as Element).closest?.("[data-banner-dismiss]")) { + dismiss(banner.id); + } + }} + dangerouslySetInnerHTML={{ __html: sanitize(banner.html) }} + /> + ), })); } else if (usageIsAboutToExceed(billing, usage) && !onBillingPage) { + const projectionId = `client-projection:${usage.usage?.periodEnd?.toISOString() ?? "period"}`; banners = [ { - id: `client-projection:${usage.usage?.periodEnd?.toISOString() ?? "period"}`, + id: projectionId, dismissible: true, node: ( { body="At the current pace you'll exceed your monthly events quota before the period ends. Upgrade your plan to avoid service disruption." actionLabel="Upgrade" actionHref={billingHref} + onDismiss={() => dismiss(projectionId)} /> ), }, @@ -202,22 +235,8 @@ const BillingBannersInner: React.FC = () => { return ( <> {visibleBanners.map(banner => ( -
+
{banner.node} - {banner.dismissible && ( - - )}
))} From b1d94e1e384e3ba15e0fc6525b9f0957d37beeab Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 13:08:40 +0400 Subject: [PATCH 10/19] =?UTF-8?q?fix(console):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20validate=20banners=20payload,=20storage-safe=20dism?= =?UTF-8?q?issal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseBillingSettings validates banners with the zod schema (safeParse); a malformed payload drops to [] with a warning instead of flowing untyped into rendering. - localStorage reads/writes go through try/catch helpers so restricted storage contexts (privacy mode) degrade to "not dismissed" instead of crashing layout rendering. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingBanners.tsx | 26 ++++++++++++++----- .../components/Billing/BillingProvider.tsx | 13 +++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 488ce8f7c..f9c4b67db 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -38,6 +38,24 @@ import { useWorkspace } from "../../lib/context"; 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 {} +}; + // The server sends a full card: structural tags + inline styles are required. // DOMPurify still strips all script vectors (tags, event handlers, javascript: // URLs); the hook below additionally restricts links to console paths. @@ -153,9 +171,7 @@ const BillingBannersInner: React.FC = () => { const billingHref = `/${workspace.slugOrId}/settings/billing`; const dismiss = (bannerId: string) => { - if (typeof window !== "undefined") { - window.localStorage.setItem(dismissKey(workspace.id, bannerId), "1"); - } + safeStorageSet(dismissKey(workspace.id, bannerId), "1"); forceRerender(); }; @@ -224,9 +240,7 @@ const BillingBannersInner: React.FC = () => { } const isDismissed = (banner: DisplayBanner) => - banner.dismissible && - typeof window !== "undefined" && - !!window.localStorage.getItem(dismissKey(workspace.id, banner.id)); + banner.dismissible && !!safeStorageGet(dismissKey(workspace.id, banner.id)); const visibleBanners = banners.filter(banner => !isDismissed(banner)); if (visibleBanners.length === 0) { return null; diff --git a/webapps/console/components/Billing/BillingProvider.tsx b/webapps/console/components/Billing/BillingProvider.tsx index 67d5ae320..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"; @@ -31,8 +32,14 @@ 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. - const banners = settings.banners ?? []; + // 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, banners }; } From 30e0cb9729ffe0b3fa405ab806855311bde3a9b6 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 18:54:30 +0400 Subject: [PATCH 11/19] feat(console): antd-based banner template filled from parametrized payloads Per follow-up review: instead of rendering server-provided full-card HTML, the console now owns the banner card template (themed card, icon tile, title + badge pill, action button via WJitsuButton) and fills it from parametrized payloads {id, severity, icon?, title, badge, body, action{text, location, subtitle?}, closeable}. HTML fragments (body with the progress bar, icon override, action subtitle) are sanitized; action locations are restricted to workspace-relative paths and prefixed with the workspace by WJitsuButton. The client-computed banners (throttle, projection) now use the exact same payload shape and template. Billing counterpart: jitsu-cloud-billing#26. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingBanners.tsx | 222 +++++++----------- webapps/console/lib/schema/index.ts | 32 ++- 2 files changed, 114 insertions(+), 140 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index f9c4b67db..0d4e0fe0d 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -1,26 +1,25 @@ -import React, { ReactNode, useReducer } from "react"; +import React, { useReducer } from "react"; import DOMPurify from "dompurify"; import { X } from "lucide-react"; -import Link from "next/link"; 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). * - * Server-provided banners come from the billing/settings response as complete - * banner-card HTML (layout, icon, progress bar, action button — inline styles, - * see billing repo's renderBannerCard). The console sanitizes and renders them - * verbatim and only owns the dismiss control. Sanitization allows structural - * markup + inline styles but still strips script vectors, and drops any link - * that is not a workspace-relative console path — a compromised or misbehaving - * billing response must not run script or send users off-origin. + * 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), rendered via a local card component matching the same - * design, with explicit priority rules: + * 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 @@ -29,7 +28,7 @@ import { useWorkspace } from "../../lib/context"; * - 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 + the local card component from here. + * 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 @@ -56,29 +55,17 @@ const safeStorageSet = (key: string, value: string): void => { } catch {} }; -// The server sends a full card: structural tags + inline styles are required. -// DOMPurify still strips all script vectors (tags, event handlers, javascript: -// URLs); the hook below additionally restricts links to console paths. -let dompurifyHookInstalled = false; -const sanitize = (html: string) => { - if (!dompurifyHookInstalled && typeof window !== "undefined") { - DOMPurify.addHook("afterSanitizeAttributes", node => { - if (node.tagName === "A") { - const href = node.getAttribute("href") || ""; - if (!href.startsWith("/") || href.startsWith("//")) { - node.removeAttribute("href"); - } - } - }); - dompurifyHookInstalled = true; - } - return DOMPurify.sanitize(html, { - ALLOWED_TAGS: ["div", "span", "p", "a", "b", "i", "em", "strong", "u", "s", "code", "br", "button"], - ALLOWED_ATTR: ["href", "style", "type", "aria-label"], - // keeps data-banner-dismiss — the delegation target for the dismiss ✕ - ALLOW_DATA_ATTR: true, +// Banner HTML fragments are copy + the progress-bar widget: structural markup +// and inline styles, no script vectors. +const sanitize = (html: string) => + DOMPurify.sanitize(html, { + ALLOWED_TAGS: ["div", "span", "p", "a", "b", "i", "em", "strong", "u", "s", "code", "br"], + ALLOWED_ATTR: ["href", "style"], }); -}; + +const Html: React.FC<{ html: string; className?: string }> = ({ html, className }) => ( + +); function usageIsAboutToExceed(billing: UseBillingResult, usage: UseUsageRes): boolean { return !!( @@ -94,58 +81,60 @@ function usageIsAboutToExceed(billing: UseBillingResult, usage: UseUsageRes): bo ); } -const cardThemes = { - warning: { card: "bg-amber-50 border-amber-200 border-l-amber-600", iconBox: "bg-amber-100 text-amber-600" }, - error: { card: "bg-red-50 border-red-200 border-l-red-600", iconBox: "bg-red-100 text-red-600" }, -} as const; +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", + }, +}; -/** - * Local card matching the server-side renderBannerCard design, used only for - * the client-computed banners until they move server-side (JITSU-123). - */ -const ClientBannerCard: React.FC<{ - theme: keyof typeof cardThemes; - title: string; - badge: string; - body: ReactNode; - actionLabel: string; - actionHref: string; - onDismiss?: () => void; -}> = ({ theme, title, badge, body, actionLabel, actionHref, onDismiss }) => { - const t = cardThemes[theme]; +/** 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 && banner.action.location.startsWith("/") && !banner.action.location.startsWith("//") + ? banner.action + : undefined; return (
- ! + {banner.icon ? : !}
- {title} - - {badge} + {banner.title} + + {banner.badge}
-
{body}
-
-
- - {actionLabel} - +
- {onDismiss && ( + {action && ( +
+ + {action.text} + + {action.subtitle && } +
+ )} + {banner.closeable && onClose && ( @@ -154,8 +143,6 @@ const ClientBannerCard: React.FC<{ ); }; -type DisplayBanner = { id: string; dismissible: boolean; node: ReactNode }; - const BillingBannersInner: React.FC = () => { const billing = useBilling(); const workspace = useWorkspace(); @@ -168,79 +155,40 @@ const BillingBannersInner: React.FC = () => { // client-computed banners would duplicate them there. Server banners render // everywhere. const onBillingPage = router.pathname.endsWith("/settings/billing"); - const billingHref = `/${workspace.slugOrId}/settings/billing`; - - const dismiss = (bannerId: string) => { - safeStorageSet(dismissKey(workspace.id, bannerId), "1"); - forceRerender(); - }; - let banners: DisplayBanner[]; + let banners: BillingBanner[]; if (usage.throttle && !onBillingPage && !(usage.throttle >= 100 && serverBanners.length > 0)) { banners = [ { id: "client-throttle", - dismissible: false, - node: ( - - Part of your events are not being delivered — your workspace is throttled at {usage.throttle}%{" "} - due to exceeding the free plan quota. - - } - actionLabel="See details" - actionHref={billingHref} - /> - ), + 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" }, + closeable: false, }, ]; } else if (serverBanners.length > 0) { - banners = serverBanners.map(banner => ({ - id: banner.id, - dismissible: banner.dismissible, - node: ( - // Dismissible server cards contain a `data-banner-dismiss` ✕ rendered - // by billing as part of the card layout; it carries no behavior of its - // own — this delegated click handler supplies it. -
{ - if ((e.target as Element).closest?.("[data-banner-dismiss]")) { - dismiss(banner.id); - } - }} - dangerouslySetInnerHTML={{ __html: sanitize(banner.html) }} - /> - ), - })); + banners = serverBanners; } else if (usageIsAboutToExceed(billing, usage) && !onBillingPage) { - const projectionId = `client-projection:${usage.usage?.periodEnd?.toISOString() ?? "period"}`; banners = [ { - id: projectionId, - dismissible: true, - node: ( - dismiss(projectionId)} - /> - ), + 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" }, + closeable: true, }, ]; } else { banners = []; } - const isDismissed = (banner: DisplayBanner) => - banner.dismissible && !!safeStorageGet(dismissKey(workspace.id, banner.id)); + 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; @@ -250,7 +198,17 @@ const BillingBannersInner: React.FC = () => { <> {visibleBanners.map(banner => (
- {banner.node} + { + safeStorageSet(dismissKey(workspace.id, banner.id), "1"); + forceRerender(); + } + : undefined + } + />
))} diff --git a/webapps/console/lib/schema/index.ts b/webapps/console/lib/schema/index.ts index 0338c573b..a4133a6d7 100644 --- a/webapps/console/lib/schema/index.ts +++ b/webapps/console/lib/schema/index.ts @@ -29,18 +29,34 @@ export const ContextApiResponse = z.object({ export type ContextApiResponse = z.infer; /** - * Ready-to-render in-app banner provided by the billing API (JITSU-88). - * `html` is the entire banner card (layout, icon, progress bar, action button) - * with self-contained inline styles; all copy, styling and dismissal policy - * are decided server-side (billing repo). The console sanitizes + renders it - * verbatim and only owns the dismiss control. + * Parametrized in-app banner provided by the billing API (JITSU-88). The + * console owns the card template (themed card, icon tile, title + badge pill, + * action button) and fills it with these fields; copy and dismissal policy are + * decided server-side. `body`, `icon` and `action.subtitle` are HTML fragments + * (sanitized before rendering) — `body` carries the quota progress-bar markup. */ export const BillingBanner = z.object({ /** Stable identity for client-side dismissal (a dismissed id stays hidden). */ id: z.string(), - dismissible: z.boolean(), - /** Complete banner card HTML from our own billing server. */ - html: z.string(), + /** Drives the template theme and the default icon. */ + severity: z.enum(["info", "warning", "error"]), + /** Optional icon HTML overriding the default severity icon. */ + icon: z.string().optional(), + title: z.string(), + /** Status pill next to the title, e.g. "82% USED". */ + badge: z.string(), + /** Body HTML (usage copy + inline progress bar). */ + body: z.string(), + action: z + .object({ + text: z.string(), + /** Workspace-relative console path; the console prefixes the workspace. */ + location: z.string(), + /** Small HTML line under the button. */ + subtitle: z.string().optional(), + }) + .optional(), + closeable: z.boolean(), }); export type BillingBanner = z.infer; From aa2f22c0a6c828fa7dfffa8d0e09ed1155957ba0 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 18:57:08 +0400 Subject: [PATCH 12/19] style(console): larger banner action button Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 0d4e0fe0d..45629fd54 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -124,7 +124,7 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({
{action && (
- + {action.text} {action.subtitle && } From 80b7841c9e8bd1e857607f923a09c8fdf5c8232f Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:00:46 +0400 Subject: [PATCH 13/19] style(console): tighten spacing between banner title and body Matches the design mock: mt-1.5/leading-relaxed read looser than the reference card (margin-top 6px, line-height 1.5). Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 45629fd54..bb91282d5 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -120,7 +120,7 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ {banner.badge}
- +
{action && (
From 58563b7a69c3fa91b70866bfcf6607c9cf9dc752 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:02:36 +0400 Subject: [PATCH 14/19] style(console): match action subtitle font and gap to the design schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gap 10px (was 8), font 12.5px (was 12), color gray-500 (#6b7280) — the reference card's right-column values. Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index bb91282d5..4c23525d9 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -123,11 +123,11 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({
{action && ( -
+
{action.text} - {action.subtitle && } + {action.subtitle && }
)} {banner.closeable && onClose && ( From f347c0fbe2b8d618abb90a7bca601d4c154546dd Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:09:49 +0400 Subject: [PATCH 15/19] fix(console): drop the banner action button on the billing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action navigates to /settings/billing — on that page it's a visual no-op that reads as a broken button. The page itself is the call to action, so the action column is stripped there. Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 4c23525d9..b2f03ada9 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -187,6 +187,13 @@ const BillingBannersInner: React.FC = () => { banners = []; } + // On the billing page the action button would navigate to the page the user + // is already on (a visual no-op) — the page itself is the call to action, so + // drop the action column there. + if (onBillingPage) { + banners = banners.map(banner => (banner.action ? { ...banner, action: undefined } : banner)); + } + const isDismissed = (banner: BillingBanner) => banner.closeable && !!safeStorageGet(dismissKey(workspace.id, banner.id)); const visibleBanners = banners.filter(banner => !isDismissed(banner)); From ead3906d43902d7ec1aec756327cba072b2a6ce8 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:17:13 +0400 Subject: [PATCH 16/19] feat(console): honor server-controlled onBillingPage visibility flags Replaces the blanket action-stripping on the billing page: banners and actions now carry an optional onBillingPage flag (missing = show). The console filters banners with onBillingPage=false on the billing page and drops actions flagged the same way. Billing counterpart: jitsu-cloud-billing#26. Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 11 +++++++---- webapps/console/lib/schema/index.ts | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index b2f03ada9..fe4b0c6b4 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -187,11 +187,14 @@ const BillingBannersInner: React.FC = () => { banners = []; } - // On the billing page the action button would navigate to the page the user - // is already on (a visual no-op) — the page itself is the call to action, so - // drop the action column there. + // 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.map(banner => (banner.action ? { ...banner, action: undefined } : banner)); + banners = banners + .filter(banner => banner.onBillingPage !== false) + .map(banner => (banner.action?.onBillingPage === false ? { ...banner, action: undefined } : banner)); } const isDismissed = (banner: BillingBanner) => diff --git a/webapps/console/lib/schema/index.ts b/webapps/console/lib/schema/index.ts index a4133a6d7..9ff7e54d3 100644 --- a/webapps/console/lib/schema/index.ts +++ b/webapps/console/lib/schema/index.ts @@ -54,9 +54,13 @@ export const BillingBanner = z.object({ location: z.string(), /** Small HTML line under the button. */ subtitle: z.string().optional(), + /** Show the action on the billing settings page. Missing = true. */ + onBillingPage: z.boolean().optional(), }) .optional(), closeable: z.boolean(), + /** Show this banner on the billing settings page. Missing = true. */ + onBillingPage: z.boolean().optional(), }); export type BillingBanner = z.infer; From 5027d320667a0df05780158d05a2c8705e647c03 Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:18:08 +0400 Subject: [PATCH 17/19] style(console): nudge banner title up to match the design schema Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index fe4b0c6b4..162ee05f1 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -114,8 +114,8 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ {banner.icon ? : !}
-
- {banner.title} +
+ {banner.title} {banner.badge} From 6bf6e3b846e6a3629048eaebcbb58531847f8b5b Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:19:13 +0400 Subject: [PATCH 18/19] style(console): restore original banner body spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the mt-0.5/leading-normal tweak — the title nudge was the right fix, the body change was not. Co-Authored-By: Claude Fable 5 --- webapps/console/components/Billing/BillingBanners.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 162ee05f1..906abb2cc 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -120,7 +120,7 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ {banner.badge}
- +
{action && (
From 1dd8d7bd3a28ef675eb6d90c8fd25b5c3e41f41b Mon Sep 17 00:00:00 2001 From: Ildar Nurislamov Date: Wed, 22 Jul 2026 19:32:06 +0400 Subject: [PATCH 19/19] =?UTF-8?q?fix(console):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20dot-segment=20paths,=20inline=20links,=20subtitle?= =?UTF-8?q?=20allowlist,=20icon=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isSafeLocation rejects dot segments (decoded) in action locations and in any surviving sanitization (restored DOMPurify hook), so neither can escape the workspace prefix or go off-origin. - Subtitles sanitize with an inline-only allowlist (no style/structure/ links). - Icon overrides that sanitize to nothing fall back to the default severity icon instead of a blank tile. - Client banners (throttle, projection) declare onBillingPage: false in their payloads, matching the server contract semantics. Co-Authored-By: Claude Fable 5 --- .../components/Billing/BillingBanners.tsx | 78 +++++++++++++++---- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/webapps/console/components/Billing/BillingBanners.tsx b/webapps/console/components/Billing/BillingBanners.tsx index 906abb2cc..36f0be42c 100644 --- a/webapps/console/components/Billing/BillingBanners.tsx +++ b/webapps/console/components/Billing/BillingBanners.tsx @@ -55,16 +55,60 @@ const safeStorageSet = (key: string, value: string): void => { } catch {} }; -// Banner HTML fragments are copy + the progress-bar widget: structural markup -// and inline styles, no script vectors. -const sanitize = (html: string) => - DOMPurify.sanitize(html, { +/** + * 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 }> = ({ html, className }) => ( - +const Html: React.FC<{ html: string; className?: string; inline?: boolean }> = ({ html, className, inline }) => ( + ); function usageIsAboutToExceed(billing: UseBillingResult, usage: UseUsageRes): boolean { @@ -104,14 +148,18 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ 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 && banner.action.location.startsWith("/") && !banner.action.location.startsWith("//") - ? banner.action - : undefined; + 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 (
- {banner.icon ? : !} + {iconHtml.trim() ? ( + + ) : ( + ! + )}
@@ -127,7 +175,7 @@ const BannerCard: React.FC<{ banner: BillingBanner; onClose?: () => void }> = ({ {action.text} - {action.subtitle && } + {action.subtitle && }
)} {banner.closeable && onClose && ( @@ -165,8 +213,9 @@ const BillingBannersInner: React.FC = () => { 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" }, + action: { text: "See details", location: "/settings/billing", onBillingPage: false }, closeable: false, + onBillingPage: false, }, ]; } else if (serverBanners.length > 0) { @@ -179,8 +228,9 @@ const BillingBannersInner: React.FC = () => { 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" }, + action: { text: "Upgrade", location: "/settings/billing", onBillingPage: false }, closeable: true, + onBillingPage: false, }, ]; } else {