diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 8f46813df..1ee2b7856 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -198,8 +198,13 @@ export class PanelLayoutManager implements AppModule { // reload source). If the user is already entitled on mount the // banner goes straight to the "active" state; otherwise it waits // up to 30s for the transition before surfacing a manual-refresh - // CTA. - showCheckoutSuccess({ waitForEntitlement: true }); + // CTA. `email` is read from auth-state (authoritative on the main + // app) and masked in the banner before rendering to keep the raw + // address out of screenshots / screen-shares of the banner. + showCheckoutSuccess({ + waitForEntitlement: true, + email: getAuthState().user?.email ?? null, + }); } else if (returnResult.kind === 'failed') { showCheckoutFailureBanner(returnResult.rawStatus); } @@ -214,8 +219,14 @@ export class PanelLayoutManager implements AppModule { // Overlay success fires BEFORE the entitlement-watcher reload. The // banner stays mounted through the reload via waitForEntitlement so // the user sees visual continuity from "Payment received!" through - // "Premium activated" without a blank intermediate state. - initCheckoutOverlay(() => showCheckoutSuccess({ waitForEntitlement: true })); + // "Premium activated" without a blank intermediate state. Read the + // email lazily at fire-time (not at register-time) so a just-signed- + // in buyer who completes checkout in the same session still sees + // the receipt acknowledgement. + initCheckoutOverlay(() => showCheckoutSuccess({ + waitForEntitlement: true, + email: getAuthState().user?.email ?? null, + })); // Reload only on a free→pro transition. Legacy-pro users whose first // snapshot is already pro (lastEntitled === null) must not trigger a diff --git a/src/services/checkout-banner-state.ts b/src/services/checkout-banner-state.ts index caab9740b..d76328320 100644 --- a/src/services/checkout-banner-state.ts +++ b/src/services/checkout-banner-state.ts @@ -34,3 +34,30 @@ export const CLASSIC_AUTO_DISMISS_MS = 5_000; export function computeInitialBannerState(entitledNow: boolean): CheckoutSuccessBannerState { return entitledNow ? 'active' : 'pending'; } + +/** + * Mask an email address for display in the success banner so the + * full address isn't rendered in plaintext (privacy — a top-of- + * viewport banner can be screen-shared, photographed, or recorded). + * + * Shape: first character of the local part + `***` + `@domain`. + * Short local parts (1 char) still render safely: `a***@x.com`. + * IDN / plus-addressing / dots in the local part pass through the + * domain unchanged so the user can still recognize "yes, that's + * where the receipt went." + * + * Returns null when the input isn't a minimally-valid email so + * callers can fall back to the email-less banner copy rather than + * render obviously-broken output. + */ +export function maskEmail(email: string | undefined | null): string | null { + if (!email || typeof email !== 'string') return null; + const trimmed = email.trim(); + const atIndex = trimmed.indexOf('@'); + // Require at least `a@b` — one char local, one char domain. + if (atIndex < 1 || atIndex === trimmed.length - 1) return null; + const local = trimmed.slice(0, atIndex); + const domain = trimmed.slice(atIndex); + const firstChar = local.charAt(0); + return `${firstChar}***${domain}`; +} diff --git a/src/services/checkout.ts b/src/services/checkout.ts index 74a3c6b0c..37c600c62 100644 --- a/src/services/checkout.ts +++ b/src/services/checkout.ts @@ -33,6 +33,7 @@ import { CLASSIC_AUTO_DISMISS_MS, EXTENDED_UNLOCK_TIMEOUT_MS, computeInitialBannerState, + maskEmail, type CheckoutSuccessBannerState, } from './checkout-banner-state'; import { loadActiveReferral } from './referral-capture'; @@ -42,6 +43,7 @@ import { resolvePlanDisplayName } from './checkout-plan-names'; export { EXTENDED_UNLOCK_TIMEOUT_MS, computeInitialBannerState, + maskEmail, type CheckoutSuccessBannerState, } from './checkout-banner-state'; @@ -732,7 +734,7 @@ function renderCheckoutErrorSurface( * warning. Never silently disappears. */ export function showCheckoutSuccess( - options?: { waitForEntitlement?: boolean }, + options?: { waitForEntitlement?: boolean; email?: string | null }, ): void { const existing = document.getElementById('checkout-success-banner'); if (existing) existing.remove(); @@ -761,7 +763,61 @@ export function showCheckoutSuccess( gap: '12px', }); - setBannerText(banner, 'pending'); + // Resolve email lazily. Clerk/auth-state is hydrated asynchronously + // in App bootstrap (src/App.ts) AFTER PanelLayoutManager mounts, so + // `getAuthState().user?.email` read synchronously at the call site + // is usually null on post-reload returns. Wrap the reference in a + // mutable container that later transitions can re-read, and + // subscribe to auth-state once to update the banner text when email + // hydrates. + let currentMaskedEmail = maskEmail(options?.email); + let unsubscribeAuth: (() => void) | null = null; + let emailPollInterval: ReturnType | null = null; + let currentState: CheckoutSuccessBannerState = 'pending'; + + const applyEmail = (raw: string | null | undefined): boolean => { + const next = maskEmail(raw ?? null); + if (next && next !== currentMaskedEmail) { + currentMaskedEmail = next; + setBannerText(banner, currentState, currentMaskedEmail); + stopEmailWatchers(); + return true; + } + return false; + }; + const stopEmailWatchers = (): void => { + unsubscribeAuth?.(); + unsubscribeAuth = null; + if (emailPollInterval) { + clearInterval(emailPollInterval); + emailPollInterval = null; + } + }; + + if (!currentMaskedEmail) { + // Two fallbacks needed. (1) subscribeAuthState should fire when Clerk + // hydrates — but auth-state.ts subscribes to clerkInstance at the + // moment subscribeAuthState is called; if showCheckoutSuccess runs + // BEFORE initClerk() resolves, clerkInstance is null and + // subscribeClerk returns a no-op unsubscribe. Nothing re-emits after + // Clerk hydrates. (2) Polling getCurrentClerkUser() directly every + // 500ms catches the late hydration regardless of auth-state's + // subscription timing. Both stop as soon as we get a valid email. + unsubscribeAuth = subscribeAuthState((state) => { + applyEmail(state.user?.email); + }); + const POLL_MS = 500; + const POLL_BUDGET_MS = 15_000; + const pollStart = Date.now(); + emailPollInterval = setInterval(() => { + if (Date.now() - pollStart > POLL_BUDGET_MS) { + if (emailPollInterval) { clearInterval(emailPollInterval); emailPollInterval = null; } + return; + } + applyEmail(getCurrentClerkUser()?.email); + }, POLL_MS); + } + setBannerText(banner, 'pending', currentMaskedEmail); document.body.appendChild(banner); requestAnimationFrame(() => { @@ -770,25 +826,26 @@ export function showCheckoutSuccess( }); if (!options?.waitForEntitlement) { - setTimeout(() => dismissBanner(banner), CLASSIC_AUTO_DISMISS_MS); + setTimeout(() => { + stopEmailWatchers(); + dismissBanner(banner); + }, CLASSIC_AUTO_DISMISS_MS); return; } const initial = computeInitialBannerState(isEntitled()); if (initial === 'active') { - // Already entitled at mount (e.g., returned to the page after the - // watcher-reload already flipped lock state, or Convex cache hit - // before any transition could fire). The 'active' branch previously - // sat forever with "Premium activated — reloading…" because: - // - onEntitlementChange listener below only fires on transitions, - // and we're already in steady pro state — no transition to - // observe. - // - No auto-dismiss / timeout existed for the fast-path. - // Treat this like a classic confirmation: show active text and - // auto-dismiss on the CLASSIC_AUTO_DISMISS_MS window so the user - // gets closure instead of a banner that hangs until a hard refresh. - setBannerText(banner, 'active'); - setTimeout(() => dismissBanner(banner), CLASSIC_AUTO_DISMISS_MS); + // Already entitled at mount. Auto-dismiss via CLASSIC_AUTO_DISMISS_MS + // (merged from PR-4's fix for the fast-path hang). PR-11 adds the + // email-banner handling + email-watcher cleanup so the stop callback + // doesn't leak into the tab's lifetime when this fast-path fires + // with an email-backfill subscription still active. + currentState = 'active'; + setBannerText(banner, 'active', currentMaskedEmail); + setTimeout(() => { + stopEmailWatchers(); + dismissBanner(banner); + }, CLASSIC_AUTO_DISMISS_MS); return; } @@ -797,7 +854,9 @@ export function showCheckoutSuccess( if (resolved) return; resolved = true; unsubscribe(); - setBannerText(banner, 'timeout'); + stopEmailWatchers(); + currentState = 'timeout'; + setBannerText(banner, 'timeout', currentMaskedEmail); Sentry.captureMessage('Checkout entitlement-activation timeout', { level: 'warning', tags: { component: 'dodo-checkout', action: 'entitlement-timeout' }, @@ -809,15 +868,23 @@ export function showCheckoutSuccess( if (!isEntitled()) return; resolved = true; clearTimeout(timeoutHandle); - setBannerText(banner, 'active'); unsubscribe(); + stopEmailWatchers(); + currentState = 'active'; + setBannerText(banner, 'active', currentMaskedEmail); }); } -function setBannerText(banner: HTMLElement, state: CheckoutSuccessBannerState): void { +function setBannerText( + banner: HTMLElement, + state: CheckoutSuccessBannerState, + maskedEmail: string | null, +): void { banner.setAttribute('data-entitlement-state', state); if (state === 'pending') { - banner.textContent = 'Payment received! Unlocking your premium features…'; + banner.textContent = maskedEmail + ? `Payment received! Receipt sent to ${maskedEmail}. Unlocking your premium features…` + : 'Payment received! Unlocking your premium features…'; return; } if (state === 'active') { diff --git a/tests/email-masking.test.mts b/tests/email-masking.test.mts new file mode 100644 index 000000000..492ebd297 --- /dev/null +++ b/tests/email-masking.test.mts @@ -0,0 +1,83 @@ +/** + * Locks the masking shape for the post-checkout success banner. + * The banner is shown at the top of the viewport during the webhook- + * propagation window, so the address can end up in screenshots, + * screen-shares, or phone photos. Masking is what prevents "PII + * casually leaking to an arbitrary observer." + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { maskEmail } from '../src/services/checkout-banner-state.ts'; + +describe('maskEmail', () => { + it('masks a typical address as first-char + *** + @domain', () => { + assert.equal(maskEmail('elie@anghami.com'), 'e***@anghami.com'); + }); + + it('handles a single-letter local part safely', () => { + assert.equal(maskEmail('a@example.com'), 'a***@example.com'); + }); + + it('preserves plus-addressing in the domain half (nothing leaks local detail)', () => { + // Plus-addressing lives in the local part which is masked entirely, + // so the `+tag` token is dropped — that's the desired privacy. + assert.equal(maskEmail('user+promos@example.com'), 'u***@example.com'); + }); + + it('preserves dots in the local part by masking everything after the first char', () => { + assert.equal(maskEmail('first.last@example.com'), 'f***@example.com'); + }); + + it('preserves a long domain unchanged', () => { + assert.equal(maskEmail('u@mail.subdomain.example.co.uk'), 'u***@mail.subdomain.example.co.uk'); + }); + + it('trims surrounding whitespace before masking', () => { + assert.equal(maskEmail(' elie@anghami.com '), 'e***@anghami.com'); + }); + + it('handles IDN-style domains by pass-through', () => { + assert.equal(maskEmail('u@bücher.example'), 'u***@bücher.example'); + }); + + it('returns null for undefined input', () => { + assert.equal(maskEmail(undefined), null); + }); + + it('returns null for null input', () => { + assert.equal(maskEmail(null), null); + }); + + it('returns null for empty string', () => { + assert.equal(maskEmail(''), null); + }); + + it('returns null when @ is missing', () => { + assert.equal(maskEmail('not-an-email'), null); + }); + + it('returns null when local part is empty (leading @)', () => { + assert.equal(maskEmail('@example.com'), null); + }); + + it('returns null when domain part is empty (trailing @)', () => { + assert.equal(maskEmail('user@'), null); + }); + + it('returns null for non-string input', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.equal(maskEmail(1234 as any), null); + }); + + it('never returns a string containing the original local part beyond the first char', () => { + const sensitive = 'bob.secret.identity@example.com'; + const masked = maskEmail(sensitive); + assert.ok(masked !== null); + // The masked form should NOT contain "bob.secret" or "secret.identity". + assert.ok(!masked.includes('bob.secret')); + assert.ok(!masked.includes('secret')); + assert.ok(!masked.includes('identity')); + }); +});