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 }) {
+
+
+
+
+
+
+
>
)}