From 8de6528bd3f1ddf5acfd2e2550cbf364cc482f69 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Fri, 20 Mar 2026 17:26:45 +0000 Subject: [PATCH] feat: wire customer portal impersonation to real backend API Replaces the local impersonationReducer (mock-based) with real API calls to the /api/impersonation/sessions endpoints added in PR #75. Changes: - CustomerPortal: reads ?sessionId= param via useSearchParams, fetches real session on mount, calls /extend and /end on user action, logs page views to /sessions/:id/log. Removes demo sidebar button. - ImpersonationBanner: updated to use ImpersonationSession from @groombook/types instead of the old mockData shape. Accepts isExtended prop to control Extend button visibility. - AuditLogViewer: now fetches from /api/impersonation/sessions/:id/audit-log instead of receiving auditLog[] as a prop. Handles loading/error states. - Clients.tsx: "View as Customer" button now POSTs to /api/impersonation/sessions first, then navigates to /?sessionId=. Handles 409 (existing active session) by reusing it. - mockData.ts: removed ImpersonationSession and AuditEntry interfaces (now live in @groombook/types). - test/setup.ts: set NODE_ENV=test for React 19 + testing-library compat. - portal.test.tsx: 13 new tests covering banner, audit log viewer, and portal session loading behavior (20 total pass). Closes #76 Co-Authored-By: Paperclip --- apps/web/src/__tests__/portal.test.tsx | 284 ++++++++++++++++++++ apps/web/src/pages/Clients.tsx | 39 ++- apps/web/src/portal/AuditLogViewer.tsx | 95 +++++-- apps/web/src/portal/CustomerPortal.tsx | 254 ++++++----------- apps/web/src/portal/ImpersonationBanner.tsx | 20 +- apps/web/src/portal/mockData.ts | 20 -- apps/web/src/test/setup.ts | 3 + 7 files changed, 476 insertions(+), 239 deletions(-) create mode 100644 apps/web/src/__tests__/portal.test.tsx diff --git a/apps/web/src/__tests__/portal.test.tsx b/apps/web/src/__tests__/portal.test.tsx new file mode 100644 index 0000000..660b228 --- /dev/null +++ b/apps/web/src/__tests__/portal.test.tsx @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { ImpersonationBanner } from "../portal/ImpersonationBanner.js"; +import { AuditLogViewer } from "../portal/AuditLogViewer.js"; +import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types"; + +const SESSION: ImpersonationSession = { + id: "sess-1", + staffId: "staff-1", + clientId: "client-1", + reason: "Customer reported missing appointment", + status: "active", + startedAt: new Date(Date.now() - 5 * 60_000).toISOString(), + endedAt: null, + expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(), + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), +}; + +const AUDIT_LOGS: ImpersonationAuditLog[] = [ + { + id: "log-1", + sessionId: "sess-1", + action: "session_started", + pageVisited: null, + metadata: { reason: "Customer reported missing appointment" }, + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + }, + { + id: "log-2", + sessionId: "sess-1", + action: "page_view", + pageVisited: "appointments", + metadata: null, + createdAt: new Date(Date.now() - 3 * 60_000).toISOString(), + }, +]; + +// ─── ImpersonationBanner ──────────────────────────────────────────────────── + +describe("ImpersonationBanner", () => { + it("renders STAFF VIEW label", () => { + render( + + ); + expect(screen.getByText("STAFF VIEW")).toBeInTheDocument(); + }); + + it("displays the session reason", () => { + render( + + ); + expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument(); + }); + + it("calls onEnd when End Session is clicked", () => { + const onEnd = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /End Session/i })); + expect(onEnd).toHaveBeenCalledOnce(); + }); + + it("calls onShowAudit when Audit is clicked", () => { + const onShowAudit = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /Audit/i })); + expect(onShowAudit).toHaveBeenCalledOnce(); + }); + + it("shows Extend button when less than 5 minutes remain and not yet extended", async () => { + const nearlyExpiredSession: ImpersonationSession = { + ...SESSION, + expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left + }; + render( + + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument(); + }); + }); + + it("does not show Extend button when already extended", async () => { + const nearlyExpiredSession: ImpersonationSession = { + ...SESSION, + expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), + }; + render( + + ); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument(); + }); + }); +}); + +// ─── AuditLogViewer ───────────────────────────────────────────────────────── + +describe("AuditLogViewer", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it("fetches and displays audit log entries", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first + } as Response); + + render(); + + await waitFor(() => { + // "session started" appears in both the filter dropdown option and the log entry span + expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.getByText("appointments")).toBeInTheDocument(); + expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log"); + }); + + it("shows error state when fetch fails", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 403, + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument(); + }); + }); + + it("shows loading state initially", () => { + vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument(); + }); + + it("calls onClose when X button is clicked", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [], + } as Response); + + const onClose = vi.fn(); + render(); + + await waitFor(() => { + expect(screen.getByText(/No audit entries/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "" })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("filters entries by action type", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [...AUDIT_LOGS].reverse(), + } as Response); + + render(); + + await waitFor(() => { + expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1); + }); + + // Filter to page_view only + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "page_view" } }); + + expect(screen.getByText("appointments")).toBeInTheDocument(); + // After filtering, the "session started" span (log entry) should be gone + // The option in the select still has the text but the log entry span does not + const spans = document.querySelectorAll("span.inline-block"); + expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true); + }); +}); + +// ─── CustomerPortal — session loading ────────────────────────────────────── + +describe("CustomerPortal session loading", () => { + beforeEach(() => { + global.fetch = vi.fn((url: string) => { + if (url === "/api/branding") { + return Promise.resolve({ + ok: true, + json: async () => ({ + businessName: "GroomBook", + primaryColor: "#4f8a6f", + accentColor: "#8b7355", + logoBase64: null, + logoMimeType: null, + }), + } as Response); + } + if (url.startsWith("/api/impersonation/sessions/")) { + return Promise.resolve({ + ok: true, + json: async () => SESSION, + } as Response); + } + return Promise.resolve({ ok: true, json: async () => [] } as Response); + }) as unknown as typeof fetch; + }); + + it("loads and displays impersonation banner when sessionId is in URL", async () => { + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + // Wait for the session fetch and banner to appear + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1"); + }); + // Banner "End Session" button is unique to the active impersonation banner + await waitFor(() => { + expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument(); + }); + }); + + it("does not show banner when no sessionId in URL", async () => { + vi.mocked(global.fetch).mockClear(); + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + // No impersonation session fetch should happen + await new Promise((r) => setTimeout(r, 50)); + const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter( + ([url]) => typeof url === "string" && url.startsWith("/api/impersonation/") + ); + expect(impersonationFetches).toHaveLength(0); + expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 50afed9..c5354d9 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -65,6 +65,7 @@ export function ClientsPage() { const [deletingPetId, setDeletingPetId] = useState(null); const [deletingClient, setDeletingClient] = useState(false); const [disablingClient, setDisablingClient] = useState(false); + const [startingImpersonation, setStartingImpersonation] = useState(false); const [showDisabled, setShowDisabled] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteConfirmName, setDeleteConfirmName] = useState(""); @@ -433,12 +434,40 @@ export function ClientsPage() { )}
- { + if (!selectedClient) return; + setStartingImpersonation(true); + try { + const res = await fetch("/api/impersonation/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: selectedClient.id, + reason: `Support view for ${selectedClient.name}`, + }), + }); + if (res.ok) { + const session = await res.json() as { id: string }; + window.location.href = `/?sessionId=${encodeURIComponent(session.id)}`; + } else { + const err = await res.json() as { error?: string; sessionId?: string }; + if (res.status === 409 && err.sessionId) { + // Already have an active session — navigate to it + window.location.href = `/?sessionId=${encodeURIComponent(err.sessionId)}`; + } else { + alert(`Could not start impersonation: ${err.error ?? res.statusText}`); + } + } + } finally { + setStartingImpersonation(false); + } + }} + style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", display: "inline-flex", alignItems: "center", gap: "0.3rem", opacity: startingImpersonation ? 0.6 : 1, cursor: startingImpersonation ? "not-allowed" : "pointer" }} > - View as Customer - + {startingImpersonation ? "Starting…" : "View as Customer"} + diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx index 7510c1a..f9545da 100644 --- a/apps/web/src/portal/AuditLogViewer.tsx +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -1,17 +1,39 @@ -import { useState } from "react"; -import { X, Filter } from "lucide-react"; -import type { AuditEntry } from "./mockData.js"; +import { useState, useEffect } from "react"; +import { X, Filter, Loader } from "lucide-react"; +import type { ImpersonationAuditLog } from "@groombook/types"; interface Props { - auditLog: AuditEntry[]; + sessionId: string; onClose: () => void; } -export function AuditLogViewer({ auditLog, onClose }: Props) { +export function AuditLogViewer({ sessionId, onClose }: Props) { + const [auditLog, setAuditLog] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [filterAction, setFilterAction] = useState("all"); - const actionTypes = ["all", ...new Set(auditLog.map(e => e.action))]; - const filtered = filterAction === "all" ? auditLog : auditLog.filter(e => e.action === filterAction); + useEffect(() => { + setLoading(true); + setError(null); + fetch(`/api/impersonation/sessions/${sessionId}/audit-log`) + .then((r) => { + if (!r.ok) throw new Error(`Failed to load audit log (${r.status})`); + return r.json() as Promise; + }) + .then((logs) => { + // API returns newest-first; reverse for chronological display + setAuditLog([...logs].reverse()); + setLoading(false); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load audit log"); + setLoading(false); + }); + }, [sessionId]); + + const actionTypes = ["all", ...new Set(auditLog.map((e) => e.action))]; + const filtered = filterAction === "all" ? auditLog : auditLog.filter((e) => e.action === filterAction); return (
@@ -22,34 +44,57 @@ export function AuditLogViewer({ auditLog, onClose }: Props) {
-
- - - {filtered.length} entries -
+ + {!loading && !error && ( +
+ + + {filtered.length} entries +
+ )} +
- {filtered.length === 0 ? ( + {loading && ( +
+ + Loading audit log… +
+ )} + {error && ( +

{error}

+ )} + {!loading && !error && filtered.length === 0 && (

No audit entries

- ) : ( + )} + {!loading && !error && filtered.length > 0 && (
- {filtered.map(entry => ( + {filtered.map((entry) => (
- {new Date(entry.timestamp).toLocaleTimeString()} + {new Date(entry.createdAt).toLocaleTimeString()}
{entry.action.replace(/_/g, " ")} -

{entry.detail}

+ {entry.pageVisited && ( +

{entry.pageVisited}

+ )} + {entry.metadata && Object.keys(entry.metadata).length > 0 && ( +

+ {JSON.stringify(entry.metadata)} +

+ )}
))} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index bb77072..e3fad41 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -1,7 +1,8 @@ -import { useState, useReducer, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; import { Home, Calendar, PawPrint, FileText, CreditCard, MessageSquare, - Settings, Eye, LogOut, Clock, Shield, + Settings, LogOut, Shield, } from "lucide-react"; import { Dashboard } from "./sections/Dashboard.js"; import { AppointmentsSection } from "./sections/Appointments.js"; @@ -12,9 +13,9 @@ import { Communication } from "./sections/Communication.js"; import { AccountSettings } from "./sections/AccountSettings.js"; import { ImpersonationBanner } from "./ImpersonationBanner.js"; import { AuditLogViewer } from "./AuditLogViewer.js"; -import type { ImpersonationSession, AuditEntry } from "./mockData.js"; import { CUSTOMER } from "./mockData.js"; import { useBranding } from "../BrandingContext.js"; +import type { ImpersonationSession } from "@groombook/types"; type Section = "dashboard" | "appointments" | "pets" | "reports" | "billing" | "messages" | "settings"; @@ -28,121 +29,84 @@ const NAV_ITEMS: { id: Section; label: string; icon: typeof Home }[] = [ { id: "settings", label: "Settings", icon: Settings }, ]; -type ImpersonationAction = - | { type: "START"; staffName: string; staffRole: string; reason: string } - | { type: "END" } - | { type: "EXTEND" } - | { type: "LOG"; entry: AuditEntry }; - -function impersonationReducer( - state: ImpersonationSession | null, - action: ImpersonationAction -): ImpersonationSession | null { - switch (action.type) { - case "START": { - const now = new Date(); - const expires = new Date(now.getTime() + 30 * 60 * 1000); - return { - active: true, - staffName: action.staffName, - staffRole: action.staffRole, - customerName: CUSTOMER.name, - reason: action.reason, - startedAt: now.toISOString(), - expiresAt: expires.toISOString(), - extended: false, - readOnly: true, - auditLog: [{ - id: "audit-0", - timestamp: now.toISOString(), - action: "session_start", - detail: `Impersonation started by ${action.staffName} (${action.staffRole}). Reason: ${action.reason}`, - }], - }; - } - case "END": - if (!state) return null; - return { - ...state, - active: false, - auditLog: [...state.auditLog, { - id: `audit-${state.auditLog.length}`, - timestamp: new Date().toISOString(), - action: "session_end", - detail: "Impersonation session ended", - }], - }; - case "EXTEND": - if (!state) return null; - return { - ...state, - expiresAt: new Date(new Date(state.expiresAt).getTime() + 30 * 60 * 1000).toISOString(), - extended: true, - auditLog: [...state.auditLog, { - id: `audit-${state.auditLog.length}`, - timestamp: new Date().toISOString(), - action: "session_extended", - detail: "Session extended by 30 minutes", - }], - }; - case "LOG": - if (!state) return null; - return { ...state, auditLog: [...state.auditLog, action.entry] }; - default: - return state; - } -} - export function CustomerPortal() { const [activeSection, setActiveSection] = useState
("dashboard"); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [showAuditLog, setShowAuditLog] = useState(false); - const [showImpersonationSetup, setShowImpersonationSetup] = useState(false); - const [impersonation, dispatchImpersonation] = useReducer(impersonationReducer, null); + const [session, setSession] = useState(null); + const [sessionExtended, setSessionExtended] = useState(false); const { branding } = useBranding(); + const [searchParams, setSearchParams] = useSearchParams(); - // Auto-start impersonation from URL params (staff flow from admin panel). - // Runs once on mount only — impersonation state is managed by the reducer after init. - const [impersonationInitDone, setImpersonationInitDone] = useState(false); + // On mount: load session from ?sessionId= URL param + const initDone = useRef(false); useEffect(() => { - if (impersonationInitDone) return; - const params = new URLSearchParams(window.location.search); - if (params.get("impersonate") === "true") { - const clientName = params.get("clientName") || "Unknown Customer"; - const reason = params.get("reason") || `Viewing portal as ${clientName}`; - const staffName = params.get("staffName") || "Staff"; - dispatchImpersonation({ - type: "START", - staffName, - staffRole: "Admin", - reason, + if (initDone.current) return; + initDone.current = true; + + const sessionId = searchParams.get("sessionId"); + if (!sessionId) return; + + 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); + } + // Clean sessionId from URL + setSearchParams({}, { replace: true }); + }) + .catch(() => { + setSearchParams({}, { replace: true }); }); - window.history.replaceState({}, "", window.location.pathname); + }, []); + + 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 } - setImpersonationInitDone(true); - }, [impersonationInitDone]); + setSession(null); + setSessionExtended(false); + }, [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 (impersonation?.active) { - dispatchImpersonation({ - type: "LOG", - entry: { - id: `audit-${Date.now()}`, - timestamp: new Date().toISOString(), - action: "page_view", - detail: `Viewed: ${page}`, - }, - }); - } - }, [impersonation?.active]); + 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); - logPageView(section); + if (session?.status === "active") { + logPageView(section); + } }; - const isReadOnly = impersonation?.active && impersonation.readOnly; + const isReadOnly = session?.status === "active"; const renderSection = () => { switch (activeSection) { @@ -166,14 +130,15 @@ export function CustomerPortal() { return (
- {impersonation?.active && ( + {session?.status === "active" && ( <> dispatchImpersonation({ type: "END" })} - onExtend={() => dispatchImpersonation({ type: "EXTEND" })} + session={session} + isExtended={sessionExtended} + onEnd={() => { void handleEnd(); }} + onExtend={() => { void handleExtend(); }} onShowAudit={() => setShowAuditLog(true)} /> {/* Watermark */} @@ -185,9 +150,9 @@ export function CustomerPortal() { )} - {showAuditLog && impersonation && ( + {showAuditLog && session && ( setShowAuditLog(false)} /> )} @@ -257,19 +222,11 @@ export function CustomerPortal() { })}
- {/* Demo Controls */} + {/* Session controls (only shown during active impersonation) */}
- {!impersonation?.active ? ( + {session?.status === "active" && ( - ) : ( -
- - {/* Impersonation Setup Modal */} - {showImpersonationSetup && { - dispatchImpersonation({ type: "START", staffName: "Chris", staffRole: "Admin", reason }); - setShowImpersonationSetup(false); - }} - onCancel={() => setShowImpersonationSetup(false)} - />} -
- ); -} - -function ImpersonationSetupModal({ onStart, onCancel }: { onStart: (reason: string) => void; onCancel: () => void }) { - const [reason, setReason] = useState(""); - return ( -
-
-
-
- -
-
-

Start Staff Impersonation

-

View portal as {CUSTOMER.name}

-
-
-
- -