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:
@@ -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';
|
||||
193
inventory-server/src/routes/product-editor-audit-log.js
Normal file
193
inventory-server/src/routes/product-editor-audit-log.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
675
inventory/src/components/settings/AuditLog.tsx
Normal file
675
inventory/src/components/settings/AuditLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
inventory/src/services/productEditorAuditLog.ts
Normal file
39
inventory/src/services/productEditorAuditLog.ts
Normal 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
Reference in New Issue
Block a user