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=<id>.
  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 <noreply@paperclip.ing>
This commit is contained in:
Scrubs McBarkley
2026-03-20 17:26:45 +00:00
parent 70958542f8
commit 8de6528bd3
7 changed files with 476 additions and 239 deletions
+70 -25
View File
@@ -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<ImpersonationAuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterAction, setFilterAction] = useState<string>("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<ImpersonationAuditLog[]>;
})
.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 (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -22,34 +44,57 @@ export function AuditLogViewer({ auditLog, onClose }: Props) {
<X size={18} className="text-stone-500" />
</button>
</div>
<div className="px-6 py-3 border-b border-stone-100 flex items-center gap-2">
<Filter size={14} className="text-stone-400" />
<select
value={filterAction}
onChange={e => setFilterAction(e.target.value)}
className="text-sm border border-stone-200 rounded-lg px-2 py-1"
>
{actionTypes.map(a => (
<option key={a} value={a}>{a === "all" ? "All actions" : a.replace(/_/g, " ")}</option>
))}
</select>
<span className="text-xs text-stone-400 ml-auto">{filtered.length} entries</span>
</div>
{!loading && !error && (
<div className="px-6 py-3 border-b border-stone-100 flex items-center gap-2">
<Filter size={14} className="text-stone-400" />
<select
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
className="text-sm border border-stone-200 rounded-lg px-2 py-1"
>
{actionTypes.map((a) => (
<option key={a} value={a}>
{a === "all" ? "All actions" : a.replace(/_/g, " ")}
</option>
))}
</select>
<span className="text-xs text-stone-400 ml-auto">{filtered.length} entries</span>
</div>
)}
<div className="flex-1 overflow-y-auto px-6 py-3">
{filtered.length === 0 ? (
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-stone-400">
<Loader size={16} className="animate-spin" />
<span className="text-sm">Loading audit log</span>
</div>
)}
{error && (
<p className="text-sm text-red-500 text-center py-8">{error}</p>
)}
{!loading && !error && filtered.length === 0 && (
<p className="text-sm text-stone-400 text-center py-8">No audit entries</p>
) : (
)}
{!loading && !error && filtered.length > 0 && (
<div className="space-y-3">
{filtered.map(entry => (
{filtered.map((entry) => (
<div key={entry.id} className="flex gap-3 text-sm">
<div className="text-xs text-stone-400 whitespace-nowrap pt-0.5 w-20 shrink-0">
{new Date(entry.timestamp).toLocaleTimeString()}
{new Date(entry.createdAt).toLocaleTimeString()}
</div>
<div>
<span className="inline-block px-2 py-0.5 bg-stone-100 text-stone-600 rounded text-xs font-medium mb-0.5">
{entry.action.replace(/_/g, " ")}
</span>
<p className="text-stone-700">{entry.detail}</p>
{entry.pageVisited && (
<p className="text-stone-700">{entry.pageVisited}</p>
)}
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
<p className="text-stone-500 text-xs">
{JSON.stringify(entry.metadata)}
</p>
)}
</div>
</div>
))}
+76 -178
View File
@@ -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<Section>("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<ImpersonationSession | null>(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<ImpersonationSession>;
})
.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 (
<div
className="min-h-screen bg-[#faf8f5] font-sans"
style={impersonation?.active ? { border: "3px solid #f59e0b" } : undefined}
style={session?.status === "active" ? { border: "3px solid #f59e0b" } : undefined}
>
{impersonation?.active && (
{session?.status === "active" && (
<>
<ImpersonationBanner
session={impersonation}
onEnd={() => 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 && (
<AuditLogViewer
auditLog={impersonation.auditLog}
sessionId={session.id}
onClose={() => setShowAuditLog(false)}
/>
)}
@@ -257,19 +222,11 @@ export function CustomerPortal() {
})}
</div>
{/* Demo Controls */}
{/* Session controls (only shown during active impersonation) */}
<div className="border-t border-stone-100 p-4 space-y-2">
{!impersonation?.active ? (
{session?.status === "active" && (
<button
onClick={() => setShowImpersonationSetup(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors"
>
<Eye size={14} />
Demo: Staff Impersonation
</button>
) : (
<button
onClick={() => dispatchImpersonation({ type: "END" })}
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} />
@@ -311,65 +268,6 @@ export function CustomerPortal() {
</div>
</main>
</div>
{/* Impersonation Setup Modal */}
{showImpersonationSetup && <ImpersonationSetupModal
onStart={(reason) => {
dispatchImpersonation({ type: "START", staffName: "Chris", staffRole: "Admin", reason });
setShowImpersonationSetup(false);
}}
onCancel={() => setShowImpersonationSetup(false)}
/>}
</div>
);
}
function ImpersonationSetupModal({ onStart, onCancel }: { onStart: (reason: string) => void; onCancel: () => void }) {
const [reason, setReason] = useState("");
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<Eye size={20} className="text-amber-700" />
</div>
<div>
<h2 className="font-semibold text-stone-800">Start Staff Impersonation</h2>
<p className="text-sm text-stone-500">View portal as {CUSTOMER.name}</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-stone-700 mb-1">
Reason for impersonation <span className="text-red-500">*</span>
</label>
<textarea
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
rows={3}
placeholder="e.g., Customer reports they can't see their upcoming appointment"
value={reason}
onChange={e => setReason(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 mb-4 px-3 py-2 bg-amber-50 rounded-lg">
<Clock size={14} className="text-amber-600" />
<span className="text-xs text-amber-700">Session will auto-expire after 30 minutes</span>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-4 py-2 border border-stone-300 rounded-lg text-sm font-medium text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => reason.trim() && onStart(reason.trim())}
disabled={!reason.trim()}
className="flex-1 px-4 py-2 bg-amber-600 text-white rounded-lg text-sm font-medium hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Session
</button>
</div>
</div>
</div>
);
}
+9 -11
View File
@@ -1,15 +1,16 @@
import { useState, useEffect } from "react";
import { Eye, Clock, LogOut, FileSearch } from "lucide-react";
import type { ImpersonationSession } from "./mockData.js";
import type { ImpersonationSession } from "@groombook/types";
interface Props {
session: ImpersonationSession;
isExtended: boolean;
onEnd: () => void;
onExtend: () => void;
onShowAudit: () => void;
}
export function ImpersonationBanner({ session, onEnd, onExtend, onShowAudit }: Props) {
export function ImpersonationBanner({ session, isExtended, onEnd, onExtend, onShowAudit }: Props) {
const [remaining, setRemaining] = useState("");
const [showWarning, setShowWarning] = useState(false);
@@ -33,20 +34,17 @@ export function ImpersonationBanner({ session, onEnd, onExtend, onShowAudit }: P
return () => clearInterval(id);
}, [session.expiresAt, onEnd]);
if (!session.active) return null;
return (
<div className="sticky top-0 z-40 bg-amber-500 text-amber-950 px-4 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium shadow-md">
<span className="flex items-center gap-1.5">
<Eye size={16} />
STAFF VIEW
</span>
<span className="hidden sm:inline">
Viewing as <strong>{session.customerName}</strong>
</span>
<span className="hidden md:inline text-amber-800 text-xs">
Reason: {session.reason}
</span>
{session.reason && (
<span className="hidden md:inline text-amber-800 text-xs">
Reason: {session.reason}
</span>
)}
<span className="hidden sm:inline text-amber-800 text-xs">
Started {new Date(session.startedAt).toLocaleTimeString()}
</span>
@@ -55,7 +53,7 @@ export function ImpersonationBanner({ session, onEnd, onExtend, onShowAudit }: P
<Clock size={14} />
{remaining}
</span>
{showWarning && !session.extended && (
{showWarning && !isExtended && (
<button
onClick={onExtend}
className="px-2 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700"
-20
View File
@@ -93,26 +93,6 @@ export interface Groomer {
avatar: string;
}
export interface ImpersonationSession {
active: boolean;
staffName: string;
staffRole: string;
customerName: string;
reason: string;
startedAt: string;
expiresAt: string;
extended: boolean;
readOnly: boolean;
auditLog: AuditEntry[];
}
export interface AuditEntry {
id: string;
timestamp: string;
action: string;
detail: string;
}
export interface LoyaltyInfo {
points: number;
nextRewardAt: number;