feat(frontend): audit log
This commit is contained in:
parent
49e9eabea0
commit
53006ea313
11 changed files with 385 additions and 1 deletions
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