From 3d7b247562065e5b21f1caad8d99a78d73b98daa Mon Sep 17 00:00:00 2001 From: Lint Roller <23+gb_lint@noreply.git.farh.net> Date: Mon, 1 Jun 2026 16:36:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(GRO-2011):=20/login=20renders=20blank=20?= =?UTF-8?q?=E2=80=94=20always=20fetch=20setup/status=20for=20unauth=20user?= =?UTF-8?q?s=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lint Roller <23+gb_lint@noreply.git.farh.net> Co-committed-by: Lint Roller <23+gb_lint@noreply.git.farh.net> --- UAT_PLAYBOOK.md | 1 + src/App.tsx | 11 +++++-- src/__tests__/App.test.tsx | 59 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index e6dbc30..271b295 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -53,6 +53,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established | | TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page | | TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session | +| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). | ### 5.2 Authentication — VITE_API_URL Set diff --git a/src/App.tsx b/src/App.tsx index 30d2091..1bbb17f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -327,11 +327,16 @@ export function App() { .catch(() => setAuthDisabled(false)); }, []); - // After session is confirmed, check if setup is needed + // After session is confirmed, check if setup is needed. + // Always run the setup/status fetch as soon as the auth state is known — even for + // unauthenticated users, so the `needsSetup` value is in place if they sign in + // mid-session. The unauth branch in the render below is handled before + // `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state. + // See GRO-2011. useEffect(() => { if (authDisabled === null || sessionLoading) return; - // Skip if no authenticated session (will redirect to login or dev selector) - if (!authDisabled && !session) return; + // In dev mode, only fetch when a dev user has been selected — otherwise the + // user is mid-redirect to the dev login selector and we don't need setup state. if (authDisabled && !getDevUser()) return; fetch("/api/setup/status") diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index ea5aea8..edddc4f 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -121,6 +121,65 @@ describe("App navigation", () => { }); }); +describe("GRO-2011 — setup/status fetch for unauthenticated users", () => { + it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => { + const setupStatusCalls: string[] = []; + + global.fetch = vi.fn((url: string) => { + if (url === "/api/dev/config") { + return Promise.resolve({ + ok: true, + json: async () => ({ authDisabled: false }), + } as Response); + } + if (url === "/api/auth/get-session") { + // Better Auth returns 200 with null session for unauthenticated users. + return Promise.resolve({ + ok: true, + json: async () => null, + } as unknown as Response); + } + if (url === "/api/setup/status") { + setupStatusCalls.push(url); + return Promise.resolve({ + ok: true, + json: async () => ({ needsSetup: false }), + } as Response); + } + if (url === "/api/branding") { + return Promise.resolve({ + ok: true, + json: async () => ({ + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, + }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + + render( + + + + ); + + // The login page should be rendered for the unauthenticated user. + await screen.findByText("Sign in to continue"); + + // Crucially, /api/setup/status must be called even when the user is unauthenticated — + // otherwise `needsSetup` stays null and a later code path can short-circuit to a + // blank page (GRO-2011). + await waitFor(() => { + expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1); + }); + expect(setupStatusCalls[0]).toBe("/api/setup/status"); + }); +}); + describe("Dev login selector", () => { it("redirects to /login when auth is disabled and no user selected", async () => { global.fetch = vi.fn((url: string) => {