Better Auth's signIn.social() returns { data: { redirect: true, url } } rather
than issuing an HTTP 30x when using the fetch client. The LoginPage handler
was discarding data.url, so the SSO button appeared to do nothing (the button
disabled but the user never left /login).
Fix: after the social sign-in call, if result.data.url is present, navigate via
window.location.href. Also add an early return in the error branch so the two
paths don't bleed into each other.
Updated UAT_PLAYBOOK.md §5.4.1 TC-WEB-SSO-2 to require a fresh/incognito
context so a stale auth cookie can't mask the regression.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The post-auth handler in CustomerPortal previously rendered the
"Portal access not configured" card when the SSO bridge returned 404
(no client row for the user's email). That trapped first-time SSO
users on a dead-end screen with no path to portal creation.
This change routes the 404 to a new OOBE component (src/portal/OOBE.tsx)
that drives portal creation:
* Mounts inline inside CustomerPortal so the post-auth flow stays
inside the portal render tree (no App-level router needed).
* Also reachable as a direct deep-link via the new /onboarding route
in App.tsx (for grooming admins or recovery flows).
* Submits to a new POST /api/portal/clients-from-auth endpoint in
groombook-api (companion commit) that creates a fresh client row
bound to the Better Auth email. 409 means the email already has a
portal record — the OOBE shows a portal-selection message.
* Uses the canonical shared signOut() from lib/auth-client (GRO-2358
invariant) for the Sign out button.
* Full window.location.href reload on submit success to reset the
bridge's cached state and land the user in their portal.
The no-access card itself is preserved for the deep-link deleted-portal
case (a customer whose portal was disabled/deleted), signalled via
?noAccess=deleted-portal on a portal sub-route. The OOBE handles the
first-time-creation case; the no-access card handles the "had a portal
but lost it" case.
Test coverage:
* "routes to /onboarding when session-from-auth returns 404 (GRO-2359)"
— proves the post-auth 404 mounts the OOBE inline, not the legacy
no-access card.
* 6 new OOBE tests: render from direct link, name prefill, form
submission, 409 portal-selection, required-name validation, shared
signOut(), redirect on no-session.
* P1 no-access tests reworked to use ?noAccess=deleted-portal so the
GRO-2358 signOut invariant is still verified on the only surviving
path to the no-access card.
UAT_PLAYBOOK §5.25.5–6e rewritten to cover the OOBE flow (form submit,
409, deep-link mount, deleted-portal no-access card).
Paired with the api PR on feature/2357-p2-portal-clients-from-auth.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The /api/portal/appointments endpoint returns ISO startTime/endTime plus
nested pet/service/staff objects, but the portal client Appointment type
expected flat date/time/petName fields. isUpcoming() read appt.date/appt.time
(both undefined), so parseTimeTo24Hour(undefined) threw a TypeError; the
useEffect try/catch set the error state and the success-path-only Book New
button became unreachable.
- Add normalizeAppointment() at the fetch boundary mapping the API shape to the
flat Appointment shape (derives display date/time from startTime, duration
from the start/end delta), tolerant of the legacy flat shape.
- Prefer absolute startTime in isUpcoming(); fall back to date/time.
- Harden parseTimeTo24Hour against blank/undefined input (no NaN).
- Add Appointment.startTime/endTime to the type.
- Tests: normalizeAppointment + isUpcoming(startTime) + parseTimeTo24Hour safety.
- Update UAT_PLAYBOOK.md §5.12.2 and new §5.12d regression cases.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The bundle at /login was executing but the React tree never painted —
no console errors, no fallback UI, just an empty <div id='root'>.
Add three layers of defense so a future failure of this shape is
captured instead of being silently swallowed:
1. window 'error' and 'unhandledrejection' listeners in main.tsx,
printing structured context to console.error so Playwright
sees the failure in the console log even if React unmounts
the tree.
2. A top-level <ErrorBoundary> in main.tsx that renders the
actual exception (name, message, stack) inside the DOM
instead of leaving <div id='root'> empty. The boundary also
logs to console.error via componentDidCatch.
3. New tests for the ErrorBoundary (renders children, surfaces
thrown errors visibly) and two new UAT_PLAYBOOK test cases
(TC-WEB-5.1.6 / 5.1.7) that explicitly assert the
'never-blank-root' invariant on UAT.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating &&
!getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For
SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session`
and then `/api/portal/session-from-auth` to populate `portalSessionId`. During
that bootstrap window (typically 100-300ms), `sessionId` is null and the guard
fires — redirecting the user to `/login` and breaking the post-sign-in flow.
App.tsx additionally returned `null` at `/login` for authenticated users
(`showCustomerPortal` is false at `/login`), leaving a blank React root even
if the redirect target was /login itself.
Fix:
- `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while
`!initComplete`. The portal chrome and its child sections only mount once
the bootstrap has resolved, so child auth guards don't fire prematurely.
- `App.tsx`: at `/login` with a valid session, redirect to `/` so the
customer lands on the portal instead of seeing a blank page.
- `App.tsx`: only return `LoginPage` when at `/login` — other portal
routes defer the auth check to `CustomerPortal` (the customer SSO bridge
resolves `portalSessionId` on mount).
- `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading
state, the /login auto-redirect, the unauth fallback, and the groomer /
impersonation non-regressions.
- `src/__tests__/portal.test.tsx`: add a regression test that asserts the
loading state is shown during the bridge and the portal nav is NOT in the
DOM mid-bootstrap.
Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image
`2026.06.01-ec29f71`):
1. From `about:blank`, complete customer SSO as `uat-customer`.
2. `browser_navigate` to `/portal`.
3. Pre-fix: redirected to `/login` with blank React root.
4. Post-fix: URL stays at `/portal`, dashboard renders with customer name.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (closes#38)
- src/portal/CustomerPortal.tsx:329 - use portalSessionId fallback for RescheduleFlow
- src/__tests__/portal.test.tsx - new regression test
- UAT_PLAYBOOK.md §5.26 - new test cases
cc @cpfarhood
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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>
Gro-1794 required UAT test cases for the booking funnel analytics events.
Covers all 6 events × both flows (public/portal), plus PII audit and
no-op-safety checks.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Documents the acceptance criteria for GRO-1592: after completing
Authentik SSO login without VITE_API_URL set, the
__Secure-better-auth.session_token cookie must be present in the
browser and sent with subsequent /api/* calls.
Updated: UAT_PLAYBOOK.md §5.3
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Add §5.23 covering:
- API persistence (page reload verification)
- Save error state (form stays open on failure)
- Saving indicator (spinner while in-flight)
Updated UAT_PLAYBOOK.md §5.23
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- §5.7: add TC-WEB-5.7.5 through TC-WEB-5.7.7 for pet size/coat in admin UI
- §5.8.1 (new): add Buffer Rules Management test cases
- TC-WEB-5.8.2 through TC-WEB-5.8.7: create/edit/delete buffer rules, size/coat filtering, empty state
- §5.9: add TC-WEB-5.9.4 and TC-WEB-5.9.5 for service default buffer minutes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Sections 5.20, 5.21, and 5.22 added to UAT_PLAYBOOK.md:
- TC-WEB-5.20.x: Buffer rules CRUD, validation, empty state (14 cases)
- TC-WEB-5.21.x: Service default buffer field in table and form (7 cases)
- TC-WEB-5.22.x: Pet size category and coat type in portal PetForm (7 cases)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* docs(GRO-1289): add UAT_PLAYBOOK.md with auth base URL test cases
Add UAT_PLAYBOOK.md covering VITE_API_URL auth resolution:
- TC-AUTH-4.1.x: Tests for when VITE_API_URL is set
- TC-AUTH-4.2.x: Tests for when VITE_API_URL is unset (window.location.origin fallback)
- TC-AUTH-4.3.x: Session persistence tests
Updated UAT_PLAYBOOK.md §4 — auth base URL resolution test cases.
GRO-1289
* docs(GRO-1289): restore full UAT_PLAYBOOK with auth base URL test cases
- Restored Pre-conditions section (§3)
- Restored original §5.1 Authentication UI test cases
- Inserted new auth base URL resolution test cases (§5.2–§5.4):
- TC-AUTH-5.2.x: VITE_API_URL set scenarios
- TC-AUTH-5.3.x: VITE_API_URL unset fallback scenarios
- TC-AUTH-5.4.x: Session persistence scenarios
- Restored all other feature test sections (§5.5–§5.17)
- Restored broader Update Policy (§7)
Updated UAT_PLAYBOOK.md §5.2–§5.4 — auth base URL resolution test cases
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>