audit log
This commit is contained in:
parent
3964346db0
commit
93bcd9a445
|
@ -2796,6 +2796,7 @@ packages:
|
||||||
|
|
||||||
/glob@7.2.3:
|
/glob@7.2.3:
|
||||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||||
|
requiresBuild: true
|
||||||
dependencies:
|
dependencies:
|
||||||
fs.realpath: 1.0.0
|
fs.realpath: 1.0.0
|
||||||
inflight: 1.0.6
|
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 AuthWrapper from '@/lib/components/wrapper/AuthWrapper';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
|
import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
|
||||||
import { ThemePanel } from '@radix-ui/themes';
|
|
||||||
import BackgroundImage from '@/lib/components/ui/BackgroundImage';
|
import BackgroundImage from '@/lib/components/ui/BackgroundImage';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
@ -19,7 +18,7 @@ export default async function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`p-4 m-0`}>
|
<body className={`p-4 h-full m-0`}>
|
||||||
<ThemeWrapper>
|
<ThemeWrapper>
|
||||||
<BackgroundImage />
|
<BackgroundImage />
|
||||||
<AuthWrapper session={await getServerSession()}>
|
<AuthWrapper session={await getServerSession()}>
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AliasEntry, approveAliasEntry, createAliasEntry, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
|
import { AliasEntry, approveAliasEntry, createAliasEntry, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
|
||||||
import { aliasesNeedApproval, isAdmin } from "./util";
|
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[]> {
|
export async function fetchAllUsers(): Promise<string[]> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
|
@ -144,3 +145,34 @@ export async function createUser(email: string, password: string) {
|
||||||
await createUserEntry(email, password);
|
await createUserEntry(email, password);
|
||||||
auditLog('createUser', email);
|
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";
|
import fs from "fs/promises";
|
||||||
|
|
||||||
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' // Unprivileged
|
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' // Unprivileged
|
||||||
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
|
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
|
||||||
|
|
||||||
|
export type AuditLog = {
|
||||||
|
user?: string | null,
|
||||||
|
ts: string,
|
||||||
|
action: AuditLogAction,
|
||||||
|
data: any,
|
||||||
|
}
|
||||||
|
|
||||||
export function auditLog(action: AuditLogAction, data?: any) {
|
export function auditLog(action: AuditLogAction, data?: any) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
|
|
||||||
const log = {
|
const log: AuditLog = {
|
||||||
user: session?.user?.email,
|
user: session?.user?.email,
|
||||||
ts: Date.now(),
|
ts: new Date().toISOString(),
|
||||||
action: action,
|
action: action,
|
||||||
data: data,
|
data: data,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { isAdmin } from "@/lib/util";
|
import { isAdmin } from "@/lib/util";
|
||||||
import { Avatar, Button, Card, Flex, IconButton, Popover, ScrollArea, Text, Tooltip } from "@radix-ui/themes";
|
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 { signOut, useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
@ -83,6 +83,13 @@ export default function NavigationPanel({ mobileUi }: { mobileUi?: boolean }) {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip content="Audit Log" side={tooltipSide}>
|
||||||
|
<Link href="/admin/audit">
|
||||||
|
<IconButton variant="outline" size='4'>
|
||||||
|
<ScrollTextIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
Loading…
Reference in a new issue