Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7daa9c480a | |||
| 746fad635f | |||
| f1cf58dc56 | |||
| 903ce2d675 | |||
| f29f1828c8 | |||
| 3d7b247562 | |||
| 198053fa31 | |||
| 228a3d746c | |||
| ad9a178c89 | |||
| 9a3b5d88c8 | |||
| 4e487db6f1 | |||
| 736535a24c | |||
| 33a1b3ed7a | |||
| 65686c8563 | |||
| 106d31a95e | |||
| 88ba9915c6 | |||
| 26cdd69a49 | |||
| a873369a9b | |||
| d78c859c2b |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitea": {
|
||||
"type": "http",
|
||||
"url": "https://git-mcp.farh.net/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${GITEA_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
|
||||
| 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
|
||||
|
||||
@@ -183,6 +186,29 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
|
||||
| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
|
||||
|
||||
#### 5.12b Dynamic Portal Time Slots (GRO-1793)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
|
||||
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
|
||||
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown |
|
||||
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
|
||||
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
|
||||
|
||||
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.12 | Confirmed badge | View appointment card with confirmed status | Green "Confirmed" badge displayed |
|
||||
| TC-WEB-5.12.13 | Pending badge | View appointment card with pending status | Amber "Pending" badge displayed |
|
||||
| TC-WEB-5.12.14 | Waitlisted badge | View appointment card with waitlisted status | Blue "Waitlisted" badge displayed |
|
||||
| TC-WEB-5.12.15 | Badge uses CSS classes | Inspect badge element | Badge uses CSS variable-based classes (e.g., bg-green-100, text-amber-600), not hardcoded colors |
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
### 5.13 Reports UI
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -324,6 +350,71 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.24.12 | No PII in analytics payloads | Fire each event and inspect detail object | Payload contains only: step, flow, timestamp — no names, emails, phone numbers, or pet names |
|
||||
| TC-WEB-5.24.13 | No-op safe | Trigger analytics with window.dispatchEvent blocked (e.g. CSP) | No error thrown; booking flow completes normally |
|
||||
|
||||
### 5.25 Customer Portal — Better Auth SSO Bridge (GRO-1867)
|
||||
|
||||
These cases cover the `CustomerPortal` initialisation path that bridges an Authentik / Better Auth session into a portal session via `POST /api/portal/session-from-auth`. The bridge runs after the URL-impersonation (`?sessionId=`) and dev-user paths have been ruled out.
|
||||
|
||||
**Pre-conditions:**
|
||||
|
||||
- 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.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.25.1 | Authenticated customer reaches portal dashboard | 1. From clean state, navigate to UAT `/login`. 2. Click "Sign in with SSO" and complete Authentik flow with a seeded **customer** identity. 3. After callback, land on `/`. | Portal dashboard renders. No redirect to `/login`. No impersonation banner. Top-right greeting reads "Hi, <FirstName>". |
|
||||
| TC-WEB-5.25.2 | Bridge call sequence | Repeat TC-WEB-5.25.1 with DevTools → Network open and the **All** tab filtered to `/api/`. | In order: `GET /api/auth/get-session` → 200. `POST /api/portal/session-from-auth` → 201 with body `{ sessionId, clientId, clientName }`. |
|
||||
| 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.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=<a-valid-staff-impersonation-session-id>`. | 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. |
|
||||
| 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=<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).
|
||||
|
||||
**Pre-conditions:**
|
||||
|
||||
- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
|
||||
- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=<picked>` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. |
|
||||
| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments/<id>/reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. |
|
||||
| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=<active>`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. |
|
||||
| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. |
|
||||
|
||||
## 6. Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
|
||||
+25
-7
@@ -327,11 +327,16 @@ export function App() {
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// After session is confirmed, check if setup is needed
|
||||
// After session is confirmed, check if setup is needed.
|
||||
// Always run the setup/status fetch as soon as the auth state is known — even for
|
||||
// unauthenticated users, so the `needsSetup` value is in place if they sign in
|
||||
// mid-session. The unauth branch in the render below is handled before
|
||||
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
|
||||
// See GRO-2011.
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||
if (!authDisabled && !session) return;
|
||||
// In dev mode, only fetch when a dev user has been selected — otherwise the
|
||||
// user is mid-redirect to the dev login selector and we don't need setup state.
|
||||
if (authDisabled && !getDevUser()) return;
|
||||
|
||||
fetch("/api/setup/status")
|
||||
@@ -373,8 +378,12 @@ export function App() {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 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 <LoginPage />;
|
||||
}
|
||||
|
||||
@@ -386,15 +395,24 @@ export function App() {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Redirect authenticated users to /admin (but preserve impersonation flow via ?sessionId=)
|
||||
// Redirect staff to /admin; allow customers to access portal (preserve impersonation via ?sessionId=)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId")) {
|
||||
const isStaff = session?.user && (session.user as any).role === "staff";
|
||||
if (!authDisabled && session && !location.pathname.startsWith("/admin") && !searchParams.has("sessionId") && isStaff) {
|
||||
return <Navigate to="/admin" replace />;
|
||||
}
|
||||
|
||||
// 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") ? (
|
||||
|
||||
@@ -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 `<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;
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,65 @@ describe("App navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
|
||||
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
|
||||
const setupStatusCalls: string[] = [];
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
// Better Auth returns 200 with null session for unauthenticated users.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (url === "/api/setup/status") {
|
||||
setupStatusCalls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// The login page should be rendered for the unauthenticated user.
|
||||
await screen.findByText("Sign in to continue");
|
||||
|
||||
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
|
||||
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
|
||||
// blank page (GRO-2011).
|
||||
await waitFor(() => {
|
||||
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(setupStatusCalls[0]).toBe("/api/setup/status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dev login selector", () => {
|
||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -379,4 +379,202 @@ describe("ConfirmationSection", () => {
|
||||
expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("renders Confirmed for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
expect(screen.getByText("Confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Pending for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Waitlisted for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
expect(screen.getByText("Waitlisted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Completed for completed status", () => {
|
||||
render(<StatusBadge status="completed" />);
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Cancelled for cancelled status", () => {
|
||||
render(<StatusBadge status="cancelled" />);
|
||||
expect(screen.getByText("Cancelled")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to status string for unknown status", () => {
|
||||
render(<StatusBadge status="custom-status" />);
|
||||
expect(screen.getByText("custom-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses correct CSS class for confirmed status", () => {
|
||||
render(<StatusBadge status="confirmed" />);
|
||||
const badge = screen.getByText("Confirmed").closest('span');
|
||||
expect(badge?.className).toContain("bg-green-100");
|
||||
expect(badge?.className).toContain("text-green-700");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for waitlisted status", () => {
|
||||
render(<StatusBadge status="waitlisted" />);
|
||||
const badge = screen.getByText("Waitlisted").closest('span');
|
||||
expect(badge?.className).toContain("bg-blue-100");
|
||||
expect(badge?.className).toContain("text-blue-600");
|
||||
});
|
||||
|
||||
it("uses correct CSS class for pending status", () => {
|
||||
render(<StatusBadge status="pending" />);
|
||||
const badge = screen.getByText("Pending").closest('span');
|
||||
expect(badge?.className).toContain("bg-amber-100");
|
||||
expect(badge?.className).toContain("text-amber-600");
|
||||
});
|
||||
|
||||
it("uses fallback styling for unknown status", () => {
|
||||
render(<StatusBadge status="unknown" />);
|
||||
const badge = screen.getByText("unknown").closest('span');
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
const RESCHEDULE_APPT = {
|
||||
id: "appt-r1",
|
||||
petId: "pet-1",
|
||||
petName: "Buddy",
|
||||
groomerId: "groomer-1",
|
||||
groomerName: "Sarah",
|
||||
services: ["Bath & Brush"],
|
||||
serviceId: "service-1",
|
||||
addOns: [],
|
||||
date: "2027-01-01",
|
||||
time: "10:00 AM",
|
||||
duration: 60,
|
||||
price: 50,
|
||||
status: "confirmed" as const,
|
||||
notes: "",
|
||||
customerNotes: "",
|
||||
confirmationStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
it("shows loading state while fetching availability", async () => {
|
||||
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Checking availability/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays fetched time slots from API", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ["9:00 AM", "10:00 AM", "2:00 PM"],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("9:00 AM")).toBeInTheDocument();
|
||||
expect(screen.getByText("10:00 AM")).toBeInTheDocument();
|
||||
expect(screen.getByText("2:00 PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when availability fetch fails", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no slots message when API returns empty array", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [] as string[],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No available slots on this date/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls /api/book/availability with the selected date", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ["9:00 AM"] as string[],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/book/availability?date=2027-02-20",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("re-fetches slots when date changes", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ["9:00 AM"] as string[],
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ["11:00 AM", "1:00 PM"] as string[],
|
||||
} as Response);
|
||||
|
||||
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
|
||||
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
|
||||
|
||||
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-10" } });
|
||||
await waitFor(() => expect(screen.getByText("9:00 AM")).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: "2027-01-15" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("11:00 AM")).toBeInTheDocument();
|
||||
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,22 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
|
||||
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";
|
||||
|
||||
// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
|
||||
// from CustomerPortal without rendering the full flow UI. The real module is
|
||||
// still loaded via importActual; only RescheduleFlow is swapped.
|
||||
const rescheduleFlowSpy = vi.hoisted(() =>
|
||||
vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
|
||||
);
|
||||
vi.mock("../portal/sections/Appointments.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
|
||||
"../portal/sections/Appointments.js"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
RescheduleFlow: rescheduleFlowSpy,
|
||||
};
|
||||
});
|
||||
|
||||
const SESSION: ImpersonationSession = {
|
||||
id: "sess-1",
|
||||
staffId: "staff-1",
|
||||
@@ -313,3 +329,287 @@ describe("CustomerPortal session loading", () => {
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
|
||||
|
||||
describe("CustomerPortal SSO bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Make sure no dev-user leaks across tests
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
const brandingResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
|
||||
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 Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} as Response);
|
||||
}
|
||||
// Subsequent portal API calls — surface them so we can assert the header
|
||||
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>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/portal/session-from-auth",
|
||||
expect.objectContaining({ method: "POST", credentials: "include" })
|
||||
);
|
||||
});
|
||||
// Client greeting reflects the bridged customer name (proof the response was consumed)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||
});
|
||||
// The impersonation banner must NOT appear — this is the customer themselves
|
||||
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
|
||||
// Sign-out escape hatch is present so the user is not stuck in a loop
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as Response);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
// Wait one tick to ensure no subsequent bridge call is queued
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||
);
|
||||
expect(bridgeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips the bridge for staff Better Auth sessions", async () => {
|
||||
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: "staff@example.com", role: "staff" } }),
|
||||
} as Response);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
|
||||
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
|
||||
);
|
||||
expect(bridgeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
|
||||
rescheduleFlowSpy.mockClear();
|
||||
|
||||
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 Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} as Response);
|
||||
}
|
||||
// Dashboard data — return an upcoming appointment so the Reschedule
|
||||
// button is rendered on the dashboard card.
|
||||
if (url === "/api/portal/appointments") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
appointments: [
|
||||
{
|
||||
id: "appt-1",
|
||||
date: "2099-01-01",
|
||||
time: "10:00",
|
||||
petName: "Buddy",
|
||||
serviceName: "Bath & Brush",
|
||||
status: "confirmed",
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/pets") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
|
||||
}
|
||||
if (url === "/api/portal/invoices") {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
// Wait for the Reschedule button to appear on the dashboard card
|
||||
const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
|
||||
fireEvent.click(rescheduleBtn);
|
||||
|
||||
// RescheduleFlow should have been invoked with the bridged portalSessionId,
|
||||
// NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
|
||||
await waitFor(() => {
|
||||
expect(rescheduleFlowSpy).toHaveBeenCalled();
|
||||
});
|
||||
const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
|
||||
expect(lastProps).toBeDefined();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+37
-3
@@ -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(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,15 @@ export function CustomerPortal() {
|
||||
// Track whether an impersonation session fetch from URL param is in-flight
|
||||
// Dashboard will not redirect while this is true, allowing the session to load
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
// Portal session ID for real SSO customers (GRO-1867). Populated by the
|
||||
// Better Auth → /api/portal/session-from-auth bridge below. Carries the
|
||||
// X-Impersonation-Session-Id header on subsequent portal API calls without
|
||||
// triggering the impersonation banner (the customer is themselves).
|
||||
const [portalSessionId, setPortalSessionId] = useState<string | null>(null);
|
||||
// User-facing message when the SSO bridge cannot resolve a client record
|
||||
// (e.g. authenticated user with no matching client row). Rendered in place
|
||||
// of the portal chrome instead of bouncing back to /login.
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -98,10 +107,64 @@ export function CustomerPortal() {
|
||||
}
|
||||
})
|
||||
.finally(() => setInitComplete(true));
|
||||
} else {
|
||||
// No valid session: staff dev users and unauthenticated users fall through here
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (devUser && devUser.type === "staff") {
|
||||
// Staff dev user — fall through; App.tsx redirects to /admin.
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real SSO customer (GRO-1867): bridge a Better Auth session into a portal
|
||||
// session via POST /api/portal/session-from-auth. The returned session ID
|
||||
// is used in the X-Impersonation-Session-Id header for portal API calls.
|
||||
(async () => {
|
||||
try {
|
||||
const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||
if (!sessionResp.ok) {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
let sessionData: { user?: { email?: string; role?: string | null } } | null = null;
|
||||
try {
|
||||
sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null;
|
||||
} catch {
|
||||
// Better Auth returns an empty body when there is no session
|
||||
}
|
||||
if (!sessionData || !sessionData.user) {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
// Staff are routed to /admin by App.tsx; don't run the customer bridge.
|
||||
if (sessionData.user.role === "staff") {
|
||||
setInitComplete(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const bridgeResp = await fetch("/api/portal/session-from-auth", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (bridgeResp.ok) {
|
||||
const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string };
|
||||
setPortalSessionId(data.sessionId);
|
||||
setClientName(data.clientName);
|
||||
} else if (bridgeResp.status === 404) {
|
||||
// Authenticated but no matching client row — show a friendly message
|
||||
// instead of bouncing back to /login (which would loop indefinitely).
|
||||
setAuthError(
|
||||
"Your account is not linked to a customer record. Please contact your groomer to set up portal access."
|
||||
);
|
||||
}
|
||||
// 401/other: fall through; App.tsx render guard will redirect to /login.
|
||||
} catch {
|
||||
// Network error — fall through; the render guard will redirect to /login.
|
||||
} finally {
|
||||
setInitComplete(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleEnd = useCallback(async () => {
|
||||
@@ -157,7 +220,7 @@ export function CustomerPortal() {
|
||||
const isReadOnly = session?.status === "active";
|
||||
|
||||
const renderSection = () => {
|
||||
const sessionId = session?.id ?? null;
|
||||
const sessionId = session?.id ?? portalSessionId;
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
||||
@@ -178,12 +241,63 @@ 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.
|
||||
if (initComplete && !session) {
|
||||
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
|
||||
if (!session && !portalSessionId) {
|
||||
if (authError) {
|
||||
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
|
||||
<Shield size={22} />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
// Best-effort sign-out; redirect to /login regardless.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const devUser = getDevUser();
|
||||
if (devUser && devUser.type === "staff") {
|
||||
return <Navigate to="/admin" replace />;
|
||||
@@ -230,7 +344,7 @@ export function CustomerPortal() {
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
sessionId={session?.id ?? portalSessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -83,14 +83,34 @@ export function isUpcoming(appt: Appointment): boolean {
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
waitlisted: 'bg-blue-100 text-blue-700',
|
||||
pending: 'bg-amber-100 text-amber-600',
|
||||
waitlisted: 'bg-blue-100 text-blue-600',
|
||||
completed: 'bg-stone-100 text-stone-600',
|
||||
cancelled: 'bg-red-100 text-red-600',
|
||||
'no-show': 'bg-yellow-100 text-yellow-700',
|
||||
scheduled: 'bg-blue-100 text-blue-700',
|
||||
scheduled: 'bg-blue-100 text-blue-600',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
confirmed: 'Confirmed',
|
||||
pending: 'Pending',
|
||||
waitlisted: 'Waitlisted',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
'no-show': 'No-show',
|
||||
scheduled: 'Scheduled',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const label = STATUS_LABELS[status] ?? status;
|
||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
@@ -298,13 +318,7 @@ function AppointmentCard({
|
||||
<span>with {appt.groomerName || 'First Available'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_COLORS[appt.status] || ''
|
||||
}`}
|
||||
>
|
||||
{appt.status}
|
||||
</span>
|
||||
<StatusBadge status={appt.status} />
|
||||
{expanded ? (
|
||||
<ChevronDown size={16} className="text-stone-400" />
|
||||
) : (
|
||||
@@ -574,16 +588,26 @@ export function RescheduleFlow({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||
|
||||
const availableTimes = [
|
||||
'9:00 AM',
|
||||
'10:00 AM',
|
||||
'11:00 AM',
|
||||
'1:00 PM',
|
||||
'2:00 PM',
|
||||
'3:00 PM',
|
||||
'4:00 PM',
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !sessionId) {
|
||||
setAvailableTimes([]);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ date: selectedDate });
|
||||
setSlotsLoading(true);
|
||||
setSlotsError(null);
|
||||
fetch(`/api/book/availability?${params.toString()}`, {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
})
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setAvailableTimes)
|
||||
.catch(() => setSlotsError('Failed to load time slots'))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedDate, sessionId]);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate || !selectedTime) return;
|
||||
@@ -655,6 +679,7 @@ export function RescheduleFlow({
|
||||
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Select date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
@@ -662,7 +687,12 @@ export function RescheduleFlow({
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{availableTimes.map((time) => (
|
||||
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability…</p>}
|
||||
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
|
||||
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
|
||||
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
|
||||
)}
|
||||
{!slotsLoading && availableTimes.map((time) => (
|
||||
<button
|
||||
key={time}
|
||||
onClick={() => setSelectedTime(time)}
|
||||
@@ -729,16 +759,26 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [slotsError, setSlotsError] = useState<string | null>(null);
|
||||
const [availableTimes, setAvailableTimes] = useState<string[]>([]);
|
||||
|
||||
const availableTimes = [
|
||||
'9:00 AM',
|
||||
'10:00 AM',
|
||||
'11:00 AM',
|
||||
'1:00 PM',
|
||||
'2:00 PM',
|
||||
'3:00 PM',
|
||||
'4:00 PM',
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!selectedDate || !sessionId) {
|
||||
setAvailableTimes([]);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ date: selectedDate });
|
||||
setSlotsLoading(true);
|
||||
setSlotsError(null);
|
||||
fetch(`/api/book/availability?${params.toString()}`, {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
})
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setAvailableTimes)
|
||||
.catch(() => setSlotsError('Failed to load time slots'))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedDate, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -1059,6 +1099,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
<h3 className="font-medium text-stone-800 mb-3">Pick Date & Time</h3>
|
||||
<input
|
||||
type="date"
|
||||
aria-label="Select date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
@@ -1066,7 +1107,12 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{availableTimes.map((time) => (
|
||||
{slotsLoading && <p className="col-span-3 text-sm text-stone-500 py-2">Checking availability…</p>}
|
||||
{!slotsLoading && slotsError && <p className="col-span-3 text-sm text-red-500 py-2">{slotsError}</p>}
|
||||
{!slotsLoading && availableTimes.length === 0 && !slotsError && (
|
||||
<p className="col-span-3 text-sm text-stone-500 py-2">No available slots on this date.</p>
|
||||
)}
|
||||
{!slotsLoading && availableTimes.map((time) => (
|
||||
<button
|
||||
key={time}
|
||||
onClick={() => setSelectedTime(time)}
|
||||
|
||||
@@ -39,6 +39,8 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
navigateFallbackDenylist: [
|
||||
/^\/api\/auth\//,
|
||||
|
||||
Reference in New Issue
Block a user