From c8d898abae7db1c6f8a7a52e106c93308cb55395 Mon Sep 17 00:00:00 2001
From: Bobby <30593201+luciferreeves@users.noreply.github.com>
Date: Tue, 10 Mar 2026 14:56:31 +0530
Subject: feat: implement audit log functionality with filtering and detail
views
---
garden/src/components/Layout.tsx | 2 +-
garden/src/pages/council/auditdetail.tsx | 260 +++++++++++++++++++++++++++++++
garden/src/pages/council/auditlog.tsx | 173 ++++++++++++++++++++
garden/src/pages/council/bannedips.tsx | 8 +-
garden/src/pages/council/user.tsx | 49 ++++--
garden/src/pages/council/users.tsx | 13 +-
garden/src/routes.ts | 2 +
garden/src/store/council.ts | 42 ++++-
garden/src/styles/council.css | 165 ++++++++++++++++++++
garden/src/types/admin.ts | 33 +++-
garden/src/types/roles.ts | 9 +-
11 files changed, 733 insertions(+), 23 deletions(-)
create mode 100644 garden/src/pages/council/auditdetail.tsx
create mode 100644 garden/src/pages/council/auditlog.tsx
diff --git a/garden/src/components/Layout.tsx b/garden/src/components/Layout.tsx
index b3ac179..23d1b7e 100644
--- a/garden/src/components/Layout.tsx
+++ b/garden/src/components/Layout.tsx
@@ -118,7 +118,7 @@ export default function Layout(props: LayoutProps) {
- Announcements
- - Audit Log
+ - Audit Log
- Banned IPs
- Bazaar
diff --git a/garden/src/pages/council/auditdetail.tsx b/garden/src/pages/council/auditdetail.tsx
new file mode 100644
index 0000000..d259b56
--- /dev/null
+++ b/garden/src/pages/council/auditdetail.tsx
@@ -0,0 +1,260 @@
+import { createSignal, onMount, Show, For } from "solid-js";
+import { useParams, A } from "@solidjs/router";
+import { api } from "../../api";
+import { auth } from "../../store/auth";
+import type { AuditLogDetail } from "../../types/admin";
+import { AUDIT_ACTION_LABELS } from "../../types/admin";
+import StaffGuard from "../../components/StaffGuard";
+
+interface FieldChange {
+ field: string;
+ old: string | number | boolean | null;
+ new: string | number | boolean | null;
+}
+
+export default function CouncilAuditDetail() {
+ const params = useParams();
+ const [entry, setEntry] = createSignal(null);
+ const [error, setError] = createSignal("");
+
+ onMount(async () => {
+ const response = await api(`/council/audit/${params.ref}`, {
+ token: auth.token(),
+ });
+ if (response.ok) {
+ setEntry(response.data);
+ } else {
+ setError("Audit log not found.");
+ }
+ });
+
+ function formatDate(date: string) {
+ const d = new Date(date);
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString();
+ }
+
+ function actionLabel(action: string) {
+ return AUDIT_ACTION_LABELS[action] || action;
+ }
+
+ function parseDetails(details: string): Record | null {
+ if (!details) return null;
+ try {
+ return JSON.parse(details);
+ } catch {
+ return null;
+ }
+ }
+
+ function formatValue(val: string | number | boolean | null | undefined): string {
+ if (val === null || val === undefined) return "—";
+ if (typeof val === "boolean") return val ? "Yes" : "No";
+ return String(val);
+ }
+
+ function renderDetails(action: string, data: Record) {
+ switch (action) {
+ case "user.ban":
+ return renderBanDetails(data as { reason: string; system_ref: string });
+ case "user.disable":
+ return renderDisableDetails(data as { reason: string; disabled_until: string | null; system_ref: string });
+ case "user.role_change":
+ return renderRoleChange(data as { old_role: string; new_role: string });
+ case "user.warn":
+ return renderWarning(data as { warning_ref: string; title: string; message: string });
+ case "user.unwarn":
+ return renderDeactivateWarning(data as { warning_ref: string });
+ case "user.edit":
+ return renderEditUser(data as { changes: FieldChange[] });
+ default:
+ return renderGeneric(data);
+ }
+ }
+
+ function renderBanDetails(data: { reason: string; system_ref: string }) {
+ return (
+
+
+ Reason
+
+
+
+ Letter Ref
+ {data.system_ref}
+
+
+ );
+ }
+
+ function renderDisableDetails(data: { reason: string; disabled_until: string | null; system_ref: string }) {
+ return (
+
+
+ Reason
+
+
+
+
+ Until
+ {formatDate(data.disabled_until!)}
+
+
+
+ Letter Ref
+ {data.system_ref}
+
+
+ );
+ }
+
+ function renderRoleChange(data: { old_role: string; new_role: string }) {
+ return (
+
+
+ Old Role
+
+ {data.old_role}
+
+
+
+ New Role
+
+ {data.new_role}
+
+
+
+ );
+ }
+
+ function renderWarning(data: { warning_ref: string; title: string; message: string }) {
+ return (
+
+
+ Ref
+ {data.warning_ref}
+
+
+ Title
+ {data.title}
+
+
+ Message
+
+
+
+ );
+ }
+
+ function renderDeactivateWarning(data: { warning_ref: string }) {
+ return (
+
+
+ Warning Ref
+ {data.warning_ref}
+
+
+ );
+ }
+
+ function renderEditUser(data: { changes: FieldChange[] }) {
+ return (
+
+
+ {(change: FieldChange) => (
+
+ {change.field}
+
+
+ {formatValue(change.old)}
+ →
+
+ {formatValue(change.new)}
+
+
+ )}
+
+
+ );
+ }
+
+ function renderGeneric(data: Record) {
+ return (
+
+
+ {([key, val]: [string, unknown]) => (
+
+ {key}
+ {typeof val === "object" ? JSON.stringify(val) : formatValue(val as string | number | boolean | null)}
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+ ← Back to Audit Log
+ Audit Log Detail
+
+
+ {error()}
+
+
+
+ {(e) => {
+ const details = parseDetails(e().details);
+ return (
+ <>
+
+
+
+
+ Ref
+ {e().system_ref}
+
+
+ Date
+ {formatDate(e().created_at)}
+
+
+ Action
+ {actionLabel(e().action)}
+
+
+
+
+ Summary
+ {e().summary}
+
+
+
+
+
+
+
+ {renderDetails(e().action, details!)}
+
+
+ >
+ );
+ }}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/garden/src/pages/council/auditlog.tsx b/garden/src/pages/council/auditlog.tsx
new file mode 100644
index 0000000..f957b6b
--- /dev/null
+++ b/garden/src/pages/council/auditlog.tsx
@@ -0,0 +1,173 @@
+import { createSignal, onMount, onCleanup, Show, For } from "solid-js";
+import { useNavigate, useSearchParams } from "@solidjs/router";
+import { council } from "../../store/council";
+import type { AuditLogEntry } from "../../types/admin";
+import { AUDIT_ACTION_LABELS, AUDIT_TARGET_LABELS } from "../../types/admin";
+import StaffGuard from "../../components/StaffGuard";
+
+export default function CouncilAuditLog() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [actionFilter, setActionFilter] = createSignal(council.auditAction());
+ const [targetFilter, setTargetFilter] = createSignal(council.auditTargetType());
+ const [actionOpen, setActionOpen] = createSignal(false);
+ const [targetOpen, setTargetOpen] = createSignal(false);
+ let actionRef: HTMLDivElement | undefined;
+ let targetRef: HTMLDivElement | undefined;
+
+ onMount(() => {
+ const p = parseInt(searchParams.page as string) || council.auditPage();
+ council.loadAuditLogs(p);
+ function handleClickOutside(e: MouseEvent) {
+ if (actionRef && !actionRef.contains(e.target as Node)) setActionOpen(false);
+ if (targetRef && !targetRef.contains(e.target as Node)) setTargetOpen(false);
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
+ });
+
+ function pickAction(value: string) {
+ setActionFilter(value);
+ setActionOpen(false);
+ council.setAuditAction(value);
+ council.setAuditTargetType(targetFilter());
+ setSearchParams({ page: "1" });
+ council.loadAuditLogs(1);
+ }
+
+ function pickTarget(value: string) {
+ setTargetFilter(value);
+ setTargetOpen(false);
+ council.setAuditTargetType(value);
+ council.setAuditAction(actionFilter());
+ setSearchParams({ page: "1" });
+ council.loadAuditLogs(1);
+ }
+
+ function clearFilters() {
+ setActionFilter("");
+ setTargetFilter("");
+ council.setAuditAction("");
+ council.setAuditTargetType("");
+ setSearchParams({ page: "1" });
+ council.loadAuditLogs(1);
+ }
+
+ function goToPage(p: number) {
+ setSearchParams({ page: String(p) });
+ council.loadAuditLogs(p);
+ }
+
+ function formatDate(date: string) {
+ const d = new Date(date);
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+
+ function actionLabel(action: string) {
+ return AUDIT_ACTION_LABELS[action] || action;
+ }
+
+ function targetLabel(type: string) {
+ return AUDIT_TARGET_LABELS[type] || type;
+ }
+
+ return (
+
+
+ Audit Log
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+ }>
+ No audit logs found.
+ }>
+
+ {(entry: AuditLogEntry) => (
+ navigate(`/council/auditlog/${entry.system_ref}`)}>
+ {formatDate(entry.created_at)}
+ {actionLabel(entry.action)}
+ {entry.actor}
+
+ {targetLabel(entry.target_type)}
+ {entry.target_ref}
+
+ {entry.summary}
+
+ )}
+
+
+
+
+
+ 1}>
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/garden/src/pages/council/bannedips.tsx b/garden/src/pages/council/bannedips.tsx
index f70b197..40e4987 100644
--- a/garden/src/pages/council/bannedips.tsx
+++ b/garden/src/pages/council/bannedips.tsx
@@ -1,4 +1,5 @@
import { createSignal, onMount, Show, For } from "solid-js";
+import { useSearchParams } from "@solidjs/router";
import { api } from "../../api";
import { auth } from "../../store/auth";
import StaffGuard from "../../components/StaffGuard";
@@ -20,6 +21,7 @@ interface PaginatedResponse {
}
export default function BannedIPs() {
+ const [searchParams, setSearchParams] = useSearchParams();
const [bans, setBans] = createSignal([]);
const [page, setPage] = createSignal(1);
const [totalPages, setTotalPages] = createSignal(1);
@@ -29,6 +31,7 @@ export default function BannedIPs() {
async function loadBans(p = 1) {
setLoading(true);
+ setSearchParams({ page: String(p) });
const response = await api(`/council/bannedips?page=${p}`, {
token: auth.token(),
});
@@ -52,7 +55,10 @@ export default function BannedIPs() {
}
}
- onMount(() => loadBans());
+ onMount(() => {
+ const p = parseInt(searchParams.page as string) || 1;
+ loadBans(p);
+ });
function formatDate(date: string) {
return new Date(date).toLocaleDateString();
diff --git a/garden/src/pages/council/user.tsx b/garden/src/pages/council/user.tsx
index 0bb0c91..0b299a0 100644
--- a/garden/src/pages/council/user.tsx
+++ b/garden/src/pages/council/user.tsx
@@ -1,8 +1,8 @@
-import { createSignal, onMount, Show, For } from "solid-js";
+import { createSignal, onMount, onCleanup, Show, For } from "solid-js";
import { useParams, A } from "@solidjs/router";
import { api, uploadFile } from "../../api";
import { auth } from "../../store/auth";
-import { UserRole } from "../../types/roles";
+import { UserRole, ROLE_LABELS } from "../../types/roles";
import type { AdminUser } from "../../types/admin";
import Modal from "../../components/Modal";
import Editor from "../../components/Editor";
@@ -324,18 +324,34 @@ export default function CouncilUser() {
}
function RoleField(props: { role: string }) {
+ const [roleOpen, setRoleOpen] = createSignal(false);
+ let roleRef: HTMLDivElement | undefined;
+
const availableRoles = () => {
const roles: UserRole[] = [UserRole.Member, UserRole.Moderator];
if (auth.user()?.role === UserRole.Owner) roles.push(UserRole.Admin);
return roles;
};
+ onMount(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (roleRef && !roleRef.contains(e.target as Node)) setRoleOpen(false);
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ onCleanup(() => document.removeEventListener("mousedown", handleClickOutside));
+ });
+
+ function pickRole(role: string) {
+ setEditing((prev: Record) => ({ ...prev, role }));
+ setRoleOpen(false);
+ }
+
return (
Role
- {props.role}
+ {ROLE_LABELS[props.role] || props.role}
@@ -345,17 +361,20 @@ export default function CouncilUser() {
Role
-
+
+
+
+
+
+
@@ -600,7 +619,7 @@ export default function CouncilUser() {
@{target().username}
{statusBadge()}
-
{target().role}
+
{ROLE_LABELS[target().role] || target().role}
diff --git a/garden/src/pages/council/users.tsx b/garden/src/pages/council/users.tsx
index 1ec73db..e88ad85 100644
--- a/garden/src/pages/council/users.tsx
+++ b/garden/src/pages/council/users.tsx
@@ -1,22 +1,29 @@
import { createSignal, onMount, Show, For } from "solid-js";
-import { useNavigate } from "@solidjs/router";
+import { useNavigate, useSearchParams } from "@solidjs/router";
import { council } from "../../store/council";
+import { ROLE_LABELS } from "../../types/roles";
import type { AdminUser } from "../../types/admin";
import StaffGuard from "../../components/StaffGuard";
export default function CouncilUsers() {
const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
const [searchInput, setSearchInput] = createSignal("");
- onMount(() => council.loadUsers());
+ onMount(() => {
+ const p = parseInt(searchParams.page as string) || 1;
+ council.loadUsers(p, council.search());
+ });
function handleSearch(event: Event) {
event.preventDefault();
council.setSearch(searchInput());
+ setSearchParams({ page: "1" });
council.loadUsers(1, searchInput());
}
function goToPage(p: number) {
+ setSearchParams({ page: String(p) });
council.loadUsers(p, council.search());
}
@@ -77,7 +84,7 @@ export default function CouncilUsers() {
{user.email}
- {user.role}
+ {ROLE_LABELS[user.role] || user.role}
diff --git a/garden/src/routes.ts b/garden/src/routes.ts
index 6dc2c96..41746a5 100644
--- a/garden/src/routes.ts
+++ b/garden/src/routes.ts
@@ -12,5 +12,7 @@ export const routes: RouteDefinition[] = [
{ path: "/council/users", component: lazy(() => import("./pages/council/users")) },
{ path: "/council/users/:username", component: lazy(() => import("./pages/council/user")) },
{ path: "/council/bannedips", component: lazy(() => import("./pages/council/bannedips")) },
+ { path: "/council/auditlog", component: lazy(() => import("./pages/council/auditlog")) },
+ { path: "/council/auditlog/:ref", component: lazy(() => import("./pages/council/auditdetail")) },
{ path: "**", component: lazy(() => import("./errors/404")) },
];
diff --git a/garden/src/store/council.ts b/garden/src/store/council.ts
index 559ecb8..dda5208 100644
--- a/garden/src/store/council.ts
+++ b/garden/src/store/council.ts
@@ -1,7 +1,7 @@
import { createSignal } from "solid-js";
import { api } from "../api";
import { auth } from "./auth";
-import type { AdminUser, PaginatedResponse } from "../types/admin";
+import type { AdminUser, AuditLogEntry, PaginatedResponse } from "../types/admin";
const [users, setUsers] = createSignal([]);
const [total, setTotal] = createSignal(0);
@@ -45,6 +45,36 @@ function toggleSort(field: string) {
loadUsers(1, search());
}
+const [auditLogs, setAuditLogs] = createSignal([]);
+const [auditTotal, setAuditTotal] = createSignal(0);
+const [auditPage, setAuditPage] = createSignal(1);
+const [auditTotalPages, setAuditTotalPages] = createSignal(0);
+const [auditLoading, setAuditLoading] = createSignal(false);
+const [auditAction, setAuditAction] = createSignal("");
+const [auditTargetType, setAuditTargetType] = createSignal("");
+
+async function loadAuditLogs(p = 1) {
+ setAuditLoading(true);
+ const params = new URLSearchParams({
+ page: String(p),
+ per_page: "20",
+ });
+ if (auditAction()) params.set("action", auditAction());
+ if (auditTargetType()) params.set("target_type", auditTargetType());
+
+ const response = await api>(`/council/audit?${params}`, {
+ token: auth.token(),
+ });
+
+ if (response.ok) {
+ setAuditLogs(response.data.items);
+ setAuditTotal(response.data.total);
+ setAuditPage(response.data.page);
+ setAuditTotalPages(response.data.total_pages);
+ }
+ setAuditLoading(false);
+}
+
export const council = {
users,
total,
@@ -57,4 +87,14 @@ export const council = {
sortField,
sortOrder,
toggleSort,
+ auditLogs,
+ auditTotal,
+ auditPage,
+ auditTotalPages,
+ auditLoading,
+ auditAction,
+ setAuditAction,
+ auditTargetType,
+ setAuditTargetType,
+ loadAuditLogs,
};
\ No newline at end of file
diff --git a/garden/src/styles/council.css b/garden/src/styles/council.css
index 64220b0..a850650 100644
--- a/garden/src/styles/council.css
+++ b/garden/src/styles/council.css
@@ -2,6 +2,12 @@
display: flex;
gap: 6px;
margin-bottom: 12px;
+ align-items: stretch;
+}
+
+.council-search .form-button {
+ padding: 0 12px;
+ margin-top: 0;
}
.council-search input {
@@ -491,3 +497,162 @@
opacity: 0.5;
cursor: not-allowed;
}
+
+.council-grid-audit .council-grid-header,
+.council-grid-audit .council-grid-row {
+ grid-template-columns: 2fr 2fr 1.5fr 2fr 3fr;
+}
+
+.council-audit-filters {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 12px;
+ align-items: center;
+}
+
+.council-audit-dropdown {
+ position: relative;
+}
+
+.council-audit-dropdown-trigger {
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ padding: 6px 8px;
+ font-family: var(--font-body);
+ font-size: 12px;
+ color: var(--color-text);
+ cursor: pointer;
+ min-width: 120px;
+ text-align: left;
+}
+
+.council-audit-dropdown-trigger:hover {
+ border-color: var(--color-red);
+}
+
+.council-audit-dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 50;
+ background: var(--color-panel);
+ border: 1px solid var(--color-border);
+ margin-top: 2px;
+ min-width: 100%;
+ max-height: 240px;
+ overflow-y: auto;
+ padding: 2px 0;
+}
+
+.council-audit-dropdown-item {
+ display: block;
+ width: 100%;
+ background: none;
+ border: none;
+ padding: 5px 8px;
+ font-family: var(--font-body);
+ font-size: 12px;
+ color: var(--color-text);
+ cursor: pointer;
+ text-align: left;
+ white-space: nowrap;
+}
+
+.council-audit-dropdown-item:hover {
+ background: var(--color-surface-hover);
+}
+
+.council-audit-dropdown-item-selected {
+ background: var(--color-red);
+ color: var(--color-white);
+}
+
+.council-audit-dropdown-item-selected:hover {
+ background: var(--color-red);
+}
+
+.council-audit-clear-btn {
+ background: none;
+ border: none;
+ font-family: var(--font-body);
+ font-size: 12px;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ padding: 0;
+}
+
+.council-audit-clear-btn:hover {
+ color: var(--color-red);
+}
+
+.council-audit-date {
+ font-size: 11px;
+ color: var(--color-text-muted);
+}
+
+.council-audit-action {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.council-audit-target {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.council-audit-target-type {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--color-text-muted);
+ background: var(--color-panel-header);
+ padding: 1px 4px;
+ flex-shrink: 0;
+}
+
+.council-audit-summary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.council-audit-ref {
+ font-family: var(--font-mono, monospace);
+ font-size: 11px;
+ color: var(--color-text-muted);
+}
+
+.council-audit-change {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.council-audit-old {
+ color: var(--color-red);
+ text-decoration: line-through;
+}
+
+.council-audit-arrow {
+ color: var(--color-text-muted);
+}
+
+.council-audit-new {
+ color: var(--color-green);
+}
+
+.council-audit-json {
+ padding: 8px 10px;
+}
+
+.council-audit-json pre {
+ font-family: var(--font-mono, monospace);
+ font-size: 11px;
+ color: var(--color-text);
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0;
+}
diff --git a/garden/src/types/admin.ts b/garden/src/types/admin.ts
index d3230c1..036c6e4 100644
--- a/garden/src/types/admin.ts
+++ b/garden/src/types/admin.ts
@@ -32,4 +32,35 @@ export interface PaginatedResponse {
page: number;
per_page: number;
total_pages: number;
-}
\ No newline at end of file
+}
+
+export interface AuditLogEntry {
+ system_ref: string;
+ actor: string;
+ action: string;
+ target_type: string;
+ target_ref: string;
+ summary: string;
+ created_at: string;
+}
+
+export interface AuditLogDetail extends AuditLogEntry {
+ details: string;
+}
+
+export const AUDIT_ACTION_LABELS: Record = {
+ "user.ban": "Ban User",
+ "user.unban": "Unban User",
+ "user.disable": "Disable User",
+ "user.enable": "Enable User",
+ "user.role_change": "Role Change",
+ "user.edit": "Edit User",
+ "user.warn": "Warn User",
+ "user.unwarn": "Deactivate Warning",
+ "ticket.update": "Update Ticket",
+};
+
+export const AUDIT_TARGET_LABELS: Record = {
+ user: "User",
+ ticket: "Ticket",
+};
\ No newline at end of file
diff --git a/garden/src/types/roles.ts b/garden/src/types/roles.ts
index 12c5e5d..db03a0f 100644
--- a/garden/src/types/roles.ts
+++ b/garden/src/types/roles.ts
@@ -5,4 +5,11 @@ export const UserRole = {
Owner: "owner",
} as const;
-export type UserRole = (typeof UserRole)[keyof typeof UserRole];
\ No newline at end of file
+export type UserRole = (typeof UserRole)[keyof typeof UserRole];
+
+export const ROLE_LABELS: Record = {
+ member: "Member",
+ moderator: "Moderator",
+ admin: "Admin",
+ owner: "Owner",
+};
\ No newline at end of file
--
cgit v1.2.3