fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap
Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating && !getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session` and then `/api/portal/session-from-auth` to populate `portalSessionId`. During that bootstrap window (typically 100-300ms), `sessionId` is null and the guard fires — redirecting the user to `/login` and breaking the post-sign-in flow. App.tsx additionally returned `null` at `/login` for authenticated users (`showCustomerPortal` is false at `/login`), leaving a blank React root even if the redirect target was /login itself. Fix: - `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while `!initComplete`. The portal chrome and its child sections only mount once the bootstrap has resolved, so child auth guards don't fire prematurely. - `App.tsx`: at `/login` with a valid session, redirect to `/` so the customer lands on the portal instead of seeing a blank page. - `App.tsx`: only return `LoginPage` when at `/login` — other portal routes defer the auth check to `CustomerPortal` (the customer SSO bridge resolves `portalSessionId` on mount). - `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading state, the /login auto-redirect, the unauth fallback, and the groomer / impersonation non-regressions. - `src/__tests__/portal.test.tsx`: add a regression test that asserts the loading state is shown during the bridge and the portal nav is NOT in the DOM mid-bootstrap. Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image `2026.06.01-ec29f71`): 1. From `about:blank`, complete customer SSO as `uat-customer`. 2. `browser_navigate` to `/portal`. 3. Pre-fix: redirected to `/login` with blank React root. 4. Post-fix: URL stays at `/portal`, dashboard renders with customer name. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<Response>((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(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user