diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ed494a2..5403e02 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,6 +12,7 @@ import { bookRouter } from "./routes/book.js"; import { reportsRouter } from "./routes/reports.js"; import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; +import { impersonationRouter } from "./routes/impersonation.js"; import { authMiddleware } from "./middleware/auth.js"; import { startReminderScheduler } from "./services/reminders.js"; @@ -46,6 +47,7 @@ api.route("/invoices", invoicesRouter); api.route("/reports", reportsRouter); api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); +api.route("/impersonation", impersonationRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/impersonation.ts b/apps/api/src/routes/impersonation.ts new file mode 100644 index 0000000..b1572f4 --- /dev/null +++ b/apps/api/src/routes/impersonation.ts @@ -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(); + +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); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4ba6a85..b2d13b8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ( +
+
); } diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index e28d424..79ce2b9 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -360,6 +362,12 @@ export function ClientsPage() { )}
+ diff --git a/apps/web/src/portal/AuditLogViewer.tsx b/apps/web/src/portal/AuditLogViewer.tsx new file mode 100644 index 0000000..1693e24 --- /dev/null +++ b/apps/web/src/portal/AuditLogViewer.tsx @@ -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([]); + 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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+

Audit Log

+ +
+ + {loading ? ( +

Loading audit log...

+ ) : logs.length === 0 ? ( +

No audit entries.

+ ) : ( + + + + + + + + + + {logs.map((log) => ( + + + + + + ))} + +
TimeActionPage
+ {new Date(log.createdAt).toLocaleTimeString()} + {log.action} + {log.pageVisited ?? "—"} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx new file mode 100644 index 0000000..765e392 --- /dev/null +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -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=. + * 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(null); + const [clientName, setClientName] = useState(""); + const [showAuditLog, setShowAuditLog] = useState(false); + const [error, setError] = useState(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 && ( +
+ {error} + +
+ )} + + {session && session.status === "active" && ( + + )} + + {/* Push content down when banner is visible */} +
+ {children} +
+ + {showAuditLog && session && ( + setShowAuditLog(false)} /> + )} + + ); +} diff --git a/apps/web/src/portal/ImpersonationBanner.tsx b/apps/web/src/portal/ImpersonationBanner.tsx new file mode 100644 index 0000000..9fa2acf --- /dev/null +++ b/apps/web/src/portal/ImpersonationBanner.tsx @@ -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 ( +
+
+ IMPERSONATING: {clientName} — Read-only mode + + Time remaining: {remaining} + +
+
+ + +
+
+ ); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8987c2a..fd97b3f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -218,6 +218,40 @@ export const reminderLogs = pgTable( (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>(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}); + export const groomingVisitLogs = pgTable("grooming_visit_logs", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dae5721..6d13d4f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -145,6 +145,31 @@ export interface Invoice { 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 | null; + createdAt: string; +} + // Paginated list response export interface PaginatedList { items: T[];