feat(frontend): audit log

This commit is contained in:
sam 2024-12-26 16:33:32 -05:00
parent 49e9eabea0
commit 53006ea313
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 385 additions and 1 deletions

View file

@ -30,7 +30,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
public async Task<IActionResult> GetAuditLogAsync( public async Task<IActionResult> GetAuditLogAsync(
[FromQuery] AuditLogEntryType? type = null, [FromQuery] AuditLogEntryType? type = null,
[FromQuery] int? limit = null, [FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null [FromQuery] Snowflake? before = null,
[FromQuery] Snowflake? after = null,
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
) )
{ {
limit = limit switch limit = limit switch
@ -45,11 +47,30 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo
if (before != null) if (before != null)
query = query.Where(e => e.Id < before.Value); query = query.Where(e => e.Id < before.Value);
else if (after != null)
query = query.Where(e => e.Id > after.Value);
if (type != null) if (type != null)
query = query.Where(e => e.Type == type); query = query.Where(e => e.Type == type);
if (byModerator != null)
query = query.Where(e => e.ModeratorId == byModerator.Value);
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync(); List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry)); return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
} }
[HttpGet("moderators")]
public async Task<IActionResult> GetModeratorsAsync(CancellationToken ct = default)
{
var moderators = await db
.Users.Where(u =>
!u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
)
.Select(u => new { u.Id, u.Username })
.OrderBy(u => u.Id)
.ToListAsync(ct);
return Ok(moderators);
}
} }

View file

@ -1,3 +1,6 @@
import type { Member } from "./member";
import type { PartialMember, PartialUser, User } from "./user";
export type CreateReportRequest = { export type CreateReportRequest = {
reason: ReportReason; reason: ReportReason;
context: string | null; context: string | null;
@ -24,3 +27,35 @@ export enum ReportReason {
Advertisement = "ADVERTISEMENT", Advertisement = "ADVERTISEMENT",
CopyrightViolation = "COPYRIGHT_VIOLATION", CopyrightViolation = "COPYRIGHT_VIOLATION",
} }
export type Report = {
id: string;
reporter: PartialUser;
target_user: PartialUser;
target_member?: PartialMember;
status: "OPEN" | "CLOSED";
reason: ReportReason;
context: string | null;
target_type: "USER" | "MEMBER";
snapshot: User | Member | null;
};
export type AuditLogEntry = {
id: string;
moderator: AuditLogEntity;
target_user?: AuditLogEntity;
target_member?: AuditLogEntity;
report_id?: string;
type: AuditLogEntryType;
reason: string | null;
cleared_fields?: string[];
};
export type AuditLogEntity = { id: string; username: string };
export enum AuditLogEntryType {
IgnoreReport = "IGNORE_REPORT",
WarnUser = "WARN_USER",
WarnUserAndClearProfile = "WARN_USER_AND_CLEAR_PROFILE",
SuspendUser = "SUSPEND_USER",
}

View file

@ -58,6 +58,13 @@
@{user.username} @{user.username}
</NavLink> </NavLink>
</NavItem> </NavItem>
{#if user.role === "ADMIN" || user.role === "MODERATOR"}
<NavItem>
<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}>
Administration
</NavLink>
</NavItem>
{/if}
<NavItem> <NavItem>
<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}> <NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}>
{$t("nav.settings")} {$t("nav.settings")}

View file

