audit log
This commit is contained in:
parent
3964346db0
commit
93bcd9a445
|
@ -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
|
||||
|
|
133
src/app/admin/audit/page.tsx
Normal file
133
src/app/admin/audit/page.tsx
Normal 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')} • {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>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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()}>
|
||||
|
|
|
@ -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 })),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue