From 7526cb1d677c50caf40de2c6bd34eebe37bf9e46 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 1 Jun 2026 16:02:46 +0000 Subject: [PATCH] fix(GRO-2011): always fetch /api/setup/status, even for unauth users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second useEffect in App skipped the setup/status fetch when `!authDisabled && !session` was true. In the deployed bundle the `needsSetup` state therefore stayed `null` for unauth users, and a later render short-circuit rendered nothing — producing the blank white viewport at https://uat.groombook.dev/login. Drop the unauth skip clause so `/api/setup/status` is always fetched as soon as the auth state is known. The unauth branch in the render is handled before `needsSetup` is consulted, so this is safe and removes the stuck-`null` state. Adds: - New unit test in src/__tests__/App.test.tsx asserting the unauthenticated path calls /api/setup/status. - UAT playbook entry TC-WEB-5.1.5 covering the blank-viewport regression scenario. Co-Authored-By: Paperclip --- 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) => { -- 2.52.0