Compare commits

...

14 Commits

Author SHA1 Message Date
Flea Flicker 7526cb1d67 fix(GRO-2011): always fetch /api/setup/status, even for unauth users
CI / Test (pull_request) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Successful in 13s
The second useEffect in App skipped the setup/status fetch when
`!authDisabled && !session` was true. In the deployed bundle the
`needsSetup` state therefore stayed `null` for unauth users, and a
later render short-circuit rendered nothing — producing the blank
white viewport at https://uat.groombook.dev/login.

Drop the unauth skip clause so `/api/setup/status` is always fetched
as soon as the auth state is known. The unauth branch in the render
is handled before `needsSetup` is consulted, so this is safe and
removes the stuck-`null` state.

Adds:
- New unit test in src/__tests__/App.test.tsx asserting the
  unauthenticated path calls /api/setup/status.
- UAT playbook entry TC-WEB-5.1.5 covering the blank-viewport
  regression scenario.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 16:16:29 +00:00
Flea Flicker 198053fa31 feat(GRO-1867): bridge Better Auth session to CustomerPortal (#34)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 14s
2026-06-01 15:47:41 +00:00
Scrubs McBarkley 228a3d746c Merge pull request 'fix(GRO-1829): add skipWaiting/clientsClaim to VitePWA workbox config' (#31) from gro-1829-swpwa-fix into dev
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 17s
CI / Build & Push Docker Image (push) Successful in 31s
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 15s
2026-05-27 02:22:25 +00:00
Flea Flicker ad9a178c89 fix: add skipWaiting/clientsClaim to VitePWA workbox config
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 33s
Root cause: SW remained in waiting phase after redeploy, serving stale
precached assets. Without skipWaiting/clientsClaim the old SW persisted
and controlled the page even after a new SW was installed.

Fixes blank-page regression where React never mounted on login.
2026-05-27 02:22:01 +00:00
The Dogfather 9a3b5d88c8 Merge pull request 'fix(GRO-1822): add role check before /admin redirect — customers access portal' (#30) from fix/gro-1822-role-based-redirect into dev
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 34s
2026-05-27 01:01:59 +00:00
Flea Flicker 4e487db6f1 fix(GRO-1822): add role check before /admin redirect — customers access portal
CI / Test (pull_request) Failing after 14s
CI / Lint & Typecheck (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
App.tsx lines 389-393 redirected ALL authenticated users to /admin,
breaking customer portal access after SSO login.

Now checks `session.user.role === "staff"` before redirecting.
Customers (role !== "staff") can access the portal at /.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-27 01:01:28 +00:00
The Dogfather 736535a24c Merge pull request 'feat(portal): add StatusBadge to appointment cards (GRO-1795)' (#26) from feature/gro-1165e-booking-status-badge into dev
CI / Test (push) Successful in 15s
CI / Lint & Typecheck (push) Successful in 17s
CI / Lint & Typecheck (pull_request) Successful in 18s
CI / Test (pull_request) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 53s
CI / Build & Push Docker Image (pull_request) Successful in 47s
Merge PR #26: feat(portal): add StatusBadge to appointment cards (GRO-1795)
2026-05-26 13:23:30 +00:00
The Dogfather 33a1b3ed7a Merge pull request 'feat(GRO-1794): booking funnel analytics events' (#24) from feature/gro-1165d-booking-analytics into dev
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 16s
CI / Build & Push Docker Image (push) Successful in 32s
CI / Build & Push Docker Image (pull_request) Successful in 52s
Merge PR #24: feat(GRO-1794): booking funnel analytics events

CTO-approved. QA passed. All CI green.
2026-05-26 13:16:06 +00:00
Flea Flicker 65686c8563 fix(GRO-1795): restore fireEvent and waitFor imports
CI / Test (pull_request) Successful in 15s
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Build & Push Docker Image (pull_request) Successful in 35s
QA regression: PR #26 removed fireEvent and waitFor from the
@testing-library/react import, breaking 21 test cases and typecheck.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 13:12:59 +00:00
Flea Flicker 106d31a95e feat(portal): add StatusBadge to appointment cards
CI / Test (pull_request) Failing after 13s
CI / Lint & Typecheck (pull_request) Failing after 16s
CI / Build & Push Docker Image (pull_request) Has been skipped
Add a StatusBadge component that renders human-readable labels
(Confirmed, Pending, Waitlisted, etc.) with semantic color classes
for appointment cards in the portal. Replaces raw status strings.

- Added STATUS_LABELS map for human-readable status labels
- Updated STATUS_COLORS to use accessible amber/blue tones
- Exported StatusBadge for testing
- Added unit tests for all 7 badge states plus fallback
- Updated UAT_PLAYBOOK.md §5.12c with status badge test cases

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:04:02 +00:00
The Dogfather 88ba9915c6 Merge pull request 'GRO-1793: Dynamic portal time slots (replace hardcoded)' (#23) from feature/gro-1165c-dynamic-timeslots into dev
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Successful in 9s
CI / Test (pull_request) Successful in 18s
CI / Lint & Typecheck (pull_request) Successful in 2m33s
CI / Build & Push Docker Image (pull_request) Successful in 8s
GRO-1793: Dynamic portal time slots (replace hardcoded) (#23)

Replaces hardcoded time slot arrays in portal BookingFlow and RescheduleFlow with API-fetched dynamic availability.
2026-05-26 12:56:25 +00:00
Flea Flicker 26cdd69a49 fix(GRO-1793): remove unused act import and add aria-label to date inputs
CI / Test (pull_request) Successful in 14s
CI / Lint & Typecheck (pull_request) Successful in 23s
CI / Build & Push Docker Image (pull_request) Successful in 34s
QA review pointed out:
- Lint error: 'act' imported but never used in test file
- 6 test failures: date input lacked accessible label

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:48:55 +00:00
Barcode Betty a873369a9b GRO-1793: Update UAT_PLAYBOOK.md §5.12b — new dynamic time slots tests
CI / Test (pull_request) Failing after 22s
CI / Lint & Typecheck (pull_request) Failing after 28s
CI / Build & Push Docker Image (pull_request) Has been skipped
Added TC-WEB-5.12.5 through TC-WEB-5.12.11 covering BookingFlow and
RescheduleFlow dynamic slot fetching, loading state, error state, and
empty state scenarios.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:25:54 +00:00
Barcode Betty d78c859c2b Replace hardcoded time slots with dynamic API availability
CI / Lint & Typecheck (pull_request) Failing after 15s
CI / Test (pull_request) Failing after 17s
CI / Build & Push Docker Image (pull_request) Has been skipped
Both BookingFlow and RescheduleFlow in Appointments.tsx now fetch
from /api/book/availability when a date is selected, matching the
public booking wizard behavior. Loading and error states shown.

- Removed hardcoded availableTimes arrays from both flows
- Added useEffect that fetches availability on date change
- Shows "Checking availability…" while loading
- Shows error message on fetch failure
- Shows "No available slots" when API returns empty

Added tests for RescheduleFlow dynamic slot fetching covering:
loading, fetched slots, error, empty, API params, and re-fetch on
date change.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-26 12:23:29 +00:00
8 changed files with 657 additions and 41 deletions
+48
View File
@@ -53,6 +53,7 @@ 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). |
### 5.2 Authentication — VITE_API_URL Set
@@ -183,6 +184,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 +348,30 @@ 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 and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory).
- `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, &lt;FirstName&gt;". |
| 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, &lt;FirstName&gt;". |
## 6. Pass/Fail Criteria
**Pass:**
+11 -5
View File
@@ -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")
@@ -386,9 +391,10 @@ 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 />;
}
+59
View File
@@ -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) => {
+199 -1
View File
@@ -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();
});
});
});
+161
View File
@@ -313,3 +313,164 @@ 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);
});
});
+101 -5
View File
@@ -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} />;
@@ -183,7 +246,40 @@ export function CustomerPortal() {
// 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 (initComplete && !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 />;
+76 -30
View File
@@ -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)}
+2
View File
@@ -39,6 +39,8 @@ export default defineConfig({
],
},
workbox: {
skipWaiting: true,
clientsClaim: true,
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,