forked from administration/panel
Compare commits
1 Commits
main
...
feat/audit
Author | SHA1 | Date |
---|---|---|
|
d3bf9411c5 |
|
@ -1,7 +1,93 @@
|
|||
export default function AuditLog() {
|
||||
import { AuditLogEntryCard } from "@/components/cards/AuditLogEntryCard";
|
||||
import { CollapsibleSection } from "@/components/common/CollapsibleSection";
|
||||
import { Permission } from "@/lib/accessPermissions";
|
||||
import { fetchAuditLogEvents } from "@/lib/actions";
|
||||
import { AuditLogEntry } from "@/lib/db";
|
||||
|
||||
const collapseGroups: { perms: Permission[], name: string }[] = [
|
||||
{
|
||||
name: "user",
|
||||
perms: [
|
||||
"users/fetch/by-id",
|
||||
"users/fetch/memberships",
|
||||
"users/fetch/notices",
|
||||
"users/fetch/relations",
|
||||
"users/fetch/strikes",
|
||||
"reports/fetch/related/by-user",
|
||||
"reports/fetch/related/against-user",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "account",
|
||||
perms: [
|
||||
"accounts/fetch/by-id",
|
||||
"accounts/fetch/by-email",
|
||||
"sessions/fetch/by-account-id",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "server",
|
||||
perms: [
|
||||
"servers/fetch/by-id",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "channel",
|
||||
perms: [
|
||||
"channels/fetch/by-id",
|
||||
"channels/fetch/by-server",
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "report",
|
||||
perms: [
|
||||
"reports/fetch/by-id",
|
||||
"reports/fetch/snapshots/by-report",
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
const getGroup = (permission: string) => collapseGroups.findIndex((list) => list.perms.find((p) => p.startsWith(permission)));
|
||||
|
||||
export default async function AuditLog() {
|
||||
const entries = await fetchAuditLogEvents();
|
||||
const items: (AuditLogEntry | AuditLogEntry[])[] = [];
|
||||
let tmp: AuditLogEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const group = getGroup(entry.permission);
|
||||
|
||||
if (tmp.length && group == getGroup(tmp[0].permission)) {
|
||||
tmp.push(entry);
|
||||
} else {
|
||||
if (tmp.length) {
|
||||
items.push(tmp);
|
||||
tmp = [];
|
||||
}
|
||||
|
||||
if (group != -1) tmp.push(entry);
|
||||
else items.push(entry);
|
||||
}
|
||||
}
|
||||
items.push(...tmp);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
chronological event log for all actions
|
||||
{items.map((entry) => Array.isArray(entry)
|
||||
? (
|
||||
<CollapsibleSection
|
||||
text={`${entry.length} ${collapseGroups[getGroup(entry[0].permission)].name} fetch event${entry.length == 1 ? "" : "s"}`}
|
||||
key={entries.indexOf(entry as any)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{entry.map((item) => <AuditLogEntryCard key={item._id} log={item} />)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)
|
||||
: (
|
||||
<AuditLogEntryCard key={entry._id} log={entry} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
import { AuditLogEntry, fetchChannelById, fetchReportById, fetchServerById, fetchUserById, fetchUsersById } from "@/lib/db";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "../ui/card";
|
||||
import { UserCard } from "./UserCard";
|
||||
import { User } from "revolt-api";
|
||||
import Link from "next/link";
|
||||
import { Permission } from "@/lib/accessPermissions";
|
||||
import { ServerCard } from "./ServerCard";
|
||||
import { ChannelCard } from "./ChannelCard";
|
||||
import { ReportCard } from "./ReportCard";
|
||||
import { decodeTime } from "ulid";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export async function AuditLogEntryCard({ log }: { log: AuditLogEntry }) {
|
||||
const perm = log.permission as Permission;
|
||||
|
||||
const ContextCard = async () => {
|
||||
const fallback = log.context ? (
|
||||
<Card>
|
||||
<CardHeader className="p-3">
|
||||
<pre>
|
||||
<code>{JSON.stringify(log.context, null, 4)}</code>
|
||||
</pre>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : null;
|
||||
|
||||
try {
|
||||
// Users
|
||||
if (perm.startsWith("users/action")
|
||||
|| perm.startsWith("users/create")
|
||||
|| perm == "users/fetch/by-id"
|
||||
|| perm == "users/fetch/notices"
|
||||
|| perm == "users/fetch/memberships"
|
||||
|| perm == "users/fetch/relations"
|
||||
|| perm == "users/fetch/strikes"
|
||||
|| perm == "reports/fetch/related/by-user"
|
||||
|| perm == "reports/fetch/related/against-user"
|
||||
|| perm == "reports/update/bulk-close/by-user"
|
||||
) {
|
||||
let users: User[] = await fetchUsersById(Array.isArray(log.context) ? log.context : [log.context]);
|
||||
if (!users.length) return fallback;
|
||||
|
||||
return (
|
||||
<>
|
||||
{users.map((user: User) => (
|
||||
<Link href={`/panel/inspect/user/${log.context}`} key={user._id}>
|
||||
<UserCard subtitle={user._id} user={user as User} />
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Accounts
|
||||
if (perm == "accounts/fetch/by-id"
|
||||
|| perm.startsWith("accounts/deletion")
|
||||
|| perm == "accounts/disable"
|
||||
|| perm == "accounts/restore"
|
||||
|| perm == "accounts/update"
|
||||
|| perm == "sessions/fetch/by-account-id"
|
||||
) {
|
||||
const user = await fetchUserById(log.context);
|
||||
|
||||
if (!user) return fallback;
|
||||
return (
|
||||
<Link href={`/panel/inspect/account/${log.context}`}>
|
||||
<UserCard subtitle={user._id} user={user as User} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Servers
|
||||
if (perm == "servers/fetch/by-id"
|
||||
|| perm.startsWith("servers/update")
|
||||
|| perm == "channels/fetch/by-server"
|
||||
) {
|
||||
const server = await fetchServerById(log.context);
|
||||
|
||||
if (!server) return fallback;
|
||||
return (
|
||||
<Link href={`/panel/inspect/server/${server._id}`}>
|
||||
<ServerCard server={server} subtitle={server._id} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Channels
|
||||
if (perm == "channels/fetch/by-id"
|
||||
|| perm.startsWith("channels/update")
|
||||
) {
|
||||
const channel = await fetchChannelById(log.context);
|
||||
|
||||
if (!channel) return fallback;
|
||||
return (
|
||||
<Link href={`/panel/inspect/channel/${channel._id}`}>
|
||||
<ChannelCard channel={channel} subtitle={channel._id} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Reports
|
||||
if (perm == "reports/fetch/by-id"
|
||||
|| perm == "reports/fetch/snapshots/by-report"
|
||||
|| perm == "reports/update/notes"
|
||||
|| perm == "reports/update/reject"
|
||||
|| perm == "reports/update/reopen"
|
||||
|| perm == "reports/update/resolve") {
|
||||
const report = await fetchReportById(log.context);
|
||||
|
||||
if (!report) return fallback;
|
||||
return (
|
||||
<Link href={`/panel/reports/${report._id}`}>
|
||||
<ReportCard report={report} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return fallback;
|
||||
} catch(e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
{log.moderator} · {log._id} · {dayjs(decodeTime(log._id)).fromNow()}
|
||||
</CardDescription>
|
||||
<code className="p-1">{log.permission}</code>
|
||||
|
||||
<ContextCard />
|
||||
|
||||
{log.args != null && (
|
||||
<Card>
|
||||
<CardHeader className="p-3">
|
||||
<pre>
|
||||
<code>{JSON.stringify(log.args, null, 2)}</code>
|
||||
</pre>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function CollapsibleSection({ children, text }: { children: React.ReactNode, text: string }) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
return collapsed ? (
|
||||
<div className="cursor-pointer select-none" onClick={() => setCollapsed(false)}>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-grow h-[1px] bg-slate-200 mx-4" />
|
||||
<span className="flex-shrink text-slate-500">{text}</span>
|
||||
<div className="flex flex-grow h-[1px] bg-slate-200 mx-4" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 border-solid border-slate-200 border-[1px] rounded-lg bg-slate-50">
|
||||
<Button variant="outline" className="mb-4" onClick={() => setCollapsed(true)}>Close</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -52,18 +52,18 @@ export function NavigationLinks() {
|
|||
>
|
||||
<Bomb className="h-4 w-4" />
|
||||
</Link>
|
||||
{/*<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/discover"
|
||||
>
|
||||
<Globe2 className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/audit"
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
</Link>
|
||||
{/*<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/discover"
|
||||
>
|
||||
<Globe2 className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "outline", size: "icon" })}
|
||||
href="/panel/activity"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { SafetyNotes, insertAuditLog } from "./db";
|
||||
|
||||
type Permission =
|
||||
export type Permission =
|
||||
| `authifier${
|
||||
| ""
|
||||
| `/classification${
|
||||
|
@ -81,7 +81,8 @@ type Permission =
|
|||
| ""
|
||||
| `/fetch${"" | `/${SafetyNotes["_id"]["type"]}`}`
|
||||
| `/update${"" | `/${SafetyNotes["_id"]["type"]}`}`}`
|
||||
| `backup${"" | `/fetch${"" | "/by-name"}`}`;
|
||||
| `backup${"" | `/fetch${"" | "/by-name"}`}`
|
||||
| `audit${"" | `/fetch`}`;
|
||||
|
||||
const PermissionSets = {
|
||||
// Admin
|
||||
|
@ -210,6 +211,8 @@ const PermissionSets = {
|
|||
|
||||
"safety_notes/fetch",
|
||||
"safety_notes/update",
|
||||
|
||||
"audit/fetch",
|
||||
] as Permission[],
|
||||
|
||||
"authifier": [
|
||||
|
|
|
@ -4,6 +4,7 @@ import { readFile, readdir, writeFile } from "fs/promises";
|
|||
import { PLATFORM_MOD_ID, RESTRICT_ACCESS_LIST } from "./constants";
|
||||
import mongo, {
|
||||
Account,
|
||||
AuditLogEntry,
|
||||
ChannelInvite,
|
||||
EmailClassification,
|
||||
createDM,
|
||||
|
@ -1053,3 +1054,15 @@ export async function fetchUsersByUsername(username: string) {
|
|||
})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function fetchAuditLogEvents() {
|
||||
await checkPermission("audit/fetch", {});
|
||||
|
||||
return await mongo()
|
||||
.db("revolt")
|
||||
.collection<AuditLogEntry>("safety_audit")
|
||||
.find({})
|
||||
// .sort({ _id: -1 })
|
||||
.limit(100)
|
||||
.toArray();
|
||||
}
|
||||
|
|
18
lib/db.ts
18
lib/db.ts
|
@ -15,7 +15,7 @@ import type {
|
|||
} from "revolt-api";
|
||||
import { ulid } from "ulid";
|
||||
import { publishMessage } from "./redis";
|
||||
import { checkPermission, hasPermissionFromSession } from "./accessPermissions";
|
||||
import { Permission, checkPermission, hasPermissionFromSession } from "./accessPermissions";
|
||||
import { PLATFORM_MOD_ID } from "./constants";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
|
@ -31,6 +31,14 @@ function mongo() {
|
|||
|
||||
export default mongo;
|
||||
|
||||
export type AuditLogEntry = {
|
||||
_id: string;
|
||||
moderator: string;
|
||||
permission: Permission | string;
|
||||
context: any;
|
||||
args: any;
|
||||
};
|
||||
|
||||
export async function insertAuditLog(
|
||||
permission: string,
|
||||
context: any,
|
||||
|
@ -41,13 +49,7 @@ export async function insertAuditLog(
|
|||
|
||||
await mongo()
|
||||
.db("revolt")
|
||||
.collection<{
|
||||
_id: string;
|
||||
moderator: string;
|
||||
permission: string;
|
||||
context: any;
|
||||
args: any;
|
||||
}>("safety_audit")
|
||||
.collection<AuditLogEntry>("safety_audit")
|
||||
.insertOne({
|
||||
_id: ulid(),
|
||||
moderator: session!.user!.email!,
|
||||
|
|
Loading…
Reference in New Issue