Compare commits

..

18 Commits

Author SHA1 Message Date
Flea Flicker affb697708 fix(GRO-2089): correct Authentik customer credential source in §5.25 pre-conditions
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 15s
The UAT_PLAYBOOK §5.25 (Customer Portal — Better Auth SSO Bridge) pre-condition
incorrectly stated that the Authentik customer password comes from
seed-uat-passwords:customer-password. That Secret holds the *Better Auth*
email+password credential — a different identity store. The actual Authentik
uat-customer password lives in authentik-uat-users-credentials:uat_customer_password,
provisioned by infra/terraform/users.tf with lifecycle.ignore_changes = [password].

UAT testers were using the Better Auth value at the Authentik OIDC step and
getting 401'd, blocking GRO-2026. Verified 2026-06-02: pulling the correct
Secret value, signing in via SSO, and POST /api/portal/session-from-auth all
succeed (returns 201 with valid portal session).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 14:40:01 +00:00
Scrubs McBarkley fdff0977ad Merge pull request 'Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback' (#40) from uat into main
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 16s
Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback

Gate checks:
- UAT: GRO-2023 done (CTO verified, ec29f719)
- Security: GRO-2032 Barkley PASS
- UAT_PLAYBOOK.md: TC-WEB-5.26 present

Fix: CustomerPortal.tsx:329 sessionId={session?.id ?? portalSessionId}
Fix commit: f29f1828c8

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:10:08 +00:00
The Dogfather ec29f71974 Merge pull request 'Promote to UAT: GRO-2012 RescheduleFlow portalSessionId fallback' (#39) from dev into uat
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 13s
2026-06-01 17:46:35 +00:00
The Dogfather bd2a0d9516 Merge pull request 'Promote dev -> uat: GRO-2011 login-blank fix (+ GRO-1867)' (#37) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 10s
2026-06-01 16:38:14 +00:00
The Dogfather 0e5e9d1f16 Merge pull request 'chore: promote dev → uat (GRO-1829 SW fix)' (#32) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 15s
Merge: promote dev → uat (GRO-1829 SW fix)
2026-05-27 02:27:32 +00:00
The Dogfather 3b4d0f15f6 Merge pull request 'chore: promote dev → uat (GRO-1795 StatusBadge)' (#28) from dev into uat
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Image (push) Successful in 34s
Merge PR #28: promote dev → uat (GRO-1795 StatusBadge)
2026-05-26 13:23:52 +00:00
The Dogfather 87939e5413 Merge pull request 'chore: promote dev → uat (GRO-1794 booking analytics)' (#27) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 12s
Merge dev → uat: GRO-1794 booking funnel analytics events
2026-05-26 13:16:39 +00:00
The Dogfather 4e3a038bf3 Merge pull request 'Promote dev → uat (GRO-1793: dynamic time slots)' (#25) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 6s
Promote dev → uat: GRO-1793 dynamic portal time slots (#25)
2026-05-26 13:02:16 +00:00
Scrubs McBarkley 2aad7cb6a0 Merge pull request 'promote: uat → main (GRO-1757 SSO auto-provision fix)' (#21) from uat into main
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 13s
2026-05-26 02:16:28 +00:00
Lint Roller 8349ea00de Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#19) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 33s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Successful in 15s
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:10 +00:00
Chris Farhood 0c41640f59 Add .mcp.json
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 4m1s
2026-05-24 18:15:24 +00:00
The Dogfather 0306c7fbd9 Merge pull request 'chore(GRO-1592): promote dev→uat SSO session cookie fix' (#16) from promote-uat-gro1592 into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:43 +00:00
Chris Farhood 93da2f1dd8 chore: promote dev→uat for GRO-1592 SSO session cookie fix
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 41s
- Fixed frontend auth client baseURL fallback to use window.location.origin
- Added UAT test coverage (TC-AUTH-5.3.4)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:13:12 +00:00
The Dogfather 62cbfe4e43 Merge pull request 'promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence)' (#14) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence) (#14)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:41 +00:00
The Dogfather db6a2a1bbf Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#11) from dev into uat
promote: dev → uat (Renovate config, GRO-1081)

Merge PR #11: dev → uat promotion
Includes: chore: add Renovate config (GRO-1081)
2026-05-20 12:42:04 +00:00
The Dogfather 032a3796ba Merge pull request 'chore: promote dev to uat (CI Docker registry fix)' (#10) from dev into uat
chore: promote dev to uat (CI Docker registry fix) (#10)

Promotes GRO-1348 CI registry fix to UAT.
2026-05-20 11:17:21 +00:00
the-dogfather-cto[bot] cac8fc947e chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
2026-05-14 21:13:56 +00:00
the-dogfather-cto[bot] 592be1301c chore: promote dev to uat (#3)
chore: promote dev to uat
2026-05-11 13:19:33 +00:00
7 changed files with 6 additions and 277 deletions
-22
View File
@@ -54,8 +54,6 @@ 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 `<div id="root">` 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
@@ -379,26 +377,6 @@ 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, &lt;FirstName&gt;". |
### 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=<id>`. | 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 `<RescheduleFlow>` (matching the fallback `renderSection()` already used).
+2 -14
View File
@@ -378,12 +378,8 @@ export function App() {
return <Navigate to="/login" replace />;
}
// 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") {
// Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
if (!authDisabled && !session) {
return <LoginPage />;
}
@@ -405,14 +401,6 @@ 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 <Navigate to="/" replace />;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
-77
View File
@@ -1,77 +0,0 @@
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 `<div id="root">`.
*
* 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<ErrorBoundaryProps, ErrorBoundaryState> {
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 (
<div
data-testid="error-boundary"
style={{
padding: "2rem",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
color: "#7f1d1d",
background: "#fef2f2",
minHeight: "100vh",
boxSizing: "border-box",
}}
>
<h1 style={{ fontSize: 18, margin: "0 0 0.5rem" }}>Something went wrong</h1>
<p style={{ margin: "0 0 1rem", color: "#991b1b" }}>
The app failed to render. The full error is shown below please share this
output when reporting the bug.
</p>
<pre
data-testid="error-boundary-message"
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "#fff",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.75rem 1rem",
margin: 0,
fontSize: 13,
lineHeight: 1.4,
}}
>
{err.name}: {err.message}
{"\n\n"}
{err.stack ?? "(no stack)"}
</pre>
</div>
);
}
return this.props.children;
}
}
-54
View File
@@ -1,54 +0,0 @@
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 <div data-testid="good-child">ok</div>;
}
describe("ErrorBoundary (GRO-2094)", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
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(
<ErrorBoundary>
<GoodChild />
</ErrorBoundary>
);
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(
<ErrorBoundary>
<ThrowingChild />
</ErrorBoundary>
);
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();
});
});
-54
View File
@@ -558,58 +558,4 @@ 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();
});
});
});
+3 -37
View File
@@ -2,41 +2,9 @@ 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");
@@ -44,10 +12,8 @@ if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<ErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+1 -19
View File
@@ -241,31 +241,13 @@ 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 (
<div
className="min-h-screen flex items-center justify-center bg-[#faf8f5]"
role="status"
aria-live="polite"
>
<div className="text-stone-500 text-sm">Loading</div>
</div>
);
}
// 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 (!session && !portalSessionId) {
if (initComplete && !session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (