From 2f0129a9d90a4e0ab409b914331e64d72119c975 Mon Sep 17 00:00:00 2001 From: Luke Chapman Date: Sun, 2 Aug 2026 19:12:12 +0100 Subject: [PATCH] =?UTF-8?q?feat(signup):=20Continue=20as=E2=80=A6=20choose?= =?UTF-8?q?r=20when=20session=20or=20pending=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop silent jump to Pay on register open. Let users complete signup for the signed-in email or switch accounts (sign out + clear pending) so Stripe Checkout matches their intent. --- apps/shell/src/auth/AuthWizard.tsx | 299 ++++++++++++++++++--- apps/shell/src/auth/accountChooser.test.ts | 121 +++++++++ apps/shell/src/auth/accountChooser.ts | 131 +++++++++ apps/shell/src/auth/auth-wizard.css | 18 ++ apps/shell/src/auth/hostedAccount.ts | 12 + 5 files changed, 547 insertions(+), 34 deletions(-) create mode 100644 apps/shell/src/auth/accountChooser.test.ts create mode 100644 apps/shell/src/auth/accountChooser.ts diff --git a/apps/shell/src/auth/AuthWizard.tsx b/apps/shell/src/auth/AuthWizard.tsx index 396402b..a2f5696 100644 --- a/apps/shell/src/auth/AuthWizard.tsx +++ b/apps/shell/src/auth/AuthWizard.tsx @@ -7,11 +7,14 @@ import { friendlyHostedProvisionError, getSupabaseClient, hasSupabaseSession, + fetchSupabaseSessionEmail, + isSupabaseEmailConfirmed, isEmailNotConfirmedError, isEmailRateLimitError, registerSupabaseAccount, resendSignupConfirmation, signInSupabaseAccount, + signOutSupabase, signupHostedDevAccount, startHostedPlanCheckout, persistSignupProfileIntent, @@ -20,6 +23,15 @@ import { SubscriptionRequiredError, type AtomAccountType, } from "./hostedAccount.js"; +import { + chooserActions, + emailsEqualIgnoreCase, + mayClearLocalSignupState, + resolveChooserIdentity, + shouldBypassChooser, + shouldShowChooser, + type ChooserAction, +} from "./accountChooser.js"; import { saveAccountType } from "../accountType.js"; import { AccountTypeSelection } from "./accountTypeSelection.js"; import { @@ -341,6 +353,13 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) const [emailConfirmedThanks, setEmailConfirmedThanks] = useState(false); const [resendNote, setResendNote] = useState(null); const confirmHandledRef = useRef(false); + /** pending = mounting; show = Continue as overlay; done = normal wizard */ + const [chooserPhase, setChooserPhase] = useState<"pending" | "show" | "done">("pending"); + const [chooserPrimaryEmail, setChooserPrimaryEmail] = useState(""); + const [chooserConflictEmail, setChooserConflictEmail] = useState(null); + const [chooserActionList, setChooserActionList] = useState([]); + const [chooserFinishPayHint, setChooserFinishPayHint] = useState(false); + const [chooserBusy, setChooserBusy] = useState(false); const labels = useMemo( () => @@ -403,7 +422,10 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) }, [handle, hosting]); useEffect(() => { - if (!usesSupabaseHostedAuth()) return; + if (!usesSupabaseHostedAuth()) { + setChooserPhase("done"); + return; + } void (async () => { const params = new URLSearchParams(window.location.search); @@ -441,9 +463,10 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) if (p.kind === "register") setHosting("hosted"); }; - if (billing === "plan-cancel" && mode === "register") { + if (billing === "plan-cancel" && (mode === "register" || mode === "login")) { if (pending) restorePending(pending); setHosting("hosted"); + setChooserPhase("done"); goTo("pay"); setError("Payment cancelled — nothing was charged. Continue when you’re ready."); return; @@ -452,6 +475,7 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) if (billing === "plan-success" && mode === "register" && pending && hasSession) { restorePending(pending); setHosting("hosted"); + setChooserPhase("done"); void (async () => { setBusy(true); setError(null); @@ -478,40 +502,54 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) return; } - if (hasSession && mode === "register") { - if (pending) restorePending(pending); - setHosting("hosted"); - const pendingLane = - pending?.billingLane === "byok" - ? "byok" - : pending?.billingLane === "standard" || - isBusinessAccountType( - pending?.accountType ?? - (pending?.accountTypes?.[0] as AtomAccountType | undefined) ?? - accountKind, - ) - ? "standard" - : pending?.billingLane; - if (pendingLane === "self_hosted") { - if (!loadFirstRunDone()) { - goTo("provisioning"); - void resumeHostedSupabaseSetup(); - } - return; - } - // Hosted: Pay until Stripe entitlement exists (ignore stale firstRunDone). - if (await isHostedSubscriptionProvisionable()) { - if (!loadFirstRunDone()) { - goTo("provisioning"); - void resumeHostedSupabaseSetup(); + const bypass = shouldBypassChooser({ + billing, + resumeSetup, + reloadMidSetup, + }); + + if ( + shouldShowChooser({ + bypass, + hasSession, + hasPending: Boolean(pending), + }) + ) { + const sessionEmail = hasSession ? await fetchSupabaseSessionEmail() : null; + const identity = resolveChooserIdentity({ + sessionEmail, + pendingEmail: pending?.email ?? null, + }); + let provisionable: boolean | null = null; + let provisionCheckFailed = false; + if (hasSession) { + try { + provisionable = await isHostedSubscriptionProvisionable(); + } catch { + provisionable = null; + provisionCheckFailed = true; } - return; } - resetFirstRunDone(); - goTo("pay"); + setChooserPrimaryEmail(identity.primaryEmail); + setChooserConflictEmail(identity.conflictEmail); + // RC-9: omit payment hint when CP check errored (advisory only). + setChooserFinishPayHint( + hasSession && !provisionCheckFailed && provisionable !== true, + ); + setChooserActionList( + chooserActions({ + mode, + hasSession, + pendingKind: pending?.kind ?? null, + provisionable, + }), + ); + setChooserPhase("show"); return; } + setChooserPhase("done"); + if (!resumeSetup && !reloadMidSetup) { if (!hasSession) { clearPendingHostedAuth(); @@ -626,6 +664,143 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) }; }, [step, mode, billingLane, accountKind, hosting]); + function applyPendingFromStorage(): ReturnType { + const pending = loadPendingHostedAuth(); + if (!pending) return null; + setEmail(pending.email); + if (pending.handle) setHandle(pending.handle); + applyPendingAccountTypes(pending, setAccountKind); + applyPendingPlanFields(pending, { + setBillingLane, + setReadinessSkuId, + setModelTierId, + setTopUpPence, + setHosting, + }); + if (pending.llmApiKey) { + setLlmConnection((prev) => ({ + ...prev, + apiKey: pending.llmApiKey ?? prev.apiKey, + providerId: + pending.llmProvider && isHostedLlmProviderId(pending.llmProvider) + ? pending.llmProvider + : prev.providerId, + baseUrl: pending.llmBaseUrl ?? prev.baseUrl, + model: pending.llmModel ?? prev.model, + })); + } + if (pending.kind === "register" && pending.billingLane !== "self_hosted") { + setHosting("hosted"); + } + return pending; + } + + async function discardConflictingPending(): Promise { + const pending = loadPendingHostedAuth(); + const sessionEmail = await fetchSupabaseSessionEmail(); + if (!pending || !sessionEmail) return; + if (!emailsEqualIgnoreCase(pending.email, sessionEmail)) { + clearPendingHostedAuth(); + } + } + + async function handleChooserAction(actionId: ChooserAction["id"]): Promise { + setChooserBusy(true); + setError(null); + try { + if (actionId === "different_account" || actionId === "start_over") { + let signOutSucceeded = true; + if (await hasSupabaseSession()) { + try { + await signOutSupabase(); + } catch (err) { + signOutSucceeded = false; + setError(err instanceof Error ? err.message : String(err)); + return; + } + } + const sessionGone = !(await hasSupabaseSession()); + if ( + !mayClearLocalSignupState({ + signOutSucceeded, + sessionGone, + }) + ) { + setError("Could not sign out of the current account. Try again."); + return; + } + clearPendingHostedAuth(); + clearSignupAtProvision(); + resetFirstRunDone(); + confirmHandledRef.current = false; + setEmail(""); + setPassword(""); + setConfirmPassword(""); + setHandle(mode === "login" ? (loadOwnerHandle() ?? "") : ""); + setChooserPhase("done"); + goTo(mode === "login" ? "credentials" : "account-type"); + return; + } + + if (actionId === "resume_pending") { + applyPendingFromStorage(); + setChooserPhase("done"); + goTo(mode === "login" ? "credentials" : "credentials"); + return; + } + + if (actionId === "complete_signup" || actionId === "continue_session") { + await discardConflictingPending(); + const pending = applyPendingFromStorage(); + setChooserPhase("done"); + + if (mode === "login" && actionId === "complete_signup") { + // Unpaid login → register Pay recovery (GATE-01). + window.location.replace("/app/?auth=register"); + return; + } + + if (!(await hasSupabaseSession())) { + goTo("credentials"); + return; + } + + if (pending?.billingLane === "self_hosted") { + setHosting("self-hosted"); + if (!loadFirstRunDone()) { + goTo("provisioning"); + void resumeHostedSupabaseSetup(); + } + return; + } + + setHosting("hosted"); + + if (!(await isSupabaseEmailConfirmed())) { + goTo("confirm-email"); + return; + } + + if (await isHostedSubscriptionProvisionable()) { + if (!loadFirstRunDone()) { + goTo("provisioning"); + if (pending?.kind === "login" || mode === "login") { + void finishHostedSupabaseLogin(); + } else { + void resumeHostedSupabaseSetup(); + } + } + return; + } + + resetFirstRunDone(); + goTo("pay"); + } + } finally { + setChooserBusy(false); + } + } + function goTo(next: AuthStepId) { if (next === "provisioning") markSignupAtProvision(); setStep(next); @@ -1362,9 +1537,11 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) } const title = - mode === "login" - ? "Log in" - : registerWizardTitle({ step, needsPay: hostedRegisterNeedsPay() }); + chooserPhase === "show" + ? "Continue as…" + : mode === "login" + ? "Log in" + : registerWizardTitle({ step, needsPay: hostedRegisterNeedsPay() }); const payPrimaryLabel = (() => { const locked = isBusinessAccountType(accountKind); @@ -1763,6 +1940,58 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps)
+ {chooserPhase === "pending" ? ( +

Checking signed-in account…

+ ) : null} + + {chooserPhase === "show" ? ( +
+

+ {chooserPrimaryEmail || "Your account"} +

+ {chooserConflictEmail ? ( +

+ Signed in as {chooserPrimaryEmail}. A different + unfinished signup for {chooserConflictEmail} will + be discarded if you continue. +

+ ) : ( +

+ {chooserFinishPayHint + ? "Payment isn’t finished for this account." + : "You’re already signed in on this device."} +

+ )} + {error ?

{error}

: null} +
+ {chooserActionList.map((action) => ( + + ))} +
+
+ ) : null} + + {chooserPhase === "done" ? ( + <>
@@ -1848,6 +2077,8 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps)
) : null} + + ) : null}
diff --git a/apps/shell/src/auth/accountChooser.test.ts b/apps/shell/src/auth/accountChooser.test.ts new file mode 100644 index 0000000..dca9586 --- /dev/null +++ b/apps/shell/src/auth/accountChooser.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + chooserActions, + emailsEqualIgnoreCase, + mayClearLocalSignupState, + resolveChooserIdentity, + shouldBypassChooser, + shouldShowChooser, +} from "./accountChooser.js"; + +describe("shouldBypassChooser", () => { + it("bypasses billing returns and resume/reload", () => { + expect( + shouldBypassChooser({ billing: "plan-success", resumeSetup: false, reloadMidSetup: false }), + ).toBe(true); + expect( + shouldBypassChooser({ billing: "plan-cancel", resumeSetup: false, reloadMidSetup: false }), + ).toBe(true); + expect( + shouldBypassChooser({ billing: null, resumeSetup: true, reloadMidSetup: false }), + ).toBe(true); + expect( + shouldBypassChooser({ billing: null, resumeSetup: false, reloadMidSetup: true }), + ).toBe(true); + expect( + shouldBypassChooser({ billing: null, resumeSetup: false, reloadMidSetup: false }), + ).toBe(false); + }); +}); + +describe("shouldShowChooser", () => { + it("shows for session or pending unless bypassed", () => { + expect(shouldShowChooser({ bypass: true, hasSession: true, hasPending: true })).toBe(false); + expect(shouldShowChooser({ bypass: false, hasSession: true, hasPending: false })).toBe(true); + expect(shouldShowChooser({ bypass: false, hasSession: false, hasPending: true })).toBe(true); + expect(shouldShowChooser({ bypass: false, hasSession: false, hasPending: false })).toBe(false); + }); +}); + +describe("resolveChooserIdentity", () => { + it("prefers session and flags pending conflict", () => { + expect( + resolveChooserIdentity({ + sessionEmail: "a@example.com", + pendingEmail: "b@example.com", + }), + ).toEqual({ + primaryEmail: "a@example.com", + conflictEmail: "b@example.com", + emailsMatch: false, + }); + expect( + resolveChooserIdentity({ + sessionEmail: "A@Example.com", + pendingEmail: "a@example.com", + }).emailsMatch, + ).toBe(true); + expect( + resolveChooserIdentity({ sessionEmail: null, pendingEmail: "p@x.com" }).primaryEmail, + ).toBe("p@x.com"); + }); +}); + +describe("chooserActions", () => { + it("unpaid register session offers Complete + Different", () => { + const actions = chooserActions({ + mode: "register", + hasSession: true, + pendingKind: "register", + provisionable: false, + }); + expect(actions.map((a) => a.id)).toEqual(["complete_signup", "different_account"]); + }); + + it("pending-only offers Resume + Start over, not Log in as", () => { + const actions = chooserActions({ + mode: "register", + hasSession: false, + pendingKind: "register", + provisionable: null, + }); + expect(actions.map((a) => a.id)).toEqual(["resume_pending", "start_over"]); + }); + + it("unpaid login session offers Finish payment then Different", () => { + const actions = chooserActions({ + mode: "login", + hasSession: true, + pendingKind: null, + provisionable: false, + }); + expect(actions.map((a) => a.id)).toEqual(["complete_signup", "different_account"]); + }); +}); + +describe("mayClearLocalSignupState", () => { + it("requires successful sign-out and gone session", () => { + expect(mayClearLocalSignupState({ signOutSucceeded: true, sessionGone: true })).toBe(true); + expect(mayClearLocalSignupState({ signOutSucceeded: false, sessionGone: true })).toBe(false); + expect(mayClearLocalSignupState({ signOutSucceeded: true, sessionGone: false })).toBe(false); + }); +}); + +describe("emailsEqualIgnoreCase", () => { + it("trims and ignores case", () => { + expect(emailsEqualIgnoreCase(" A@x.com ", "a@x.com")).toBe(true); + expect(emailsEqualIgnoreCase("a@x.com", "b@x.com")).toBe(false); + }); +}); + +describe("self_hosted continue semantics", () => { + it("paid login continue is continue_session not complete_signup", () => { + const actions = chooserActions({ + mode: "login", + hasSession: true, + pendingKind: null, + provisionable: true, + }); + expect(actions[0]?.id).toBe("continue_session"); + }); +}); diff --git a/apps/shell/src/auth/accountChooser.ts b/apps/shell/src/auth/accountChooser.ts new file mode 100644 index 0000000..8253951 --- /dev/null +++ b/apps/shell/src/auth/accountChooser.ts @@ -0,0 +1,131 @@ +import type { AuthWizardMode } from "./authSteps.js"; + +export type ChooserActionId = + | "complete_signup" + | "continue_session" + | "resume_pending" + | "different_account" + | "start_over"; + +export type ChooserAction = { + id: ChooserActionId; + label: string; + primary?: boolean; +}; + +/** Billing return + email-confirm resume + mid-setup reload skip the chooser. */ +export function shouldBypassChooser(opts: { + billing: string | null; + resumeSetup: boolean; + reloadMidSetup: boolean; +}): boolean { + if (opts.billing === "plan-success" || opts.billing === "plan-cancel") return true; + if (opts.resumeSetup || opts.reloadMidSetup) return true; + return false; +} + +export function shouldShowChooser(opts: { + bypass: boolean; + hasSession: boolean; + hasPending: boolean; +}): boolean { + if (opts.bypass) return false; + return opts.hasSession || opts.hasPending; +} + +export function emailsEqualIgnoreCase(a: string | null | undefined, b: string | null | undefined): boolean { + const left = (a ?? "").trim().toLowerCase(); + const right = (b ?? "").trim().toLowerCase(); + if (!left || !right) return left === right; + return left === right; +} + +/** Session email wins; flag conflict when pending differs. */ +export function resolveChooserIdentity(opts: { + sessionEmail: string | null; + pendingEmail: string | null; +}): { + primaryEmail: string; + conflictEmail: string | null; + emailsMatch: boolean; +} { + const session = (opts.sessionEmail ?? "").trim(); + const pending = (opts.pendingEmail ?? "").trim(); + if (session && pending && !emailsEqualIgnoreCase(session, pending)) { + return { + primaryEmail: session, + conflictEmail: pending, + emailsMatch: false, + }; + } + const primary = session || pending || ""; + return { primaryEmail: primary, conflictEmail: null, emailsMatch: true }; +} + +/** + * Visible chooser actions from mode × session × pending × provisionable. + * provisionable null = unknown (treat unpaid-safe for complete/continue labels). + */ +export function chooserActions(opts: { + mode: AuthWizardMode; + hasSession: boolean; + pendingKind: "register" | "login" | null; + provisionable: boolean | null; +}): ChooserAction[] { + const unpaid = opts.provisionable !== true; + const actions: ChooserAction[] = []; + + if (opts.hasSession) { + if (opts.mode === "register") { + if (unpaid) { + actions.push({ + id: "complete_signup", + label: "Complete signup", + primary: true, + }); + } else { + actions.push({ + id: "continue_session", + label: "Continue setup", + primary: true, + }); + } + } else { + // login + if (unpaid) { + actions.push({ + id: "complete_signup", + label: "Finish payment", + primary: true, + }); + } else { + actions.push({ + id: "continue_session", + label: "Continue", + primary: true, + }); + } + } + actions.push({ id: "different_account", label: "Use a different account" }); + return actions; + } + + // Pending only — no session + if (opts.pendingKind === "register") { + actions.push({ + id: "resume_pending", + label: "Resume signup", + primary: true, + }); + } + actions.push({ id: "start_over", label: "Start over", primary: actions.length === 0 }); + return actions; +} + +/** Fail-closed: only clear local signup state after sign-out succeeded. */ +export function mayClearLocalSignupState(opts: { + signOutSucceeded: boolean; + sessionGone: boolean; +}): boolean { + return opts.signOutSucceeded && opts.sessionGone; +} diff --git a/apps/shell/src/auth/auth-wizard.css b/apps/shell/src/auth/auth-wizard.css index 6736735..718d41e 100644 --- a/apps/shell/src/auth/auth-wizard.css +++ b/apps/shell/src/auth/auth-wizard.css @@ -315,6 +315,24 @@ color: var(--shell-text); } +.auth-chooser { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; + min-height: 0; +} + +.auth-chooser-actions { + flex-direction: column; + align-items: stretch; + margin-top: 8px; +} + +.auth-chooser-actions .atom-btn { + width: 100%; +} + .auth-slide .auth-spinner { margin-top: 12px; } diff --git a/apps/shell/src/auth/hostedAccount.ts b/apps/shell/src/auth/hostedAccount.ts index b25dff1..e29ad50 100644 --- a/apps/shell/src/auth/hostedAccount.ts +++ b/apps/shell/src/auth/hostedAccount.ts @@ -157,6 +157,18 @@ export async function hasSupabaseSession(): Promise { return Boolean(data.session); } +export async function fetchSupabaseSessionEmail(): Promise { + if (!isSupabaseConfigured()) return null; + const { data } = await getSupabaseClient().auth.getUser(); + return data.user?.email?.trim() || null; +} + +export async function isSupabaseEmailConfirmed(): Promise { + if (!isSupabaseConfigured()) return false; + const { data } = await getSupabaseClient().auth.getUser(); + return Boolean(data.user?.email_confirmed_at); +} + /** Drop cached tokens when the Supabase user was deleted server-side. */ export async function clearStaleSupabaseSession(): Promise { if (!isSupabaseConfigured()) return;