瀏覽代碼

Audit log table and modal

Jamie Curnow 3 月之前
父節點
當前提交
429046f32e

+ 29 - 0
backend/internal/audit-log.js

@@ -36,6 +36,35 @@ const internalAuditLog = {
 		return await query;
 	},
 
+	/**
+	 * @param  {Access}   access
+	 * @param  {Object}   [data]
+	 * @param  {Integer}  [data.id]          Defaults to the token user
+	 * @param  {Array}    [data.expand]
+	 * @return {Promise}
+	 */
+	get: async (access, data) => {
+		await access.can("auditlog:list");
+
+		const query = auditLogModel
+			.query()
+			.andWhere("id", data.id)
+			.allowGraph("[user]")
+			.first();
+
+		if (typeof data.expand !== "undefined" && data.expand !== null) {
+			query.withGraphFetched(`[${data.expand.join(", ")}]`);
+		}
+
+		const row = await query;
+
+		if (!row?.id) {
+			throw new errs.ItemNotFoundError(data.id);
+		}
+
+		return row;
+	},
+
 	/**
 	 * This method should not be publicly used, it doesn't check certain things. It will be assumed
 	 * that permission to add to audit log is already considered, however the access token is used for

+ 52 - 0
backend/routes/audit-log.js

@@ -52,4 +52,56 @@ router
 		}
 	});
 
+/**
+ * Specific audit log entry
+ *
+ * /api/audit-log/123
+ */
+router
+	.route("/:event_id")
+	.options((_, res) => {
+		res.sendStatus(204);
+	})
+	.all(jwtdecode())
+
+	/**
+	 * GET /api/audit-log/123
+	 *
+	 * Retrieve a specific entry
+	 */
+	.get(async (req, res, next) => {
+		try {
+			const data = await validator(
+				{
+					required: ["event_id"],
+					additionalProperties: false,
+					properties: {
+						event_id: {
+							$ref: "common#/properties/id",
+						},
+						expand: {
+							$ref: "common#/properties/expand",
+						},
+					},
+				},
+				{
+					event_id: req.params.event_id,
+					expand:
+						typeof req.query.expand === "string"
+							? req.query.expand.split(",")
+							: null,
+				},
+			);
+
+			const item = await internalAuditLog.get(res.locals.access, {
+				id: data.event_id,
+				expand: data.expand,
+			});
+			res.status(200).send(item);
+		} catch (err) {
+			logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
+			next(err);
+		}
+	});
+
 export default router;

+ 3 - 2
frontend/src/api/backend/getAuditLog.ts

@@ -1,9 +1,10 @@
 import * as api from "./base";
+import type { AuditLogExpansion } from "./getAuditLogs";
 import type { AuditLog } from "./models";
 
