auditing
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AdminDiagnosticsDto,
|
||||
ApiResponse,
|
||||
CompanyProfileDto,
|
||||
CompanyProfileInput,
|
||||
@@ -127,6 +128,9 @@ export const api = {
|
||||
me(token: string) {
|
||||
return request<LoginResponse["user"]>("/api/v1/auth/me", undefined, token);
|
||||
},
|
||||
getAdminDiagnostics(token: string) {
|
||||
return request<AdminDiagnosticsDto>("/api/v1/admin/diagnostics", undefined, token);
|
||||
},
|
||||
getCompanyProfile(token: string) {
|
||||
return request<CompanyProfileDto>("/api/v1/company-profile", undefined, token);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./auth/AuthProvider";
|
||||
import { DashboardPage } from "./modules/dashboard/DashboardPage";
|
||||
import { LoginPage } from "./modules/login/LoginPage";
|
||||
import { AdminDiagnosticsPage } from "./modules/settings/AdminDiagnosticsPage";
|
||||
import { CompanySettingsPage } from "./modules/settings/CompanySettingsPage";
|
||||
import { CrmDetailPage } from "./modules/crm/CrmDetailPage";
|
||||
import { CrmFormPage } from "./modules/crm/CrmFormPage";
|
||||
@@ -54,6 +55,10 @@ const router = createBrowserRouter([
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.companyRead]} />,
|
||||
children: [{ path: "/settings/company", element: <CompanySettingsPage /> }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.adminManage]} />,
|
||||
children: [{ path: "/settings/admin-diagnostics", element: <AdminDiagnosticsPage /> }],
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute requiredPermissions={[permissions.crmRead]} />,
|
||||
children: [
|
||||
|
||||
169
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
169
client/src/modules/settings/AdminDiagnosticsPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { AdminDiagnosticsDto } from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string) {
|
||||
try {
|
||||
return JSON.parse(metadataJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminDiagnosticsPage() {
|
||||
const { token } = useAuth();
|
||||
const [diagnostics, setDiagnostics] = useState<AdminDiagnosticsDto | null>(null);
|
||||
const [status, setStatus] = useState("Loading diagnostics...");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
api
|
||||
.getAdminDiagnostics(token)
|
||||
.then((nextDiagnostics) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setDiagnostics(nextDiagnostics);
|
||||
setStatus("Diagnostics loaded.");
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setStatus(error.message || "Unable to load diagnostics.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!diagnostics) {
|
||||
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
["Server time", formatDateTime(diagnostics.serverTime)],
|
||||
["Node runtime", diagnostics.nodeVersion],
|
||||
["Audit events", diagnostics.auditEventCount.toString()],
|
||||
["Active users", `${diagnostics.activeUserCount} / ${diagnostics.userCount}`],
|
||||
["Sales docs", diagnostics.salesDocumentCount.toString()],
|
||||
["Work orders", diagnostics.workOrderCount.toString()],
|
||||
["Projects", diagnostics.projectCount.toString()],
|
||||
["Attachments", diagnostics.attachmentCount.toString()],
|
||||
];
|
||||
|
||||
const footprintCards = [
|
||||
["Database URL", diagnostics.databaseUrl],
|
||||
["Data directory", diagnostics.dataDir],
|
||||
["Uploads directory", diagnostics.uploadsDir],
|
||||
["Client origin", diagnostics.clientOrigin],
|
||||
["Company profile", diagnostics.companyProfilePresent ? "Present" : "Missing"],
|
||||
["Roles / permissions", `${diagnostics.roleCount} / ${diagnostics.permissionCount}`],
|
||||
["Customers / vendors", `${diagnostics.customerCount} / ${diagnostics.vendorCount}`],
|
||||
["Inventory / warehouses", `${diagnostics.inventoryItemCount} / ${diagnostics.warehouseCount}`],
|
||||
["Purchase orders", diagnostics.purchaseOrderCount.toString()],
|
||||
["Shipments", diagnostics.shipmentCount.toString()],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin Diagnostics</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Operational runtime and audit visibility</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm text-muted">
|
||||
This view surfaces environment footprint, record counts, and recent change activity so admin review does not require direct database access.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/settings/company" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Company settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map(([label, value]) => (
|
||||
<div key={label} className="rounded-3xl border border-line/70 bg-page/70 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted">{label}</p>
|
||||
<p className="mt-3 text-lg font-bold text-text">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">System Footprint</p>
|
||||
<div className="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
{footprintCards.map(([label, value]) => (
|
||||
<div key={label} className="rounded-2xl border border-line/70 bg-page/70 px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{label}</p>
|
||||
<p className="mt-2 break-all text-sm text-text">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Audit Trail</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Latest cross-module write activity</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{status}</p>
|
||||
</div>
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-line/70 text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
<th className="px-3 py-3">When</th>
|
||||
<th className="px-3 py-3">Actor</th>
|
||||
<th className="px-3 py-3">Entity</th>
|
||||
<th className="px-3 py-3">Action</th>
|
||||
<th className="px-3 py-3">Summary</th>
|
||||
<th className="px-3 py-3">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
{diagnostics.recentAuditEvents.map((event) => {
|
||||
const metadata = parseMetadata(event.metadataJson);
|
||||
return (
|
||||
<tr key={event.id} className="align-top">
|
||||
<td className="px-3 py-3 text-muted">{formatDateTime(event.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-text">{event.actorName ?? "System"}</td>
|
||||
<td className="px-3 py-3 text-text">
|
||||
<div>{event.entityType}</div>
|
||||
{event.entityId ? <div className="text-xs text-muted">{event.entityId}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className="rounded-full bg-page px-2 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-text">
|
||||
{event.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-text">{event.summary}</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
{Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "No metadata"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CompanyProfileInput } from "@mrp/shared";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../../auth/AuthProvider";
|
||||
@@ -6,7 +7,7 @@ import { api } from "../../lib/api";
|
||||
import { useTheme } from "../../theme/ThemeProvider";
|
||||
|
||||
export function CompanySettingsPage() {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const { applyBrandProfile } = useTheme();
|
||||
const [form, setForm] = useState<CompanyProfileInput | null>(null);
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
@@ -145,6 +146,20 @@ export function CompanySettingsPage() {
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSave}>
|
||||
{user?.permissions.includes("admin.manage") ? (
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Admin</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">Diagnostics and audit trail</h3>
|
||||
<p className="mt-2 text-sm text-muted">Review runtime footprint and recent change activity from the admin diagnostics surface.</p>
|
||||
</div>
|
||||
<Link to="/settings/admin-diagnostics" className="rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
|
||||
Open diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel backdrop-blur 2xl:p-5">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user