feat: Wire customer portal impersonation to real backend API #78

Merged
ghost merged 1 commits from feat/impersonation-frontend-wiring into main 2026-03-20 23:17:11 +00:00
7 changed files with 476 additions and 239 deletions
+284
View File
@@ -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();
});
});
+34 -5
View File
@@ -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>
+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;
+3
View File
@@ -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";