feat: Wire customer portal impersonation to real backend API #78
@@ -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(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the session reason", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
const onEnd = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
|
||||
expect(onEnd).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
const onShowAudit = vi.fn();
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={SESSION}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={false}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ImpersonationBanner
|
||||
session={nearlyExpiredSession}
|
||||
isExtended={true}
|
||||
onEnd={vi.fn()}
|
||||
onExtend={vi.fn()}
|
||||
onShowAudit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);
|
||||
|
||||
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(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ export function ClientsPage() {
|
||||
const [deletingPetId, setDeletingPetId] = useState<string | null>(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() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
|
||||
<a
|
||||
href={`/?impersonate=true&clientName=${encodeURIComponent(selectedClient.name)}&staffName=${encodeURIComponent("Staff")}&reason=${encodeURIComponent(`Support view for ${selectedClient.name}`)}`}
|
||||
style={{ ...btnStyle, backgroundColor: "#fef3c7", color: "#92400e", borderColor: "#fde68a", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "0.3rem" }}
|
||||
<button
|
||||
disabled={startingImpersonation}
|
||||
onClick={async () => {
|
||||
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
|
||||
</a>
|
||||
{startingImpersonation ? "Starting…" : "View as Customer"}
|
||||
</button>
|
||||
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
|
||||
Edit client
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// React 19 uses the development build for `act` — required for testing-library compatibility
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
Reference in New Issue
Block a user