From 2577e33c50a1d9bc8e0968f6a15228d12103fd1e Mon Sep 17 00:00:00 2001 From: "groombook-engineer[bot]" <269742240+groombook-engineer[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:20:36 +0000 Subject: [PATCH 01/12] feat(GRO-653): add portal session middleware and server-side audit logging (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(GRO-653): add portal session middleware and server-side audit logging - Add validatePortalSession middleware that reads X-Impersonation-Session-Id header, queries impersonationSessions, and sets portalClientId + portalSessionId on the context - Add portalAudit middleware that logs all portal requests to impersonationAuditLogs table - Apply both middlewares to the portalRouter - Replace all getClientIdFromSession() calls with c.get("portalClientId") - Remove getClientIdFromSession() helper and inline session checks in waitlist routes - Consistent session.expiry > new Date() check across all routes Co-Authored-By: Paperclip * fix(GRO-653): remove unused sessionId variable and and import Fix lint errors flagged by QA: - Remove unused `sessionId` variable from PATCH waitlist handler - Remove unused `and` import from portal.ts πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Paperclip --------- Co-authored-by: Flea Flicker Co-authored-by: Paperclip Co-authored-by: Flea Flicker --- apps/api/src/middleware/portalAudit.ts | 45 +++++++ apps/api/src/middleware/portalSession.ts | 40 +++++++ apps/api/src/routes/portal.ts | 145 ++++------------------- 3 files changed, 108 insertions(+), 122 deletions(-) create mode 100644 apps/api/src/middleware/portalAudit.ts create mode 100644 apps/api/src/middleware/portalSession.ts diff --git a/apps/api/src/middleware/portalAudit.ts b/apps/api/src/middleware/portalAudit.ts new file mode 100644 index 0000000..a18129d --- /dev/null +++ b/apps/api/src/middleware/portalAudit.ts @@ -0,0 +1,45 @@ +import type { MiddlewareHandler } from "hono"; +import { getDb, impersonationAuditLogs } from "@groombook/db"; +import type { PortalEnv } from "./portalSession.js"; + +/** + * Server-side audit logging middleware for portal routes. + * Applied after validatePortalSession in the middleware chain. + * + * After the route handler completes (await next()), inserts an audit log entry + * into impersonationAuditLogs: + * - sessionId: from c.get("portalSessionId") + * - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments") + * - pageVisited: c.req.path + * - metadata: { method, statusCode: c.res.status } + * + * Log entries are written for both success and error responses. + * Does NOT throw if audit logging fails β€” errors are logged but the user's + * request is not affected. + */ +export const portalAudit: MiddlewareHandler = async (c, next) => { + await next(); + + const sessionId = c.get("portalSessionId"); + if (!sessionId) return; + + const method = c.req.method; + const routePath = c.req.path; + const pageVisited = c.req.path; + const statusCode = c.res.status; + + try { + const db = getDb(); + await db + .insert(impersonationAuditLogs) + .values({ + sessionId, + action: `${method} ${routePath}`, + pageVisited, + metadata: { method, statusCode }, + }) + .returning(); + } catch (err) { + console.error("[portalAudit] Failed to write audit log:", err); + } +}; diff --git a/apps/api/src/middleware/portalSession.ts b/apps/api/src/middleware/portalSession.ts new file mode 100644 index 0000000..6dfdb03 --- /dev/null +++ b/apps/api/src/middleware/portalSession.ts @@ -0,0 +1,40 @@ +import type { MiddlewareHandler } from "hono"; +import { and, eq, getDb, impersonationSessions } from "@groombook/db"; + +export interface PortalEnv { + Variables: { + portalClientId: string; + portalSessionId: string; + }; +} + +/** + * Validates the X-Impersonation-Session-Id header against the impersonationSessions table. + * Must be applied to all portal routes. + * + * Reads x-session-id from request headers, queries impersonationSessions for a row where + * id = sessionId AND status = 'active', and checks session.expiresAt > new Date(). + * Returns 401 if session is invalid/missing/expired. + * On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id). + */ +export const validatePortalSession: MiddlewareHandler = async (c, next) => { + const sessionId = c.req.header("X-Impersonation-Session-Id"); + if (!sessionId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const db = getDb(); + const [session] = await db + .select() + .from(impersonationSessions) + .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) + .limit(1); + + if (!session || session.expiresAt <= new Date()) { + return c.json({ error: "Unauthorized" }, 401); + } + + c.set("portalClientId", session.clientId); + c.set("portalSessionId", session.id); + await next(); +}; diff --git a/apps/api/src/routes/portal.ts b/apps/api/src/routes/portal.ts index 8b10b56..d768bc8 100644 --- a/apps/api/src/routes/portal.ts +++ b/apps/api/src/routes/portal.ts @@ -1,33 +1,22 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v3"; -import { and, eq, inArray } from "@groombook/db"; +import { eq, inArray } from "@groombook/db"; import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db"; -import type { AppEnv } from "../middleware/rbac.js"; +import { validatePortalSession } from "../middleware/portalSession.js"; +import { portalAudit } from "../middleware/portalAudit.js"; +import type { PortalEnv } from "../middleware/portalSession.js"; -export const portalRouter = new Hono(); +export const portalRouter = new Hono(); -// ─── Session helper ─────────────────────────────────────────────────────────── - -async function getClientIdFromSession(sessionId: string | null | undefined): Promise { - if (!sessionId) return null; - const db = getDb(); - const [session] = await db - .select() - .from(impersonationSessions) - .where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active"))) - .limit(1); - if (!session || session.expiresAt <= new Date()) return null; - return session.clientId; -} +// Apply middleware to all portal routes +portalRouter.use("/*", validatePortalSession, portalAudit); // ─── GET routes ────────────────────────────────────────────────────────────── portalRouter.get("/me", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1); if (!client) return c.json({ error: "Not found" }, 404); @@ -49,9 +38,7 @@ portalRouter.get("/services", async (c) => { portalRouter.get("/appointments", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const now = new Date(); const allAppts = await db @@ -101,9 +88,7 @@ portalRouter.get("/appointments", async (c) => { portalRouter.get("/pets", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes }))); @@ -111,9 +96,7 @@ portalRouter.get("/pets", async (c) => { portalRouter.get("/invoices", async (c) => { const db = getDb(); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId)); const invoiceIds = clientInvoices.map(i => i.id); @@ -148,12 +131,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -196,12 +174,7 @@ portalRouter.patch( portalRouter.post("/appointments/:id/confirm", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -250,12 +223,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => { portalRouter.post("/appointments/:id/cancel", async (c) => { const db = getDb(); const id = c.req.param("id"); - - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [appt] = await db .select() @@ -319,28 +287,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - let clientId: string | null = null; - if (sessionId) { - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - if (session && session.expiresAt > new Date()) { - clientId = session.clientId; - } - } - - if (!clientId) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .insert(waitlistEntries) @@ -364,26 +311,7 @@ portalRouter.patch( const db = getDb(); const id = c.req.param("id"); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [existing] = await db .select() @@ -392,7 +320,7 @@ portalRouter.patch( .limit(1); if (!existing) return c.json({ error: "Not found" }, 404); - if (existing.clientId !== session.clientId) { + if (existing.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -414,26 +342,7 @@ portalRouter.patch( portalRouter.delete("/waitlist/:id", async (c) => { const db = getDb(); const id = c.req.param("id"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - - if (!sessionId) { - return c.json({ error: "Unauthorized" }, 401); - } - - const [session] = await db - .select() - .from(impersonationSessions) - .where( - and( - eq(impersonationSessions.id, sessionId), - eq(impersonationSessions.status, "active") - ) - ) - .limit(1); - - if (!session || session.expiresAt <= new Date()) { - return c.json({ error: "Unauthorized" }, 401); - } + const clientId = c.get("portalClientId"); const [entry] = await db .select() @@ -442,7 +351,7 @@ portalRouter.delete("/waitlist/:id", async (c) => { .limit(1); if (!entry) return c.json({ error: "Not found" }, 404); - if (entry.clientId !== session.clientId) { + if (entry.clientId !== clientId) { return c.json({ error: "Forbidden" }, 403); } @@ -475,9 +384,7 @@ portalRouter.post( async (c) => { const db = getDb(); const body = c.req.valid("json"); - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const invoiceRows = await db .select() @@ -514,9 +421,7 @@ portalRouter.post( ); portalRouter.get("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const methods = await listPaymentMethods(clientId); if (methods === null) return c.json({ error: "Payment service unavailable" }, 503); @@ -524,9 +429,7 @@ portalRouter.get("/payment-methods", async (c) => { }); portalRouter.post("/payment-methods", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? ""; const customerId = await getOrCreateStripeCustomer(clientId); @@ -539,9 +442,7 @@ portalRouter.post("/payment-methods", async (c) => { }); portalRouter.delete("/payment-methods/:id", async (c) => { - const sessionId = c.req.header("X-Impersonation-Session-Id"); - const clientId = await getClientIdFromSession(sessionId); - if (!clientId) return c.json({ error: "Unauthorized" }, 401); + const clientId = c.get("portalClientId"); const paymentMethodId = c.req.param("id"); -- 2.52.0 From c7865443697c858c66d0f8459d7559f8c4a5c6de Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:17:01 +0000 Subject: [PATCH 02/12] Fix frontend error handling and code quality (GRO-642) HIGH Priority: 1. SetupWizard.jsx -> SetupWizard.tsx: renamed to .tsx with proper TypeScript types 2. deleteAppt missing error handling: added try/catch, response.ok check, alert on failure 3. GlobalSearch missing error state: added error state with user-visible error message MEDIUM Priority: 4. CustomerPortal unsafe type cast: fixed 'as any' to proper PortalAppointment type 5. Logo upload XSS risk: sanitized MIME types to png/jpeg/gif/webp only, removed SVG 6. Reports error handling: added ok checks before json() parsing to guard against invalid JSON on error responses LOW Priority: 8. Modal accessibility: added role='dialog', aria-modal='true', focus trap, Escape key handler, restore focus on close 9. PetPhotoUpload file size: added 50MB max file size check before resize 10. Types package: added photoKey and photoUploadedAt to Pet interface Co-Authored-By: Paperclip --- apps/web/src/App.tsx | 2 +- apps/web/src/components/GlobalSearch.tsx | 16 +++- apps/web/src/components/PetPhotoUpload.tsx | 6 ++ apps/web/src/pages/Appointments.tsx | 54 ++++++++++- apps/web/src/pages/Reports.tsx | 10 +-- apps/web/src/pages/Settings.tsx | 6 +- apps/web/src/pages/SetupWizard.d.ts | 2 +- .../{SetupWizard.jsx => SetupWizard.tsx} | 89 +++++++++---------- apps/web/src/portal/CustomerPortal.tsx | 7 +- apps/web/src/portal/sections/Appointments.tsx | 2 +- packages/types/src/index.ts | 2 + 11 files changed, 131 insertions(+), 65 deletions(-) rename apps/web/src/pages/{SetupWizard.jsx => SetupWizard.tsx} (89%) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 28bde7c..83e95d6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,7 +12,7 @@ import { SettingsPage } from "./pages/Settings.js"; import { BookingConfirmedPage } from "./pages/BookingConfirmed.js"; import { BookingCancelledPage } from "./pages/BookingCancelled.js"; import { BookingErrorPage } from "./pages/BookingError.js"; -import { SetupWizard } from "./pages/SetupWizard.jsx"; +import { SetupWizard } from "./pages/SetupWizard.tsx"; import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; diff --git a/apps/web/src/components/GlobalSearch.tsx b/apps/web/src/components/GlobalSearch.tsx index 8971fde..deb4770 100644 --- a/apps/web/src/components/GlobalSearch.tsx +++ b/apps/web/src/components/GlobalSearch.tsx @@ -26,6 +26,7 @@ export function GlobalSearch() { const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); const [open, setOpen] = useState(false); const inputRef = useRef(null); const dropdownRef = useRef(null); @@ -45,15 +46,18 @@ export function GlobalSearch() { debounceRef.current = setTimeout(async () => { setLoading(true); + setError(null); try { const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); if (res.ok) { const data: SearchResults = await res.json(); setResults(data); setOpen(true); + } else { + setError("Search failed. Please try again."); } - } catch (err) { - console.warn("GlobalSearch: fetch error", err); + } catch { + setError("Search failed. Please try again."); } finally { setLoading(false); } @@ -160,7 +164,13 @@ export function GlobalSearch() { )} - {!loading && !hasResults && ( + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && !error && !hasResults && (
No results found
diff --git a/apps/web/src/components/PetPhotoUpload.tsx b/apps/web/src/components/PetPhotoUpload.tsx index f33ce48..0b479f3 100644 --- a/apps/web/src/components/PetPhotoUpload.tsx +++ b/apps/web/src/components/PetPhotoUpload.tsx @@ -71,6 +71,12 @@ export function PetPhotoUpload({ petId, onUploaded }: Props) { } async function handleFile(file: File) { + const MAX_FILE_SIZE = 50 * 1024 * 1024; + if (file.size > MAX_FILE_SIZE) { + setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." }); + return; + } + if (!ACCEPTED_TYPES.includes(file.type)) { setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." }); return; diff --git a/apps/web/src/pages/Appointments.tsx b/apps/web/src/pages/Appointments.tsx index 386354d..1dd7046 100644 --- a/apps/web/src/pages/Appointments.tsx +++ b/apps/web/src/pages/Appointments.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types"; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -273,7 +273,15 @@ export function AppointmentsPage() { cascade !== "this_only" ? `/api/appointments/${id}?cascade=${cascade}` : `/api/appointments/${id}`; - await fetch(url, { method: "DELETE" }); + try { + const res = await fetch(url, { method: "DELETE" }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + } catch (e: unknown) { + alert(e instanceof Error ? e.message : "Failed to delete appointment"); + } setSelectedAppt(null); await loadAppointments(); } @@ -819,8 +827,49 @@ function AppointmentDetail({ } function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { + const modalRef = useRef(null); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement; + const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusableElements = modalRef.current?.querySelectorAll(focusableSelectors); + const firstFocusable = focusableElements?.[0]; + firstFocusable?.focus(); + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + if (!modalRef.current) return; + const focusables = modalRef.current.querySelectorAll(focusableSelectors); + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + previouslyFocused?.focus(); + }; + }, [onClose]); + return (
{ if (e.target === e.currentTarget) onClose(); }} >
, - revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }>, - apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }>, - svcRes.json() as Promise<{ rows: ServiceRow[] }>, - clientRes.json() as Promise, + summRes.ok ? summRes.json() as Promise : summRes.text().then(() => { throw new Error("summary response not ok"); }), + revRes.ok ? revRes.json() as Promise<{ byPeriod: RevenuePeriod[]; byGroomer: RevenueByGroomer[] }> : revRes.text().then(() => { throw new Error("revenue response not ok"); }), + apptRes.ok ? apptRes.json() as Promise<{ byPeriod: ApptPeriod[] }> : apptRes.text().then(() => { throw new Error("appointments response not ok"); }), + svcRes.ok ? svcRes.json() as Promise<{ rows: ServiceRow[] }> : svcRes.text().then(() => { throw new Error("services response not ok"); }), + clientRes.ok ? clientRes.json() as Promise : clientRes.text().then(() => { throw new Error("clients response not ok"); }), ]); setSummary(summData); diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 088a685..5ccb943 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -149,9 +149,9 @@ export function SettingsPage() { return; } - const validTypes = ["image/png", "image/svg+xml", "image/jpeg", "image/webp"]; + const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { - setMessage({ type: "error", text: "Logo must be PNG, SVG, JPEG, or WebP." }); + setMessage({ type: "error", text: "Logo must be PNG, JPEG, GIF, or WebP." }); return; } @@ -393,7 +393,7 @@ issuerUrl: authForm.issuerUrl, diff --git a/apps/web/src/pages/SetupWizard.d.ts b/apps/web/src/pages/SetupWizard.d.ts index 5758e2b..786c80d 100644 --- a/apps/web/src/pages/SetupWizard.d.ts +++ b/apps/web/src/pages/SetupWizard.d.ts @@ -1 +1 @@ -export { SetupWizard } from "./SetupWizard.jsx"; +export { SetupWizard } from "./SetupWizard.tsx"; diff --git a/apps/web/src/pages/SetupWizard.jsx b/apps/web/src/pages/SetupWizard.tsx similarity index 89% rename from apps/web/src/pages/SetupWizard.jsx rename to apps/web/src/pages/SetupWizard.tsx index 666b67c..8587519 100644 --- a/apps/web/src/pages/SetupWizard.jsx +++ b/apps/web/src/pages/SetupWizard.tsx @@ -2,16 +2,39 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useBranding } from "../BrandingContext.js"; -export function SetupWizard({ onSetupComplete }) { +interface SetupStatus { + showAuthProviderStep?: boolean; +} + +interface TestResult { + ok: boolean; + error?: string; +} + +interface AuthFormState { + providerId: string; + displayName: string; + issuerUrl: string; + internalBaseUrl: string; + clientId: string; + clientSecret: string; + scopes: string; +} + +interface Step { + id: string; + title: string; + description: string; +} + +export function SetupWizard({ onSetupComplete }: { onSetupComplete?: () => void }) { const navigate = useNavigate(); const { refresh: refreshBranding } = useBranding(); - // Fetch setup status to determine if auth provider step is needed - const [setupStatus, setSetupStatus] = useState(null); // null = loading + const [setupStatus, setSetupStatus] = useState(null); const [loadingStatus, setLoadingStatus] = useState(true); - // Auth provider form state - const [authForm, setAuthForm] = useState({ + const [authForm, setAuthForm] = useState({ providerId: "authentik", displayName: "", issuerUrl: "", @@ -21,16 +44,16 @@ export function SetupWizard({ onSetupComplete }) { scopes: "openid profile email", }); const [testingConnection, setTestingConnection] = useState(false); - const [testResult, setTestResult] = useState(null); // {ok: boolean, error?: string} + const [testResult, setTestResult] = useState(null); const [step, setStep] = useState(0); const [businessName, setBusinessName] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); useEffect(() => { fetch("/api/setup/status") - .then((r) => r.json()) + .then((r) => r.json() as Promise) .then((data) => { setSetupStatus(data); setLoadingStatus(false); @@ -40,8 +63,7 @@ export function SetupWizard({ onSetupComplete }) { }); }, []); - // Build steps dynamically based on setup status - const STEPS = setupStatus?.showAuthProviderStep + const STEPS: Step[] = setupStatus?.showAuthProviderStep ? [ { id: "welcome", title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." }, { id: "auth", title: "Auth Provider", description: "Configure your authentication provider to secure your GroomBook instance." }, @@ -63,9 +85,8 @@ export function SetupWizard({ onSetupComplete }) { const isFirst = step === 0; const canGoBack = step > 0 && step < STEPS.length - 1; - // Determine if we can proceed - depends on which step we're on const canGoNext = (() => { - if (step === STEPS.length - 1) return true; // done step + if (step === STEPS.length - 1) return true; if (current?.id === "business") return businessName.trim().length > 0; if (current?.id === "auth") { return ( @@ -94,9 +115,9 @@ export function SetupWizard({ onSetupComplete }) { scopes: authForm.scopes, }), }); - const data = await res.json(); + const data = (await res.json()) as TestResult; setTestResult(data); - } catch (e) { + } catch { setTestResult({ ok: false, error: "Network error. Please try again." }); } finally { setTestingConnection(false); @@ -105,12 +126,10 @@ export function SetupWizard({ onSetupComplete }) { const handleNext = async () => { if (step === STEPS.length - 1) { - // Done - redirect to admin navigate("/admin"); return; } - // Submit auth provider config if (current?.id === "auth") { setLoading(true); setError(null); @@ -129,12 +148,12 @@ export function SetupWizard({ onSetupComplete }) { }), }); if (!res.ok) { - const data = await res.json(); + const data = (await res.json()) as { error?: string }; setError(data.error || "Failed to save auth provider configuration. Please try again."); setLoading(false); return; } - } catch (e) { + } catch { setError("Network error. Please try again."); setLoading(false); return; @@ -142,7 +161,6 @@ export function SetupWizard({ onSetupComplete }) { setLoading(false); } - // Submit business name and complete setup if (current?.id === "business" && businessName.trim()) { setLoading(true); setError(null); @@ -153,16 +171,14 @@ export function SetupWizard({ onSetupComplete }) { body: JSON.stringify({ businessName: businessName.trim() }), }); if (!res.ok) { - const data = await res.json(); + const data = (await res.json()) as { error?: string }; setError(data.error || "Setup failed. Please try again."); setLoading(false); return; } - // Refresh branding so the nav bar shows the new business name refreshBranding(); - // Clear needsSetup state in App so the redirect to /admin sticks if (onSetupComplete) onSetupComplete(); - } catch (e) { + } catch { setError("Network error. Please try again."); setLoading(false); return; @@ -192,7 +208,7 @@ export function SetupWizard({ onSetupComplete }) { ); } - const inputStyle = { + const inputStyle: React.CSSProperties = { width: "100%", padding: "0.6rem 0.85rem", borderRadius: 8, @@ -220,7 +236,6 @@ export function SetupWizard({ onSetupComplete }) { maxWidth: 480, width: "100%", }}> - {/* Progress dots */}
{STEPS.map((_, i) => (
- {/* Step indicator */}

Step {step + 1} of {STEPS.length}

- {/* Title */}

{current?.title}

- {/* Description */}

{current?.description}

- {/* Step: Business name input */} {current?.id === "business" && ( setBusinessName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()} + onKeyDown={(e) => e.key === "Enter" && canGoNext && void handleNext()} autoFocus style={inputStyle} /> )} - {/* Step: Auth provider config form */} {current?.id === "auth" && (
- {/* Provider ID */}
- {/* Display Name */}
- {/* Issuer URL */}
- {/* Internal Base URL (optional) */}
- {/* Client ID */}
- {/* Client Secret */}
- {/* Scopes */}
- {/* Test Connection button */} - {/* Test result */} {testResult && (
)} - {/* Step: Super user info */} {current?.id === "superuser" && (
)} - {/* Step: Second admin info */} {current?.id === "admin" && (
)} - {/* Error message */} {error && (

)} - {/* Navigation buttons */}

)} + )} +
+ {loadingLogs &&
Loading…
} + {logs && logs.length === 0 &&
No visits yet
} + {logs && logs.length > 0 && ( + <> + {logs.slice(0, 3).map((log) => ( +
+ {new Date(log.groomedAt).toLocaleDateString()} + {log.cutStyle && Β· {log.cutStyle}} + {log.notes && Β· {log.notes}} +
+ ))} + {logs.length > 3 && ( +
+{logs.length - 3} more visits
+ )} + + )} +
+ ); + })()} +
+ ))} +
+ )} +
+ ); +} -- 2.52.0 From ea7bf4f49b6641598ff408d4f9e3e9230b15b46a Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 11:31:06 +0000 Subject: [PATCH 11/12] fix(GRO-749): use correct impersonation header in portal Appointments Replace Authorization: Bearer with X-Impersonation-Session-Id in all 5 mutation handlers in Appointments.tsx (confirm, cancel, save-notes, reschedule, booking). The portal backend validates X-Impersonation-Session-Id header, not Authorization Bearer. Co-Authored-By: Paperclip --- apps/web/src/portal/sections/Appointments.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 65e4c18..f5fad62 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -379,7 +379,7 @@ export function ConfirmationSection({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/confirm`, { method: 'POST', @@ -455,7 +455,7 @@ function CancelAppointmentButton({ try { const headers: Record = {}; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/cancel`, { method: 'POST', @@ -507,7 +507,7 @@ export function CustomerNotesSection({ try { const headers: Record = { 'Content-Type': 'application/json' }; if (sessionId) { - headers['Authorization'] = `Bearer ${sessionId}`; + headers['X-Impersonation-Session-Id'] = sessionId ?? ''; } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: 'PATCH', @@ -600,7 +600,7 @@ export function RescheduleFlow({ setError(null); try { const headers: Record = { 'Content-Type': 'application/json' }; - if (sessionId) headers['Authorization'] = `Bearer ${sessionId}`; + if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId ?? ''; const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, { method: 'POST', headers, @@ -784,7 +784,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${sessionId}`, + 'X-Impersonation-Session-Id': sessionId ?? '', }, body: JSON.stringify({ petId: selectedPet.id, -- 2.52.0 From 89505a2363f5c656c074ec77bf6becb9feef90fd Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 17 Apr 2026 12:14:49 +0000 Subject: [PATCH 12/12] fix(GRO-749): update test assertions to use X-Impersonation-Session-Id header QA found test assertion failures - tests were asserting the old (incorrect) Authorization: Bearer header instead of the correct X-Impersonation-Session-Id. Co-Authored-By: Paperclip --- apps/web/src/__tests__/Appointments.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index b223866..bc42a07 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -93,7 +93,7 @@ describe("CustomerNotesSection", () => { "/api/portal/appointments/appt-1/notes", expect.objectContaining({ headers: expect.objectContaining({ - "Authorization": "Bearer test-session-id", + "X-Impersonation-Session-Id": "test-session-id", }), }) ); @@ -269,7 +269,7 @@ describe("ConfirmationSection", () => { "/api/portal/appointments/appt-1/confirm", expect.objectContaining({ headers: expect.objectContaining({ - "Authorization": "Bearer test-session-id", + "X-Impersonation-Session-Id": "test-session-id", }), }) ); -- 2.52.0