feat: implement Staff Impersonation backend and wire frontend

Add server-side impersonation session management with full audit
logging, replacing the frontend-only mock. Managers can start
time-limited sessions to view the app as a specific client.

Backend:
- Add impersonation_sessions and impersonation_audit_logs tables
  (Drizzle schema) with proper FK constraints and status enum
- Add Hono API routes: start/get/extend/end session + audit logging
- Server-side session expiration, one-active-per-staff enforcement
- Staff role validation (manager-only)

Frontend:
- Add CustomerPortal wrapper with URL-param session init
- Add ImpersonationBanner with live countdown timer
- Add AuditLogViewer modal for session audit trail
- Add "View as Customer" button on Clients page
- Auto-log page visits during impersonation

Closes #74

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Groom Book CEO
2026-03-20 02:09:41 +00:00
parent e546a73496
commit 4923606bb7
9 changed files with 668 additions and 0 deletions
+3
View File
@@ -7,6 +7,7 @@ import { InvoicesPage } from "./pages/Invoices.js";
import { BookPage } from "./pages/Book.js";
import { ReportsPage } from "./pages/Reports.js";
import { GroupBookingPage } from "./pages/GroupBooking.js";
import { CustomerPortal } from "./portal/CustomerPortal.js";
const NAV_LINKS = [
{ to: "/", label: "Appointments" },
@@ -21,6 +22,7 @@ const NAV_LINKS = [
export function App() {
const location = useLocation();
return (
<CustomerPortal>
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
<nav
style={{
@@ -83,5 +85,6 @@ export function App() {
</Routes>
</main>
</div>
</CustomerPortal>
);
}
+8
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
// ─── Forms ───────────────────────────────────────────────────────────────────
@@ -41,6 +42,7 @@ const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "
// ─── Component ───────────────────────────────────────────────────────────────
export function ClientsPage() {
const navigate = useNavigate();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -360,6 +362,12 @@ export function ClientsPage() {
)}
</div>
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
<button
onClick={() => navigate(`/clients?impersonate=${selectedClient.id}`)}
style={{ ...btnStyle, backgroundColor: "#7c3aed", color: "#fff", borderColor: "#7c3aed" }}
>
View as Customer
</button>
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
Edit client
</button>
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { ImpersonationAuditLog } from "@groombook/types";
interface Props {
sessionId: string;
onClose: () => void;
}
export function AuditLogViewer({ sessionId, onClose }: Props) {
const [logs, setLogs] = useState<ImpersonationAuditLog[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/impersonation/sessions/${sessionId}/audit-log`)
.then((r) => r.json())
.then((data) => setLogs(data as ImpersonationAuditLog[]))
.finally(() => setLoading(false));
}, [sessionId]);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10000,
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
style={{
background: "#fff",
borderRadius: 8,
padding: "1.5rem",
maxWidth: 600,
width: "calc(100% - 2rem)",
maxHeight: "80vh",
overflowY: "auto",
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Audit Log</h2>
<button
onClick={onClose}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid #d1d5db",
borderRadius: 4,
background: "#f9fafb",
cursor: "pointer",
fontSize: 13,
}}
>
Close
</button>
</div>
{loading ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>Loading audit log...</p>
) : logs.length === 0 ? (
<p style={{ color: "#6b7280", fontSize: 14 }}>No audit entries.</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: "2px solid #e5e7eb" }}>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Time</th>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Action</th>
<th style={{ textAlign: "left", padding: "0.4rem 0.5rem", color: "#6b7280" }}>Page</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: "0.4rem 0.5rem", color: "#6b7280", whiteSpace: "nowrap" }}>
{new Date(log.createdAt).toLocaleTimeString()}
</td>
<td style={{ padding: "0.4rem 0.5rem" }}>{log.action}</td>
<td style={{ padding: "0.4rem 0.5rem", color: "#6b7280" }}>
{log.pageVisited ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams, useLocation } from "react-router-dom";
import type { ImpersonationSession } from "@groombook/types";
import { ImpersonationBanner } from "./ImpersonationBanner.js";
import { AuditLogViewer } from "./AuditLogViewer.js";
interface Props {
children: React.ReactNode;
}
/**
* Wraps the app to provide impersonation state.
* Start impersonation by navigating with ?impersonate=<clientId>.
* The banner is non-dismissable while a session is active.
*/
export function CustomerPortal({ children }: Props) {
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [clientName, setClientName] = useState("");
const [showAuditLog, setShowAuditLog] = useState(false);
const [error, setError] = useState<string | null>(null);
// Start session from URL param
const impersonateClientId = searchParams.get("impersonate");
const startSession = useCallback(
async (clientId: string) => {
try {
const res = await fetch("/api/impersonation/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string; sessionId?: string };
if (res.status === 409 && err.sessionId) {
// Already have an active session — load it
const existing = await fetch(`/api/impersonation/sessions/${err.sessionId}`);
if (existing.ok) {
setSession((await existing.json()) as ImpersonationSession);
}
} else {
setError(err.error ?? `HTTP ${res.status}`);
}
return;
}
setSession((await res.json()) as ImpersonationSession);
} catch {
setError("Failed to start impersonation session");
}
},
[]
);
useEffect(() => {
if (impersonateClientId && !session) {
// Fetch client name
fetch(`/api/clients/${impersonateClientId}`)
.then((r) => r.json())
.then((c: { name?: string }) => setClientName(c.name ?? "Unknown"))
.catch(() => setClientName("Unknown"));
void startSession(impersonateClientId);
// Clean the URL param
const next = new URLSearchParams(searchParams);
next.delete("impersonate");
setSearchParams(next, { replace: true });
}
}, [impersonateClientId, session, searchParams, setSearchParams, startSession]);
// Log page visits
useEffect(() => {
if (!session || session.status !== "active") return;
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "page_visit", pageVisited: location.pathname }),
});
}, [location.pathname, session]);
async function endSession() {
if (!session) return;
const res = await fetch(`/api/impersonation/sessions/${session.id}/end`, {
method: "POST",
});
if (res.ok) {
setSession(null);
setClientName("");
}
}
async function extendSession() {
if (!session) return;
const res = await fetch(`/api/impersonation/sessions/${session.id}/extend`, {
method: "POST",
});
if (res.ok) {
setSession((await res.json()) as ImpersonationSession);
}
}
return (
<>
{error && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
background: "#fef2f2",
color: "#dc2626",
padding: "0.5rem 1rem",
fontSize: 14,
zIndex: 9999,
textAlign: "center",
}}
>
{error}
<button
onClick={() => setError(null)}
style={{ marginLeft: "1rem", cursor: "pointer", background: "none", border: "none", color: "#dc2626", textDecoration: "underline" }}
>
Dismiss
</button>
</div>
)}
{session && session.status === "active" && (
<ImpersonationBanner
clientName={clientName}
expiresAt={session.expiresAt}
onEnd={endSession}
onExtend={extendSession}
/>
)}
{/* Push content down when banner is visible */}
<div style={{ paddingTop: session?.status === "active" ? "2.5rem" : 0 }}>
{children}
</div>
{showAuditLog && session && (
<AuditLogViewer sessionId={session.id} onClose={() => setShowAuditLog(false)} />
)}
</>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
interface Props {
clientName: string;
expiresAt: string;
onEnd: () => void;
onExtend: () => void;
}
export function ImpersonationBanner({ clientName, expiresAt, onEnd, onExtend }: Props) {
const [remaining, setRemaining] = useState("");
useEffect(() => {
function tick() {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) {
setRemaining("Expired");
return;
}
const mins = Math.floor(diff / 60_000);
const secs = Math.floor((diff % 60_000) / 1000);
setRemaining(`${mins}:${secs.toString().padStart(2, "0")}`);
}
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
background: "#dc2626",
color: "#fff",
padding: "0.5rem 1rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
zIndex: 9999,
fontSize: 14,
fontFamily: "system-ui, sans-serif",
}}
>
<div>
<strong>IMPERSONATING:</strong> {clientName} Read-only mode
<span style={{ marginLeft: "1rem", opacity: 0.85 }}>
Time remaining: {remaining}
</span>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={onExtend}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid rgba(255,255,255,0.5)",
borderRadius: 4,
background: "transparent",
color: "#fff",
cursor: "pointer",
fontSize: 13,
}}
>
Extend
</button>
<button
onClick={onEnd}
style={{
padding: "0.25rem 0.6rem",
border: "1px solid #fff",
borderRadius: 4,
background: "#fff",
color: "#dc2626",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
End Session
</button>
</div>
</div>
);
}