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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user