diff --git a/apps/web/src/app/mobile-auth/MobileAuthClient.tsx b/apps/web/src/app/mobile-auth/MobileAuthClient.tsx index f3cbe0d70..f0a4f8223 100644 --- a/apps/web/src/app/mobile-auth/MobileAuthClient.tsx +++ b/apps/web/src/app/mobile-auth/MobileAuthClient.tsx @@ -7,7 +7,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; import { useSession } from "@openmapx/core"; import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AuthDialog } from "@/components/auth/AuthDialog"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; @@ -49,6 +49,14 @@ export function MobileAuthClient({ const { data: session, isPending } = useSession(); const [phase, setPhase] = useState("idle"); const authenticated = Boolean(session); + // better-auth reports every refetch of an empty session as pending, and the + // password step triggers one just before it asks for a two-factor code. + // Hiding the dialog then would remount it on its password form, so only the + // first session check keeps it hidden. + const [sessionChecked, setSessionChecked] = useState(!isPending); + useEffect(() => { + if (!isPending) setSessionChecked(true); + }, [isPending]); // Signing in happens here, in the real browser, where OAuth providers and // platform passkeys work. Every sign-in route returns to this exact handoff // so the app's state and challenge survive a provider round trip. @@ -112,7 +120,7 @@ export function MobileAuthClient({ {purposeText} - {!authenticated && !isPending && ( + {!authenticated && sessionChecked && ( <> {t("signInFirst")} diff --git a/apps/web/src/app/mobile-auth/MobileAuthPage.test.tsx b/apps/web/src/app/mobile-auth/MobileAuthPage.test.tsx index 3c041d829..544971ff4 100644 --- a/apps/web/src/app/mobile-auth/MobileAuthPage.test.tsx +++ b/apps/web/src/app/mobile-auth/MobileAuthPage.test.tsx @@ -155,6 +155,37 @@ describe("MobileAuthClient", () => { expect(view.queryByTestId("auth-dialog")).toBeNull(); }); + it("keeps the sign-in dialog mounted while a signed-out session refetches", () => { + // better-auth reports a refetch of an empty session as pending. The password + // step triggers one before it asks for the two-factor code, so unmounting + // here would reset the dialog to its password form. + sessionState.data = null; + const view = renderClient(); + const dialog = view.getByTestId("auth-dialog"); + + sessionState.isPending = true; + view.rerender( + + + , + ); + + expect(view.getByTestId("auth-dialog")).toBe(dialog); + }); + + it("shows no sign-in dialog before the first session check finishes", () => { + sessionState.data = null; + sessionState.isPending = true; + const view = renderClient(); + + expect(view.queryByTestId("auth-dialog")).toBeNull(); + }); + it("issues nothing when the server refuses", async () => { stubIssue({ ok: false }); const view = renderClient();