diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index fb51993..f3daa58 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -377,6 +377,26 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe | TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. | | TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". | +### 5.27 Customer Portal — Authenticated HTML-route cold mount (GRO-2099) + +These cases guard against the regression where a customer who had just completed SSO sign-in was bounced back to `/login` (with a blank React root) when navigating directly to `/portal`, `/book`, `/schedule`, or even `/login` itself. Root cause: `Dashboard.tsx`'s `!sessionId && !isImpersonating && !getDevUser()` guard fired during the CustomerPortal's bootstrap — before the SSO bridge resolved `portalSessionId` — and redirected to `/login`. The fix: `CustomerPortal` now shows a loading state while the bootstrap is in flight, so the portal chrome and its `!sessionId` child guards do not mount prematurely. App.tsx additionally redirects an authenticated user at `/login` to `/` instead of rendering `null`. + +**Pre-conditions:** + +- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test. +- Clear cookies and localStorage between cases. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.27.1 | Authenticated customer lands on `/portal` after direct nav | 1. From clean state, complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. Land on `/`. 3. `browser_navigate` (full page load) directly to `/portal`. | Final URL stays at `/portal`. The React root is non-empty. The portal dashboard renders with the customer's name. No `Navigate to /login` fires. | +| TC-WEB-5.27.2 | Authenticated customer lands on `/book` and `/schedule` after direct nav | From TC-WEB-5.27.1, `browser_navigate` to `/book` then `/schedule` (one fresh navigation each). | Each final URL stays at the navigated path. The portal chrome is visible. The page does not redirect to `/login`. | +| TC-WEB-5.27.3 | Authenticated customer at `/login` is auto-redirected to `/` | From TC-WEB-5.27.1, `browser_navigate` to `/login`. | The browser ends at `/` (not at a blank `/login`). The portal dashboard renders. No blank React root at `/login`. | +| TC-WEB-5.27.4 | Loading state is visible during the bootstrap, no portal chrome flash | 1. With the UAT build under test, open DevTools → Network and throttle to Slow 3G. 2. Sign in via SSO. 3. Land on `/`. | A "Loading…" element (`role="status"`) is briefly visible. The portal nav (Home / Appointments / etc.) is NOT visible during the loading window. No `Navigate to /login` fires during the bootstrap. | +| TC-WEB-5.27.5 | SSO bridge still runs and yields 201 | From TC-WEB-5.27.4 (or TC-WEB-5.27.1), inspect Network. | The same `GET /api/auth/get-session` (200) → `POST /api/portal/session-from-auth` (201) sequence from TC-WEB-5.25.2 still runs. The customer name appears in the greeting. | +| TC-WEB-5.27.6 | Unauthenticated direct nav to `/portal` still ends at `/login` (no regression) | Clear cookies. `browser_navigate` to `/portal`. | The portal briefly shows the loading state, then `CustomerPortal`'s `!session && !portalSessionId` guard redirects to `/login`. The login form renders. No infinite loop. | +| TC-WEB-5.27.7 | Groomer SSO still works (no regression) | 1. From clean state, sign in via SSO as the groomer identity (uat-groomer). 2. Land on `/`. | `App.tsx`'s staff check redirects to `/admin`. The groomer nav renders. No `CustomerPortal` flash. No `/portal` redirect loop. | +| TC-WEB-5.27.8 | Impersonation session still works (no regression) | 1. With an active impersonation session, open `/?sessionId=`. | The amber "STAFF VIEW" chrome renders. The portal loads. No `/login` redirect. | + ### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012) These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `` (matching the fallback `renderSection()` already used). diff --git a/src/App.tsx b/src/App.tsx index 1bbb17f..b97da60 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -378,8 +378,12 @@ export function App() { return ; } - // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users) - if (!authDisabled && !session) { + // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users). + // At /login with a valid session, fall through so the staff redirect below can + // route staff to /admin and the final render can redirect customers to / (portal). + // Previously, an authenticated customer at /login would see a blank page because + // the final render returns null at /login (showCustomerPortal is false). See GRO-2099. + if (!authDisabled && !session && location.pathname === "/login") { return ; } @@ -401,6 +405,14 @@ export function App() { // Don't render portal chrome at /login — DevLoginSelector is shown instead const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login"; + // At /login with a valid session, redirect to the portal root. Without this, + // the final render returns null at /login (showCustomerPortal is false) and + // the user sees a blank page after a successful sign-in. Staff are routed + // to /admin by the earlier staff check. See GRO-2099. + if (!authDisabled && session && location.pathname === "/login") { + return ; + } + return ( {location.pathname.startsWith("/admin") ? ( diff --git a/src/__tests__/portal.test.tsx b/src/__tests__/portal.test.tsx index 96f1adf..73e0a92 100644 --- a/src/__tests__/portal.test.tsx +++ b/src/__tests__/portal.test.tsx @@ -558,4 +558,58 @@ describe("CustomerPortal SSO bridge", () => { expect(lastProps!.sessionId).toBe("sso-sess-1"); expect(lastProps!.appointment.id).toBe("appt-1"); }); + + // GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard) + // must NOT render before the SSO bridge resolves. A loading state must be + // shown instead. Previously, the Dashboard's redirect-to-/login guard fired + // mid-bootstrap, leaving the user with a blank page after sign-in. + it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => { + // Slow bridge: resolve get-session and session-from-auth after a tick so + // we can observe the loading state mid-bootstrap. + let resolveBridge!: (value: Response) => void; + const bridgePromise = new Promise((resolve) => { + resolveBridge = resolve; + }); + + global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/branding") return Promise.resolve(brandingResponse); + if (url === "/api/auth/get-session") { + return Promise.resolve({ + ok: true, + json: async () => ({ user: { email: "customer@example.com", role: "customer" } }), + } as Response); + } + if (url === "/api/portal/session-from-auth" && init?.method === "POST") { + return bridgePromise; + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as unknown as typeof fetch; + + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + // Loading state is visible while the bridge is in flight. The portal nav + // (Home / Appointments / etc.) must NOT be present — its presence would + // indicate the chrome is rendering with a null session, which is the + // pre-GRO-2099 bug. + expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i); + expect(screen.queryByText("Home")).not.toBeInTheDocument(); + expect(screen.queryByText("Appointments")).not.toBeInTheDocument(); + + // Resolve the bridge and confirm the portal renders normally. + resolveBridge({ + ok: true, + status: 201, + json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }), + } as Response); + + await waitFor(() => { + expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument(); + }); + }); }); diff --git a/src/portal/CustomerPortal.tsx b/src/portal/CustomerPortal.tsx index 3b284b7..acb2f44 100644 --- a/src/portal/CustomerPortal.tsx +++ b/src/portal/CustomerPortal.tsx @@ -241,13 +241,31 @@ export function CustomerPortal() { const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); + // Show a loading state while the SSO bridge is in progress. The portal chrome + // and its sections (e.g. Dashboard) assume a session is established and run + // their own auth guards — rendering them before the bridge resolves triggers + // a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the + // post-sign-in flow. Once `initComplete` is true we know whether a session was + // established and can render the correct branch. See GRO-2099. + if (!initComplete) { + return ( +
+
Loading…
+
+ ); + } + // After init completes, redirect unauthenticated users to /login and staff to /admin. // The portal chrome must NEVER be visible to users without a valid client session. // For client dev users, we stay on the portal even if session is null — the dev-session // response may not have id set immediately, or there may be timing issues with the // session state. Dev users are verified via localStorage and the dev-session flow. // SSO customers are recognised by portalSessionId (set by the Better Auth bridge). - if (initComplete && !session && !portalSessionId) { + if (!session && !portalSessionId) { if (authError) { // GRO-1867: graceful 404 fallback — authenticated user has no client row. return (