summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-10 14:56:31 +0530
committerBobby <[email protected]>2026-03-10 14:56:31 +0530
commitc8d898abae7db1c6f8a7a52e106c93308cb55395 (patch)
treee6b409361c71a18bb898553f0e121bf796b82159
parent6f2f3c013d88df6a2b37d0978194c7cb0bf1274c (diff)
downloadpagoda-c8d898abae7db1c6f8a7a52e106c93308cb55395.tar.xz
pagoda-c8d898abae7db1c6f8a7a52e106c93308cb55395.zip
feat: implement audit log functionality with filtering and detail views
-rw-r--r--garden/src/components/Layout.tsx2
-rw-r--r--garden/src/pages/council/auditdetail.tsx260
-rw-r--r--garden/src/pages/council/auditlog.tsx173
-rw-r--r--garden/src/pages/council/bannedips.tsx8
-rw-r--r--garden/src/pages/council/user.tsx49
-rw-r--r--garden/src/pages/council/users.tsx13
-rw-r--r--garden/src/routes.ts2
-rw-r--r--garden/src/store/council.ts42
-rw-r--r--garden/src/styles/council.css165
-rw-r--r--garden/src/types/admin.ts33
-rw-r--r--garden/src/types/roles.ts9
11 files changed, 733 insertions, 23 deletions
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) {
<ul>
<Show when={auth.user()?.role === UserRole.Owner || auth.user()?.role === UserRole.Admin}>
<li><A href="/council/announcements">Announcements</A></li>
- <li><A href="/council/audit-log">Audit Log</A></li>
+ <li><A href="/council/auditlog">Audit Log</A></li>
<li><A href="/council/bannedips">Banned IPs</A></li>
</Show>
<li><A href="/council/bazaar">Bazaar</A></li>
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<AuditLogDetail | null>(null);
+ const [error, setError] = createSignal("");
+
+ onMount(async () => {
+ const response = await api<AuditLogDetail>(`/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<string, unknown> | 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<string, unknown>) {
+ 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 (
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Reason</span>
+ <span class="council-detail-value council-detail-html" innerHTML={data.reason} />
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Letter Ref</span>
+ <span class="council-detail-value council-audit-ref">{data.system_ref}</span>
+ </div>
+ </div>
+ );
+ }
+
+ function renderDisableDetails(data: { reason: string; disabled_until: string | null; system_ref: string }) {
+ return (
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Reason</span>
+ <span class="council-detail-value council-detail-html" innerHTML={data.reason} />
+ </div>
+ <Show when={data.disabled_until}>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Until</span>
+ <span class="council-detail-value">{formatDate(data.disabled_until!)}</span>
+ </div>
+ </Show>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Letter Ref</span>
+ <span class="council-detail-value council-audit-ref">{data.system_ref}</span>
+ </div>
+ </div>
+ );
+ }
+
+ function renderRoleChange(data: { old_role: string; new_role: string }) {
+ return (
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Old Role</span>
+ <span class="council-detail-value">
+ <span class={`council-role council-role-${data.old_role}`}>{data.old_role}</span>
+ </span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">New Role</span>
+ <span class="council-detail-value">
+ <span class={`council-role council-role-${data.new_role}`}>{data.new_role}</span>
+ </span>
+ </div>
+ </div>
+ );
+ }
+
+ function renderWarning(data: { warning_ref: string; title: string; message: string }) {
+ return (
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Ref</span>
+ <span class="council-detail-value council-audit-ref">{data.warning_ref}</span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Title</span>
+ <span class="council-detail-value">{data.title}</span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Message</span>
+ <span class="council-detail-value council-detail-html" innerHTML={data.message} />
+ </div>
+ </div>
+ );
+ }
+
+ function renderDeactivateWarning(data: { warning_ref: string }) {
+ return (
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Warning Ref</span>
+ <span class="council-detail-value council-audit-ref">{data.warning_ref}</span>
+ </div>
+ </div>
+ );
+ }
+
+ function renderEditUser(data: { changes: FieldChange[] }) {
+ return (
+ <div class="council-detail-table">
+ <For each={data.changes}>
+ {(change: FieldChange) => (
+ <div class="council-detail-row">
+ <span class="council-detail-label">{change.field}</span>
+ <span class="council-detail-value council-audit-change">
+ <Show when={change.old !== null && change.old !== undefined}>
+ <span class="council-audit-old">{formatValue(change.old)}</span>
+ <span class="council-audit-arrow">→</span>
+ </Show>
+ <span class="council-audit-new">{formatValue(change.new)}</span>
+ </span>
+ </div>
+ )}
+ </For>
+ </div>
+ );
+ }
+
+ function renderGeneric(data: Record<string, unknown>) {
+ return (
+ <div class="council-detail-table">
+ <For each={Object.entries(data)}>
+ {([key, val]: [string, unknown]) => (
+ <div class="council-detail-row">
+ <span class="council-detail-label">{key}</span>
+ <span class="council-detail-value">{typeof val === "object" ? JSON.stringify(val) : formatValue(val as string | number | boolean | null)}</span>
+ </div>
+ )}
+ </For>
+ </div>
+ );
+ }
+
+ return (
+ <StaffGuard>
+ <section>
+ <A href="/council/auditlog" class="council-detail-back">← Back to Audit Log</A>
+ <h2 class="page-title">Audit Log Detail</h2>
+
+ <Show when={error()}>
+ <div class="form-error">{error()}</div>
+ </Show>
+
+ <Show when={entry()}>
+ {(e) => {
+ const details = parseDetails(e().details);
+ return (
+ <>
+ <div class="council-detail-section">
+ <div class="council-detail-section-header">Entry</div>
+ <div class="council-detail-table">
+ <div class="council-detail-row">
+ <span class="council-detail-label">Ref</span>
+ <span class="council-detail-value council-audit-ref">{e().system_ref}</span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Date</span>
+ <span class="council-detail-value">{formatDate(e().created_at)}</span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Action</span>
+ <span class="council-detail-value council-audit-action">{actionLabel(e().action)}</span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Actor</span>
+ <span class="council-detail-value">
+ <A href={`/council/users/${e().actor}`}>{e().actor}</A>
+ </span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Target</span>
+ <span class="council-detail-value">
+ <span class="council-audit-target-type">{e().target_type}</span>
+ <Show when={e().target_type === "user"} fallback={<span>{e().target_ref}</span>}>
+ <A href={`/council/users/${e().target_ref}`}>{e().target_ref}</A>
+ </Show>
+ </span>
+ </div>
+ <div class="council-detail-row">
+ <span class="council-detail-label">Summary</span>
+ <span class="council-detail-value">{e().summary}</span>
+ </div>
+ </div>
+ </div>
+
+ <Show when={details}>
+ <div class="council-detail-section">
+ <div class="council-detail-section-header">Details</div>
+ {renderDetails(e().action, details!)}
+ </div>
+ </Show>
+ </>
+ );
+ }}
+ </Show>
+ </section>
+ </StaffGuard>
+ );
+} \ 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 (
+ <StaffGuard>
+ <section>
+ <h2 class="page-title">Audit Log</h2>
+
+ <div class="council-audit-filters">
+ <div class="council-audit-dropdown" ref={actionRef}>
+ <button type="button" class="council-audit-dropdown-trigger" onClick={() => { setTargetOpen(false); setActionOpen(!actionOpen()); }}>
+ {actionFilter() ? actionLabel(actionFilter()) : "All Actions"}
+ </button>
+ <Show when={actionOpen()}>
+ <div class="council-audit-dropdown-menu">
+ <button type="button" class="council-audit-dropdown-item" classList={{ "council-audit-dropdown-item-selected": !actionFilter() }} onClick={() => pickAction("")}>All Actions</button>
+ <For each={Object.entries(AUDIT_ACTION_LABELS)}>
+ {([key, label]: [string, string]) => (
+ <button type="button" class="council-audit-dropdown-item" classList={{ "council-audit-dropdown-item-selected": actionFilter() === key }} onClick={() => pickAction(key)}>{label}</button>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+ <div class="council-audit-dropdown" ref={targetRef}>
+ <button type="button" class="council-audit-dropdown-trigger" onClick={() => { setActionOpen(false); setTargetOpen(!targetOpen()); }}>
+ {targetFilter() ? targetLabel(targetFilter()) : "All Targets"}
+ </button>
+ <Show when={targetOpen()}>
+ <div class="council-audit-dropdown-menu">
+ <button type="button" class="council-audit-dropdown-item" classList={{ "council-audit-dropdown-item-selected": !targetFilter() }} onClick={() => pickTarget("")}>All Targets</button>
+ <For each={Object.entries(AUDIT_TARGET_LABELS)}>
+ {([key, label]: [string, string]) => (
+ <button type="button" class="council-audit-dropdown-item" classList={{ "council-audit-dropdown-item-selected": targetFilter() === key }} onClick={() => pickTarget(key)}>{label}</button>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
+ <Show when={actionFilter() || targetFilter()}>
+ <button type="button" class="council-audit-clear-btn" onClick={clearFilters}>Clear</button>
+ </Show>
+ </div>
+
+ <div class="council-grid council-grid-audit">
+ <div class="council-grid-header">
+ <span>Date</span>
+ <span>Action</span>
+ <span>Actor</span>
+ <span>Target</span>
+ <span>Summary</span>
+ </div>
+ <Show when={!council.auditLoading()} fallback={
+ <div class="council-grid-empty">Loading...</div>
+ }>
+ <Show when={council.auditLogs().length} fallback={
+ <div class="council-grid-empty">No audit logs found.</div>
+ }>
+ <For each={council.auditLogs()}>
+ {(entry: AuditLogEntry) => (
+ <div class="council-grid-row" onClick={() => navigate(`/council/auditlog/${entry.system_ref}`)}>
+ <span class="council-audit-date">{formatDate(entry.created_at)}</span>
+ <span class="council-audit-action">{actionLabel(entry.action)}</span>
+ <span>{entry.actor}</span>
+ <span class="council-audit-target">
+ <span class="council-audit-target-type">{targetLabel(entry.target_type)}</span>
+ {entry.target_ref}
+ </span>
+ <span class="council-audit-summary">{entry.summary}</span>
+ </div>
+ )}
+ </For>
+ </Show>
+ </Show>
+ </div>
+
+ <Show when={council.auditTotalPages() > 1}>
+ <div class="council-pagination">
+ <button
+ type="button"
+ class="council-page-btn"
+ disabled={council.auditPage() <= 1}
+ onClick={() => goToPage(council.auditPage() - 1)}
+ >
+ Prev
+ </button>
+ <span class="council-page-info">
+ Page {council.auditPage()} of {council.auditTotalPages()} ({council.auditTotal()} entries)
+ </span>
+ <button
+ type="button"
+ class="council-page-btn"
+ disabled={council.auditPage() >= council.auditTotalPages()}
+ onClick={() => goToPage(council.auditPage() + 1)}
+ >
+ Next
+ </button>
+ </div>
+ </Show>
+ </section>
+ </StaffGuard>
+ );
+} \ 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<IPBan[]>([]);
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<PaginatedResponse>(`/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<string, string>) => ({ ...prev, role }));
+ setRoleOpen(false);
+ }
+
return (
<Show when={editing()["role"] !== undefined} fallback={
<div class="council-detail-row">
<span class="council-detail-label">Role</span>
<span class="council-detail-value">
- <span class={`council-role council-role-${props.role}`}>{props.role}</span>
+ <span class={`council-role council-role-${props.role}`}>{ROLE_LABELS[props.role] || props.role}</span>
<Show when={canChangeRole()}>
<button type="button" class="council-detail-edit-trigger" onClick={() => startEdit("role", props.role)}>Edit</button>
</Show>
@@ -345,17 +361,20 @@ export default function CouncilUser() {
<div class="council-detail-row">
<span class="council-detail-label">Role</span>
<span class="council-detail-editable">
- <select
- class="council-detail-role-select"
- aria-label="Change role"
- title="Change role"
- value={editing()["role"]}
- onChange={(e) => setEditing((prev: Record<string, string>) => ({ ...prev, role: e.currentTarget.value }))}
- >
- <For each={availableRoles()}>
- {(role: UserRole) => <option value={role}>{role}</option>}
- </For>
- </select>
+ <div class="council-audit-dropdown" ref={roleRef}>
+ <button type="button" class="council-audit-dropdown-trigger" onClick={() => setRoleOpen(!roleOpen())}>
+ {ROLE_LABELS[editing()["role"]] || editing()["role"]}
+ </button>
+ <Show when={roleOpen()}>
+ <div class="council-audit-dropdown-menu">
+ <For each={availableRoles()}>
+ {(role: UserRole) => (
+ <button type="button" class="council-audit-dropdown-item" classList={{ "council-audit-dropdown-item-selected": editing()["role"] === role }} onClick={() => pickRole(role)}>{ROLE_LABELS[role]}</button>
+ )}
+ </For>
+ </div>
+ </Show>
+ </div>
<button type="button" class="council-detail-edit-btn council-action-save" onClick={() => saveRole(editing()["role"])}>Save</button>
<button type="button" class="council-detail-edit-btn" onClick={() => cancelEdit("role")}>Cancel</button>
</span>
@@ -600,7 +619,7 @@ export default function CouncilUser() {
<img src={target().avatar_url} alt="" class="council-detail-card-avatar" />
<span class="council-detail-card-username">@{target().username}</span>
<span class={`council-status council-status-${statusBadge()}`}>{statusBadge()}</span>
- <span class={`council-role council-role-${target().role}`}>{target().role}</span>
+ <span class={`council-role council-role-${target().role}`}>{ROLE_LABELS[target().role] || target().role}</span>
</div>
</div>
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() {
</span>
<span>{user.email}</span>
<span>
- <span class={`council-role council-role-${user.role}`}>{user.role}</span>
+ <span class={`council-role council-role-${user.role}`}>{ROLE_LABELS[user.role] || user.role}</span>
</span>
<span>
<span class={`council-status council-status-${statusBadge(user)}`}>
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<AdminUser[]>([]);
const [total, setTotal] = createSignal(0);
@@ -45,6 +45,36 @@ function toggleSort(field: string) {
loadUsers(1, search());
}
+const [auditLogs, setAuditLogs] = createSignal<AuditLogEntry[]>([]);
+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<PaginatedResponse<AuditLogEntry>>(`/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<T> {
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<string, string> = {
+ "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<string, string> = {
+ 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<string, string> = {
+ member: "Member",
+ moderator: "Moderator",
+ admin: "Admin",
+ owner: "Owner",
+}; \ No newline at end of file