@ -0,0 +1,8 @@
<script lang="ts">
import type { AuditLogEntity } from "$api/models/moderation";
type Props = { entity: AuditLogEntity };
let { entity }: Props = $props();
</script>
<strong>{entity.username}</strong> <span class="text-secondary">({entity.id})</span>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { AuditLogEntry } from "$api/models/moderation";
import { idTimestamp } from "$lib";
import { renderMarkdown } from "$lib/markdown";
import { DateTime } from "luxon";
import AuditLogEntity from "./AuditLogEntity.svelte";
type Props = { entry: AuditLogEntry };
let { entry }: Props = $props();
let reason = $derived(renderMarkdown(entry.reason));
let date = $derived(idTimestamp(entry.id).toLocaleString(DateTime.DATETIME_MED));
</script>
<svelte:head>
<title>Audit log</title>
</svelte:head>
<div class="card my-1 p-2">
<h6 class="d-flex">
<span class="flex-grow-1">
<AuditLogEntity entity={entry.moderator} />
{#if entry.type === "IGNORE_REPORT"}
ignored a report
{:else if entry.type === "WARN_USER" || entry.type === "WARN_USER_AND_CLEAR_PROFILE"}
warned
{:else if entry.type === "SUSPEND_USER"}
suspended
{:else}
(unknown action <code>{entry.type}</code>)
{/if}
{#if entry.target_user}
<AuditLogEntity entity={entry.target_user} />
{/if}
{#if entry.target_member}
for member <AuditLogEntity entity={entry.target_member} />
{/if}
</span>
<small class="text-secondary">{date}</small>
</h6>
{#if reason}
<details>
<summary>Reason</summary>
{@html reason}
</details>
{:else}
<em>(no reason given)</em>
{/if}
</div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Props = { title: string; onlyNumber?: boolean; children: Snippet };
let { title, onlyNumber = true, children }: Props = $props();
</script>
<div class="col-md">
<div class="card">
<div class="card-body">
<h5 class="card-title">{title}</h5>
<p class="card-text text-center" class:fs-1={onlyNumber}>
{@render children()}
</p>
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { Report } from "$api/models/moderation";
import { idTimestamp } from "$lib";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, fetch, cookies }) => {
const { meUser } = await parent();
if (!meUser) redirect(303, "/");
if (meUser.role !== "ADMIN" && meUser.role !== "MODERATOR") {
throw new ApiError({
status: 403,
code: ErrorCode.Forbidden,
message: "Only admins and moderators can use this page.",
});
}
const reports = await apiRequest<Report[]>("GET", "/moderation/reports", { fetch, cookies });
const staleReportCount = reports.filter(
(r) => idTimestamp(r.id).diffNow(["days"]).days >= 7,
).length;
return {
user: meUser,
isAdmin: meUser.role === "ADMIN",
reportCount: reports.length,
staleReportCount,
};
};

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { LayoutData } from "./$types";
import { isActive } from "$lib/pageUtils.svelte";
type Props = { data: LayoutData; children: Snippet };
let { data, children }: Props = $props();
</script>
<div class="container">
<div class="row">
<div class="col-md-3 mt-1 mb-3">
<div class="list-group">
<a
href="/admin"
class="list-group-item list-group-item-action"
class:active={isActive("/admin")}
>
Dashboard
</a>
<a
href="/admin/reports"
class="list-group-item list-group-item-action"
class:active={isActive("/admin/reports", true)}
>
Reports
{#if data.reportCount}
<span
class="badge"
class:text-bg-danger={data.reportCount >= 10}
class:text-bg-secondary={data.reportCount < 10}
>
{data.reportCount >= 100 ? "99+" : data.reportCount.toString()}
</span>
{/if}
</a>
<a
href="/admin/audit-log"
class="list-group-item list-group-item-action"
class:active={isActive("/admin/audit-log", true)}
>
Audit log
</a>
</div>
</div>
<div class="col-md-9">
{@render children?.()}
</div>
</div>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import DashboardCard from "$components/admin/DashboardCard.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
</script>
<h1>Dashboard</h1>
<div class="row gx-3 gy-3">
<DashboardCard title="Users" onlyNumber={false}>
<span class="fs-1">{data.meta.users.total.toLocaleString("en")}</span>
<br />
<small>({data.meta.users.active_month.toLocaleString("en")} active in the last month)</small>
</DashboardCard>
<DashboardCard title="Members">{data.meta.members.toLocaleString("en")}</DashboardCard>
<DashboardCard title="Open reports" onlyNumber={false}>
<span class="fs-1">{data.reportCount.toLocaleString("en")}</span>
<br />
<small>({data.staleReportCount} older than 1 week)</small>
</DashboardCard>
</div>

View file

@ -0,0 +1,38 @@
import { apiRequest } from "$api";
import { type AuditLogEntity, type AuditLogEntry } from "$api/models/moderation.js";
export const load = async ({ url, fetch, cookies }) => {
const type = url.searchParams.get("type");
const before = url.searchParams.get("before");
const after = url.searchParams.get("after");
const byModerator = url.searchParams.get("by-moderator");
let limit: number = 100;
if (url.searchParams.has("limit")) limit = parseInt(url.searchParams.get("limit")!);
const params = new URLSearchParams();
params.set("limit", limit.toString());
if (type) params.set("type", type);
if (before) params.set("before", before);
if (after) params.set("after", after);
if (byModerator) params.set("by-moderator", byModerator);
const entries = await apiRequest<AuditLogEntry[]>(
"GET",
`/moderation/audit-log?${params.toString()}`,
{
fetch,
cookies,
},
);
const moderators = await apiRequest<AuditLogEntity[]>("GET", "/moderation/audit-log/moderators", {
fetch,
cookies,
});
let modFilter: AuditLogEntity | null = null;
if (byModerator)
modFilter = entries.find((e) => e.moderator.id === byModerator)?.moderator || null;
return { entries, type, before, after, modFilter, url: url.toString(), moderators };
};

View file

@ -0,0 +1,105 @@
<script lang="ts">
import type { AuditLogEntity } from "$api/models/moderation";
import AuditLogEntryCard from "$components/admin/AuditLogEntryCard.svelte";
import {
ButtonDropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
const addTypeFilter = (type: string | null) => {
const url = new URL(data.url);
if (type) url.searchParams.set("type", type);
else url.searchParams.delete("type");
return url.toString();
};
const addModerator = (mod: AuditLogEntity | null) => {
const url = new URL(data.url);
if (mod) url.searchParams.set("by-moderator", mod.id);
else url.searchParams.delete("by-moderator");
return url.toString();
};
const addBefore = (id: string) => {
const url = new URL(data.url);
url.searchParams.delete("after");
url.searchParams.set("before", id);
return url.toString();
};
const addAfter = (id: string) => {
const url = new URL(data.url);
url.searchParams.delete("before");
url.searchParams.set("after", id);
return url.toString();
};
</script>
<h1>Audit log</h1>
<div class="btn-group">
<ButtonDropdown>
<DropdownToggle color="secondary" outline caret active={!!data.type}>
Filter by type
</DropdownToggle>
<DropdownMenu>
<DropdownItem href={addTypeFilter("IgnoreReport")} active={data.type === "IgnoreReport"}>
Ignore report
</DropdownItem>
<DropdownItem href={addTypeFilter("WarnUser")} active={data.type === "WarnUser"}>
Warn user
</DropdownItem>
<DropdownItem
href={addTypeFilter("WarnUserAndClearProfile")}
active={data.type === "WarnUserAndClearProfile"}
>
Warn user and clear profile
</DropdownItem>
<DropdownItem href={addTypeFilter("SuspendUser")} active={data.type === "SuspendUser"}>
Suspend user
</DropdownItem>
{#if data.type}
<DropdownItem href={addTypeFilter(null)}>Remove filter</DropdownItem>
{/if}
</DropdownMenu>
</ButtonDropdown>
<ButtonDropdown>
<DropdownToggle color="secondary" outline caret active={!!data.modFilter}>
Filter by moderator
</DropdownToggle>
<DropdownMenu>
{#each data.moderators as mod (mod.id)}
<DropdownItem href={addModerator(mod)} active={data.modFilter?.id === mod.id}>
{mod.username}
</DropdownItem>
{/each}
{#if data.modFilter}
<DropdownItem href={addModerator(null)}>Remove filter</DropdownItem>
{/if}
</DropdownMenu>
</ButtonDropdown>
</div>
{#if data.before}
<a href={addAfter(data.before)}>Show newer entries</a>
{/if}
{#each data.entries as entry (entry.id)}
<AuditLogEntryCard {entry} />
{:else}
<p class="text-secondary m-3">There are no entries matching your filter</p>
{/each}
{#if data.entries.length === 100}
<a href={addBefore(data.entries[data.entries.length - 1].id)}>Show older entries</a>
{/if}