mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
83
tests/email-masking.test.mts
Normal file
83
tests/email-masking.test.mts
Normal 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user