diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index db861b7..eb3de6c 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -452,6 +452,7 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe | TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. | | TC-WEB-5.25.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. | | TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. | +| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. | | TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). | | TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. | | TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. | diff --git a/src/__tests__/portal.test.tsx b/src/__tests__/portal.test.tsx index d73629e..f62fccd 100644 --- a/src/__tests__/portal.test.tsx +++ b/src/__tests__/portal.test.tsx @@ -1057,4 +1057,75 @@ describe("OOBE portal-creation flow (GRO-2359)", () => { Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); }); + + it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => { + // Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets, + // Report Cards, Billing, Messages, Settings) had no visible sign-out + // control — only the OOBE and the no-access card exposed one. This + // leaves users signed-in with no escape hatch. The fix lands a + // "Sign out" button in the sidebar footer that wires to the same + // canonical `signOut()` already used by OOBE / no-access / AdminLayout. + signOutSpy.mockClear(); + + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + configurable: true, + }); + + 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: "uat-customer@groombook.dev", role: "customer" } }), + } as Response); + } + if (url === "/api/portal/session-from-auth" && init?.method === "POST") { + return Promise.resolve({ + ok: true, + status: 201, + json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as unknown as typeof fetch; + + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + // Land on the chrome (proof: customer greeting is rendered, no + // no-access card, no OOBE). + await waitFor(() => { + expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument(); + }); + expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument(); + + // The new chrome sign-out is scoped by data-testid so it doesn't + // collide with other surfaces that may also render "Sign out" labels + // (e.g. the impersonation banner uses "End Session"). + const signOutButton = screen.getByTestId("portal-chrome-signout"); + expect(signOutButton).toHaveTextContent(/Sign out/i); + fireEvent.click(signOutButton); + + // Same canonical handler as OOBE / no-access / AdminLayout — never + // a raw fetch("/api/auth/sign-out") and never a navigate() without + // signOut() (the OOBE/no-access surface uses window.location.href + // for a hard reload so cached state is reset). + await waitFor(() => { + expect(signOutSpy).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(window.location.href).toBe("/login"); + }); + + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); + }); }); diff --git a/src/portal/CustomerPortal.tsx b/src/portal/CustomerPortal.tsx index 45154aa..7a0e752 100644 --- a/src/portal/CustomerPortal.tsx +++ b/src/portal/CustomerPortal.tsx @@ -451,7 +451,14 @@ export function CustomerPortal() { })} - {/* Session controls (only shown during active impersonation) */} + {/* Session controls — Sign out is always reachable from the portal + chrome (GRO-2373). End Impersonation is staff-only and only + appears during an active impersonation session. Both share the + same LogOut icon for visual consistency, but route to distinct + handlers: handleSignOut calls the canonical Better Auth + `signOut()` (mirroring OOBE and the no-access card); handleEnd + tears down the staff impersonation session and returns to the + admin clients list. */}
{session?.status === "active" && (
Customer Portal v1.0