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(
 | 
					    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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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",
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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")}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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