feat(checkout): show masked receipt email in success banner (#3265)

Squashed rebase onto current main (which now includes #3259, #3260,
#3261, #3270, #3273, #3274). Source-only diff extracted via
`git diff b688d2ff3 HEAD`; 3 of 4 files applied cleanly. One conflict
in src/services/checkout.ts on the already-entitled fast-path —
both sides added auto-dismiss logic, PR-11's is the superset (adds
email handling + stopEmailWatchers cleanup). Kept PR-11's version.

Changes:
- src/services/checkout-banner-state.ts: maskEmail helper (pure)
- src/services/checkout.ts: email param + poll + subscription for
  late Clerk hydration; cleaned up on all banner-exit paths
- src/app/panel-layout.ts: pass email to showCheckoutSuccess
- tests/email-masking.test.mts: maskEmail regression suite
This commit is contained in:
Elie Habib
2026-04-22 15:59:03 +04:00
committed by GitHub
parent dee3b97cfd
commit 7bc2dc03f8
4 changed files with 212 additions and 24 deletions

View File

@@ -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

View File

@@ -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}`;
}

View File

@@ -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<typeof setInterval> | 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') {

View File

@@ -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'));
});
});