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..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
@@ -354,7 +356,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.
@@ -372,6 +379,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/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/__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/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(
-
-
-
+
+
+
+
+
);
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 (
+
+ );
+ }
+
// 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 (