diff options
| -rw-r--r-- | garden/src/components/Layout.tsx | 2 | ||||
| -rw-r--r-- | garden/src/pages/council/auditdetail.tsx | 260 | ||||
| -rw-r--r-- | garden/src/pages/council/auditlog.tsx | 173 | ||||
| -rw-r--r-- | garden/src/pages/council/bannedips.tsx | 8 | ||||
| -rw-r--r-- | garden/src/pages/council/user.tsx | 49 | ||||
| -rw-r--r-- | garden/src/pages/council/users.tsx | 13 | ||||
| -rw-r--r-- | garden/src/routes.ts | 2 | ||||
| -rw-r--r-- | garden/src/store/council.ts | 42 | ||||
| -rw-r--r-- | garden/src/styles/council.css | 165 | ||||
| -rw-r--r-- | garden/src/types/admin.ts | 33 | ||||
| -rw-r--r-- | garden/src/types/roles.ts | 9 |
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 |
