import { useState, useCallback, useEffect, useRef } from "react"; import { useSearchParams, Navigate } from "react-router-dom"; import { Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, Settings, LogOut, Shield, } from "lucide-react"; import { Dashboard } from "./sections/Dashboard.js"; import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js"; import { PetProfiles } from "./sections/PetProfiles.js"; import { ReportCards } from "./sections/ReportCards.js"; import { BillingPayments } from "./sections/BillingPayments.js"; import { Communication } from "./sections/Communication.js"; import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; import { useBranding } from "../BrandingContext.js"; import { getDevUser } from "../pages/DevLoginSelector.js"; import type { ImpersonationSession } from "@groombook/types"; import type { Appointment as PortalAppointment } from "./sections/Appointments.js"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [ { id: "dashboard", label: "Home", icon: Home }, { id: "appointments", label: "Appointments", icon: Calendar }, { id: "pets", label: "My Pets", icon: PawPrint }, { id: "reports", label: "Report Cards", icon: FileText }, { id: "billing", label: "Billing", icon: CreditCard }, { id: "messages", label: "Messages", icon: MessageSquare }, { id: "settings", label: "Settings", icon: Settings }, ]; export function CustomerPortal() { const [activeSection, setActiveSection] = useState
("dashboard"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); const [showReschedule, setShowReschedule] = useState(false); const [rescheduleAppointment, setRescheduleAppointment] = useState(null); const [session, setSession] = useState(null); const [sessionExtended, setSessionExtended] = useState(false); const [clientName, setClientName] = useState(""); const [initComplete, setInitComplete] = useState(false); // Track whether an impersonation session fetch from URL param is in-flight // Dashboard will not redirect while this is true, allowing the session to load const [isImpersonating, setIsImpersonating] = useState(false); // Portal session ID for real SSO customers (GRO-1867). Populated by the // Better Auth → /api/portal/session-from-auth bridge below. Carries the // X-Impersonation-Session-Id header on subsequent portal API calls without // triggering the impersonation banner (the customer is themselves). const [portalSessionId, setPortalSessionId] = useState(null); // User-facing message when the SSO bridge cannot resolve a client record // (e.g. authenticated user with no matching client row). Rendered in place // of the portal chrome instead of bouncing back to /login. const [authError, setAuthError] = useState(null); const { branding } = useBranding(); const [searchParams, setSearchParams] = useSearchParams(); // On mount: load session from ?sessionId= URL param OR from dev user in localStorage const initDone = useRef(false); useEffect(() => { if (initDone.current) return; initDone.current = true; const sessionId = searchParams.get("sessionId"); if (sessionId) { setIsImpersonating(true); // Real impersonation session from URL param fetch(`/api/impersonation/sessions/${sessionId}`) .then((r) => { if (!r.ok) return null; return r.json() as Promise; }) .then((s) => { if (s && s.status === "active") { setSession(s); fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } }) .then(r => r.ok ? r.json() : null) .then(data => { if (data?.name) setClientName(data.name); }) .catch(() => {}); } setSearchParams({}, { replace: true }); }) .catch(() => { setSearchParams({}, { replace: true }); }) .finally(() => { setInitComplete(true); setIsImpersonating(false); }); return; } // Dev mode: check for dev user in localStorage and create a dev session const devUser = getDevUser(); if (devUser && devUser.type === "client") { fetch("/api/portal/dev-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: devUser.id }), }) .then((r) => { if (!r.ok) return null; return r.json() as Promise; }) .then((s) => { if (s && s.id) { setSession(s); setClientName(devUser.name); } }) .finally(() => setInitComplete(true)); return; } if (devUser && devUser.type === "staff") { // Staff dev user — fall through; App.tsx redirects to /admin. setInitComplete(true); return; } // Real SSO customer (GRO-1867): bridge a Better Auth session into a portal // session via POST /api/portal/session-from-auth. The returned session ID // is used in the X-Impersonation-Session-Id header for portal API calls. (async () => { try { const sessionResp = await fetch("/api/auth/get-session", { credentials: "include" }); if (!sessionResp.ok) { setInitComplete(true); return; } let sessionData: { user?: { email?: string; role?: string | null } } | null = null; try { sessionData = (await sessionResp.json()) as { user?: { email?: string; role?: string | null } } | null; } catch { // Better Auth returns an empty body when there is no session } if (!sessionData || !sessionData.user) { setInitComplete(true); return; } // Staff are routed to /admin by App.tsx; don't run the customer bridge. if (sessionData.user.role === "staff") { setInitComplete(true); return; } const bridgeResp = await fetch("/api/portal/session-from-auth", { method: "POST", credentials: "include", }); if (bridgeResp.ok) { const data = await bridgeResp.json() as { sessionId: string; clientId: string; clientName: string }; setPortalSessionId(data.sessionId); setClientName(data.clientName); } else if (bridgeResp.status === 404) { // Authenticated but no matching client row — show a friendly message // instead of bouncing back to /login (which would loop indefinitely). setAuthError( "Your account is not linked to a customer record. Please contact your groomer to set up portal access." ); } // 401/other: fall through; App.tsx render guard will redirect to /login. } catch { // Network error — fall through; the render guard will redirect to /login. } finally { setInitComplete(true); } })(); }, []); const handleEnd = useCallback(async () => { if (!session) return; try { await fetch(`/api/impersonation/sessions/${session.id}/end`, { method: "POST" }); } catch { // Ignore — session ends on the client regardless } setSession(null); setSessionExtended(false); window.location.href = "/admin/clients"; }, [session]); const handleExtend = useCallback(async () => { if (!session) return; try { const r = await fetch(`/api/impersonation/sessions/${session.id}/extend`, { method: "POST" }); if (r.ok) { const updated = await r.json() as ImpersonationSession; setSession(updated); setSessionExtended(true); } } catch { // Best-effort } }, [session]); const logPageView = useCallback((page: string) => { if (!session) return; void fetch(`/api/impersonation/sessions/${session.id}/log`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "page_view", pageVisited: page }), }); }, [session]); const handleNavClick = (section: Section) => { setActiveSection(section); setMobileNavOpen(false); if (session?.status === "active") { logPageView(section); } }; const handleReschedule = useCallback((appointmentId: string) => { // Look up the full appointment from Dashboard's displayed data // The appointment was already fetched by Dashboard, so we use the ID to find it setRescheduleAppointment({ id: appointmentId } as PortalAppointment); setShowReschedule(true); }, []); const isReadOnly = session?.status === "active"; const renderSection = () => { const sessionId = session?.id ?? portalSessionId; switch (activeSection) { case "dashboard": return ; case "appointments": return ; case "pets": return ; case "reports": return ; case "billing": return ; case "messages": return ; case "settings": return ; } }; const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase(); // Show a loading state while the SSO bridge is in progress. The portal chrome // and its sections (e.g. Dashboard) assume a session is established and run // their own auth guards — rendering them before the bridge resolves triggers // a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the // post-sign-in flow. Once `initComplete` is true we know whether a session was // established and can render the correct branch. See GRO-2099. if (!initComplete) { return (
Loading…
); } // After init completes, redirect unauthenticated users to /login and staff to /admin. // The portal chrome must NEVER be visible to users without a valid client session. // For client dev users, we stay on the portal even if session is null — the dev-session // response may not have id set immediately, or there may be timing issues with the // session state. Dev users are verified via localStorage and the dev-session flow. // SSO customers are recognised by portalSessionId (set by the Better Auth bridge). if (!session && !portalSessionId) { if (authError) { // GRO-1867: graceful 404 fallback — authenticated user has no client row. return (

Portal access not configured

{authError}

); } const devUser = getDevUser(); if (devUser && devUser.type === "staff") { return ; } if (devUser && devUser.type === "client") { // Don't redirect — dev session creation may have failed or session.id may be null // The portal should still render for client dev users } else { return ; } } return (
{session?.status === "active" && ( <> { void handleEnd(); }} onExtend={() => { void handleExtend(); }} onShowAudit={() => setShowAuditLog(true)} /> {/* Watermark */}
STAFF VIEW
)} {showAuditLog && session && ( setShowAuditLog(false)} /> )} {showReschedule && rescheduleAppointment && ( { setShowReschedule(false); setRescheduleAppointment(null); }} sessionId={session?.id ?? portalSessionId} /> )} {/* Mobile Header */}
{branding.businessName}
{avatarInitials}
{/* Sidebar Navigation */} {/* Mobile nav overlay */} {mobileNavOpen && (
setMobileNavOpen(false)} /> )} {/* Main Content */}

{NAV_ITEMS.find(n => n.id === activeSection)?.label}

Hi, {clientName.split(" ")[0] || "Guest"}
{avatarInitials}
{renderSection()}
); }