fix(GRO-2234): transparent re-mint on 401 for portal Book New submit
CI / Test (pull_request) Failing after 14m28s
CI / Lint & Typecheck (pull_request) Failing after 14m29s
CI / Build & Push Docker Image (pull_request) Has been skipped

A deliberately-paced Book New wizard could outlive the portal impersonation
session, so the final POST /api/portal/waitlist returned 401 and the UI showed
"Failed to book appointment. Please try again."

BookingFlow now retries once on a 401: it re-mints a fresh portal session via
POST /api/portal/session-from-auth (the customer's Better Auth cookie is still
valid) and resubmits the waitlist request with the new
X-Impersonation-Session-Id. Falls through to the existing error if no Better
Auth session is available (staff/dev impersonation paths).

- Appointments.tsx: remintPortalSession() helper; handleConfirmBooking submits
  via submitWaitlist(id) and retries once after a 401 re-mint.
- Test: first waitlist POST 401 -> re-mint -> retry with fresh id -> success;
  asserts exactly one re-mint and the header sequence.
- UAT_PLAYBOOK.md 5.12e: TC-WEB-5.12.25 slow-wizard submit succeeds.

Companion to groombook/api GRO-2234 (bounded sliding expiration).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Flea Flicker
2026-06-08 18:55:00 +00:00
parent 3d0c3c551b
commit 915a310e0a
3 changed files with 131 additions and 14 deletions
+50 -14
View File
@@ -8,6 +8,28 @@ import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
/**
* Re-mint an SSO-bridge portal session from the active Better Auth session.
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
* impersonation session lapsed during a slow wizard), the customer's Better
* Auth cookie is still valid, so we can transparently obtain a fresh portal
* session id and retry once. Returns the new session id, or null if no Better
* Auth session is available (e.g. staff/dev impersonation paths).
*/
async function remintPortalSession(): Promise<string | null> {
try {
const res = await fetch('/api/portal/session-from-auth', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return null;
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
return data.sessionId ?? null;
} catch {
return null;
}
}
async function fetchAvailability(
params: { serviceId: string; date: string },
sessionId: string | null,
@@ -993,26 +1015,40 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
setSubmitting(true);
setError(null);
try {
const response = await fetch('/api/portal/waitlist', {
const payload = JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
});
const submitWaitlist = (id: string) =>
fetch('/api/portal/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Impersonation-Session-Id': sessionId ?? '',
'X-Impersonation-Session-Id': id,
},
body: JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
}),
body: payload,
});
try {
let response = await submitWaitlist(sessionId);
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
// The customer's Better Auth session is still valid, so transparently
// re-mint a fresh portal session and retry once before surfacing an error.
if (response.status === 401) {
const freshSessionId = await remintPortalSession();
if (freshSessionId) {
response = await submitWaitlist(freshSessionId);
}
}
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });