From 93bcd9a4452b9d474bb91d9ff8c55aa92f61a11f Mon Sep 17 00:00:00 2001 From: Lea Date: Fri, 19 Jan 2024 14:09:14 +0100 Subject: [PATCH] audit log --- pnpm-lock.yaml | 1 + src/app/admin/audit/page.tsx | 133 ++++++++++++++++++++++ src/app/layout.tsx | 3 +- src/lib/actions.ts | 34 +++++- src/lib/audit.ts | 13 ++- src/lib/components/ui/NavigationPanel.tsx | 9 +- 6 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 src/app/admin/audit/page.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82b4572..4e42438 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2796,6 +2796,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + requiresBuild: true dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 diff --git a/src/app/admin/audit/page.tsx b/src/app/admin/audit/page.tsx new file mode 100644 index 0000000..f59b3d4 --- /dev/null +++ b/src/app/admin/audit/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { fetchAuditLog } from "@/lib/actions"; +import { AuditLog, AuditLogAction } from "@/lib/audit"; +import GhostMessage from "@/lib/components/ui/GhostMessage"; +import LoadingSpinner from "@/lib/components/ui/LoadingSpinner"; +import { GRAVATAR_DEFAULT } from "@/lib/constants"; +import { sha256sum } from "@/lib/util"; +import { Avatar, Card, Code, Flex, Grid, Heading, IconButton, Text } from "@radix-ui/themes"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { ArrowLeftIcon, ArrowRightIcon, AsteriskSquareIcon, GhostIcon, LogInIcon, UserCheckIcon, UserPlusIcon, UsersIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +dayjs.extend(relativeTime); + +const LOG_ACTION_HUMAN_READABLE: { [key in AuditLogAction]?: string } = { + requestAlias: "Requested an alias", + createAlias: "Created an alias", + approveAlias: "Approved an alias", + deleteAlias: "Deleted an alias", + login: "Logged in", + changeOwnPassword: "Changed their own password", + createUser: "Created a new user", +}; + +export default function Audit() { + const [page, setPage] = useState(0); + const [perPage, setPerPage] = useState(0); + const [totalItems, setTotalItems] = useState(0); + const [data, setData] = useState<(AuditLog & { index: number })[] | null>(null); + + useEffect(() => { + console.log("Fetching page", page); + fetchAuditLog(page).then((res) => { + if (res.page != page) setPage(res.page); + setData(res.items); + setPerPage(res.perPage); + setTotalItems(res.totalItems); + }); + }, [page]); + + const logActionIcons: { [key in AuditLogAction]?: React.ReactNode } = { + requestAlias: , + createAlias: , + approveAlias: , + deleteAlias: , + login: , + changeOwnPassword: , + createUser: , + }; + + function NavigationButtons() { + return ( + + + page > 0 && setPage(page - 1)}> + + + + + { + data + ? `${perPage * page + 1} - ${perPage * page + data.length} / ${totalItems}` + : "Loading..." + } + + + setPage(page + 1)}> + + + + + ); + } + + return ( + <> + + Audit Log + + + + { + data + ? data.length + ? + { + data.map((item) => ( + + + + + {item.user} + + + { + LOG_ACTION_HUMAN_READABLE[item.action] + ? + {logActionIcons[item.action]} + {LOG_ACTION_HUMAN_READABLE[item.action]} + + : {item.action} + } + + {item.data && ( + + {JSON.stringify(item.data, null, 4)} + + )} + + {dayjs(item.ts).format('DD/MM/YYYY HH:mm:ss')} • {dayjs(item.ts).fromNow()} + + + )) + } + + + : + } header="Nothing here" message="There don't appear to be any log entries" /> + + : + } header="Loading" /> + + } + + ); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 41e2a96..ee9306c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,6 @@ import './globals.css'; import AuthWrapper from '@/lib/components/wrapper/AuthWrapper'; import { getServerSession } from 'next-auth'; import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper'; -import { ThemePanel } from '@radix-ui/themes'; import BackgroundImage from '@/lib/components/ui/BackgroundImage'; export const metadata: Metadata = { @@ -19,7 +18,7 @@ export default async function RootLayout({ }) { return ( - + diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 33dfffe..82e47ae 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -3,7 +3,8 @@ import { getServerSession } from "next-auth"; import { AliasEntry, approveAliasEntry, createAliasEntry, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db"; import { aliasesNeedApproval, isAdmin } from "./util"; -import { auditLog } from "./audit"; +import { AuditLog, auditLog } from "./audit"; +import fs from "fs/promises"; export async function fetchAllUsers(): Promise { return new Promise(async (resolve, reject) => { @@ -144,3 +145,34 @@ export async function createUser(email: string, password: string) { await createUserEntry(email, password); auditLog('createUser', email); } + +export async function fetchAuditLog(page: number): Promise<{ page: number, perPage: number, totalItems: number, items: (AuditLog & { index: number })[] }> { + const itemsPerPage = 10; + + const session = await getServerSession(); + if (!session?.user?.email) throw new Error("Unauthenticated"); + if (!isAdmin(session)) throw new Error("Unauthorized"); + + if (!process.env.AUDIT_FILE_PATH) return { page: page, perPage: itemsPerPage, totalItems: 0, items: [] }; + + const lines = (await fs.readFile(process.env.AUDIT_FILE_PATH)) + .toString("utf8") + .trim() + .split("\n") + .reverse(); + + if (page > Math.floor(lines.length / itemsPerPage)) { + page = Math.ceil(lines.length / itemsPerPage) - 1; + } else if (page < 0) { + page = 0; + } + + return { + page: page, + perPage: itemsPerPage, + totalItems: lines.length, + items: lines + .slice(itemsPerPage * page, itemsPerPage * (page + 1)) + .map((item, i) => ({ ...JSON.parse(item), index: page * itemsPerPage + i })), + }; +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index ba9a20f..29ea040 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -1,17 +1,24 @@ -import { Session, getServerSession } from "next-auth"; +import { getServerSession } from "next-auth"; import fs from "fs/promises"; export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' // Unprivileged | 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged +export type AuditLog = { + user?: string | null, + ts: string, + action: AuditLogAction, + data: any, +} + export function auditLog(action: AuditLogAction, data?: any) { (async () => { try { const session = await getServerSession(); - const log = { + const log: AuditLog = { user: session?.user?.email, - ts: Date.now(), + ts: new Date().toISOString(), action: action, data: data, }; diff --git a/src/lib/components/ui/NavigationPanel.tsx b/src/lib/components/ui/NavigationPanel.tsx index 7e01d56..ef61263 100644 --- a/src/lib/components/ui/NavigationPanel.tsx +++ b/src/lib/components/ui/NavigationPanel.tsx @@ -2,7 +2,7 @@ import { isAdmin } from "@/lib/util"; import { Avatar, Button, Card, Flex, IconButton, Popover, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; -import { BookUserIcon, HomeIcon, Users2Icon } from "lucide-react"; +import { BookUserIcon, HomeIcon, ScrollTextIcon, Users2Icon } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import Link from "next/link"; import dayjs from "dayjs"; @@ -83,6 +83,13 @@ export default function NavigationPanel({ mobileUi }: { mobileUi?: boolean }) { + + + + + + + )}