diff --git a/backend/internal/audit-log.js b/backend/internal/audit-log.js index 6d3ee9ac..02700dc5 100644 --- a/backend/internal/audit-log.js +++ b/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 diff --git a/backend/routes/audit-log.js b/backend/routes/audit-log.js index 0c51e38a..7cd232df 100644 --- a/backend/routes/audit-log.js +++ b/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; diff --git a/frontend/src/api/backend/getAuditLog.ts b/frontend/src/api/backend/getAuditLog.ts index e798b8fb..cea0872d 100644 --- a/frontend/src/api/backend/getAuditLog.ts +++ b/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 { +export async function getAuditLog(id: number, expand?: AuditLogExpansion[], params = {}): Promise { return await api.get({ - url: "/audit-log", + url: `/audit-log/${id}`, params: { expand: expand?.join(","), ...params, diff --git a/frontend/src/api/backend/getAuditLogs.ts b/frontend/src/api/backend/getAuditLogs.ts new file mode 100644 index 00000000..e1be7c28 --- /dev/null +++ b/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 { + return await api.get({ + url: "/audit-log", + params: { + expand: expand?.join(","), + ...params, + }, + }); +} diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 9cd5b526..f5e63edc 100644 --- a/frontend/src/api/backend/index.ts +++ b/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"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 328c8fc6..7a8037a7 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -40,6 +40,8 @@ export interface AuditLog { objectId: number; action: string; meta: Record; + // Expansions: + user?: User; } export interface AccessList { diff --git a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx b/frontend/src/components/Table/Formatter/DomainsFormatter.tsx index daa7f08e..6f5a037f 100644 --- a/frontend/src/components/Table/Formatter/DomainsFormatter.tsx +++ b/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) { {createdOn ? (
- {intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })} + {intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
) : null} diff --git a/frontend/src/components/Table/Formatter/EventFormatter.tsx b/frontend/src/components/Table/Formatter/EventFormatter.tsx new file mode 100644 index 00000000..77892b3d --- /dev/null +++ b/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) => ( + {intl.formatMessage({ id: `event.${event.action}-${event.objectType}` })} +); + +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 = ; + break; + } + + return ico; +}; + +interface Props { + row: AuditLog; +} +export function EventFormatter({ row }: Props) { + return ( +
+
+ {getIcon(row)} {getEventTitle(row)} — {getEventValue(row)} +
+
{DateTimeFormat(row.createdOn)}
+
+ ); +} diff --git a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx b/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx index db8d60ef..a01ab640 100644 --- a/frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx +++ b/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) {
{disabled ? intl.formatMessage({ id: "disabled" }) - : intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })} + : intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
) : null} diff --git a/frontend/src/components/Table/Formatter/index.ts b/frontend/src/components/Table/Formatter/index.ts index 60bcbf73..33f447b2 100644 --- a/frontend/src/components/Table/Formatter/index.ts +++ b/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"; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 2c9b4921..f163b904 100644 --- a/frontend/src/hooks/index.ts +++ b/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"; diff --git a/frontend/src/hooks/useAuditLog.ts b/frontend/src/hooks/useAuditLog.ts new file mode 100644 index 00000000..95e08ebc --- /dev/null +++ b/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({ + queryKey: ["audit-log", id], + queryFn: () => fetchAuditLog(id), + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + +export { useAuditLog }; diff --git a/frontend/src/hooks/useAuditLogs.ts b/frontend/src/hooks/useAuditLogs.ts new file mode 100644 index 00000000..bbe8b506 --- /dev/null +++ b/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({ + queryKey: ["audit-logs", { expand }], + queryFn: () => fetchAuditLogs(expand), + staleTime: 10 * 1000, + ...options, + }); +}; + +export { fetchAuditLogs, useAuditLogs }; diff --git a/frontend/src/locale/DateTimeFormat.ts b/frontend/src/locale/DateTimeFormat.ts new file mode 100644 index 00000000..fb8e66c8 --- /dev/null +++ b/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 }; diff --git a/frontend/src/locale/index.ts b/frontend/src/locale/index.ts index c6dca46d..6d9ac03c 100644 --- a/frontend/src/locale/index.ts +++ b/frontend/src/locale/index.ts @@ -1 +1,2 @@ +export * from "./DateTimeFormat"; export * from "./IntlProvider"; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index e1e7cc5e..c857c08a 100644 --- a/frontend/src/locale/lang/en.json +++ b/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", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 951a47ba..b67e8207 100644 --- a/frontend/src/locale/src/en.json +++ b/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" }, diff --git a/frontend/src/modals/EventDetailsModal.tsx b/frontend/src/modals/EventDetailsModal.tsx new file mode 100644 index 00000000..c105c3f5 --- /dev/null +++ b/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 ( + + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} + {isLoading && } + {!isLoading && data && ( + <> + + {intl.formatMessage({ id: "action.view-details" })} + + +
+
+ +
+
+ +
+
+
+								{JSON.stringify(data.meta, null, 2)}
+							
+
+
+ + + + + )} +
+ ); +} diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index e3368418..6e8e04cd 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -83,7 +83,11 @@ export function PermissionsModal({ userId, onClose }: Props) { return ( - {!isLoading && error && {error?.message || "Unknown error"}} + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} {isLoading && } {!isLoading && data && ( - {!isLoading && error && {error?.message || "Unknown error"}} + {!isLoading && error && ( + + {error?.message || "Unknown error"} + + )} {(isLoading || currentIsLoading) && } {!isLoading && !currentIsLoading && data && currentUser && ( void; +} +export default function Table({ data, isFetching, onSelectItem }: Props) { + const columnHelper = createColumnHelper(); + const columns = useMemo( + () => [ + columnHelper.accessor((row: AuditLog) => row.user, { + id: "user.avatar", + cell: (info: any) => { + const value = info.getValue(); + return ; + }, + 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 ; + }, + }), + columnHelper.display({ + id: "id", + cell: (info: any) => { + return ( + + ); + }, + meta: { + className: "text-end w-1", + }, + }), + ], + [columnHelper, onSelectItem], + ); + + const tableInstance = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + rowCount: data.length, + meta: { + isFetching, + }, + enableSortingRemoval: false, + }); + + return ; +} diff --git a/frontend/src/pages/AuditLog/TableWrapper.tsx b/frontend/src/pages/AuditLog/TableWrapper.tsx new file mode 100644 index 00000000..17422f2c --- /dev/null +++ b/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 ; + } + + if (isError) { + return {error?.message || "Unknown error"}; + } + + return ( +
+
+
+
+
+
+

{intl.formatMessage({ id: "auditlog.title" })}

+
+
+
+
+ + + + +
+
+
+
+
+ + {eventId ? setEventId(0)} /> : null} + + + ); +} diff --git a/frontend/src/pages/AuditLog/index.tsx b/frontend/src/pages/AuditLog/index.tsx index 4e251871..af124229 100644 --- a/frontend/src/pages/AuditLog/index.tsx +++ b/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 ( - + ); };