+ )}
+ {!loading && !error && filtered.length === 0 && (
- ) : (
+ )}
+ {!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" && (
- ) : (
-