audit log

This commit is contained in:
Lea 2024-01-19 14:09:14 +01:00
parent 3964346db0
commit 93bcd9a445
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
6 changed files with 186 additions and 7 deletions

View file

@ -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

View file

@ -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: <UsersIcon />,
createAlias: <UsersIcon />,
approveAlias: <UserCheckIcon />,
deleteAlias: <XIcon />,
login: <LogInIcon />,
changeOwnPassword: <AsteriskSquareIcon />,
createUser: <UserPlusIcon />,
};
function NavigationButtons() {
return (
<Flex gap="3" justify="end">
<Card>
<IconButton variant="ghost" onClick={() => page > 0 && setPage(page - 1)}>
<ArrowLeftIcon />
</IconButton>
</Card>
<Card><Text className="select-none">
{
data
? `${perPage * page + 1} - ${perPage * page + data.length} / ${totalItems}`
: "Loading..."
}
</Text></Card>
<Card>
<IconButton variant="ghost" onClick={() => setPage(page + 1)}>
<ArrowRightIcon />
</IconButton>
</Card>
</Flex>
);
}
return (
<>
<Flex justify="between" align="center" pb="4">
<Heading>Audit Log</Heading>
<NavigationButtons />
</Flex>
{
data
? data.length
? <Grid gap="4">
{
data.map((item) => (
<Card key={item.index}>
<Flex gap="3" direction="column">
<Flex direction='row' gap='4' align='center'>
<Avatar
size="2"
src={`https://gravatar.com/avatar/${sha256sum(item.user)}?d=${GRAVATAR_DEFAULT}`}
radius='full'
fallback={item.user?.slice(0, 1) || "@"}
/>
<Text size='3'>{item.user}</Text>
</Flex>
{
LOG_ACTION_HUMAN_READABLE[item.action]
? <Flex gap="3" direction="row" ml="2" my="1" className="select-none">
{logActionIcons[item.action]}
<Text size="4" color="gray">{LOG_ACTION_HUMAN_READABLE[item.action]}</Text>
</Flex>
: <Text><Code color="gray" size="4">{item.action}</Code></Text>
}
{item.data && (
<Code color="gray" className="whitespace-pre">
{JSON.stringify(item.data, null, 4)}
</Code>
)}
<Text size="2" weight="light">{dayjs(item.ts).format('DD/MM/YYYY HH:mm:ss')} &bull; {dayjs(item.ts).fromNow()}</Text>
</Flex>
</Card>
))
}
<NavigationButtons />
</Grid>
: <Card>
<GhostMessage icon={<GhostIcon />} header="Nothing here" message="There don't appear to be any log entries" />
</Card>
: <Card>
<GhostMessage icon={<LoadingSpinner />} header="Loading" />
</Card>
}
</>
);
}

View file

@ -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 (
<html lang="en">
<body className={`p-4 m-0`}>
<body className={`p-4 h-full m-0`}>
<ThemeWrapper>
<BackgroundImage />
<AuthWrapper session={await getServerSession()}>

View file

@ -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<string[]> {
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 })),
};
}

View file

@ -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,
};

View file

@ -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 }) {
</IconButton>
</Link>
</Tooltip>
<Tooltip content="Audit Log" side={tooltipSide}>
<Link href="/admin/audit">
<IconButton variant="outline" size='4'>
<ScrollTextIcon />
</IconButton>
</Link>
</Tooltip>
</>
)}
</Flex>