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 htsLookupRouter = require('./routes/hts-lookup');
|
||||||
const importSessionsRouter = require('./routes/import-sessions');
|
const importSessionsRouter = require('./routes/import-sessions');
|
||||||
const importAuditLogRouter = require('./routes/import-audit-log');
|
const importAuditLogRouter = require('./routes/import-audit-log');
|
||||||
|
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
|
||||||
const newsletterRouter = require('./routes/newsletter');
|
const newsletterRouter = require('./routes/newsletter');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
@@ -135,6 +136,7 @@ async function startServer() {
|
|||||||
app.use('/api/hts-lookup', htsLookupRouter);
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
app.use('/api/import-sessions', importSessionsRouter);
|
app.use('/api/import-sessions', importSessionsRouter);
|
||||||
app.use('/api/import-audit-log', importAuditLogRouter);
|
app.use('/api/import-audit-log', importAuditLogRouter);
|
||||||
|
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
|
||||||
app.use('/api/newsletter', newsletterRouter);
|
app.use('/api/newsletter', newsletterRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// 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 axios from "axios";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
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 { AiSuggestionBadge } from "@/components/product-import/steps/ValidationStep/components/AiSuggestionBadge";
|
||||||
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
import { AiDescriptionCompare } from "@/components/ai/AiDescriptionCompare";
|
||||||
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
|
import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageChanges } from "@/services/productEditor";
|
||||||
|
import { createProductEditorAuditLog } from "@/services/productEditorAuditLog";
|
||||||
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
import { EditableComboboxField } from "./EditableComboboxField";
|
import { EditableComboboxField } from "./EditableComboboxField";
|
||||||
import { EditableInput } from "./EditableInput";
|
import { EditableInput } from "./EditableInput";
|
||||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||||
@@ -201,6 +203,7 @@ export function ProductEditForm({
|
|||||||
initialImages?: ProductImage[];
|
initialImages?: ProductImage[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { user } = useContext(AuthContext);
|
||||||
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
const [lineOptions, setLineOptions] = useState<LineOption[]>([]);
|
||||||
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
const [sublineOptions, setSublineOptions] = useState<LineOption[]>([]);
|
||||||
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
||||||
@@ -291,13 +294,12 @@ export function ProductEditForm({
|
|||||||
if (t >= 10 && t <= 13) cats.push(String(item.value));
|
if (t >= 10 && t <= 13) cats.push(String(item.value));
|
||||||
else if (t >= 20 && t <= 21) themes.push(String(item.value));
|
else if (t >= 20 && t <= 21) themes.push(String(item.value));
|
||||||
}
|
}
|
||||||
const updatedValues = {
|
if (originalValuesRef.current) {
|
||||||
...formValues,
|
originalValuesRef.current.categories = cats;
|
||||||
categories: cats,
|
originalValuesRef.current.themes = themes;
|
||||||
themes,
|
}
|
||||||
};
|
setValue("categories", cats);
|
||||||
originalValuesRef.current = { ...updatedValues };
|
setValue("themes", themes);
|
||||||
reset(updatedValues);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Non-critical — just leave arrays empty
|
// Non-critical — just leave arrays empty
|
||||||
@@ -390,22 +392,28 @@ export function ProductEditForm({
|
|||||||
const imageChanges = computeImageChanges();
|
const imageChanges = computeImageChanges();
|
||||||
|
|
||||||
// Extract taxonomy changes for separate API calls
|
// 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) {
|
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;
|
delete changes.categories;
|
||||||
}
|
}
|
||||||
if ("themes" in changes) {
|
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;
|
delete changes.themes;
|
||||||
}
|
}
|
||||||
if ("colors" in changes) {
|
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;
|
delete changes.colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFieldChanges = Object.keys(changes).length > 0;
|
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) {
|
if (!hasFieldChanges && !imageChanges && taxonomyCalls.length === 0) {
|
||||||
toast.info("No changes to submit");
|
toast.info("No changes to submit");
|
||||||
return;
|
return;
|
||||||
@@ -413,16 +421,62 @@ export function ProductEditForm({
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
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 }>[] = [];
|
const promises: Promise<{ success: boolean; error?: unknown; message?: string }>[] = [];
|
||||||
|
|
||||||
if (hasFieldChanges) {
|
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) {
|
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) {
|
for (const { type, ids, previousIds } of taxonomyCalls) {
|
||||||
promises.push(submitTaxonomySet({ pid: product.pid, type, ids, environment: "prod" }));
|
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);
|
const results = await Promise.all(promises);
|
||||||
@@ -449,7 +503,7 @@ export function ProductEditForm({
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[product.pid, reset, computeImageChanges, productImages]
|
[product.pid, reset, computeImageChanges, productImages, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve options for a field config
|
// 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 { UserManagement } from "@/components/settings/UserManagement";
|
||||||
import { PromptManagement } from "@/components/settings/PromptManagement";
|
import { PromptManagement } from "@/components/settings/PromptManagement";
|
||||||
import { ReusableImageManagement } from "@/components/settings/ReusableImageManagement";
|
import { ReusableImageManagement } from "@/components/settings/ReusableImageManagement";
|
||||||
|
import { AuditLog } from "@/components/settings/AuditLog";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Protected } from "@/components/auth/Protected";
|
import { Protected } from "@/components/auth/Protected";
|
||||||
@@ -53,6 +54,7 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ id: "user-management", permission: "settings:user_management", label: "User Management" },
|
{ id: "user-management", permission: "settings:user_management", label: "User Management" },
|
||||||
{ id: "data-management", permission: "settings:data_management", label: "Data Management" },
|
{ id: "data-management", permission: "settings:data_management", label: "Data Management" },
|
||||||
|
{ id: "audit-log", permission: "settings:audit_log", label: "Audit Log" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -264,6 +266,21 @@ export function Settings() {
|
|||||||
<UserManagement />
|
<UserManagement />
|
||||||
</Protected>
|
</Protected>
|
||||||
</TabsContent>
|
</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>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</motion.div>
|
</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