Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 532869f926 | |||
| a7f2e2e6b3 | |||
| a12bf019fa | |||
| bfe3ccf3b2 | |||
| b52b8e10ad | |||
| f58a0e569b |
+41
-2
@@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| 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.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
|
||||
|
||||
These cases exercise the full StatusBadge palette as it is now produced live by
|
||||
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
|
||||
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
|
||||
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
|
||||
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
|
||||
|
||||
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
|
||||
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
|
||||
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
|
||||
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
|
||||
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
|
||||
> mismatch (item 1).
|
||||
|
||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -427,8 +446,13 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
||||
| 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.5 | 404 from SSO bridge routes to OOBE (GRO-2359) | 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 post-auth handler mounts the **OOBE** (`src/portal/OOBE.tsx`) — a centred card titled **"Welcome — let's set up your portal"** with form fields for name (prefilled from the Better Auth session), phone, address, and notes. The legacy "Portal access not configured" card is **not** rendered on the new-user path. No redirect loop, no portal chrome. |
|
||||
| TC-WEB-5.25.6 | OOBE form submission creates the portal (GRO-2359) | From TC-WEB-5.25.5, fill in the OOBE form and click **Create my portal**. | `POST /api/portal/clients-from-auth` is called with `{ name, phone, address, notes }`; the email is taken from the Better Auth session (the API binds the new client row to the SSO identity). The page reloads to `/`, the bridge re-runs, and the user lands in their portal dashboard. DevTools → Network shows `POST /api/portal/clients-from-auth` → 201 followed by `POST /api/portal/session-from-auth` → 201. |
|
||||
| TC-WEB-5.25.6b | OOBE handles portal selection (409 from clients-from-auth) (GRO-2359) | 1. Sign in via SSO with an email that already exists in `clients` (e.g. a previously deleted-then-recreated account). 2. Land on OOBE. 3. Click **Create my portal**. | The API returns 409 "A customer record with this email already exists". The OOBE re-enables the submit button and shows the portal-selection message: "A customer record with this email already exists. Please contact your groomer to link your account." The shared signOut() button remains reachable so the user can exit if needed. |
|
||||
| TC-WEB-5.25.6c | OOBE uses the shared signOut() handler (GRO-2358, GRO-2359) | From TC-WEB-5.25.5, click **Sign out** in the OOBE footer. | The same shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout` and the no-access card); browser navigates to `/login`; the Authentik session cookie is cleared. The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
|
||||
| TC-WEB-5.25.6d | OOBE is mountable from a direct deep-link (GRO-2359) | 1. Sign in via SSO as any customer. 2. In a new tab, navigate to `https://uat.groombook.dev/onboarding`. | The OOBE form mounts (the App.tsx `/onboarding` route resolves before the CustomerPortal `!sessionId` guards). The submit, signOut, and field-validation behaviour are identical to the post-auth mount. |
|
||||
| TC-WEB-5.25.6e | Deleted-portal deep-link still reaches the no-access card (GRO-2358, GRO-2359) | 1. Sign in via SSO as a customer whose `clients` row was disabled/deleted by the groomer. 2. Land on a portal sub-route with `?noAccess=deleted-portal` (e.g. visit `https://uat.groombook.dev/appointments?noAccess=deleted-portal` directly). | The no-access card renders (the deep-link deleted-portal case — the OOBE is reserved for first-time creation). The shared signOut() from GRO-2358 is wired identically. This proves the no-access card is still reachable for non-new-user failure modes and the CMPO "no-trap" invariant holds across the auth boundary. |
|
||||
| TC-WEB-5.25.6f | In-portal chrome sidebar exposes a Sign out button (GRO-2373) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the portal chrome, look at the sidebar footer (the section below the navigation links, where "Customer Portal v1.0" sits). 3. Locate the **Sign out** button (a stone-grey button above the version label, with a LogOut icon). 4. Click it. | A **Sign out** button is present in the sidebar footer (not buried in the Settings page, not hidden in a dropdown — it's visible on every portal sub-route, including Home, Appointments, My Pets, Report Cards, Billing, Messages, Settings). Clicking it fires the same shared `signOut()` from `lib/auth-client` (same handler as the OOBE footer, the no-access card, and `AdminLayout`'s top-bar "Logout"); `POST /api/auth/sign-out` → 200 `{"success":true}`; the browser navigates to `/login`; the Better Auth / Authentik session cookie is cleared. Proves the CMPO "no-trap" invariant (originally established in GRO-2355) holds on the third authenticated surface — the in-portal chrome — which the GRO-2358 P1 fix did not cover. |
|
||||
| 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. |
|
||||
@@ -505,6 +529,21 @@ The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab han
|
||||
| TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. |
|
||||
| TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). |
|
||||
|
||||
### 5.30 Route Planner — Navigation Export & Offline (GRO-2160)
|
||||
|
||||
When a route has stops, an export panel offers **Open in Google Maps** and **Open in Apple Maps** buttons. Each fetches `GET /api/routes/:routeId/export/google-maps` (or `/apple-maps`) and opens the returned deep-link URL in the device's maps app (Google Maps `https://www.google.com/maps/dir/?...`, Apple Maps `maps://...`). The page detects the device OS (iOS / Android / desktop) and renders the most relevant button prominently (filled) with the other as a secondary outline button; on iOS Apple Maps leads, otherwise Google Maps leads. Offline support: the existing Workbox `NetworkFirst` rule caches `/api/routes/*` responses (24h TTL) so a previously-loaded route still renders without network; a `CacheFirst` rule (`osm-tiles`, 7-day TTL, 400 entries) caches OpenStreetMap tiles. On every route load and after each optimize/reorder, the page pre-warms the OSM tiles covering the route's bounding box (zooms 12–14, capped at 80 tiles) so the map is viewable offline. The layout is responsive: below 768px the map/stop-list stack to one column, the map shrinks, and the export buttons go full-width.
|
||||
|
||||
| Test Case | Description | Steps | Expected Result |
|
||||
|-----------|-------------|-------|-----------------|
|
||||
| TC-WEB-5.30.1 | Export buttons render | Open `/admin/routes` for a route with ≥1 stop. | An export panel shows both **Open in Google Maps** and **Open in Apple Maps** buttons. Buttons are absent when there are no stops. |
|
||||
| TC-WEB-5.30.2 | Google Maps deep link | Click **Open in Google Maps**. | A `GET /api/routes/:routeId/export/google-maps` fires and the returned `https://www.google.com/maps/dir/?...` URL opens (new tab / Google Maps app) with origin, destination, and waypoints in route order. |
|
||||
| TC-WEB-5.30.3 | Apple Maps deep link | On iOS (or emulation), click **Open in Apple Maps**. | A `GET /api/routes/:routeId/export/apple-maps` fires and the returned `maps://...` URL opens Apple Maps with the route chained `+to:`. |
|
||||
| TC-WEB-5.30.4 | Platform-aware prominence | Open the page on an iPhone (or iOS UA emulation) vs Android/desktop. | On iOS the **Apple Maps** button is the prominent (filled) one and Google Maps is the secondary (outline); on Android/desktop **Google Maps** is prominent and Apple Maps secondary. Both buttons are always available. |
|
||||
| TC-WEB-5.30.5 | Export error handling | Trigger an export that errors (e.g. route exceeds the platform waypoint cap). | The pre-opened tab is closed and an inline error message is shown; no silent failure. |
|
||||
| TC-WEB-5.30.6 | Offline route data | Load a route online, then in DevTools → Network set **Offline** and reload `/admin/routes` for the same groomer/date. | The route data still loads from the `api-cache` (NetworkFirst fallback); stops, summary, and badge render without network. |
|
||||
| TC-WEB-5.30.7 | Offline map tiles | After viewing/optimizing a route online, go **Offline** and view the same route. | The OSM map tiles for the route area render from the `osm-tiles` CacheFirst cache (pre-warmed); the map is not blank in the route's vicinity. |
|
||||
| TC-WEB-5.30.8 | Responsive mobile layout | Open the page at a phone width (≤768px, e.g. 390px). | Map and stop-list stack into a single column, the map height shrinks, and the export buttons span full width. No horizontal scroll; controls remain usable with a thumb. |
|
||||
|
||||
## 6. Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
|
||||
+13
-1
@@ -16,6 +16,7 @@ import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { SetupWizard } from "./pages/SetupWizard.tsx";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { OOBE } from "./portal/OOBE.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
@@ -406,7 +407,13 @@ export function App() {
|
||||
}
|
||||
|
||||
// Don't render portal chrome at /login — DevLoginSelector is shown instead
|
||||
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
|
||||
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login" && location.pathname !== "/onboarding";
|
||||
// GRO-2359: OOBE is mountable from a direct link (deep-link to /onboarding)
|
||||
// and from the post-auth callback (CustomerPortal navigates here when the
|
||||
// SSO bridge returns 404). Render the OOBE component standalone so it's
|
||||
// outside the portal chrome (no `!sessionId` guards, no `!initComplete`
|
||||
// loading states to fight — the OOBE handles its own auth resolution).
|
||||
const showOOBE = location.pathname === "/onboarding";
|
||||
|
||||
// At /login with a valid session, redirect to the portal root. Without this,
|
||||
// the final render returns null at /login (showCustomerPortal is false) and
|
||||
@@ -425,6 +432,11 @@ export function App() {
|
||||
</Routes>
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : showOOBE ? (
|
||||
<>
|
||||
<OOBE />
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
) : showCustomerPortal ? (
|
||||
<>
|
||||
<CustomerPortal />
|
||||
|
||||
@@ -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, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -517,6 +517,52 @@ describe("StatusBadge", () => {
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
|
||||
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
|
||||
// `no-show` (hyphen) — without normalization it rendered raw gray text.
|
||||
it("renders the styled No-show badge for DB `no_show` status", () => {
|
||||
render(<StatusBadge status="no_show" />);
|
||||
const badge = screen.getByText("No-show").closest('span');
|
||||
expect(badge?.className).toContain("bg-yellow-100");
|
||||
expect(badge?.className).toContain("text-yellow-700");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
|
||||
it("maps underscore status keys to the hyphen palette key", () => {
|
||||
expect(normalizeStatusKey("no_show")).toBe("no-show");
|
||||
});
|
||||
|
||||
it("leaves already-hyphenated / single-word keys unchanged", () => {
|
||||
expect(normalizeStatusKey("no-show")).toBe("no-show");
|
||||
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
|
||||
it("derives Pending for an upcoming, unconfirmed appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("keeps the raw status when the appointment is confirmed", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
|
||||
).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("does not derive Pending for a past appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("always shows Waitlisted for a waitlist-backed entry", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
|
||||
).toBe("waitlisted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
|
||||
@@ -82,6 +82,18 @@ function mockFetch(meRole: "manager" | "groomer") {
|
||||
if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response);
|
||||
}
|
||||
if (/^\/api\/routes\/[^/]+\/export\/google-maps$/.test(url)) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ platform: "google-maps", url: "https://www.google.com/maps/dir/?api=1", stopCount: 2, waypointCount: 0 }),
|
||||
} as Response);
|
||||
}
|
||||
if (/^\/api\/routes\/[^/]+\/export\/apple-maps$/.test(url)) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ platform: "apple-maps", url: "maps://?saddr=51.5,-0.1&daddr=51.52,-0.12", stopCount: 2, waypointCount: 0 }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||
});
|
||||
}
|
||||
@@ -169,4 +181,31 @@ describe("RoutesPage", () => {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("renders both navigation export buttons when the route has stops", async () => {
|
||||
global.fetch = mockFetch("manager") as unknown as typeof fetch;
|
||||
render(<RoutesPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "Open in Google Maps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open in Apple Maps" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches the export deep link and opens it when Open in Google Maps is clicked", async () => {
|
||||
const fetchMock = mockFetch("manager");
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
const openSpy = vi
|
||||
.spyOn(window, "open")
|
||||
.mockReturnValue({ location: { href: "" }, close: vi.fn() } as unknown as Window);
|
||||
|
||||
render(<RoutesPage />);
|
||||
await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Google Maps" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/routes/r1/export/google-maps")
|
||||
);
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+532
-16
@@ -21,6 +21,18 @@ vi.mock("../portal/sections/Appointments.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
||||
// assert the no-access screen's logout button uses the SAME handler as
|
||||
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
||||
// the one authenticated surface that renders without the portal chrome, so
|
||||
// a regression here would trap the user. We do NOT use `importActual`
|
||||
// because the real `createAuthClient()` requires a runtime `baseURL`
|
||||
// (Better Auth) that the JSDOM test environment can't supply.
|
||||
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
vi.mock("../lib/auth-client.js", () => ({
|
||||
signOut: signOutSpy,
|
||||
}));
|
||||
|
||||
const SESSION: ImpersonationSession = {
|
||||
id: "sess-1",
|
||||
staffId: "staff-1",
|
||||
@@ -52,6 +64,22 @@ const AUDIT_LOGS: ImpersonationAuditLog[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Shared test fixtures ───────────────────────────────────────────────────
|
||||
|
||||
// `brandingResponse` is the mock /api/branding payload used by every test
|
||||
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
|
||||
// describe blocks can both reach it without redefining the same body.
|
||||
const brandingResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response;
|
||||
|
||||
// ─── ImpersonationBanner ────────────────────────────────────────────────────
|
||||
|
||||
describe("ImpersonationBanner", () => {
|
||||
@@ -336,19 +364,10 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
beforeEach(() => {
|
||||
// Make sure no dev-user leaks across tests
|
||||
window.localStorage.clear();
|
||||
// Reset shared signOut() spy so per-test counts are deterministic
|
||||
signOutSpy.mockClear();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -394,14 +413,21 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
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 () => {
|
||||
it("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
|
||||
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
|
||||
// The post-auth handler must now navigate to /onboarding so the OOBE
|
||||
// component can drive portal creation. The no-access card itself is
|
||||
// reserved for the deep-link deleted-portal case (see the next two
|
||||
// tests, which exercise ?noAccess=deleted-portal).
|
||||
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" } }),
|
||||
json: async () => ({
|
||||
user: { email: "stranger@example.com", name: "Stranger", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
@@ -414,6 +440,9 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
// MemoryRouter is required for the React Router context used by
|
||||
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
|
||||
// and let the post-auth handler navigate the router to /onboarding.
|
||||
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
@@ -421,12 +450,156 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// The bridge 404 must NOT render the legacy no-access card. The OOBE
|
||||
// form is the new-user surface.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
||||
// Reset the spy so previous tests don't leak into this assertion.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
// JSDOM throws on window.location.href assignment by default; swap in a
|
||||
// writable stub so the navigation is observable, then restore after.
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
|
||||
// on the new-user path. The no-access card itself is reserved for the
|
||||
// deep-link deleted-portal case, which is signalled via
|
||||
// ?noAccess=deleted-portal. A server-side "client disabled" check
|
||||
// (future GRO) is the natural trigger.
|
||||
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);
|
||||
}
|
||||
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||
// server-side portal-disabled check; the no-access card is mounted
|
||||
// because of the URL param, not because of the bridge.
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: "Portal disabled" }),
|
||||
} 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={["/?noAccess=deleted-portal"]}>
|
||||
<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();
|
||||
|
||||
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||
// no-access screen is mounted because of the deleted-portal signal, not
|
||||
// because the user clicked anything.
|
||||
expect(signOutSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Drive the click. The handler is the SAME `signOut()` exported from
|
||||
// auth-client that AdminLayout uses, so verifying this call is enough to
|
||||
// prove the no-access screen reaches the canonical sign-out surface.
|
||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The handler always navigates to /login — even if the network call to
|
||||
// /api/auth/sign-out fails — so a transient auth-server hiccup never
|
||||
// leaves the user trapped on an authenticated screen.
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
||||
// AC requires verifying the SAME logout handler is reachable from at
|
||||
// least one other authenticated surface — here a deep link to a portal
|
||||
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
||||
// whose portal was deleted. The no-access screen is the only
|
||||
// authenticated surface without a route guard, so the handler must
|
||||
// fire identically.
|
||||
//
|
||||
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
|
||||
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
||||
// NOT be 404 (which would route to /onboarding). A 500 models a
|
||||
// server-side portal-disabled check; the no-access card is mounted
|
||||
// because of the URL param, not because of the bridge.
|
||||
if (url === "/api/portal/session-from-auth") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: "Portal disabled" }),
|
||||
} 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={["/appointments?noAccess=deleted-portal"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||
@@ -613,3 +786,346 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OOBE portal-creation flow (GRO-2359)", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
// The OOBE is mounted both from the post-auth callback (CustomerPortal
|
||||
// navigates to /onboarding on bridge 404) and from a direct deep-link.
|
||||
// This set of tests exercises the direct-link mount, the form submit, and
|
||||
// the shared signOut() handler. The post-auth routing is covered by the
|
||||
// "routes to /onboarding when session-from-auth returns 404" test above.
|
||||
|
||||
function setupOOBEAuthMock(opts: { role?: string } = {}) {
|
||||
const role = opts.role ?? "customer";
|
||||
return 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: "new-sso@example.com", name: "New SSO", role },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
|
||||
});
|
||||
// All three primary form fields are present.
|
||||
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
|
||||
// Submit and shared signOut are both present.
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
|
||||
const fetchMock = 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: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
id: "new-client-id",
|
||||
name: "New SSO",
|
||||
email: "new-sso@example.com",
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
|
||||
// Fill phone + address and submit.
|
||||
fireEvent.change(screen.getByLabelText(/phone/i), {
|
||||
target: { value: "555-1234" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/address/i), {
|
||||
target: { value: "1 Main St" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
// The endpoint must have been called with the form values, normalised
|
||||
// (phone/address trimmed). We don't assert navigation here because the
|
||||
// MemoryRouter would need a history prop to assert a URL change — the
|
||||
// internal `navigate("/")` call is the contract.
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(fetchMock).mock.calls;
|
||||
const onboardCall = calls.find(([u]) =>
|
||||
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
|
||||
);
|
||||
expect(onboardCall).toBeDefined();
|
||||
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
|
||||
expect(body).toEqual({
|
||||
name: "New SSO",
|
||||
phone: "555-1234",
|
||||
address: "1 Main St",
|
||||
notes: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the portal-selection message when the API returns 409 (GRO-2359)", 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: "new-sso@example.com", name: "New SSO", role: "customer" },
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ error: "A customer record with this email already exists" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
|
||||
});
|
||||
// The submit button is re-enabled after the error so the user can retry.
|
||||
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("requires the name field before submitting (GRO-2359)", async () => {
|
||||
// Use a session WITHOUT a name so the OOBE starts with an empty form.
|
||||
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: "noname@example.com", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
||||
|
||||
// The name-required error is shown; no API call was made.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
|
||||
signOutSpy.mockClear();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
global.fetch = setupOOBEAuthMock();
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
|
||||
|
||||
// Same canonical handler as AdminLayout and the no-access card, per
|
||||
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
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: false, status: 401, json: async () => ({}) } as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const { OOBE } = await import("../portal/OOBE.js");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/onboarding"]}>
|
||||
<OOBE />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
|
||||
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
|
||||
// Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets,
|
||||
// Report Cards, Billing, Messages, Settings) had no visible sign-out
|
||||
// control — only the OOBE and the no-access card exposed one. This
|
||||
// leaves users signed-in with no escape hatch. The fix lands a
|
||||
// "Sign out" button in the sidebar footer that wires to the same
|
||||
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
|
||||
signOutSpy.mockClear();
|
||||
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { href: "" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
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: "uat-customer@groombook.dev", role: "customer" } }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
||||
} 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>
|
||||
);
|
||||
|
||||
// Land on the chrome (proof: customer greeting is rendered, no
|
||||
// no-access card, no OOBE).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
|
||||
|
||||
// The new chrome sign-out is scoped by data-testid so it doesn't
|
||||
// collide with other surfaces that may also render "Sign out" labels
|
||||
// (e.g. the impersonation banner uses "End Session").
|
||||
const signOutButton = screen.getByTestId("portal-chrome-signout");
|
||||
expect(signOutButton).toHaveTextContent(/Sign out/i);
|
||||
fireEvent.click(signOutButton);
|
||||
|
||||
// Same canonical handler as OOBE / no-access / AdminLayout — never
|
||||
// a raw fetch("/api/auth/sign-out") and never a navigate() without
|
||||
// signOut() (the OOBE/no-access surface uses window.location.href
|
||||
// for a hard reload so cached state is reset).
|
||||
await waitFor(() => {
|
||||
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/login");
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||
});
|
||||
});
|
||||
|
||||
+234
-3
@@ -94,6 +94,111 @@ function fmtTime(iso: string): string {
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
// ─── Navigation export ────────────────────────────────────────────────────────
|
||||
|
||||
/** Navigation target platforms supported by the API export endpoints. */
|
||||
type NavigationPlatform = "google-maps" | "apple-maps";
|
||||
|
||||
type DevicePlatform = "ios" | "android" | "other";
|
||||
|
||||
/**
|
||||
* Best-effort mobile-OS detection so we can surface the most useful navigation
|
||||
* app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere
|
||||
* else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we
|
||||
* also treat a touch-capable "MacIntel" device as iOS.
|
||||
*/
|
||||
function detectPlatform(): DevicePlatform {
|
||||
if (typeof navigator === "undefined") return "other";
|
||||
const ua = navigator.userAgent || "";
|
||||
if (/iphone|ipad|ipod/i.test(ua)) return "ios";
|
||||
if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios";
|
||||
if (/android/i.test(ua)) return "android";
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ─── Offline map-tile pre-warming ────────────────────────────────────────────
|
||||
|
||||
/** OSM tile zoom levels pre-fetched around a route so the map renders offline. */
|
||||
const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const;
|
||||
/** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */
|
||||
const MAX_PREWARM_TILES = 80;
|
||||
/** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */
|
||||
const TILE_SUBDOMAINS = ["a", "b", "c"] as const;
|
||||
|
||||
/** Web-Mercator longitude → tile X index at the given zoom. */
|
||||
function lonToTileX(lon: number, z: number): number {
|
||||
return Math.floor(((lon + 180) / 360) * 2 ** z);
|
||||
}
|
||||
|
||||
/** Web-Mercator latitude → tile Y index at the given zoom. */
|
||||
function latToTileY(lat: number, z: number): number {
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
return Math.floor(
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the browser/service-worker cache with the OSM tiles covering the route's
|
||||
* bounding box (plus a one-tile margin) across a few zoom levels. Tiles are
|
||||
* fetched via `new Image()` so they hit the same URLs Leaflet later requests and
|
||||
* land in the CacheFirst tile cache, making the map viewable offline. Bounded by
|
||||
* MAX_PREWARM_TILES so a sprawling route never floods the network.
|
||||
*/
|
||||
function prewarmRouteTiles(
|
||||
stops: Array<{ latitude: number; longitude: number }>
|
||||
): void {
|
||||
if (typeof window === "undefined" || stops.length === 0) return;
|
||||
const lats = stops.map((s) => s.latitude);
|
||||
const lons = stops.map((s) => s.longitude);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLon = Math.min(...lons);
|
||||
const maxLon = Math.max(...lons);
|
||||
|
||||
const urls: string[] = [];
|
||||
for (const z of PREWARM_ZOOM_LEVELS) {
|
||||
const x0 = lonToTileX(minLon, z) - 1;
|
||||
const x1 = lonToTileX(maxLon, z) + 1;
|
||||
// Tile Y grows as latitude decreases, so maxLat → smaller Y.
|
||||
const y0 = latToTileY(maxLat, z) - 1;
|
||||
const y1 = latToTileY(minLat, z) + 1;
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue;
|
||||
const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length];
|
||||
urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of urls.slice(0, MAX_PREWARM_TILES)) {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Responsive layout ────────────────────────────────────────────────────────
|
||||
|
||||
/** Tracks a `max-width` media query so the page can adapt to phone widths. */
|
||||
function useIsMobile(maxWidthPx = 768): boolean {
|
||||
const query = `(max-width: ${maxWidthPx}px)`;
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia(query).matches
|
||||
: false
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mq = window.matchMedia(query);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
setIsMobile(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<RouteStatus, { bg: string; fg: string; label: string }> = {
|
||||
draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" },
|
||||
optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" },
|
||||
@@ -221,6 +326,117 @@ function SortableStop({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation export controls. Fetches a platform deep-link from the API and opens
|
||||
* it. The button matching the detected device OS is shown prominently (filled);
|
||||
* the other is offered as a secondary outline button. On desktop both are
|
||||
* secondary and Google Maps leads.
|
||||
*/
|
||||
function NavExportButtons({
|
||||
routeId,
|
||||
primaryColor,
|
||||
fullWidth,
|
||||
}: {
|
||||
routeId: string;
|
||||
primaryColor: string;
|
||||
fullWidth: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<NavigationPlatform | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const platform = useMemo(detectPlatform, []);
|
||||
|
||||
const openIn = useCallback(
|
||||
async (target: NavigationPlatform) => {
|
||||
setBusy(target);
|
||||
setError(null);
|
||||
// Pre-open a tab synchronously: mobile Safari/Chrome block window.open()
|
||||
// calls that happen after an await (no longer in the user-gesture turn).
|
||||
const win = window.open("", "_blank");
|
||||
try {
|
||||
const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`);
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Export failed (${r.status})`);
|
||||
}
|
||||
const { url } = (await r.json()) as { url: string };
|
||||
if (win) win.location.href = url;
|
||||
else window.location.href = url;
|
||||
} catch (e) {
|
||||
win?.close();
|
||||
setError(e instanceof Error ? e.message : "Export failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[routeId]
|
||||
);
|
||||
|
||||
const baseBtn: React.CSSProperties = {
|
||||
padding: "0.55rem 1rem",
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: busy ? "wait" : "pointer",
|
||||
flex: fullWidth ? "1 1 0" : "0 0 auto",
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = {
|
||||
...baseBtn,
|
||||
border: "none",
|
||||
background: primaryColor,
|
||||
color: "#fff",
|
||||
};
|
||||
const secondaryBtn: React.CSSProperties = {
|
||||
...baseBtn,
|
||||
border: `1px solid ${primaryColor}`,
|
||||
background: "#fff",
|
||||
color: primaryColor,
|
||||
};
|
||||
|
||||
const label = (p: NavigationPlatform) =>
|
||||
busy === p
|
||||
? "Opening…"
|
||||
: p === "google-maps"
|
||||
? "Open in Google Maps"
|
||||
: "Open in Apple Maps";
|
||||
|
||||
const google = (
|
||||
<button
|
||||
key="google"
|
||||
type="button"
|
||||
onClick={() => openIn("google-maps")}
|
||||
disabled={busy !== null}
|
||||
style={platform === "ios" ? secondaryBtn : primaryBtn}
|
||||
>
|
||||
{label("google-maps")}
|
||||
</button>
|
||||
);
|
||||
const apple = (
|
||||
<button
|
||||
key="apple"
|
||||
type="button"
|
||||
onClick={() => openIn("apple-maps")}
|
||||
disabled={busy !== null}
|
||||
style={platform === "ios" ? primaryBtn : secondaryBtn}
|
||||
>
|
||||
{label("apple-maps")}
|
||||
</button>
|
||||
);
|
||||
// Prominent (filled) button first; secondary second.
|
||||
const ordered = platform === "ios" ? [apple, google] : [google, apple];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<span style={{ fontSize: 12, color: "#4b5563", fontWeight: 600, marginRight: 4 }}>
|
||||
Navigate
|
||||
</span>
|
||||
{ordered}
|
||||
</div>
|
||||
{error && <div style={{ fontSize: 12, color: "#991b1b" }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoutesPage() {
|
||||
@@ -241,6 +457,7 @@ export function RoutesPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isGroomer = me?.role === "groomer";
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Resolve the current staff member; groomers are pinned to their own route.
|
||||
useEffect(() => {
|
||||
@@ -393,6 +610,13 @@ export function RoutesPage() {
|
||||
[data]
|
||||
);
|
||||
|
||||
// Pre-warm OSM map tiles for the route area whenever a route (re)loads or is
|
||||
// re-optimized, so the map stays viewable offline. Runs after today's route is
|
||||
// fetched on page load and after every optimize/reorder that yields new stops.
|
||||
useEffect(() => {
|
||||
if (mapStops.length > 0) prewarmRouteTiles(mapStops);
|
||||
}, [mapStops]);
|
||||
|
||||
const stops = data?.stops ?? [];
|
||||
const route = data?.route ?? null;
|
||||
|
||||
@@ -468,9 +692,16 @@ export function RoutesPage() {
|
||||
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
||||
{/* Navigation export — open the route in the device's maps app */}
|
||||
{route && stops.length > 0 && (
|
||||
<div style={{ marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
|
||||
<NavExportButtons routeId={route.id} primaryColor={primaryColor} fullWidth={isMobile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
|
||||
{/* Map */}
|
||||
<div style={{ height: 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
||||
<div style={{ height: isMobile ? 340 : 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
|
||||
{mapStops.length > 0 ? (
|
||||
<Suspense fallback={<Centered>Loading map…</Centered>}>
|
||||
<RouteMap stops={mapStops} primaryColor={primaryColor} />
|
||||
@@ -481,7 +712,7 @@ export function RoutesPage() {
|
||||
</div>
|
||||
|
||||
{/* Stop list panel — drag-to-reorder */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: isMobile ? "none" : 540, overflowY: isMobile ? "visible" : "auto" }}>
|
||||
{stops.length === 0 && !loading && (
|
||||
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
|
||||
)}
|
||||
|
||||
@@ -13,8 +13,10 @@ import { Communication } from "./sections/Communication.js";
|
||||
import { AccountSettings } from "./sections/AccountSettings.js";
|
||||
import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { OOBE } from "./OOBE.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
@@ -52,6 +54,13 @@ export function CustomerPortal() {
|
||||
// (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);
|
||||
// GRO-2359 — the SSO bridge 404 (no client row for the user's email)
|
||||
// routes the user into the OOBE. We mount the OOBE inline rather than
|
||||
// navigating to /onboarding so the post-auth flow stays inside the
|
||||
// CustomerPortal render tree (test-isolated, no App-level router needed
|
||||
// for the integration to work). The /onboarding route in App.tsx is
|
||||
// still the mount point for direct deep-links to the same component.
|
||||
const [showOOBE, setShowOOBE] = useState(false);
|
||||
const { branding } = useBranding();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@@ -62,6 +71,18 @@ export function CustomerPortal() {
|
||||
initDone.current = true;
|
||||
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
// GRO-2359: a deep-link to a portal sub-route with ?noAccess=deleted-portal
|
||||
// is the only path that still shows the no-access card. The post-auth
|
||||
// 404-from-bridge path now navigates to /onboarding (OOBE) so the new
|
||||
// user can create a portal. The deleted-portal case is set explicitly
|
||||
// (e.g. a groomer who disabled a client) and uses the same no-access
|
||||
// UI with the shared signOut() — that was the GRO-2358 invariant.
|
||||
const noAccess = searchParams.get("noAccess");
|
||||
if (noAccess === "deleted-portal") {
|
||||
setAuthError(
|
||||
"Your portal access has been removed. Please contact your groomer if you think this is a mistake.",
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
setIsImpersonating(true);
|
||||
@@ -152,11 +173,13 @@ export function CustomerPortal() {
|
||||
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."
|
||||
);
|
||||
// Authenticated but no matching client row — mount the OOBE
|
||||
// (GRO-2359) so the user can create their portal record instead
|
||||
// of landing on the no-access card. The no-access card itself is
|
||||
// still reachable for the deleted-portal case (see GRO-2358) via
|
||||
// the ?noAccess=deleted-portal deep-link, but is no longer in
|
||||
// the new-user path.
|
||||
setShowOOBE(true);
|
||||
}
|
||||
// 401/other: fall through; App.tsx render guard will redirect to /login.
|
||||
} catch {
|
||||
@@ -193,6 +216,19 @@ export function CustomerPortal() {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
||||
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
||||
// the same implementation as `AdminLayout`. Failure to reach the server
|
||||
// still leaves the SPA free to navigate to /login.
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const logPageView = useCallback((page: string) => {
|
||||
if (!session) return;
|
||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||
@@ -266,6 +302,15 @@ export function CustomerPortal() {
|
||||
// session state. Dev users are verified via localStorage and the dev-session flow.
|
||||
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
|
||||
if (!session && !portalSessionId) {
|
||||
// GRO-2359 — new-user path: mount the OOBE inline so the SSO bridge's
|
||||
// 404 hands the user a portal-creation form instead of the no-access
|
||||
// card. onCompleted triggers a full page reload to /, which re-runs
|
||||
// the bridge (now with a matching client row) and lands the user in
|
||||
// the portal. A full reload (not React Router navigate) is the
|
||||
// safest reset of the bridge's cached state.
|
||||
if (showOOBE) {
|
||||
return <OOBE onCompleted={() => { window.location.href = "/"; }} />;
|
||||
}
|
||||
if (authError) {
|
||||
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
|
||||
return (
|
||||
@@ -281,14 +326,7 @@ export function CustomerPortal() {
|
||||
<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";
|
||||
}}
|
||||
onClick={() => { void handleSignOut(); }}
|
||||
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} />
|
||||
@@ -413,7 +451,14 @@ export function CustomerPortal() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Session controls (only shown during active impersonation) */}
|
||||
{/* Session controls — Sign out is always reachable from the portal
|
||||
chrome (GRO-2373). End Impersonation is staff-only and only
|
||||
appears during an active impersonation session. Both share the
|
||||
same LogOut icon for visual consistency, but route to distinct
|
||||
handlers: handleSignOut calls the canonical Better Auth
|
||||
`signOut()` (mirroring OOBE and the no-access card); handleEnd
|
||||
tears down the staff impersonation session and returns to the
|
||||
admin clients list. */}
|
||||
<div className="border-t border-stone-100 p-4 space-y-2">
|
||||
{session?.status === "active" && (
|
||||
<button
|
||||
@@ -424,6 +469,15 @@ export function CustomerPortal() {
|
||||
End Impersonation
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleSignOut(); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-stone-700 bg-stone-50 hover:bg-stone-100 transition-colors"
|
||||
data-testid="portal-chrome-signout"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs text-stone-400">
|
||||
<Shield size={12} />
|
||||
Customer Portal v1.0
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LogOut, Shield, Sparkles } from "lucide-react";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
|
||||
/**
|
||||
* OOBE (Out-of-Box Experience) for a first-time Authentik SSO user whose
|
||||
* email does not match any existing `clients` row.
|
||||
*
|
||||
* The post-auth handler in `CustomerPortal.tsx` redirects to this component
|
||||
* when `POST /api/portal/session-from-auth` returns 404. From here the new
|
||||
* user can either:
|
||||
* (a) Create a fresh customer record bound to their SSO email, or
|
||||
* (b) Sign out (the no-access screen is no longer in the new-user path).
|
||||
*
|
||||
* After successful creation, the OOBE navigates to `/` so the portal's
|
||||
* existing SSO bridge re-runs and lands the user in their portal with a
|
||||
* real `X-Impersonation-Session-Id` header. No new client state is
|
||||
* required — the bridge re-resolves the session and the rest of the
|
||||
* portal is unchanged.
|
||||
*
|
||||
* GRO-2359 — root-cause fix for "new SSO user lands on Portal access not
|
||||
* configured" (companion to GRO-2358, which restored logout on that screen).
|
||||
*/
|
||||
|
||||
type OOBEFormState = {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
type OOBEStatus = "loading" | "ready" | "submitting" | "error";
|
||||
|
||||
const EMPTY_FORM: OOBEFormState = {
|
||||
name: "",
|
||||
phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
type OOBEProps = {
|
||||
/**
|
||||
* Override the post-success destination. Defaults to `/` so the SSO bridge
|
||||
* re-runs. Test suites pass a custom destination to keep assertions
|
||||
* deterministic without a real portal session.
|
||||
*/
|
||||
onCompleted?: () => void;
|
||||
};
|
||||
|
||||
export function OOBE({ onCompleted }: OOBEProps = {}) {
|
||||
const [status, setStatus] = useState<OOBEStatus>("loading");
|
||||
const [form, setForm] = useState<OOBEFormState>(EMPTY_FORM);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionEmail, setSessionEmail] = useState<string | null>(null);
|
||||
|
||||
// Resolve the Better Auth session on mount. The OOBE is gated to
|
||||
// authenticated users — if no session exists the API will reject the
|
||||
// creation request, so we redirect to /login early. We prefill `name`
|
||||
// from the Better Auth `user.name` if the SSO provider returned one.
|
||||
//
|
||||
// We use a full `window.location.href` redirect (not `navigate`) so the
|
||||
// OOBE works the same way whether it's mounted from the post-auth
|
||||
// callback (inside CustomerPortal's render tree) or from a direct
|
||||
// deep-link (mounted by App.tsx). A full reload also resets any
|
||||
// cached state in the parent component.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/auth/get-session", { credentials: "include" });
|
||||
if (!r.ok) {
|
||||
if (!cancelled) window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
const data = (await r.json().catch(() => null)) as
|
||||
| { user?: { email?: string; name?: string; role?: string | null } }
|
||||
| null;
|
||||
if (cancelled) return;
|
||||
if (!data?.user) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
if (data.user.role === "staff") {
|
||||
window.location.href = "/admin";
|
||||
return;
|
||||
}
|
||||
setSessionEmail(data.user.email ?? null);
|
||||
setForm((prev) => ({ ...prev, name: data.user?.name ?? prev.name }));
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
if (!cancelled) window.location.href = "/login";
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(field: keyof OOBEFormState) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (status === "submitting") return;
|
||||
if (!form.name.trim()) {
|
||||
setError("Please tell us your name so we can set up your portal.");
|
||||
return;
|
||||
}
|
||||
setStatus("submitting");
|
||||
setError(null);
|
||||
try {
|
||||
const r = await fetch("/api/portal/clients-from-auth", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim() || null,
|
||||
address: form.address.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
// Let the parent (or default) decide where to land. The default
|
||||
// is the portal root, which re-runs the SSO bridge. A full
|
||||
// `window.location.href` reload resets any cached state in the
|
||||
// parent (the bridge reads from Better Auth cookies, so a fresh
|
||||
// request picks up the new client row).
|
||||
if (onCompleted) {
|
||||
onCompleted();
|
||||
} else {
|
||||
window.location.href = "/";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (r.status === 409) {
|
||||
setStatus("ready");
|
||||
setError(
|
||||
"A customer record with this email already exists. Please contact your groomer to link your account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = (await r.json().catch(() => null)) as { error?: string } | null;
|
||||
setStatus("ready");
|
||||
setError(body?.error ?? "We couldn't set up your portal. Please try again.");
|
||||
} catch {
|
||||
setStatus("ready");
|
||||
setError("Network error. Please check your connection and try again.");
|
||||
}
|
||||
},
|
||||
[form, onCompleted, status],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6 py-10"
|
||||
role="main"
|
||||
>
|
||||
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-100 text-emerald-700 flex items-center justify-center mx-auto mb-4">
|
||||
<Sparkles size={22} />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-stone-800 text-center mb-1">
|
||||
Welcome — let's set up your portal
|
||||
</h1>
|
||||
<p className="text-sm text-stone-600 text-center mb-6">
|
||||
You're signed in{sessionEmail ? ` as ${sessionEmail}` : ""}. We just need a few
|
||||
details to create your customer record.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-name"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Your name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
value={form.name}
|
||||
onChange={handleChange("name")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-phone"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Phone <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={form.phone}
|
||||
onChange={handleChange("phone")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-address"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Address <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="oobe-address"
|
||||
name="address"
|
||||
type="text"
|
||||
autoComplete="street-address"
|
||||
value={form.address}
|
||||
onChange={handleChange("address")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="oobe-notes"
|
||||
className="block text-xs font-medium text-stone-700 mb-1"
|
||||
>
|
||||
Notes <span className="text-stone-400">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="oobe-notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={handleChange("notes")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-stone-300 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
|
||||
disabled={status === "submitting"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2"
|
||||
>
|
||||
<Shield size={14} className="mt-0.5 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{status === "submitting" ? "Setting up…" : "Create my portal"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-stone-100 flex items-center justify-between">
|
||||
<p className="text-xs text-stone-500">
|
||||
Wrong account? Sign out and try a different one.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleSignOut();
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
<LogOut size={12} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OOBE;
|
||||
@@ -315,9 +315,19 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
scheduled: 'Scheduled',
|
||||
};
|
||||
|
||||
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
|
||||
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
|
||||
// appointment renders as a raw gray `no_show` label instead of the styled
|
||||
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
|
||||
// palette key so DB-sourced statuses resolve to their intended badge style.
|
||||
export function normalizeStatusKey(status: string): string {
|
||||
return status.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const label = STATUS_LABELS[status] ?? status;
|
||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||
const key = normalizeStatusKey(status);
|
||||
const label = STATUS_LABELS[key] ?? status;
|
||||
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{label}
|
||||
@@ -325,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Derives the badge state shown on an Upcoming/Past card from the appointment's
|
||||
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
|
||||
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
|
||||
// - an upcoming appointment the groomer has not yet confirmed
|
||||
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
|
||||
// and reduces anxiety-driven follow-up messages
|
||||
// - otherwise the raw status drives the badge
|
||||
export function deriveDisplayStatus(appt: Appointment): string {
|
||||
if (appt.status === 'waitlisted') return 'waitlisted';
|
||||
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
|
||||
return appt.status;
|
||||
}
|
||||
|
||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
@@ -508,11 +531,24 @@ function AppointmentCard({
|
||||
sessionId: string | null;
|
||||
onReschedule: (appt: Appointment) => void;
|
||||
}) {
|
||||
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
|
||||
// confirmed appointment: it gets a muted, dashed-border card and a subtext
|
||||
// line so the customer can tell it apart from booked appointments, and the
|
||||
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
|
||||
const isWaitlist = appt.status === 'waitlisted';
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
isWaitlist
|
||||
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
|
||||
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
|
||||
className={`w-full flex items-center gap-4 p-4 text-left ${
|
||||
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
||||
{appt.petName?.charAt(0) || 'P'}
|
||||
@@ -532,8 +568,13 @@ function AppointmentCard({
|
||||
</span>
|
||||
<span>with {appt.groomerName || 'First Available'}</span>
|
||||
</div>
|
||||
{isWaitlist && (
|
||||
<p className="text-xs text-stone-400 mt-1">
|
||||
You're on the waitlist — we'll let you know if a spot opens.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={appt.status} />
|
||||
<StatusBadge status={deriveDisplayStatus(appt)} />
|
||||
{expanded ? (
|
||||
<ChevronDown size={16} className="text-stone-400" />
|
||||
) : (
|
||||
@@ -567,11 +608,14 @@ function AppointmentCard({
|
||||
{appt.notes}
|
||||
</p>
|
||||
)}
|
||||
{isUpcoming(appt) && !readOnly && (
|
||||
{!isWaitlist && isUpcoming(appt) && !readOnly && (
|
||||
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
|
||||
{appt.status !== 'completed' &&
|
||||
{!isWaitlist && isUpcoming(appt) && (
|
||||
<ConfirmationSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{!isWaitlist &&
|
||||
appt.status !== 'completed' &&
|
||||
appt.status !== 'cancelled' &&
|
||||
!readOnly && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
|
||||
@@ -57,6 +57,23 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// OpenStreetMap raster tiles for the Route Planner map. CacheFirst so
|
||||
// tiles pre-warmed for a route render offline during the day. Capped
|
||||
// entries + 7-day TTL keep the cache bounded.
|
||||
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*\.png$/i,
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "osm-tiles",
|
||||
expiration: {
|
||||
maxEntries: 400,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user