From 661bd4f90255d9b56f7e96a4f4a717470c186b86 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Thu, 11 Jun 2026 15:43:32 +0000 Subject: [PATCH] =?UTF-8?q?Promote=20uat=20=E2=86=92=20main=20(PROD):=20GR?= =?UTF-8?q?O-2358=20logout=20on=20no-access=20screen=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote uat → main (PROD): GRO-2358 — restore logout on 'Portal access not configured' screen. Squashed from uat-to-main/GRO-2358 (0d24fe0). Cherry-pick of validated uat squash bfe3ccf. Pre-merge gates green: CI (Lint+Typecheck 30s, Test 23s, Docker Build 11s); CTO Gitea review APPROVED (comment 13465); QA GRO-2362 done; UAT GRO-2363 4/4 PASS on git.farh.net/groombook/web:2026.06.11-bfe3ccf; Security GRO-2364 cleared. Head branch uat-to-main/GRO-2358 retained for Flea's post-deploy verification. Refs GRO-2358, GRO-2362, GRO-2363, GRO-2364, GRO-2367. Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> --- UAT_PLAYBOOK.md | 3 +- src/__tests__/portal.test.tsx | 140 ++++++++++++++++++++++++++++++++++ src/portal/CustomerPortal.tsx | 23 ++++-- 3 files changed, 157 insertions(+), 9 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 6cf326d..65eb6d5 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -447,7 +447,8 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe | TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. | | TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. | | TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. | -| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). | +| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch (GRO-2358) | From TC-WEB-5.25.5 click **Sign out**. | The shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout`); browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). 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.6b | 404 fallback Sign-out on deep-link (GRO-2358) | From TC-WEB-5.25.5, instead of staying on `/`, navigate directly to a portal sub-route (e.g. `/appointments`, `/pets`, `/billing`). The no-access card renders. Click **Sign out**. | The same shared `signOut()` handler fires and the browser navigates to `/login`. The no-access screen must surface an escape hatch on every authenticated route — not just `/` — so a stale or deep link into a portal the user has no access to can never trap them. | | 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 73e0a92..fdff7dd 100644 --- a/src/__tests__/portal.test.tsx +++ b/src/__tests__/portal.test.tsx @@ -21,6 +21,18 @@ vi.mock("../portal/sections/Appointments.js", async () => { }; }); +// Spy on the canonical `signOut()` from the shared auth-client so we can +// assert the no-access screen's logout button uses the SAME handler as +// `AdminLayout`. We mock at the module boundary — the no-access screen is +// the one authenticated surface that renders without the portal chrome, so +// a regression here would trap the user. We do NOT use `importActual` +// because the real `createAuthClient()` requires a runtime `baseURL` +// (Better Auth) that the JSDOM test environment can't supply. +const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined)); +vi.mock("../lib/auth-client.js", () => ({ + signOut: signOutSpy, +})); + const SESSION: ImpersonationSession = { id: "sess-1", staffId: "staff-1", @@ -336,6 +348,8 @@ describe("CustomerPortal SSO bridge", () => { beforeEach(() => { // Make sure no dev-user leaks across tests window.localStorage.clear(); + // Reset shared signOut() spy so per-test counts are deterministic + signOutSpy.mockClear(); }); const brandingResponse = { @@ -429,6 +443,132 @@ describe("CustomerPortal SSO bridge", () => { expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument(); }); + it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => { + // Reset the spy so previous tests don't leak into this assertion. + signOutSpy.mockClear(); + + // JSDOM throws on window.location.href assignment by default; swap in a + // writable stub so the navigation is observable, then restore after. + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + configurable: true, + }); + + global.fetch = vi.fn((input: RequestInfo) => { + 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: "stranger@example.com", role: "customer" } }), + } as Response); + } + if (url === "/api/portal/session-from-auth") { + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({ error: "No client record found for this user" }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as unknown as typeof fetch; + + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument(); + }); + + // Pre-condition: the shared signOut() must NOT have been called yet — the + // no-access screen is mounted because the bridge failed, not because the + // user clicked anything. + expect(signOutSpy).not.toHaveBeenCalled(); + + // Drive the click. The handler is the SAME `signOut()` exported from + // auth-client that AdminLayout uses, so verifying this call is enough to + // prove the no-access screen reaches the canonical sign-out surface. + const signOutButton = screen.getByRole("button", { name: /Sign out/i }); + fireEvent.click(signOutButton); + + await waitFor(() => { + expect(signOutSpy).toHaveBeenCalledTimes(1); + }); + + // 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. + await waitFor(() => { + expect(window.location.href).toBe("/login"); + }); + + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); + }); + + it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => { + // AC requires verifying the SAME logout handler is reachable from at + // least one other authenticated surface — here a deep link to a portal + // sub-route (e.g. /appointments) for a user with a Better Auth session + // but no client record. The no-access screen is the only authenticated + // surface without a route guard, so the handler must fire identically. + signOutSpy.mockClear(); + + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + configurable: true, + }); + + global.fetch = vi.fn((input: RequestInfo) => { + 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: "stranger@example.com", role: "customer" } }), + } as Response); + } + if (url === "/api/portal/session-from-auth") { + return Promise.resolve({ + ok: false, + status: 404, + json: async () => ({ error: "No client record found for this user" }), + } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as unknown as typeof fetch; + + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument(); + }); + + const signOutButton = screen.getByRole("button", { name: /Sign out/i }); + fireEvent.click(signOutButton); + + await waitFor(() => { + expect(signOutSpy).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(window.location.href).toBe("/login"); + }); + + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); + }); + it("does not call session-from-auth when there is no Better Auth session", async () => { global.fetch = vi.fn((input: RequestInfo) => { const url = typeof input === "string" ? input : input.toString(); diff --git a/src/portal/CustomerPortal.tsx b/src/portal/CustomerPortal.tsx index acb2f44..e91b4a4 100644 --- a/src/portal/CustomerPortal.tsx +++ b/src/portal/CustomerPortal.tsx @@ -15,6 +15,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import { useBranding } from "../BrandingContext.js"; import { getDevUser } from "../pages/DevLoginSelector.js"; +import { signOut } from "../lib/auth-client.js"; import type { ImpersonationSession } from "@groombook/types"; import type { Appointment as PortalAppointment } from "./sections/Appointments.js"; @@ -193,6 +194,19 @@ export function CustomerPortal() { } }, [session]); + // Shared sign-out handler — wires the canonical Better Auth `signOut()` so + // every authenticated surface (no-access screen, portal chrome, etc.) uses + // the same implementation as `AdminLayout`. Failure to reach the server + // still leaves the SPA free to navigate to /login. + const handleSignOut = useCallback(async () => { + try { + await signOut(); + } catch { + // Best-effort; navigate to /login regardless so the user is never trapped. + } + window.location.href = "/login"; + }, []); + const logPageView = useCallback((page: string) => { if (!session) return; void fetch(`/api/impersonation/sessions/${session.id}/log`, { @@ -281,14 +295,7 @@ export function CustomerPortal() {

Portal access not configured

{authError}