Merge pull request #1124 from Tria-plc/freight/nati-2

Audit log and presistent cbe pnr
This commit is contained in:
Nathnael Wondisha
2026-08-05 17:23:54 +03:00
committed by GitHub
28 changed files with 839 additions and 294 deletions

View File

@@ -1,3 +1,6 @@
# Dev server port. Default: 5283.
PORT=5283
VITE_API_URL=http://localhost:3001
VITE_BASE_API_URL=http://localhost:3001

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5183 --clearScreen false",
"dev": "vite --clearScreen false",
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
"build": "vite build",
"preview": "vite preview --port 5183",

View File

@@ -7,6 +7,7 @@ import {
FileSignature,
FileText,
Hammer,
History,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -76,6 +77,7 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportPage from "./pages/reports/ReportPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
import AuditLogsPage from "./pages/audit/AuditLogsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import {
@@ -582,6 +584,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Audit logs",
href: "/dashboard/audit-logs",
icon: <History />,
permission: FREIGHT_PERMS.audit.view,
},
{
label: "Configuration",
href: "/dashboard/configuration",
@@ -1595,6 +1603,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="audit-logs"
element={
<RequirePermission permission={FREIGHT_PERMS.audit.view}>
<AuditLogsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage dropdown options used across the platform",
},
},
{
prefix: "/dashboard/audit-logs",
meta: {
title: "Audit Logs",
subtitle: "Request and entity-level activity recorded across the freight API",
},
},
{
prefix: "/dashboard/configuration/contract-validity-periods",
meta: {

View File

@@ -309,6 +309,10 @@ export const URL_CONSTANTS = {
SUMMARY: "/payments/summary",
},
AUDIT: {
LOGS: "/audit/logs",
},
LOCOMOTIVES: {
BASE: "/locomotives",
BY_ID: (id: string) => `/locomotives/${id}`,

View File

@@ -276,6 +276,9 @@ export const FREIGHT_PERMS = {
manage: "edr_freight_app:settings:dropdown:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
},
staff: {
roles: {
view: "edr_freight_app:staff:roles:view",

View File

@@ -0,0 +1,190 @@
import { Badge, Box, Card, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type {
AuditLogRow,
AuditQueryMethod,
AuditUser,
LocalizedText,
} from "@/services/audit.service";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
const ACTION_LABELS: Record<AuditQueryMethod, string> = {
INSERT: "Created",
UPDATE: "Updated",
DELETE: "Deleted",
INSERT_CHILD: "Linked child",
DELETE_CHILD: "Unlinked child",
};
const ACTION_COLORS: Record<AuditQueryMethod, string> = {
INSERT: "edr-green",
UPDATE: "yellow",
DELETE: "red",
INSERT_CHILD: "indigo",
DELETE_CHILD: "gray",
};
function formatDateTime(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// See LocalizedText: `name`/`title` lifted from a raw audited entity can be
// a plain string or IAM's { am, en } — never render either directly.
// "undefined undefined" is the producer's own broken template when no user
// was attached at all (unauthenticated/customer flows, e.g. Fayda
// verification) — filtered out here rather than shown as raw garbage.
function localize(value: LocalizedText | null | undefined): string | undefined {
if (!value) return undefined;
if (typeof value === "object") return value.en ?? value.am ?? undefined;
if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined;
return value;
}
function formatUser(user: AuditUser | null | undefined): string {
return localize(user?.name) ?? user?.id ?? "—";
}
function summarize(row: AuditLogRow): string {
if (row.changes?.length) {
return row.changes
.slice(0, 2)
.map((c) => c.field)
.join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : "");
}
if (row.payload) {
return (
localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—"
);
}
return "—";
}
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function AuditLogsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const filter = {
skip: pagination.pageIndex * pagination.pageSize,
take: pagination.pageSize,
};
const { data, isLoading, isError } = useQuery(
api.audit.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.count ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<AuditLogRow>[] = [
{
id: "time",
header: () => <span className={tableHeader}>Time</span>,
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{formatDateTime(row.original.createdAt)}
</span>
),
},
{
id: "action",
header: () => <span className={tableHeader}>Action</span>,
cell: ({ row }) => (
<Badge
color={ACTION_COLORS[row.original.queryMethod] ?? "gray"}
variant="light"
radius="sm"
>
{ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod}
</Badge>
),
},
{
id: "entity",
header: () => <span className={tableHeader}>Entity</span>,
cell: ({ row }) => (
<span className="font-mono text-sm text-foreground">
{row.original.entityName}
</span>
),
},
{
id: "user",
header: () => <span className={tableHeader}>User</span>,
cell: ({ row }) => (
<span className="text-sm text-foreground">
{formatUser(row.original.auditLog?.user)}
</span>
),
},
{
id: "summary",
header: () => <span className={tableHeader}>Summary</span>,
cell: ({ row }) => (
<span className="truncate text-sm text-muted-foreground">
{summarize(row.original)}
</span>
),
},
];
return (
<PageContainer>
<PageHeader
title="Audit Logs"
subtitle="Request and entity-level activity recorded across the freight API."
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -163,6 +163,11 @@ import {
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import {
auditService,
type AuditLogListFilter,
type PaginatedAuditLogs,
} from "./audit.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import {
@@ -2136,6 +2141,15 @@ export const api = {
),
},
audit: {
list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>(
"audit",
"list",
({ filter }) => auditService.list(filter),
({ filter }) => ["audit", "list", filter ?? {}],
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",

View File

@@ -0,0 +1,70 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const A = URL_CONSTANTS.AUDIT;
// Shape from @tria-plc/auditlog's AuditLogCommandController — see
// local-packages/FRONTEND_GUIDE.md.
export type AuditQueryMethod =
| "INSERT"
| "UPDATE"
| "DELETE"
| "INSERT_CHILD"
| "DELETE_CHILD";
export interface AuditFieldChange {
field: string;
from: unknown;
to: unknown;
}
// IAM entities (users, orgs, positions, ...) name themselves bilingually —
// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited
// entity (auditLog.user, payload) can come back as either a plain string or
// this shape; both `name` fields below reflect that.
export type LocalizedText = string | { am?: string; en?: string };
// The vendored interceptor's own broken template produces a plain string
// ("undefined undefined") when no user was attached at all (unauthenticated/
// customer flows) — that's the non-bilingual string case for `name` here.
export interface AuditUser {
id?: string;
name?: LocalizedText;
organizationId?: string;
organizationName?: string;
[key: string]: unknown;
}
export interface AuditLogRow {
id?: string;
createdAt: string;
deletedAt?: string | null;
entityName: string;
queryMethod: AuditQueryMethod;
changes?: AuditFieldChange[] | null;
payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null;
auditLog?: { id?: string; user?: AuditUser | null };
}
export interface AuditLogListFilter {
skip?: number;
take?: number;
}
export interface PaginatedAuditLogs {
items: AuditLogRow[];
count: number;
}
export const auditService = {
list: async (filter?: AuditLogListFilter): Promise<PaginatedAuditLogs> => {
const params: Record<string, number | undefined> = {
skip: filter?.skip,
take: filter?.take,
};
const response = await client.get<PaginatedAuditLogs>(A.LOGS, { params });
const data = unwrap(response.data) as PaginatedAuditLogs;
return { items: data.items ?? [], count: data.count ?? 0 };
},
};

View File

@@ -2,6 +2,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { loadEnv } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const streamBrowserifyPath = require.resolve("stream-browserify");
export default defineConfig(() => {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
return {
plugins: [react(), tailwindcss()],
resolve: {
@@ -31,7 +34,7 @@ export default defineConfig(() => {
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
server: {
port: 5183,
port: Number(env.PORT) || 5283,
host: "0.0.0.0",
},
test: {

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000 --clearScreen false",
"dev": "vite --clearScreen false",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173",
"lint": "eslint src",

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
"@mantine/core": mantineCore,
"@mantine/hooks": mantineHooks,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
"@mantine/core": mantineCore,
"@mantine/hooks": mantineHooks,
},
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
optimizeDeps: {
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
},
server: {
port: 5173,
host: "0.0.0.0",
},
test: {
environment: "node",
},
optimizeDeps: {
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
},
server: {
port: Number(env.PORT) || 5273,
host: "0.0.0.0",
},
test: {
environment: "node",
},
};
});