mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-09-15 19:32:35 +00:00
Audit log table and modal
This commit is contained in:
@@ -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,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;
|
||||
|
@@ -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
frontend/src/api/backend/getAuditLogs.ts
Normal file
14
frontend/src/api/backend/getAuditLogs.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
@@ -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";
|
||||
|
@@ -40,6 +40,8 @@ export interface AuditLog {
|
||||
objectId: number;
|
||||
action: string;
|
||||
meta: Record<string, any>;
|
||||
// Expansions:
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface AccessList {
|
||||
|
@@ -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
frontend/src/components/Table/Formatter/EventFormatter.tsx
Normal file
53
frontend/src/components/Table/Formatter/EventFormatter.tsx
Normal file
@@ -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)} — <span className="badge">{getEventValue(row)}</span>
|
||||
</div>
|
||||
<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -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,6 +1,7 @@
|
||||
export * from "./CertificateFormatter";
|
||||
export * from "./DomainsFormatter";
|
||||
export * from "./EmailFormatter";
|
||||
export * from "./EventFormatter";
|
||||
export * from "./GravatarFormatter";
|
||||
export * from "./RolesFormatter";
|
||||
export * from "./StatusFormatter";
|
||||
|
@@ -1,4 +1,6 @@
|
||||
export * from "./useAccessLists";
|
||||
export * from "./useAuditLog";
|
||||
export * from "./useAuditLogs";
|
||||
export * from "./useDeadHosts";
|
||||
export * from "./useHealth";
|
||||
export * from "./useHostReport";
|
||||
|
17
frontend/src/hooks/useAuditLog.ts
Normal file
17
frontend/src/hooks/useAuditLog.ts
Normal file
@@ -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
frontend/src/hooks/useAuditLogs.ts
Normal file
17
frontend/src/hooks/useAuditLogs.ts
Normal file
@@ -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
frontend/src/locale/DateTimeFormat.ts
Normal file
15
frontend/src/locale/DateTimeFormat.ts
Normal file
@@ -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 +1,2 @@
|
||||
export * from "./DateTimeFormat";
|
||||
export * from "./IntlProvider";
|
||||
|
@@ -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",
|
||||
|
@@ -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
frontend/src/modals/EventDetailsModal.tsx
Normal file
50
frontend/src/modals/EventDetailsModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -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
|
||||
|
@@ -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,5 +1,6 @@
|
||||
export * from "./ChangePasswordModal";
|
||||
export * from "./DeleteConfirmModal";
|
||||
export * from "./EventDetailsModal";
|
||||
export * from "./PermissionsModal";
|
||||
export * from "./SetPasswordModal";
|
||||
export * from "./UserModal";
|
||||
|
74
frontend/src/pages/AuditLog/Table.tsx
Normal file
74
frontend/src/pages/AuditLog/Table.tsx
Normal file
@@ -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
frontend/src/pages/AuditLog/TableWrapper.tsx
Normal file
53
frontend/src/pages/AuditLog/TableWrapper.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
};
|
||||
|
Reference in New Issue
Block a user