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:
@@ -12,6 +12,7 @@ import { bookRouter } from "./routes/book.js";
|
|||||||
import { reportsRouter } from "./routes/reports.js";
|
import { reportsRouter } from "./routes/reports.js";
|
||||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||||
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||||
|
import { impersonationRouter } from "./routes/impersonation.js";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { startReminderScheduler } from "./services/reminders.js";
|
import { startReminderScheduler } from "./services/reminders.js";
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ api.route("/invoices", invoicesRouter);
|
|||||||
api.route("/reports", reportsRouter);
|
api.route("/reports", reportsRouter);
|
||||||
api.route("/appointment-groups", appointmentGroupsRouter);
|
api.route("/appointment-groups", appointmentGroupsRouter);
|
||||||
api.route("/grooming-logs", groomingLogsRouter);
|
api.route("/grooming-logs", groomingLogsRouter);
|
||||||
|
api.route("/impersonation", impersonationRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
getDb,
|
||||||
|
impersonationSessions,
|
||||||
|
impersonationAuditLogs,
|
||||||
|
staff,
|
||||||
|
clients,
|
||||||
|
desc,
|
||||||
|
} from "@groombook/db";
|
||||||
|
import type { JwtPayload } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
type Env = { Variables: { jwtPayload: JwtPayload } };
|
||||||
|
|
||||||
|
export const impersonationRouter = new Hono<Env>();
|
||||||
|
|
||||||
|
const SESSION_TIMEOUT_MINUTES = 30;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function expiresAt(minutes = SESSION_TIMEOUT_MINUTES) {
|
||||||
|
return new Date(Date.now() + minutes * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the staff row for the authenticated OIDC subject. */
|
||||||
|
async function resolveStaff(sub: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.oidcSub, sub));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expire any timed-out active sessions for a given staff member. */
|
||||||
|
async function expireTimedOutSessions(staffId: string) {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const active = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.staffId, staffId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const s of active) {
|
||||||
|
if (s.expiresAt <= now) {
|
||||||
|
await db
|
||||||
|
.update(impersonationSessions)
|
||||||
|
.set({ status: "expired", endedAt: now })
|
||||||
|
.where(eq(impersonationSessions.id, s.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST / — Start a new impersonation session ─────────────────────────────
|
||||||
|
|
||||||
|
const startSessionSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
reason: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
impersonationRouter.post(
|
||||||
|
"/sessions",
|
||||||
|
zValidator("json", startSessionSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const jwt = c.get("jwtPayload") as JwtPayload;
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
// Resolve authenticated staff
|
||||||
|
const staffRow = await resolveStaff(jwt.sub);
|
||||||
|
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
|
||||||
|
if (staffRow.role !== "manager") {
|
||||||
|
return c.json({ error: "Only managers can impersonate clients" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client exists
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, body.clientId));
|
||||||
|
if (!client) return c.json({ error: "Client not found" }, 404);
|
||||||
|
|
||||||
|
// Expire timed-out sessions first
|
||||||
|
await expireTimedOutSessions(staffRow.id);
|
||||||
|
|
||||||
|
// Enforce one active session per staff member
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.staffId, staffRow.id),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "You already have an active impersonation session", sessionId: existing.id },
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.insert(impersonationSessions)
|
||||||
|
.values({
|
||||||
|
staffId: staffRow.id,
|
||||||
|
clientId: body.clientId,
|
||||||
|
reason: body.reason ?? null,
|
||||||
|
expiresAt: expiresAt(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log session start
|
||||||
|
await db.insert(impersonationAuditLogs).values({
|
||||||
|
sessionId: session!.id,
|
||||||
|
action: "session_started",
|
||||||
|
metadata: { reason: body.reason ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(session!, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /sessions/:id — Get session details ────────────────────────────────
|
||||||
|
|
||||||
|
impersonationRouter.get("/sessions/:id", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
return c.json(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /sessions/:id/extend — Extend session timeout ─────────────────────
|
||||||
|
|
||||||
|
impersonationRouter.post("/sessions/:id/extend", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const jwt = c.get("jwtPayload") as JwtPayload;
|
||||||
|
const staffRow = await resolveStaff(jwt.sub);
|
||||||
|
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
if (session.staffId !== staffRow.id) {
|
||||||
|
return c.json({ error: "Not your session" }, 403);
|
||||||
|
}
|
||||||
|
if (session.status !== "active") {
|
||||||
|
return c.json({ error: "Session is not active" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpiry = expiresAt();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(impersonationSessions)
|
||||||
|
.set({ expiresAt: newExpiry })
|
||||||
|
.where(eq(impersonationSessions.id, session.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(impersonationAuditLogs).values({
|
||||||
|
sessionId: session.id,
|
||||||
|
action: "session_extended",
|
||||||
|
metadata: { newExpiresAt: newExpiry.toISOString() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /sessions/:id/end — End session ────────────────────────────────────
|
||||||
|
|
||||||
|
impersonationRouter.post("/sessions/:id/end", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const jwt = c.get("jwtPayload") as JwtPayload;
|
||||||
|
const staffRow = await resolveStaff(jwt.sub);
|
||||||
|
if (!staffRow) return c.json({ error: "Staff record not found" }, 403);
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
if (session.staffId !== staffRow.id) {
|
||||||
|
return c.json({ error: "Not your session" }, 403);
|
||||||
|
}
|
||||||
|
if (session.status !== "active") {
|
||||||
|
return c.json({ error: "Session is not active" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(impersonationSessions)
|
||||||
|
.set({ status: "ended", endedAt: now })
|
||||||
|
.where(eq(impersonationSessions.id, session.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(impersonationAuditLogs).values({
|
||||||
|
sessionId: session.id,
|
||||||
|
action: "session_ended",
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /sessions/:id/log — Log an audit entry ────────────────────────────
|
||||||
|
|
||||||
|
const logEntrySchema = z.object({
|
||||||
|
action: z.string().min(1).max(200),
|
||||||
|
pageVisited: z.string().max(500).optional(),
|
||||||
|
metadata: z.record(z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
impersonationRouter.post(
|
||||||
|
"/sessions/:id/log",
|
||||||
|
zValidator("json", logEntrySchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
if (session.status !== "active") {
|
||||||
|
return c.json({ error: "Session is not active" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entry] = await db
|
||||||
|
.insert(impersonationAuditLogs)
|
||||||
|
.values({
|
||||||
|
sessionId: session.id,
|
||||||
|
action: body.action,
|
||||||
|
pageVisited: body.pageVisited ?? null,
|
||||||
|
metadata: body.metadata ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(entry, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /sessions/:id/audit-log — Get audit trail ──────────────────────────
|
||||||
|
|
||||||
|
impersonationRouter.get("/sessions/:id/audit-log", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(eq(impersonationSessions.id, c.req.param("id")));
|
||||||
|
if (!session) return c.json({ error: "Session not found" }, 404);
|
||||||
|
|
||||||
|
const logs = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationAuditLogs)
|
||||||
|
.where(eq(impersonationAuditLogs.sessionId, session.id))
|
||||||
|
.orderBy(desc(impersonationAuditLogs.createdAt));
|
||||||
|
|
||||||
|
return c.json(logs);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { InvoicesPage } from "./pages/Invoices.js";
|
|||||||
import { BookPage } from "./pages/Book.js";
|
import { BookPage } from "./pages/Book.js";
|
||||||
import { ReportsPage } from "./pages/Reports.js";
|
import { ReportsPage } from "./pages/Reports.js";
|
||||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||||
|
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ to: "/", label: "Appointments" },
|
{ to: "/", label: "Appointments" },
|
||||||
@@ -21,6 +22,7 @@ const NAV_LINKS = [
|
|||||||
export function App() {
|
export function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return (
|
return (
|
||||||
|
<CustomerPortal>
|
||||||
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||||
<nav
|
<nav
|
||||||
style={{
|
style={{
|
||||||
@@ -83,5 +85,6 @@ export function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</CustomerPortal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
import type { Client, GroomingVisitLog, Pet } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Forms ───────────────────────────────────────────────────────────────────
|
// ─── Forms ───────────────────────────────────────────────────────────────────
|
||||||
@@ -41,6 +42,7 @@ const EMPTY_VISIT_LOG: VisitLogForm = { cutStyle: "", productsUsed: "", notes: "
|
|||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ClientsPage() {
|
export function ClientsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [clients, setClients] = useState<Client[]>([]);
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -360,6 +362,12 @@ export function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginLeft: "auto" }}>
|
<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}>
|
<button onClick={() => openEditClient(selectedClient)} style={btnStyle}>
|
||||||
Edit client
|
Edit client
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -218,6 +218,40 @@ export const reminderLogs = pgTable(
|
|||||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const impersonationSessionStatusEnum = pgEnum(
|
||||||
|
"impersonation_session_status",
|
||||||
|
["active", "ended", "expired"]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const impersonationSessions = pgTable("impersonation_sessions", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
staffId: uuid("staff_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => staff.id, { onDelete: "restrict" }),
|
||||||
|
clientId: uuid("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "restrict" }),
|
||||||
|
reason: text("reason"),
|
||||||
|
status: impersonationSessionStatusEnum("status").notNull().default("active"),
|
||||||
|
startedAt: timestamp("started_at").notNull().defaultNow(),
|
||||||
|
endedAt: timestamp("ended_at"),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const impersonationAuditLogs = pgTable("impersonation_audit_logs", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
sessionId: uuid("session_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
pageVisited: text("page_visited"),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
petId: uuid("pet_id")
|
petId: uuid("pet_id")
|
||||||
|
|||||||
@@ -145,6 +145,31 @@ export interface Invoice {
|
|||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||||
|
|
||||||
|
export interface ImpersonationSession {
|
||||||
|
id: string;
|
||||||
|
staffId: string;
|
||||||
|
clientId: string;
|
||||||
|
reason: string | null;
|
||||||
|
status: ImpersonationSessionStatus;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImpersonationAuditLog {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
action: string;
|
||||||
|
pageVisited: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Paginated list response
|
// Paginated list response
|
||||||
export interface PaginatedList<T> {
|
export interface PaginatedList<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
Reference in New Issue
Block a user