-export async function getAuditLog(expand?: string[], params = {}): Promise<AuditLog[]> {
+export async function getAuditLog(id: number, expand?: AuditLogExpansion[], params = {}): Promise<AuditLog> {
 	return await api.get({
-		url: "/audit-log",
+		url: `/audit-log/${id}`,
 		params: {
 			expand: expand?.join(","),
 			...params,

+ 14 - 0
frontend/src/api/backend/getAuditLogs.ts

@@ -0,0 +1,14 @@
+import * as api from "./base";
+import type { AuditLog } from "./models";
+
+export type AuditLogExpansion = "user";
+
+export async function getAuditLogs(expand?: AuditLogExpansion[], params = {}): Promise<AuditLog[]> {
+	return await api.get({
+		url: "/audit-log",
+		params: {
+			expand: expand?.join(","),
+			...params,
+		},
+	});
+}

+ 1 - 0
frontend/src/api/backend/index.ts

@@ -16,6 +16,7 @@ export * from "./downloadCertificate";
 export * from "./getAccessList";
 export * from "./getAccessLists";
 export * from "./getAuditLog";
+export * from "./getAuditLogs";
 export * from "./getCertificate";
 export * from "./getCertificates";
 export * from "./getDeadHost";

+ 2 - 0
frontend/src/api/backend/models.ts

@@ -40,6 +40,8 @@ export interface AuditLog {
 	objectId: number;
 	action: string;
 	meta: Record<string, any>;
+	// Expansions:
+	user?: User;
 }
 
 export interface AccessList {

+ 2 - 3
frontend/src/components/Table/Formatter/DomainsFormatter.tsx

@@ -1,5 +1,4 @@
-import { intlFormat, parseISO } from "date-fns";
-import { intl } from "src/locale";
+import { DateTimeFormat, intl } from "src/locale";
 
 interface Props {
 	domains: string[];
@@ -17,7 +16,7 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
 			</div>
 			{createdOn ? (
 				<div className="text-secondary mt-1">
-					{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
+					{intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
 				</div>
 			) : null}
 		</div>

+ 53 - 0
frontend/src/components/Table/Formatter/EventFormatter.tsx

@@ -0,0 +1,53 @@
+import { IconUser } from "@tabler/icons-react";
+import type { AuditLog } from "src/api/backend";
+import { DateTimeFormat, intl } from "src/locale";
+
+const getEventTitle = (event: AuditLog) => (
+	<span>{intl.formatMessage({ id: `event.${event.action}-${event.objectType}` })}</span>
+);
+
+const getEventValue = (event: AuditLog) => {
+	switch (event.objectType) {
+		case "user":
+			return event.meta?.name;
+		default:
+			return `UNKNOWN EVENT TYPE: ${event.objectType}`;
+	}
+};
+
+const getColorForAction = (action: string) => {
+	switch (action) {
+		case "created":
+			return "text-lime";
+		case "deleted":
+			return "text-red";
+		default:
+			return "text-blue";
+	}
+};
+
+const getIcon = (row: AuditLog) => {
+	const c = getColorForAction(row.action);
+	let ico = null;
+	switch (row.objectType) {
+		case "user":
+			ico = <IconUser size={16} className={c} />;
+			break;
+	}
+
+	return ico;
+};
+
+interface Props {
+	row: AuditLog;
+}
+export function EventFormatter({ row }: Props) {
+	return (
+		<div className="flex-fill">
+			<div className="font-weight-medium">
+				{getIcon(row)} {getEventTitle(row)} &mdash; <span className="badge">{getEventValue(row)}</span>
+			</div>
+			<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div>
+		</div>
+	);
+}

+ 2 - 3
frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx

@@ -1,5 +1,4 @@
-import { intlFormat, parseISO } from "date-fns";
-import { intl } from "src/locale";
+import { DateTimeFormat, intl } from "src/locale";
 
 interface Props {
 	value: string;
@@ -16,7 +15,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
 				<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
 					{disabled
 						? intl.formatMessage({ id: "disabled" })
-						: intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
+						: intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
 				</div>
 			) : null}
 		</div>

+ 1 - 0
frontend/src/components/Table/Formatter/index.ts

@@ -1,6 +1,7 @@
 export * from "./CertificateFormatter";
 export * from "./DomainsFormatter";
 export * from "./EmailFormatter";
+export * from "./EventFormatter";
 export * from "./GravatarFormatter";
 export * from "./RolesFormatter";
 export * from "./StatusFormatter";

+ 2 - 0
frontend/src/hooks/index.ts

@@ -1,4 +1,6 @@
 export * from "./useAccessLists";
+export * from "./useAuditLog";
+export * from "./useAuditLogs";
 export * from "./useDeadHosts";
 export * from "./useHealth";
 export * from "./useHostReport";

+ 17 - 0
frontend/src/hooks/useAuditLog.ts

@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { type AuditLog, getAuditLog } from "src/api/backend";
+
+const fetchAuditLog = (id: number) => {
+	return getAuditLog(id, ["user"]);
+};
+
+const useAuditLog = (id: number, options = {}) => {
+	return useQuery<AuditLog, Error>({
+		queryKey: ["audit-log", id],
+		queryFn: () => fetchAuditLog(id),
+		staleTime: 5 * 60 * 1000, // 5 minutes
+		...options,
+	});
+};
+
+export { useAuditLog };

+ 17 - 0
frontend/src/hooks/useAuditLogs.ts

@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { type AuditLog, type AuditLogExpansion, getAuditLogs } from "src/api/backend";
+
+const fetchAuditLogs = (expand?: AuditLogExpansion[]) => {
+	return getAuditLogs(expand);
+};
+
+const useAuditLogs = (expand?: AuditLogExpansion[], options = {}) => {
+	return useQuery<AuditLog[], Error>({
+		queryKey: ["audit-logs", { expand }],
+		queryFn: () => fetchAuditLogs(expand),
+		staleTime: 10 * 1000,
+		...options,
+	});
+};
+
+export { fetchAuditLogs, useAuditLogs };

+ 15 - 0
frontend/src/locale/DateTimeFormat.ts

@@ -0,0 +1,15 @@
+import { intlFormat, parseISO } from "date-fns";
+
+const DateTimeFormat = (isoDate: string) =>
+	intlFormat(parseISO(isoDate), {
+		weekday: "long",
+		year: "numeric",
+		month: "numeric",
+		day: "numeric",
+		hour: "numeric",
+		minute: "numeric",
+		second: "numeric",
+		hour12: true,
+	});
+
+export { DateTimeFormat };

+ 1 - 0
frontend/src/locale/index.ts

@@ -1 +1,2 @@
+export * from "./DateTimeFormat";
 export * from "./IntlProvider";

+ 6 - 0
frontend/src/locale/lang/en.json

@@ -12,13 +12,16 @@
   "action.edit": "Edit",
   "action.enable": "Enable",
   "action.permissions": "Permissions",
+  "action.view-details": "View Details",
   "auditlog.title": "Audit Log",
   "cancel": "Cancel",
   "certificates.title": "SSL Certificates",
+  "close": "Close",
   "column.access": "Access",
   "column.authorization": "Authorization",
   "column.destination": "Destination",
   "column.email": "Email",
+  "column.event": "Event",
   "column.http-code": "Access",
   "column.incoming-port": "Incoming Port",
   "column.name": "Name",
@@ -41,6 +44,9 @@
   "empty-subtitle": "Why don't you create one?",
   "error.invalid-auth": "Invalid email or password",
   "error.passwords-must-match": "Passwords must match",
+  "event.created-user": "Created User",
+  "event.deleted-user": "Deleted User",
+  "event.updated-user": "Updated User",
   "footer.github-fork": "Fork me on Github",
   "hosts.title": "Hosts",
   "http-only": "HTTP Only",

+ 18 - 0
frontend/src/locale/src/en.json

@@ -41,12 +41,18 @@
 	"auditlog.title": {
 		"defaultMessage": "Audit Log"
 	},
+	"action.view-details": {
+		"defaultMessage": "View Details"
+	},
 	"cancel": {
 		"defaultMessage": "Cancel"
 	},
 	"certificates.title": {
 		"defaultMessage": "SSL Certificates"
 	},
+	"close": {
+		"defaultMessage": "Close"
+	},
 	"created-on": {
 		"defaultMessage": "Created: {date}"
 	},
@@ -62,6 +68,9 @@
 	"column.email": {
 		"defaultMessage": "Email"
 	},
+	"column.event": {
+		"defaultMessage": "Event"
+	},
 	"column.http-code": {
 		"defaultMessage": "Access"
 	},
@@ -122,6 +131,15 @@
 	"error.invalid-auth": {
 		"defaultMessage": "Invalid email or password"
 	},
+	"event.created-user": {
+		"defaultMessage": "Created User"
+	},
+	"event.deleted-user": {
+		"defaultMessage": "Deleted User"
+	},
+	"event.updated-user": {
+		"defaultMessage": "Updated User"
+	},
 	"footer.github-fork": {
 		"defaultMessage": "Fork me on Github"
 	},

+ 50 - 0
frontend/src/modals/EventDetailsModal.tsx

@@ -0,0 +1,50 @@
+import { Alert } from "react-bootstrap";
+import Modal from "react-bootstrap/Modal";
+import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components";
+import { useAuditLog } from "src/hooks";
+import { intl } from "src/locale";
+
+interface Props {
+	id: number;
+	onClose: () => void;
+}
+export function EventDetailsModal({ id, onClose }: Props) {
+	const { data, isLoading, error } = useAuditLog(id);
+
+	return (
+		<Modal show onHide={onClose} animation={false}>
+			{!isLoading && error && (
+				<Alert variant="danger" className="m-3">
+					{error?.message || "Unknown error"}
+				</Alert>
+			)}
+			{isLoading && <Loading noLogo />}
+			{!isLoading && data && (
+				<>
+					<Modal.Header closeButton>
+						<Modal.Title>{intl.formatMessage({ id: "action.view-details" })}</Modal.Title>
+					</Modal.Header>
+					<Modal.Body>
+						<div className="row">
+							<div className="col-md-2">
+								<GravatarFormatter url={data.user?.avatar || ""} />
+							</div>
+							<div className="col-md-10">
+								<EventFormatter row={data} />
+							</div>
+							<hr className="mt-4 mb-3" />
+							<pre>
+								<code>{JSON.stringify(data.meta, null, 2)}</code>
+							</pre>
+						</div>
+					</Modal.Body>
+					<Modal.Footer>
+						<Button data-bs-dismiss="modal" onClick={onClose}>
+							{intl.formatMessage({ id: "close" })}
+						</Button>
+					</Modal.Footer>
+				</>
+			)}
+		</Modal>
+	);
+}

+ 5 - 1
frontend/src/modals/PermissionsModal.tsx

@@ -83,7 +83,11 @@ export function PermissionsModal({ userId, onClose }: Props) {
 
 	return (
 		<Modal show onHide={onClose} animation={false}>
-			{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
+			{!isLoading && error && (
+				<Alert variant="danger" className="m-3">
+					{error?.message || "Unknown error"}
+				</Alert>
+			)}
 			{isLoading && <Loading noLogo />}
 			{!isLoading && data && (
 				<Formik

+ 5 - 1
frontend/src/modals/UserModal.tsx

@@ -49,7 +49,11 @@ export function UserModal({ userId, onClose }: Props) {
 
 	return (
 		<Modal show onHide={onClose} animation={false}>
-			{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
+			{!isLoading && error && (
+				<Alert variant="danger" className="m-3">
+					{error?.message || "Unknown error"}
+				</Alert>
+			)}
 			{(isLoading || currentIsLoading) && <Loading noLogo />}
 			{!isLoading && !currentIsLoading && data && currentUser && (
 				<Formik

+ 1 - 0
frontend/src/modals/index.ts

@@ -1,5 +1,6 @@
 export * from "./ChangePasswordModal";
 export * from "./DeleteConfirmModal";
+export * from "./EventDetailsModal";
 export * from "./PermissionsModal";
 export * from "./SetPasswordModal";
 export * from "./UserModal";

+ 74 - 0
frontend/src/pages/AuditLog/Table.tsx

@@ -0,0 +1,74 @@
+import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
+import { useMemo } from "react";
+import type { AuditLog } from "src/api/backend";
+import { EventFormatter, GravatarFormatter } from "src/components";
+import { TableLayout } from "src/components/Table/TableLayout";
+import { intl } from "src/locale";
+
+interface Props {
+	data: AuditLog[];
+	isFetching?: boolean;
+	onSelectItem?: (id: number) => void;
+}
+export default function Table({ data, isFetching, onSelectItem }: Props) {
+	const columnHelper = createColumnHelper<AuditLog>();
+	const columns = useMemo(
+		() => [
+			columnHelper.accessor((row: AuditLog) => row.user, {
+				id: "user.avatar",
+				cell: (info: any) => {
+					const value = info.getValue();
+					return <GravatarFormatter url={value.avatar} name={value.name} />;
+				},
+				meta: {
+					className: "w-1",
+				},
+			}),
+			columnHelper.accessor((row: AuditLog) => row.user?.name, {
+				id: "user.name",
+				header: intl.formatMessage({ id: "column.name" }),
+			}),
+			columnHelper.accessor((row: AuditLog) => row, {
+				id: "objectType",
+				header: intl.formatMessage({ id: "column.event" }),
+				cell: (info: any) => {
+					return <EventFormatter row={info.getValue()} />;
+				},
+			}),
+			columnHelper.display({
+				id: "id",
+				cell: (info: any) => {
+					return (
+						<button
+							type="button"
+							className="btn btn-action btn-sm px-1"
+							onClick={(e) => {
+								e.preventDefault();
+								onSelectItem?.(info.row.original.id);
+							}}
+						>
+							{intl.formatMessage({ id: "action.view-details" })}
+						</button>
+					);
+				},
+				meta: {
+					className: "text-end w-1",
+				},
+			}),
+		],
+		[columnHelper, onSelectItem],
+	);
+
+	const tableInstance = useReactTable<AuditLog>({
+		columns,
+		data,
+		getCoreRowModel: getCoreRowModel(),
+		rowCount: data.length,
+		meta: {
+			isFetching,
+		},
+		enableSortingRemoval: false,
+	});
+
+	return <TableLayout tableInstance={tableInstance} />;
+}

+ 53 - 0
frontend/src/pages/AuditLog/TableWrapper.tsx

@@ -0,0 +1,53 @@
+import { IconSearch } from "@tabler/icons-react";
+import { useState } from "react";
+import Alert from "react-bootstrap/Alert";
+import { LoadingPage } from "src/components";
+import { useAuditLogs } from "src/hooks";
+import { intl } from "src/locale";
+import { EventDetailsModal } from "src/modals";
+import Table from "./Table";
+
+export default function TableWrapper() {
+	const [eventId, setEventId] = useState(0);
+	const { isFetching, isLoading, isError, error, data } = useAuditLogs(["user"]);
+
+	if (isLoading) {
+		return <LoadingPage />;
+	}
+
+	if (isError) {
+		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
+	}
+
+	return (
+		<div className="card mt-4">
+			<div className="card-status-top bg-purple" />
+			<div className="card-table">
+				<div className="card-header">
+					<div className="row w-full">
+						<div className="col">
+							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "auditlog.title" })}</h2>
+						</div>
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								<div className="input-group input-group-flat w-auto">
+									<span className="input-group-text input-group-text-sm">
+										<IconSearch size={16} />
+									</span>
+									<input
+										id="advanced-table-search"
+										type="text"
+										className="form-control form-control-sm"
+										autoComplete="off"
+									/>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<Table data={data ?? []} isFetching={isFetching} onSelectItem={setEventId} />
+				{eventId ? <EventDetailsModal id={eventId} onClose={() => setEventId(0)} /> : null}
+			</div>
+		</div>
+	);
+}

+ 2 - 2
frontend/src/pages/AuditLog/index.tsx

@@ -1,10 +1,10 @@
 import { HasPermission } from "src/components";
-import AuditTable from "./AuditTable";
+import TableWrapper from "./TableWrapper";
 
 const AuditLog = () => {
 	return (
 		<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
-			<AuditTable />
+			<TableWrapper />
 		</HasPermission>
 	);
 };