diff --git a/apps/shell/src/auth/AuthWizard.tsx b/apps/shell/src/auth/AuthWizard.tsx index 657fb70..93d8cbe 100644 --- a/apps/shell/src/auth/AuthWizard.tsx +++ b/apps/shell/src/auth/AuthWizard.tsx @@ -25,13 +25,15 @@ import { } from "./hostedAccount.js"; import { chooserActionButtonLabel, - chooserActions, - chooserConflictCopy, emailsEqualIgnoreCase, mayClearLocalSignupState, - resolveChooserIdentity, + mayDiscardPendingAfterLoginWithSession, + registerChooserActions, + registerChooserBody, + resolveRegisterChooserIdentity, shouldBypassChooser, - shouldShowChooser, + shouldRenderRegisterChooser, + shouldShowRegisterChooser, type ChooserAction, } from "./accountChooser.js"; import { saveAccountType } from "../accountType.js"; @@ -355,12 +357,14 @@ 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 */ + /** Prevents register interstitial re-fire after Remove/Create this mount. */ + const chooserResolvedRef = useRef(false); + /** pending = mounting; show = register interstitial; done = normal wizard */ const [chooserPhase, setChooserPhase] = useState<"pending" | "show" | "done">("pending"); - const [chooserPrimaryEmail, setChooserPrimaryEmail] = useState(""); - const [chooserConflictEmail, setChooserConflictEmail] = useState(null); + const [chooserSessionEmail, setChooserSessionEmail] = useState(null); + const [chooserPendingEmail, setChooserPendingEmail] = useState(null); + const [chooserEmailsMatch, setChooserEmailsMatch] = useState(true); const [chooserActionList, setChooserActionList] = useState([]); - const [chooserFinishPayHint, setChooserFinishPayHint] = useState(false); const [chooserBusy, setChooserBusy] = useState(false); const labels = useMemo( @@ -429,6 +433,11 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) return; } + let cancelled = false; + // Reset resolution when switching Login ↔ Register. + chooserResolvedRef.current = false; + setChooserPhase("pending"); + void (async () => { const params = new URLSearchParams(window.location.search); const resumeSetup = params.get("resume") === "1"; @@ -437,6 +446,7 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) const isReload = nav?.type === "reload"; const reloadMidSetup = isReload && isSignupAtProvision(); const hasSession = await hasSupabaseSession(); + if (cancelled) return; const pending = loadPendingHostedAuth(); const restorePending = (p: NonNullable) => { @@ -510,50 +520,48 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) reloadMidSetup, }); + // SIGNUP-CHOOSER-02: register-only interstitial. Login never shows it. if ( - shouldShowChooser({ + shouldShowRegisterChooser({ + mode, bypass, hasSession, hasPending: Boolean(pending), + chooserResolved: chooserResolvedRef.current, }) ) { + if (cancelled || mode !== "register") return; const sessionEmail = hasSession ? await fetchSupabaseSessionEmail() : null; - const identity = resolveChooserIdentity({ + if (cancelled || mode !== "register") return; + const identity = resolveRegisterChooserIdentity({ sessionEmail, pendingEmail: pending?.email ?? null, }); - let provisionable: boolean | null = null; - let provisionCheckFailed = false; - if (hasSession) { - try { - provisionable = await isHostedSubscriptionProvisionable(); - } catch { - provisionable = null; - provisionCheckFailed = true; - } - } - setChooserPrimaryEmail(identity.primaryEmail); - setChooserConflictEmail(identity.conflictEmail); - // RC-9: omit payment hint when CP check errored (advisory only). - setChooserFinishPayHint( - hasSession && !provisionCheckFailed && provisionable !== true, - ); + setChooserSessionEmail(identity.sessionEmail); + setChooserPendingEmail(identity.pendingEmail); + setChooserEmailsMatch(identity.emailsMatch); setChooserActionList( - chooserActions({ - mode, + registerChooserActions({ hasSession, + sessionEmail: identity.sessionEmail, pendingKind: pending?.kind ?? null, - provisionable, + pendingEmail: identity.pendingEmail, }), ); + // Final cancel/mode check — stale register async must not paint after Login switch. + if (cancelled || mode !== "register") return; setChooserPhase("show"); return; } + if (cancelled) return; setChooserPhase("done"); if (!resumeSetup && !reloadMidSetup) { - if (!hasSession) { + if (!hasSession && mode === "login") { + // Login: do not clear register pending just because login opened without session. + confirmHandledRef.current = false; + } else if (!hasSession && mode === "register" && !pending) { clearPendingHostedAuth(); clearSignupAtProvision(); confirmHandledRef.current = false; @@ -596,6 +604,10 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) goTo("confirm-email"); } })(); + + return () => { + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); @@ -697,10 +709,9 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) return pending; } - async function discardConflictingPending(): Promise { + async function discardConflictingPendingForSession(sessionEmail: string): Promise { const pending = loadPendingHostedAuth(); - const sessionEmail = await fetchSupabaseSessionEmail(); - if (!pending || !sessionEmail) return; + if (!pending?.email) return; if (!emailsEqualIgnoreCase(pending.email, sessionEmail)) { clearPendingHostedAuth(); } @@ -710,7 +721,16 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) setChooserBusy(true); setError(null); try { - if (actionId === "different_account" || actionId === "start_over") { + if (actionId === "remove_registration") { + clearPendingHostedAuth(); + clearSignupAtProvision(); + chooserResolvedRef.current = true; + setChooserPhase("done"); + goTo("account-type"); + return; + } + + if (actionId === "create_new_account") { let signOutSucceeded = true; if (await hasSupabaseSession()) { try { @@ -738,44 +758,82 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) setEmail(""); setPassword(""); setConfirmPassword(""); - setHandle(mode === "login" ? (loadOwnerHandle() ?? "") : ""); + setHandle(""); + chooserResolvedRef.current = true; setChooserPhase("done"); - goTo(mode === "login" ? "credentials" : "account-type"); + goTo("account-type"); return; } - if (actionId === "resume_pending") { + if (actionId === "continue_registration") { + const pending = loadPendingHostedAuth(); + const pendingEmail = pending?.email?.trim() ?? ""; + const sessionEmail = (await fetchSupabaseSessionEmail()) ?? ""; + if ( + sessionEmail && + pendingEmail && + !emailsEqualIgnoreCase(sessionEmail, pendingEmail) + ) { + try { + await signOutSupabase(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } + if (await hasSupabaseSession()) { + setError("Could not sign out of the current account. Try again."); + return; + } + } applyPendingFromStorage(); + chooserResolvedRef.current = true; 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"); + const pendingAfter = loadPendingHostedAuth(); + if (pendingAfter?.billingLane === "self_hosted") { + setHosting("self-hosted"); + if (await hasSupabaseSession()) { + goTo("provisioning"); + void runHostedSupabaseProvisioning(); + } else { + goTo("credentials"); + } return; } - - if (pending?.billingLane === "self_hosted") { - setHosting("self-hosted"); - if (!loadFirstRunDone()) { + if (await hasSupabaseSession()) { + if (!(await isSupabaseEmailConfirmed())) { + goTo("confirm-email"); + return; + } + if (await isHostedSubscriptionProvisionable()) { goTo("provisioning"); - void resumeHostedSupabaseSetup(); + void runHostedSupabaseProvisioning(); + return; } + goTo("pay"); return; } + goTo("credentials"); + return; + } + if (actionId === "login_with_session") { + // Confirm live session before discarding any conflicting pending draft. + const hasLiveSession = await hasSupabaseSession(); + const sessionEmail = hasLiveSession + ? ((await fetchSupabaseSessionEmail()) ?? "") + : ""; + if ( + !mayDiscardPendingAfterLoginWithSession({ + hasLiveSession, + sessionEmail, + }) + ) { + setError("Session expired. Create a new account or try Log in."); + return; + } + await discardConflictingPendingForSession(sessionEmail); + chooserResolvedRef.current = true; + setChooserPhase("done"); setHosting("hosted"); if (!(await isSupabaseEmailConfirmed())) { @@ -786,11 +844,9 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) if (await isHostedSubscriptionProvisionable()) { if (!loadFirstRunDone()) { goTo("provisioning"); - if (pending?.kind === "login" || mode === "login") { - void finishHostedSupabaseLogin(); - } else { - void resumeHostedSupabaseSetup(); - } + void finishHostedSupabaseLogin(); + } else { + navigateAfterAuthSuccess(); } return; } @@ -1538,9 +1594,15 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) } } + // Stale register mount must never paint the interstitial on Login (diff F-1). + const showRegisterChooser = shouldRenderRegisterChooser({ mode, chooserPhase }); + const showWizardBody = + chooserPhase === "done" || (chooserPhase === "show" && mode !== "register"); + const showChooserPending = chooserPhase === "pending" && !showWizardBody; + const title = - chooserPhase === "show" - ? "Continue as…" + showRegisterChooser + ? "Create account" : mode === "login" ? "Log in" : registerWizardTitle({ step, needsPay: hostedRegisterNeedsPay() }); @@ -1942,33 +2004,20 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps)
- {chooserPhase === "pending" ? ( + {showChooserPending ? (

Checking signed-in account…

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

- {chooserPrimaryEmail || "Your account"} -

- {chooserConflictEmail ? ( - <> -

- {chooserConflictCopy(chooserPrimaryEmail, chooserConflictEmail)} -

- {chooserFinishPayHint ? ( -

- You’re signed in — finish payment to open Atom. -

- ) : null} - - ) : ( -

- {chooserFinishPayHint - ? "You’re signed in — finish payment to open Atom." - : "You’re already signed in on this device."} -

- )} +

Already started?

+

+ {registerChooserBody({ + sessionEmail: chooserSessionEmail, + pendingEmail: chooserPendingEmail, + emailsMatch: chooserEmailsMatch, + })} +

{error ?

{error}

: null}
{chooserActionList.map((action) => ( @@ -1983,17 +2032,14 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) disabled={chooserBusy} onClick={() => void handleChooserAction(action.id)} > - {chooserActionButtonLabel({ - action, - primaryEmail: chooserPrimaryEmail, - })} + {chooserActionButtonLabel({ action })} ))}
) : null} - {chooserPhase === "done" ? ( + {showWizardBody ? ( <> diff --git a/apps/shell/src/auth/accountChooser.test.ts b/apps/shell/src/auth/accountChooser.test.ts index 291da1a..e5791bf 100644 --- a/apps/shell/src/auth/accountChooser.test.ts +++ b/apps/shell/src/auth/accountChooser.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { chooserActionButtonLabel, - chooserActions, - chooserConflictCopy, emailsEqualIgnoreCase, mayClearLocalSignupState, - resolveChooserIdentity, + mayDiscardPendingAfterLoginWithSession, + registerChooserActions, + registerChooserBody, + resolveRegisterChooserIdentity, shouldBypassChooser, - shouldShowChooser, + shouldRenderRegisterChooser, + shouldShowRegisterChooser, } from "./accountChooser.js"; describe("shouldBypassChooser", () => { @@ -30,91 +32,176 @@ describe("shouldBypassChooser", () => { }); }); -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("shouldShowRegisterChooser", () => { + it("never shows for login even with session and pending", () => { + expect( + shouldShowRegisterChooser({ + mode: "login", + bypass: false, + hasSession: true, + hasPending: true, + chooserResolved: false, + }), + ).toBe(false); }); -}); -describe("resolveChooserIdentity", () => { - it("prefers session and flags pending conflict", () => { + it("shows for register with session or pending unless bypassed or resolved", () => { expect( - resolveChooserIdentity({ - sessionEmail: "a@example.com", - pendingEmail: "b@example.com", + shouldShowRegisterChooser({ + mode: "register", + bypass: false, + hasSession: true, + hasPending: false, + chooserResolved: false, }), - ).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"); + shouldShowRegisterChooser({ + mode: "register", + bypass: true, + hasSession: true, + hasPending: true, + chooserResolved: false, + }), + ).toBe(false); + expect( + shouldShowRegisterChooser({ + mode: "register", + bypass: false, + hasSession: true, + hasPending: true, + chooserResolved: true, + }), + ).toBe(false); + }); +}); + +describe("shouldRenderRegisterChooser", () => { + it("never renders on login even if phase is show (stale mount)", () => { + expect(shouldRenderRegisterChooser({ mode: "login", chooserPhase: "show" })).toBe(false); + }); + + it("renders only for register + show", () => { + expect(shouldRenderRegisterChooser({ mode: "register", chooserPhase: "show" })).toBe(true); + expect(shouldRenderRegisterChooser({ mode: "register", chooserPhase: "done" })).toBe(false); + }); +}); + +describe("mayDiscardPendingAfterLoginWithSession", () => { + it("requires live session and non-empty email before discard", () => { + expect( + mayDiscardPendingAfterLoginWithSession({ hasLiveSession: false, sessionEmail: "a@x.com" }), + ).toBe(false); + expect( + mayDiscardPendingAfterLoginWithSession({ hasLiveSession: true, sessionEmail: "" }), + ).toBe(false); + expect( + mayDiscardPendingAfterLoginWithSession({ hasLiveSession: true, sessionEmail: "a@x.com" }), + ).toBe(true); }); }); -describe("chooserActions", () => { - it("unpaid register session offers Continue as + Different (not Complete signup)", () => { - const actions = chooserActions({ - mode: "register", +describe("registerChooserActions", () => { + it("conflict X≠Y offers four distinct actions", () => { + const actions = registerChooserActions({ hasSession: true, + sessionEmail: "a@x.com", pendingKind: "register", - provisionable: false, + pendingEmail: "b@y.com", }); - expect(actions.map((a) => a.id)).toEqual(["complete_signup", "different_account"]); - expect(actions[0]?.label).toBe("Continue as"); + expect(actions.map((a) => a.id)).toEqual([ + "login_with_session", + "continue_registration", + "remove_registration", + "create_new_account", + ]); }); - it("pending-only offers Resume + Start over, not Log in as", () => { - const actions = chooserActions({ - mode: "register", + it("same email omits duplicate Continue registration", () => { + const actions = registerChooserActions({ + hasSession: true, + sessionEmail: "a@x.com", + pendingKind: "register", + pendingEmail: "a@x.com", + }); + expect(actions.map((a) => a.id)).toEqual([ + "login_with_session", + "remove_registration", + "create_new_account", + ]); + }); + + it("pending-only offers Continue Remove Create new", () => { + const actions = registerChooserActions({ hasSession: false, + sessionEmail: null, pendingKind: "register", - provisionable: null, + pendingEmail: "p@x.com", }); - expect(actions.map((a) => a.id)).toEqual(["resume_pending", "start_over"]); + expect(actions.map((a) => a.id)).toEqual([ + "continue_registration", + "remove_registration", + "create_new_account", + ]); }); - it("unpaid login session offers Continue as then Different", () => { - const actions = chooserActions({ - mode: "login", - hasSession: true, - pendingKind: null, - provisionable: false, + it("login-kind pending on register offers Remove + Create new only", () => { + const actions = registerChooserActions({ + hasSession: false, + sessionEmail: null, + pendingKind: "login", + pendingEmail: "p@x.com", + }); + expect(actions.map((a) => a.id)).toEqual(["remove_registration", "create_new_account"]); + }); +}); + +describe("resolveRegisterChooserIdentity", () => { + it("exposes both emails without preferring discard", () => { + expect( + resolveRegisterChooserIdentity({ + sessionEmail: "a@x.com", + pendingEmail: "b@y.com", + }), + ).toEqual({ + sessionEmail: "a@x.com", + pendingEmail: "b@y.com", + emailsMatch: false, }); - expect(actions.map((a) => a.id)).toEqual(["complete_signup", "different_account"]); - expect(actions[0]?.label).toBe("Continue as"); + }); + + it("emailsMatch is false when only one side is present", () => { + expect( + resolveRegisterChooserIdentity({ sessionEmail: "a@x.com", pendingEmail: null }).emailsMatch, + ).toBe(false); }); }); -describe("chooserConflictCopy", () => { - it("explains signed-in wins and draft is discarded", () => { - const copy = chooserConflictCopy("a@x.com", "b@y.com"); - expect(copy).toContain("signed in as a@x.com"); +describe("registerChooserBody", () => { + it("explains logged-in vs unfinished registration without Complete signup", () => { + const copy = registerChooserBody({ + sessionEmail: "a@x.com", + pendingEmail: "b@y.com", + emailsMatch: false, + }); + expect(copy).toContain("logged in as a@x.com"); expect(copy).toContain("b@y.com"); - expect(copy).toContain("discards that draft"); expect(copy.toLowerCase()).not.toContain("complete signup"); }); }); describe("chooserActionButtonLabel", () => { - it("uses Continue as email for signed-in primary", () => { + it("includes email for login_with_session", () => { expect( chooserActionButtonLabel({ - action: { id: "complete_signup", label: "Continue as", primary: true }, - primaryEmail: "a@x.com", + action: { + id: "login_with_session", + label: "Log in with this account", + email: "a@x.com", + primary: true, + }, }), - ).toBe("Continue as a@x.com"); + ).toBe("Log in with this account (a@x.com)"); }); }); @@ -129,18 +216,6 @@ describe("mayClearLocalSignupState", () => { 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"); + expect(emailsEqualIgnoreCase("a@x.com", "b@y.com")).toBe(false); }); }); diff --git a/apps/shell/src/auth/accountChooser.ts b/apps/shell/src/auth/accountChooser.ts index 52f2d3f..5535973 100644 --- a/apps/shell/src/auth/accountChooser.ts +++ b/apps/shell/src/auth/accountChooser.ts @@ -1,15 +1,16 @@ import type { AuthWizardMode } from "./authSteps.js"; export type ChooserActionId = - | "complete_signup" - | "continue_session" - | "resume_pending" - | "different_account" - | "start_over"; + | "login_with_session" + | "continue_registration" + | "remove_registration" + | "create_new_account"; export type ChooserAction = { id: ChooserActionId; label: string; + /** Email shown on the button when set. */ + email?: string; primary?: boolean; }; @@ -24,15 +25,41 @@ export function shouldBypassChooser(opts: { return false; } -export function shouldShowChooser(opts: { +/** + * Register-only interstitial (SIGNUP-CHOOSER-02). + * Login must never show this — mode must be register. + */ +export function shouldShowRegisterChooser(opts: { + mode: AuthWizardMode; bypass: boolean; hasSession: boolean; hasPending: boolean; + chooserResolved: boolean; }): boolean { - if (opts.bypass) return false; + if (opts.mode !== "register") return false; + if (opts.bypass || opts.chooserResolved) return false; return opts.hasSession || opts.hasPending; } +/** Render gate: stale register mount must not paint chooser on Login. */ +export function shouldRenderRegisterChooser(opts: { + mode: AuthWizardMode; + chooserPhase: "pending" | "show" | "done"; +}): boolean { + return opts.mode === "register" && opts.chooserPhase === "show"; +} + +/** + * login_with_session may clear a conflicting pending draft only after a live + * session email is confirmed (diff F-2). + */ +export function mayDiscardPendingAfterLoginWithSession(opts: { + hasLiveSession: boolean; + sessionEmail: string | null | undefined; +}): boolean { + return opts.hasLiveSession && Boolean((opts.sessionEmail ?? "").trim()); +} + export function emailsEqualIgnoreCase(a: string | null | undefined, b: string | null | undefined): boolean { const left = (a ?? "").trim().toLowerCase(); const right = (b ?? "").trim().toLowerCase(); @@ -40,97 +67,116 @@ export function emailsEqualIgnoreCase(a: string | null | undefined, b: string | return left === right; } -/** Session email wins; flag conflict when pending differs. */ -export function resolveChooserIdentity(opts: { +export function resolveRegisterChooserIdentity(opts: { sessionEmail: string | null; pendingEmail: string | null; }): { - primaryEmail: string; - conflictEmail: string | null; + sessionEmail: string | null; + pendingEmail: 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 }; + const session = (opts.sessionEmail ?? "").trim() || null; + const pending = (opts.pendingEmail ?? "").trim() || null; + // emailsMatch is true only when both sides are present and equal; otherwise false (not N/A). + return { + sessionEmail: session, + pendingEmail: pending, + emailsMatch: Boolean(session && pending && emailsEqualIgnoreCase(session, pending)), + }; } /** - * Visible chooser actions from mode × session × pending × provisionable. - * provisionable null = unknown (treat unpaid-safe for complete/continue labels). + * Register interstitial actions — never used for login mode. */ -export function chooserActions(opts: { - mode: AuthWizardMode; +export function registerChooserActions(opts: { hasSession: boolean; + sessionEmail: string | null; pendingKind: "register" | "login" | null; - provisionable: boolean | null; + pendingEmail: string | null; }): ChooserAction[] { - const unpaid = opts.provisionable !== true; const actions: ChooserAction[] = []; + const session = (opts.sessionEmail ?? "").trim() || null; + const pendingEmail = (opts.pendingEmail ?? "").trim() || null; + const sameEmail = + Boolean(session && pendingEmail && emailsEqualIgnoreCase(session, pendingEmail)); - if (opts.hasSession) { - // Already signed in — never say "Complete signup for …" (reads as register again). - if (unpaid) { - actions.push({ - id: "complete_signup", - label: "Continue as", - primary: true, - }); - } else { - actions.push({ - id: "continue_session", - label: "Continue as", - primary: true, - }); - } - actions.push({ id: "different_account", label: "Use a different account" }); - return actions; + if (opts.hasSession && session) { + actions.push({ + id: "login_with_session", + label: "Log in with this account", + email: session, + primary: true, + }); } - // Pending only — no session - if (opts.pendingKind === "register") { + // Continue registration only for register-kind pending; omit when same email as session + // (Log in with this account already covers finishing that identity). + if (opts.pendingKind === "register" && pendingEmail && !sameEmail) { actions.push({ - id: "resume_pending", - label: "Resume signup for", - primary: true, + id: "continue_registration", + label: "Continue this registration", + email: pendingEmail, + primary: !opts.hasSession, }); } - actions.push({ id: "start_over", label: "Start over", primary: actions.length === 0 }); - return actions; -} -/** Body copy when signed-in email ≠ unfinished local draft. */ -export function chooserConflictCopy(sessionEmail: string, draftEmail: string): string { - return ( - `You’re signed in as ${sessionEmail}. This device also has an unfinished signup draft ` + - `for ${draftEmail}. Continuing uses the signed-in account and discards that draft.` - ); + if (opts.pendingKind === "register" || opts.pendingKind === "login") { + if (pendingEmail || opts.pendingKind) { + actions.push({ + id: "remove_registration", + label: pendingEmail + ? `Remove this registration` + : "Remove this registration", + email: pendingEmail ?? undefined, + }); + } + } + + actions.push({ + id: "create_new_account", + label: "Create a new account", + primary: actions.length === 0, + }); + + return actions; } -/** Button caption: session actions use the email after “Continue as”. */ export function chooserActionButtonLabel(opts: { action: ChooserAction; - primaryEmail: string; }): string { - const email = opts.primaryEmail.trim(); + const email = opts.action.email?.trim(); if (!email) return opts.action.label; - if ( - opts.action.id === "complete_signup" || - opts.action.id === "continue_session" || - opts.action.id === "resume_pending" - ) { - return `${opts.action.label} ${email}`; + if (opts.action.id === "login_with_session") { + return `${opts.action.label} (${email})`; + } + if (opts.action.id === "continue_registration" || opts.action.id === "remove_registration") { + return `${opts.action.label} (${email})`; } return opts.action.label; } +export function registerChooserBody(opts: { + sessionEmail: string | null; + pendingEmail: string | null; + emailsMatch: boolean; +}): string { + const session = opts.sessionEmail?.trim() || null; + const pending = opts.pendingEmail?.trim() || null; + if (session && pending && !opts.emailsMatch) { + return ( + `You’re currently logged in as ${session}. There’s also an unfinished registration ` + + `for ${pending} on this device. Choose what to do next.` + ); + } + if (session) { + return `You’re currently logged in as ${session}. Log in with this account, or create a new one.`; + } + if (pending) { + return `There’s an unfinished registration for ${pending} on this device.`; + } + return "Choose how to continue."; +} + /** Fail-closed: only clear local signup state after sign-out succeeded. */ export function mayClearLocalSignupState(opts: { signOutSucceeded: boolean;