feat(frontend): audit log
This commit is contained in:
		
							parent
							
								
									49e9eabea0
								
							
						
					
					
						commit
						53006ea313
					
				
					 11 changed files with 385 additions and 1 deletions
				
			
		|  | @ -30,7 +30,9 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo | |||
|     public async Task<IActionResult> GetAuditLogAsync( | ||||
|         [FromQuery] AuditLogEntryType? type = 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 | ||||
|  | @ -45,11 +47,30 @@ public class AuditLogController(DatabaseContext db, ModerationRendererService mo | |||
| 
 | ||||
|         if (before != null) | ||||
|             query = query.Where(e => e.Id < before.Value); | ||||
|         else if (after != null) | ||||
|             query = query.Where(e => e.Id > after.Value); | ||||
| 
 | ||||
|         if (type != null) | ||||
|             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(); | ||||
| 
 | ||||
|         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); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,3 +1,6 @@ | |||
| import type { Member } from "./member"; | ||||
| import type { PartialMember, PartialUser, User } from "./user"; | ||||
| 
 | ||||
| export type CreateReportRequest = { | ||||
| 	reason: ReportReason; | ||||
| 	context: string | null; | ||||
|  | @ -24,3 +27,35 @@ export enum ReportReason { | |||
| 	Advertisement = "ADVERTISEMENT", | ||||
| 	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", | ||||
| } | ||||
|  |  | |||
|  | @ -58,6 +58,13 @@ | |||
| 						@{user.username} | ||||
| 					</NavLink> | ||||
| 				</NavItem> | ||||
| 				{#if user.role === "ADMIN" || user.role === "MODERATOR"} | ||||
| 					<NavItem> | ||||
| 						<NavLink href="/admin" active={page.url.pathname.startsWith(`/admin`)}> | ||||
| 							Administration | ||||
| 						</NavLink> | ||||
| 					</NavItem> | ||||
| 				{/if} | ||||
| 				<NavItem> | ||||
| 					<NavLink href="/settings" active={page.url.pathname.startsWith("/settings")}> | ||||
| 						{$t("nav.settings")} | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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> | ||||
|  | @ -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> | ||||
							
								
								
									
										30
									
								
								Foxnouns.Frontend/src/routes/admin/+layout.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								Foxnouns.Frontend/src/routes/admin/+layout.server.ts
									
										
									
									
									
										Normal 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, | ||||
| 	}; | ||||
| }; | ||||
							
								
								
									
										50
									
								
								Foxnouns.Frontend/src/routes/admin/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								Foxnouns.Frontend/src/routes/admin/+layout.svelte
									
										
									
									
									
										Normal 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> | ||||
							
								
								
									
										23
									
								
								Foxnouns.Frontend/src/routes/admin/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Foxnouns.Frontend/src/routes/admin/+page.svelte
									
										
									
									
									
										Normal 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> | ||||
							
								
								
									
										38
									
								
								Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								Foxnouns.Frontend/src/routes/admin/audit-log/+page.server.ts
									
										
									
									
									
										Normal 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 }; | ||||
| }; | ||||
							
								
								
									
										105
									
								
								Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								Foxnouns.Frontend/src/routes/admin/audit-log/+page.svelte
									
										
									
									
									
										Normal 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} | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue