Add product editor audit log, fix bug that would overwrite editor fields if edited too soon after load, add audit log ui

This commit is contained in:
2026-03-26 11:42:32 -04:00
parent 9643cf191f
commit 23b94d1c48
8 changed files with 1054 additions and 20 deletions

View File

@@ -0,0 +1,54 @@
-- Migration: Create product_editor_audit_log table
-- Permanent audit trail of all product editor API submissions
-- Run this against your PostgreSQL database
CREATE TABLE IF NOT EXISTS product_editor_audit_log (
id SERIAL PRIMARY KEY,
-- Who made the edit
user_id INTEGER NOT NULL,
username VARCHAR(255),
-- Which product
pid INTEGER NOT NULL,
-- What was submitted
action VARCHAR(50) NOT NULL, -- 'product_edit', 'image_changes', 'taxonomy_set'
request_payload JSONB NOT NULL, -- The exact payload sent to the external API
target_endpoint VARCHAR(255), -- The API URL that was called
-- What came back
success BOOLEAN NOT NULL,
response_payload JSONB, -- Full API response
error_message TEXT, -- Extracted error message on failure
-- Metadata
duration_ms INTEGER, -- How long the API call took
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index for looking up edits by product
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid
ON product_editor_audit_log (pid);
-- Index for looking up edits by user
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_id
ON product_editor_audit_log (user_id);
-- Index for time-based queries
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_created_at
ON product_editor_audit_log (created_at DESC);
-- Composite index for product + time queries
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_pid_created
ON product_editor_audit_log (pid, created_at DESC);
-- Composite index for user + time queries
CREATE INDEX IF NOT EXISTS idx_pe_audit_log_user_created
ON product_editor_audit_log (user_id, created_at DESC);
COMMENT ON TABLE product_editor_audit_log IS 'Permanent audit log of all product editor API submissions';
COMMENT ON COLUMN product_editor_audit_log.action IS 'Type of edit: product_edit, image_changes, or taxonomy_set';
COMMENT ON COLUMN product_editor_audit_log.request_payload IS 'Exact payload sent to the external API';
COMMENT ON COLUMN product_editor_audit_log.response_payload IS 'Full response received from the external API';
COMMENT ON COLUMN product_editor_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds';

View File

@@ -0,0 +1,193 @@
const express = require('express');
const router = express.Router();
// Create a new audit log entry
router.post('/', async (req, res) => {
try {
const {
user_id,
username,
pid,
action,
request_payload,
target_endpoint,
success,
response_payload,
error_message,
duration_ms,
} = req.body;
// Validate required fields
if (!user_id) {
return res.status(400).json({ error: 'user_id is required' });
}
if (!pid) {
return res.status(400).json({ error: 'pid is required' });
}
if (!action) {
return res.status(400).json({ error: 'action is required' });
}
if (!request_payload) {
return res.status(400).json({ error: 'request_payload is required' });
}
if (typeof success !== 'boolean') {
return res.status(400).json({ error: 'success (boolean) is required' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO product_editor_audit_log (
user_id,
username,
pid,
action,
request_payload,
target_endpoint,
success,
response_payload,
error_message,
duration_ms
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at
`, [
user_id,
username || null,
pid,
action,
JSON.stringify(request_payload),
target_endpoint || null,
success,
response_payload ? JSON.stringify(response_payload) : null,
error_message || null,
duration_ms || null,
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating product editor audit log:', error);
res.status(500).json({
error: 'Failed to create product editor audit log',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// List audit log entries (with pagination)
router.get('/', async (req, res) => {
try {
const { user_id, pid, action, limit = 50, offset = 0, success: successFilter } = req.query;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const conditions = [];
const params = [];
let paramIndex = 1;
if (user_id) {
conditions.push(`user_id = $${paramIndex++}`);
params.push(user_id);
}
if (pid) {
conditions.push(`pid = $${paramIndex++}`);
params.push(pid);
}
if (action) {
conditions.push(`action = $${paramIndex++}`);
params.push(action);
}
if (successFilter !== undefined) {
conditions.push(`success = $${paramIndex++}`);
params.push(successFilter === 'true');
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(' AND ')}`
: '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) FROM product_editor_audit_log ${whereClause}`,
params
);
// Get paginated results (exclude large payload columns in list view)
const dataParams = [...params, parseInt(limit, 10), parseInt(offset, 10)];
const result = await pool.query(`
SELECT
id,
user_id,
username,
pid,
action,
target_endpoint,
success,
error_message,
duration_ms,
created_at
FROM product_editor_audit_log
${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
`, dataParams);
res.json({
total: parseInt(countResult.rows[0].count, 10),
entries: result.rows,
});
} catch (error) {
console.error('Error fetching product editor audit log:', error);
res.status(500).json({
error: 'Failed to fetch product editor audit log',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Get a single audit log entry (with full payloads)
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(
'SELECT * FROM product_editor_audit_log WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Audit log entry not found' });
}
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching product editor audit log entry:', error);
res.status(500).json({
error: 'Failed to fetch audit log entry',
details: error instanceof Error ? error.message : 'Unknown error'
});
}
});
// Error handling middleware
router.use((err, req, res, next) => {
console.error('Product editor audit log route error:', err);
res.status(500).json({
error: 'Internal server error',
details: err.message
});
});
module.exports = router;

View File

@@ -25,6 +25,7 @@ const brandsAggregateRouter = require('./routes/brandsAggregate');
const htsLookupRouter = require('./routes/hts-lookup');
const importSessionsRouter = require('./routes/import-sessions');
const importAuditLogRouter = require('./routes/import-audit-log');
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
const newsletterRouter = require('./routes/newsletter');
// Get the absolute path to the .env file
@@ -135,6 +136,7 @@ async function startServer() {
app.use('/api/hts-lookup', htsLookupRouter);
app.use('/api/import-sessions', importSessionsRouter);
app.use('/api/import-audit-log', importAuditLogRouter);
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
app.use('/api/newsletter', newsletterRouter);
// Basic health check route

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useContext } from "react";
import axios from "axios";
import { useForm, Controller } from "react-hook-form";
import { toast } from "sonner";
@@ -13,6 +13,8 @@ import { useInlineAiValidation } from "@/components/product-import/steps/Validat
import { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
import { createProductEditorAuditLog } from "@/services/productEditorAuditLog";
import { AuthContext } from "@/contexts/AuthContext";
import { EditableComboboxField } from "./EditableComboboxField";
import { EditableInput } from "./EditableInput";
import { EditableMultiSelect } from "./EditableMultiSelect";
@@ -201,6 +203,7 @@ export function ProductEditForm({
initialImages?: ProductImage[];
onClose: () => void;
}) {
const { user } = useContext(AuthContext);
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
const [productImages, setProductImages] = useState<ProductImage[]>([]);
@@ -291,13 +294,12 @@ export function ProductEditForm({
if (t >= 10 && t <= 13) cats.push(String(item.value));
else if (t >= 20 && t <= 21) themes.push(String(item.value));
}
const updatedValues = {
...formValues,
categories: cats,
themes,
};
originalValuesRef.current = { ...updatedValues };
reset(updatedValues);
if (originalValuesRef.current) {
originalValuesRef.current.categories = cats;
originalValuesRef.current.themes = themes;
}
setValue("categories", cats);
setValue("themes", themes);
})
.catch(() => {
// Non-critical — just leave arrays empty
@@ -390,22 +392,28 @@ export function ProductEditForm({
const imageChanges = computeImageChanges();
// Extract taxonomy changes for separate API calls
const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[] }[] = [];
const taxonomyCalls: { type: "cats" | "themes" | "colors"; ids: number[]; previousIds: number[] }[] = [];
if ("categories" in changes) {
taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number) });
taxonomyCalls.push({ type: "cats", ids: (changes.categories as string[]).map(Number), previousIds: (original.categories as string[] ?? []).map(Number) });
delete changes.categories;
}
if ("themes" in changes) {
taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number) });
taxonomyCalls.push({ type: "themes", ids: (changes.themes as string[]).map(Number), previousIds: (original.themes as string[] ?? []).map(Number) });
delete changes.themes;
}
if ("colors" in changes) {
taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number) });
taxonomyCalls.push({ type: "colors", ids: (changes.colors as string[]).map(Number), previousIds: (original.colors as string[] ?? []).map(Number) });
delete changes.colors;
}
const hasFieldChanges = Object.keys(changes).length > 0;
// Build previous values for changed fields (for audit trail)
const previousValues: Record<string, unknown> = {};
for (const key of Object.keys(changes)) {
previousValues[key] = original[key as keyof ProductFormValues];
}
if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) {
toast.info("No changes to submit");
return;
@@ -413,16 +421,62 @@ export function ProductEditForm({
setIsSubmitting(true);
try {
// Helper: call an API function, time it, fire audit log
const audited = async <T extends { success: boolean; error?: unknown; message?: string }>(
action: 'product_edit' | 'image_changes' | 'taxonomy_set',
targetEndpoint: string,
requestPayload: unknown,
fn: () => Promise<T>,
): Promise<T> => {
const start = Date.now();
try {
const result = await fn();
createProductEditorAuditLog({
user_id: user?.id ?? 0,
username: user?.username,
pid: product.pid,
action,
request_payload: requestPayload,
target_endpoint: targetEndpoint,
success: result.success,
response_payload: result,
error_message: result.success ? undefined : (
typeof result.error === 'string' ? result.error
: Array.isArray(result.error) ? result.error.join('; ')
: result.message
),
duration_ms: Date.now() - start,
});
return result;
} catch (err) {
createProductEditorAuditLog({
user_id: user?.id ?? 0,
username: user?.username,
pid: product.pid,
action,
request_payload: requestPayload,
target_endpoint: targetEndpoint,
success: false,
error_message: err instanceof Error ? err.message : 'Unknown error',
duration_ms: Date.now() - start,
});
throw err;
}
};
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
if (hasFieldChanges) {
promises.push(submitProductEdit({ pid: product.pid, changes, environment: "prod" }));
promises.push(audited('product_edit', '/apiv2/product/edit', { pid: product.pid, changes, previous_values: previousValues },
() => submitProductEdit({ pid: product.pid, changes, environment: "prod" })));
}
if (imageChanges) {
promises.push(submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" }));
promises.push(audited('image_changes', '/apiv2/product/image_changes', { pid: product.pid, imageChanges },
() => submitImageChanges({ pid: product.pid, imageChanges, environment: "prod" })));
}
for (const { type, ids } of taxonomyCalls) {
promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }));
for (const { type, ids, previousIds } of taxonomyCalls) {
promises.push(audited('taxonomy_set', `/apiv2/product/${type}/${product.pid}/set`, { pid: product.pid, type, ids, previous_ids: previousIds },
() => submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" })));
}
const results = await Promise.all(promises);
@@ -449,7 +503,7 @@ export function ProductEditForm({
setIsSubmitting(false);
}
},
[product.pid, reset, computeImageChanges, productImages]
[product.pid, reset, computeImageChanges, productImages, user]
);
// Resolve options for a field config

View File

@@ -0,0 +1,675 @@
import { useState, useEffect, useCallback } from "react";
import axios from "axios";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, ChevronLeft, ChevronRight, Eye, Search, ChevronDown, ChevronRight as ChevronRightIcon, Copy, Check } from "lucide-react";
interface AuditEntry {
id: number;
user_id: number;
username: string | null;
success: boolean;
error_message: string | null;
duration_ms: number | null;
created_at: string;
target_endpoint: string | null;
// Import-specific
product_count?: number;
environment?: string;
created_count?: number;
errored_count?: number;
// Editor-specific
pid?: number;
action?: string;
}
interface AuditDetail extends AuditEntry {
request_payload: unknown;
response_payload: unknown;
// Import-specific
use_test_data_source?: boolean;
session_id?: number | null;
}
type LogType = "editor" | "import";
const PAGE_SIZE = 25;
const ACTION_LABELS: Record<string, string> = {
product_edit: "Product Edit",
image_changes: "Image Changes",
taxonomy_set: "Taxonomy Set",
};
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function formatDuration(ms: number | null): string {
if (ms === null) return "-";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function AuditLog() {
const [logType, setLogType] = useState<LogType>("editor");
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [detail, setDetail] = useState<AuditDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
// Filters
const [filterPid, setFilterPid] = useState("");
const [filterSuccess, setFilterSuccess] = useState<string>("all");
const [filterAction, setFilterAction] = useState<string>("all");
const baseUrl = logType === "editor" ? "/api/product-editor-audit-log" : "/api/import-audit-log";
const fetchEntries = useCallback(async () => {
setIsLoading(true);
try {
const params: Record<string, string | number> = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
if (filterSuccess !== "all") params.success = filterSuccess;
if (logType === "editor") {
if (filterPid.trim()) params.pid = filterPid.trim();
if (filterAction !== "all") params.action = filterAction;
}
const res = await axios.get(baseUrl, { params });
setEntries(res.data.entries);
setTotal(res.data.total);
} catch {
setEntries([]);
setTotal(0);
} finally {
setIsLoading(false);
}
}, [baseUrl, page, filterSuccess, filterPid, filterAction, logType]);
useEffect(() => {
fetchEntries();
}, [fetchEntries]);
// Reset page when filters or log type change
useEffect(() => {
setPage(0);
}, [filterSuccess, filterPid, filterAction, logType]);
const handleTabChange = (val: string) => {
setLogType(val as LogType);
setFilterPid("");
setFilterSuccess("all");
setFilterAction("all");
setEntries([]);
setTotal(0);
setDetail(null);
};
const viewDetail = async (id: number) => {
setIsLoadingDetail(true);
try {
const res = await axios.get(`${baseUrl}/${id}`);
setDetail(res.data);
} catch {
setDetail(null);
} finally {
setIsLoadingDetail(false);
}
};
const totalPages = Math.ceil(total / PAGE_SIZE);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Audit Log</h2>
<p className="text-muted-foreground text-sm mt-1">
View API call history for product imports and editor changes.
</p>
</div>
<Tabs value={logType} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="editor">Product Editor</TabsTrigger>
<TabsTrigger value="import">Product Import</TabsTrigger>
</TabsList>
<TabsContent value="editor" className="mt-4">
<Filters
filterPid={filterPid}
onFilterPidChange={setFilterPid}
filterSuccess={filterSuccess}
onFilterSuccessChange={setFilterSuccess}
filterAction={filterAction}
onFilterActionChange={setFilterAction}
showPidFilter
showActionFilter
/>
</TabsContent>
<TabsContent value="import" className="mt-4">
<Filters
filterPid=""
onFilterPidChange={() => {}}
filterSuccess={filterSuccess}
onFilterSuccessChange={setFilterSuccess}
filterAction=""
onFilterActionChange={() => {}}
showPidFilter={false}
showActionFilter={false}
/>
</TabsContent>
</Tabs>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{total} {total === 1 ? "entry" : "entries"}
</CardTitle>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
{totalPages > 0 ? `${page + 1} / ${totalPages}` : "0 / 0"}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : entries.length === 0 ? (
<p className="text-center text-muted-foreground py-12">No entries found.</p>
) : logType === "editor" ? (
<EditorTable entries={entries} onView={viewDetail} />
) : (
<ImportTable entries={entries} onView={viewDetail} />
)}
</CardContent>
</Card>
<Dialog open={detail !== null} onOpenChange={(open) => { if (!open) setDetail(null); }}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{isLoadingDetail ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : detail ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Entry #{detail.id}
<SuccessBadge success={detail.success} />
</DialogTitle>
</DialogHeader>
<DetailView detail={detail} logType={logType} />
</>
) : null}
</DialogContent>
</Dialog>
</div>
);
}
// --- Sub-components ---
function Filters({
filterPid,
onFilterPidChange,
filterSuccess,
onFilterSuccessChange,
filterAction,
onFilterActionChange,
showPidFilter,
showActionFilter,
}: {
filterPid: string;
onFilterPidChange: (v: string) => void;
filterSuccess: string;
onFilterSuccessChange: (v: string) => void;
filterAction: string;
onFilterActionChange: (v: string) => void;
showPidFilter: boolean;
showActionFilter: boolean;
}) {
return (
<div className="flex items-center gap-3 flex-wrap">
{showPidFilter && (
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by PID..."
value={filterPid}
onChange={(e) => onFilterPidChange(e.target.value)}
className="pl-8 w-40"
/>
</div>
)}
<Select value={filterSuccess} onValueChange={onFilterSuccessChange}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All results</SelectItem>
<SelectItem value="true">Success only</SelectItem>
<SelectItem value="false">Failures only</SelectItem>
</SelectContent>
</Select>
{showActionFilter && (
<Select value={filterAction} onValueChange={onFilterActionChange}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All actions</SelectItem>
<SelectItem value="product_edit">Product Edit</SelectItem>
<SelectItem value="image_changes">Image Changes</SelectItem>
<SelectItem value="taxonomy_set">Taxonomy Set</SelectItem>
</SelectContent>
</Select>
)}
</div>
);
}
function SuccessBadge({ success }: { success: boolean }) {
return success ? (
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50 dark:bg-green-950 dark:border-green-800 dark:text-green-400">
Success
</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-800 dark:text-red-400">
Failed
</Badge>
);
}
function EditorTable({ entries, onView }: { entries: AuditEntry[]; onView: (id: number) => void }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">Time</TableHead>
<TableHead>User</TableHead>
<TableHead>PID</TableHead>
<TableHead>Action</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-xs text-muted-foreground">{formatDate(e.created_at)}</TableCell>
<TableCell>{e.username ?? `#${e.user_id}`}</TableCell>
<TableCell className="font-mono text-sm">{e.pid}</TableCell>
<TableCell>
<Badge variant="secondary" className="font-normal">
{ACTION_LABELS[e.action ?? ""] ?? e.action}
</Badge>
</TableCell>
<TableCell><SuccessBadge success={e.success} /></TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDuration(e.duration_ms)}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => onView(e.id)} title="View details">
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function ImportTable({ entries, onView }: { entries: AuditEntry[]; onView: (id: number) => void }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Products</TableHead>
<TableHead>Env</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created / Errored</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-xs text-muted-foreground">{formatDate(e.created_at)}</TableCell>
<TableCell>{e.username ?? `#${e.user_id}`}</TableCell>
<TableCell>{e.product_count}</TableCell>
<TableCell>
<Badge variant={e.environment === "prod" ? "default" : "secondary"} className="font-normal">
{e.environment}
</Badge>
</TableCell>
<TableCell><SuccessBadge success={e.success} /></TableCell>
<TableCell className="text-xs">
{e.created_count ?? 0} / {e.errored_count ?? 0}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDuration(e.duration_ms)}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => onView(e.id)} title="View details">
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType }) {
return (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
<div>
<span className="text-muted-foreground">User</span>
<p className="font-medium">{detail.username ?? `#${detail.user_id}`}</p>
</div>
<div>
<span className="text-muted-foreground">Time</span>
<p className="font-medium">{new Date(detail.created_at).toLocaleString()}</p>
</div>
{logType === "editor" && (
<>
<div>
<span className="text-muted-foreground">Product ID</span>
<p className="font-medium font-mono">{detail.pid}</p>
</div>
<div>
<span className="text-muted-foreground">Action</span>
<p className="font-medium">{ACTION_LABELS[detail.action ?? ""] ?? detail.action}</p>
</div>
</>
)}
{logType === "import" && (
<>
<div>
<span className="text-muted-foreground">Product Count</span>
<p className="font-medium">{detail.product_count}</p>
</div>
<div>
<span className="text-muted-foreground">Environment</span>
<p className="font-medium">{detail.environment}</p>
</div>
<div>
<span className="text-muted-foreground">Created / Errored</span>
<p className="font-medium">{detail.created_count ?? 0} / {detail.errored_count ?? 0}</p>
</div>
</>
)}
<div>
<span className="text-muted-foreground">Endpoint</span>
<p className="font-medium font-mono text-xs break-all">{detail.target_endpoint ?? "-"}</p>
</div>
<div>
<span className="text-muted-foreground">Duration</span>
<p className="font-medium">{formatDuration(detail.duration_ms)}</p>
</div>
</div>
{detail.error_message && (
<div>
<span className="text-muted-foreground">Error</span>
<p className="text-red-600 dark:text-red-400 mt-1 text-xs bg-red-50 dark:bg-red-950 p-2 rounded-md break-all">
{detail.error_message}
</p>
</div>
)}
<PayloadSection label="Request Payload" data={detail.request_payload} />
{detail.response_payload != null && (
<PayloadSection label="Response Payload" data={detail.response_payload} />
)}
</div>
);
}
// --- Formatted JSON viewer ---
/** Unwrap double-encoded JSON strings from JSONB columns */
function parsePayload(raw: unknown): unknown {
if (typeof raw === "string") {
try { return JSON.parse(raw); } catch { return raw; }
}
return raw;
}
function PayloadSection({ label, data: rawData }: { label: string; data: unknown }) {
const data = parsePayload(rawData);
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);
const copyJson = () => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => setShowRaw((v) => !v)}>
{showRaw ? "Formatted" : "Raw JSON"}
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={copyJson} title="Copy JSON">
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
</div>
{showRaw ? (
<pre className="p-3 bg-muted rounded-md text-xs max-h-72 overflow-y-auto font-mono whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)}
</pre>
) : (
<div className="p-3 bg-muted rounded-md text-xs max-h-72 overflow-y-auto">
<JsonValue value={data} depth={0} />
</div>
)}
</div>
);
}
function JsonValue({ value, depth }: { value: unknown; depth: number }) {
if (value === null || value === undefined) {
return <span className="text-muted-foreground italic">null</span>;
}
if (typeof value === "boolean") {
return <span className={value ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>{String(value)}</span>;
}
if (typeof value === "number") {
return <span className="text-blue-600 dark:text-blue-400">{value}</span>;
}
if (typeof value === "string") {
return <JsonString value={value} />;
}
if (Array.isArray(value)) {
return <JsonArray items={value} depth={depth} />;
}
if (typeof value === "object") {
return <JsonObject obj={value as Record<string, unknown>} depth={depth} />;
}
return <span>{String(value)}</span>;
}
const STRING_TRUNCATE_LIMIT = 1000;
function JsonString({ value }: { value: string }) {
const [expanded, setExpanded] = useState(false);
if (value.length <= STRING_TRUNCATE_LIMIT || expanded) {
return (
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap">
{value}
{expanded && value.length > STRING_TRUNCATE_LIMIT && (
<button onClick={() => setExpanded(false)} className="ml-1 text-muted-foreground hover:text-foreground text-[10px] underline">
show less
</button>
)}
</span>
);
}
return (
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap">
{value.slice(0, STRING_TRUNCATE_LIMIT)}
<span className="text-muted-foreground">... </span>
<button onClick={() => setExpanded(true)} className="text-muted-foreground hover:text-foreground text-[10px] underline">
show all ({value.length} chars)
</button>
</span>
);
}
function JsonObject({ obj, depth }: { obj: Record<string, unknown>; depth: number }) {
const entries = Object.entries(obj);
if (entries.length === 0) {
return <span className="text-muted-foreground">{"{}"}</span>;
}
// For shallow objects with only primitive values, show inline
const allPrimitive = entries.every(([, v]) => v === null || typeof v !== "object");
if (allPrimitive && entries.length <= 4) {
return (
<span className="inline">
{"{ "}
{entries.map(([k, v], i) => (
<span key={k}>
<span className="text-purple-600 dark:text-purple-400">{k}</span>
<span className="text-muted-foreground">: </span>
<JsonValue value={v} depth={depth + 1} />
{i < entries.length - 1 && <span className="text-muted-foreground">, </span>}
</span>
))}
{" }"}
</span>
);
}
return (
<div className="space-y-1">
{entries.map(([key, val]) => (
<JsonField key={key} fieldKey={key} value={val} depth={depth} />
))}
</div>
);
}
/** Renders a single key-value pair. Nested objects/arrays use the key as a collapsible label. */
function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknown; depth: number; expanded?: boolean }) {
const [open, setOpen] = useState(depth < 1);
const isNestedObj = value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length > 0;
const isNestedArr = Array.isArray(value) && value.length > 0 && !value.every((v) => v === null || typeof v !== "object");
if (isNestedObj || isNestedArr) {
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 hover:text-foreground"
>
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span>
</button>
{open && (
<div className="ml-4 border-l border-border pl-3 mt-1">
<JsonValue value={value} depth={depth + 1} />
</div>
)}
</div>
);
}
// Primitive or small array — render inline
return (
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span>
<span className="text-muted-foreground">: </span>
<JsonValue value={value} depth={depth + 1} />
</div>
);
}
function JsonArray({ items, depth }: { items: unknown[]; depth: number }) {
if (items.length === 0) {
return <span className="text-muted-foreground">[]</span>;
}
// Small arrays of primitives — show inline
const allPrimitive = items.every((v) => v === null || typeof v !== "object");
if (allPrimitive && items.length <= 10) {
return (
<span className="inline">
{"["}
{items.map((v, i) => (
<span key={i}>
<JsonValue value={v} depth={depth + 1} />
{i < items.length - 1 && <span className="text-muted-foreground">, </span>}
</span>
))}
{"]"}
</span>
);
}
return (
<div className="space-y-1">
{items.map((item, i) => (
<div key={i}>
<span className="text-muted-foreground mr-1">[{i}]</span>
<JsonValue value={item} depth={depth + 1} />
</div>
))}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { TemplateManagement } from "@/components/settings/TemplateManagement";
import { UserManagement } from "@/components/settings/UserManagement";
import { PromptManagement } from "@/components/settings/PromptManagement";
import { ReusableImageManagement } from "@/components/settings/ReusableImageManagement";
import { AuditLog } from "@/components/settings/AuditLog";
import { motion } from 'framer-motion';
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Protected } from "@/components/auth/Protected";
@@ -53,6 +54,7 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
tabs: [
{ id: "user-management", permission: "settings:user_management", label: "User Management" },
{ id: "data-management", permission: "settings:data_management", label: "Data Management" },
{ id: "audit-log", permission: "settings:audit_log", label: "Audit Log" },
]
}
];
@@ -251,8 +253,8 @@ export function Settings() {
</TabsContent>
<TabsContent value="user-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:user_management"
<Protected
permission="settings:user_management"
fallback={
<Alert>
<AlertDescription>
@@ -264,6 +266,21 @@ export function Settings() {
<UserManagement />
</Protected>
</TabsContent>
<TabsContent value="audit-log" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
adminOnly
fallback={
<Alert>
<AlertDescription>
You don't have permission to access the Audit Log.
</AlertDescription>
</Alert>
}
>
<AuditLog />
</Protected>
</TabsContent>
</div>
</Tabs>
</motion.div>

View File

@@ -0,0 +1,39 @@
/**
* Product Editor Audit Log API Service
*
* Logs every product editor submission to a permanent audit trail.
* Fire-and-forget by default — callers should not block on the result.
*/
const BASE_URL = '/api/product-editor-audit-log';
export interface ProductEditorAuditLogEntry {
user_id: number;
username?: string;
pid: number;
action: 'product_edit' | 'image_changes' | 'taxonomy_set';
request_payload: unknown;
target_endpoint?: string;
success: boolean;
response_payload?: unknown;
error_message?: string;
duration_ms?: number | null;
}
/**
* Send an audit log entry to the backend.
* Designed to be fire-and-forget — errors are logged but never thrown
* so that a logging failure never blocks the user's editing flow.
*/
export async function createProductEditorAuditLog(entry: ProductEditorAuditLogEntry): Promise<void> {
try {
await fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry),
});
} catch (error) {
// Never throw — audit logging should not disrupt the editor flow
console.error('Failed to write product editor audit log:', error);
}
}

File diff suppressed because one or more lines are too long