From 903ce2d675e2ff2f60e7526f891123952e55fce5 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 14:48:03 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(GRO-2089):=20correct=20Authentik=20cust?= =?UTF-8?q?omer=20credential=20source=20in=20UAT=5FPLAYBOOK=20=C2=A75.25?= =?UTF-8?q?=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25 --- .mcp.json | 11 +++++++++++ UAT_PLAYBOOK.md | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6efc1ca --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "gitea": { + "type": "http", + "url": "https://git-mcp.farh.net/mcp", + "headers": { + "Authorization": "Bearer ${GITEA_TOKEN}" + } + } + } +} diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 2a2faae..fb51993 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -354,7 +354,12 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe **Pre-conditions:** -- UAT is configured with Authentik SSO and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory). +- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run: + ```bash + CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \ + -o jsonpath='{.data.uat_customer_password}' | base64 -d) + ``` + The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run. - `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT. - Clear cookies and localStorage between cases unless otherwise noted. -- 2.52.0 From f1cf58dc56774dc538ac029876bf7c6f7fe0d611 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 16:35:16 +0000 Subject: [PATCH 2/3] fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- UAT_PLAYBOOK.md | 20 +++++++++++++ src/App.tsx | 16 +++++++++-- src/__tests__/portal.test.tsx | 54 +++++++++++++++++++++++++++++++++++ src/portal/CustomerPortal.tsx | 20 ++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) 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 ( -- 2.52.0 From 7daa9c480a65c686d67b117632f55c170a56c1c1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 16:05:15 +0000 Subject: [PATCH 3/3] fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle at /login was executing but the React tree never painted — no console errors, no fallback UI, just an empty
. Add three layers of defense so a future failure of this shape is captured instead of being silently swallowed: 1. window 'error' and 'unhandledrejection' listeners in main.tsx, printing structured context to console.error so Playwright sees the failure in the console log even if React unmounts the tree. 2. A top-level in main.tsx that renders the actual exception (name, message, stack) inside the DOM instead of leaving
empty. The boundary also logs to console.error via componentDidCatch. 3. New tests for the ErrorBoundary (renders children, surfaces thrown errors visibly) and two new UAT_PLAYBOOK test cases (TC-WEB-5.1.6 / 5.1.7) that explicitly assert the 'never-blank-root' invariant on UAT. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 2 + src/ErrorBoundary.tsx | 77 ++++++++++++++++++++++++++++ src/__tests__/ErrorBoundary.test.tsx | 54 +++++++++++++++++++ src/main.tsx | 40 +++++++++++++-- 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/ErrorBoundary.tsx create mode 100644 src/__tests__/ErrorBoundary.test.tsx diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f3daa58..58f2b31 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -54,6 +54,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | 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). | +| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `
` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. | +| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. | ### 5.2 Authentication — VITE_API_URL Set diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx new file mode 100644 index 0000000..5e0b730 --- /dev/null +++ b/src/ErrorBoundary.tsx @@ -0,0 +1,77 @@ +import { Component } from "react"; +import type { ErrorInfo, ReactNode } from "react"; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +/** + * Top-level ErrorBoundary — renders the error visibly so the actual exception + * appears in the DOM (and therefore in the Playwright snapshot) instead of + * React 18+ unmounting the entire tree to a blank `
`. + * + * Background: GRO-2094. The bundle was executing but never painting, with + * the failure swallowed. Surfacing the error here is the first step; the + * real fix is in the underlying component that threw. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Also surface to the console — this is what the test harness greps for. + // eslint-disable-next-line no-console + console.error("[ErrorBoundary] Uncaught render error:", error, info); + } + + render() { + if (this.state.error) { + const err = this.state.error; + return ( +
+

Something went wrong

+

+ The app failed to render. The full error is shown below — please share this + output when reporting the bug. +

+
+            {err.name}: {err.message}
+            {"\n\n"}
+            {err.stack ?? "(no stack)"}
+          
+
+ ); + } + return this.props.children; + } +} diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx new file mode 100644 index 0000000..6d6d4c9 --- /dev/null +++ b/src/__tests__/ErrorBoundary.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { ErrorBoundary } from "../ErrorBoundary"; + +function ThrowingChild(): never { + throw new Error("synthetic render-time failure for GRO-2094"); +} + +function GoodChild() { + return
ok
; +} + +describe("ErrorBoundary (GRO-2094)", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + // React 18+ logs caught render errors to console.error via React's own + // instrumentation; suppress it so test output is clean but capture it + // for an assertion below. + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + cleanup(); + }); + + it("renders children when nothing throws", () => { + render( + + + + ); + expect(screen.getByTestId("good-child")).toBeInTheDocument(); + expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument(); + }); + + it("renders the error visibly when a child throws during render", () => { + render( + + + + ); + + const fallback = screen.getByTestId("error-boundary"); + expect(fallback).toBeInTheDocument(); + const message = screen.getByTestId("error-boundary-message"); + // The actual exception is shown — no more silent blank root. + expect(message.textContent).toContain("synthetic render-time failure for GRO-2094"); + // The boundary also calls console.error so it shows up in the Playwright + // console log even if the DOM-rendered fallback is somehow missed. + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 3920a8d..77ffcf1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,41 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App.js"; +import { ErrorBoundary } from "./ErrorBoundary.js"; import { installDevFetchInterceptor } from "./lib/devFetch.js"; import "./index.css"; +// -------------------------------------------------------------------- +// Global error capture (GRO-2094). +// +// Symptom: React root stays empty at /login — bundle parses, no console +// errors, no error boundary fallback. Some failure is being swallowed +// before it reaches React's commit phase. These listeners make sure any +// thrown error or unhandled promise rejection is at least visible in +// the console (and in the Playwright network/console log) instead of +// vanishing into the void. +// -------------------------------------------------------------------- +function reportGlobalError(kind: string, payload: unknown): void { + // eslint-disable-next-line no-console + console.error(`[${kind}]`, payload); +} + +window.addEventListener("error", (event) => { + reportGlobalError("window.error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + }); +}); + +window.addEventListener("unhandledrejection", (event) => { + reportGlobalError("unhandledrejection", { + reason: event.reason, + }); +}); + installDevFetchInterceptor(); const root = document.getElementById("root"); @@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found"); createRoot(root).render( - - - + + + + + ); -- 2.52.0