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