519 lines
22 KiB
TypeScript
519 lines
22 KiB
TypeScript
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 { 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";
|
|
|
|
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<Section>("dashboard");
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
|
const [showReschedule, setShowReschedule] = useState(false);
|
|
const [rescheduleAppointment, setRescheduleAppointment] = useState<PortalAppointment | null>(null);
|
|
const [session, setSession] = useState<ImpersonationSession | null>(null);
|
|
const [sessionExtended, setSessionExtended] = useState(false);
|
|
const [clientName, setClientName] = useState<string>("");
|
|
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<string | null>(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<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();
|
|
|
|
// 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");
|
|
// 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);
|
|
// Real impersonation session from URL param
|
|
fetch(`/api/impersonation/sessions/${sessionId}`)
|
|
.then((r) => {
|
|
if (!r.ok) return null;
|
|
return r.json() as Promise<ImpersonationSession>;
|
|
})
|
|
.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<ImpersonationSession>;
|
|
})
|
|
.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 — 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 {
|
|
// 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]);
|
|
|
|
// 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`, {
|
|
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 <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} isImpersonating={isImpersonating} />;
|
|
case "appointments":
|
|
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
|
|
case "pets":
|
|
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
|
|
case "reports":
|
|
return <ReportCards sessionId={sessionId} />;
|
|
case "billing":
|
|
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
|
|
case "messages":
|
|
return <Communication readOnly={!!isReadOnly} />;
|
|
case "settings":
|
|
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
// 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) {
|
|
// 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 (
|
|
<div
|
|
className="min-h-screen flex items-center justify-center bg-[#faf8f5] font-sans px-6"
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
<div className="max-w-md w-full bg-white rounded-xl shadow-sm border border-stone-200 p-8 text-center">
|
|
<div className="w-12 h-12 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center mx-auto mb-4">
|
|
<Shield size={22} />
|
|
</div>
|
|
<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={() => { 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} />
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
const devUser = getDevUser();
|
|
if (devUser && devUser.type === "staff") {
|
|
return <Navigate to="/admin" replace />;
|
|
}
|
|
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 <Navigate to="/login" replace />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="min-h-screen bg-[#faf8f5] font-sans"
|
|
style={session?.status === "active" ? { border: "3px solid #f59e0b" } : undefined}
|
|
>
|
|
{session?.status === "active" && (
|
|
<>
|
|
<ImpersonationBanner
|
|
session={session}
|
|
isExtended={sessionExtended}
|
|
onEnd={() => { void handleEnd(); }}
|
|
onExtend={() => { void handleExtend(); }}
|
|
onShowAudit={() => setShowAuditLog(true)}
|
|
/>
|
|
{/* Watermark */}
|
|
<div className="fixed inset-0 pointer-events-none z-10 flex items-center justify-center opacity-[0.04]">
|
|
<div className="text-8xl font-bold text-amber-900 -rotate-45 select-none tracking-widest">
|
|
STAFF VIEW
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{showAuditLog && session && (
|
|
<AuditLogViewer
|
|
sessionId={session.id}
|
|
onClose={() => setShowAuditLog(false)}
|
|
/>
|
|
)}
|
|
|
|
{showReschedule && rescheduleAppointment && (
|
|
<RescheduleFlow
|
|
appointment={rescheduleAppointment}
|
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
|
sessionId={session?.id ?? portalSessionId}
|
|
/>
|
|
)}
|
|
|
|
{/* Mobile Header */}
|
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
|
<button
|
|
onClick={() => setMobileNavOpen(!mobileNavOpen)}
|
|
className="p-2 text-stone-600 hover:text-stone-900"
|
|
aria-label="Toggle navigation"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={mobileNavOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
|
|
</svg>
|
|
</button>
|
|
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
|
{avatarInitials}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex">
|
|
{/* Sidebar Navigation */}
|
|
<nav className={`
|
|
${mobileNavOpen ? "translate-x-0" : "-translate-x-full"}
|
|
md:translate-x-0 fixed md:sticky top-0 left-0 z-30
|
|
w-64 h-screen bg-white border-r border-stone-200
|
|
flex flex-col transition-transform duration-200
|
|
`}>
|
|
<div className="hidden md:flex items-center gap-3 px-6 py-5 border-b border-stone-100">
|
|
{branding.logoBase64 && branding.logoMimeType ? (
|
|
<img
|
|
src={`data:${branding.logoMimeType};base64,${branding.logoBase64}`}
|
|
alt=""
|
|
className="w-10 h-10 rounded-xl object-contain"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg" style={{ background: branding.accentColor }}>
|
|
🐾
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="font-semibold text-stone-800 text-sm">{branding.businessName}</div>
|
|
<div className="text-xs text-stone-500">Grooming</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
|
{NAV_ITEMS.map(({ id, label, icon: Icon }) => {
|
|
const active = id === activeSection;
|
|
return (
|
|
<button
|
|
key={id}
|
|
onClick={() => handleNavClick(id)}
|
|
className={`
|
|
w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors
|
|
${active
|
|
? "bg-stone-100 text-stone-800 font-semibold"
|
|
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900"
|
|
}
|
|
`}
|
|
>
|
|
<Icon size={18} />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 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
|
|
onClick={() => { void handleEnd(); }}
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 transition-colors"
|
|
>
|
|
<LogOut size={14} />
|
|
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
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* Mobile nav overlay */}
|
|
{mobileNavOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/30 z-20 md:hidden"
|
|
onClick={() => setMobileNavOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 min-h-screen overflow-x-hidden">
|
|
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-stone-800">
|
|
{NAV_ITEMS.find(n => n.id === activeSection)?.label}
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
|
|
{avatarInitials}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 md:p-8 max-w-6xl w-full overflow-hidden">
|
|
{renderSection()}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|