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
+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>