From a658a1a6fa6dc7295f6fe98f3d19fbb4a9af4149 Mon Sep 17 00:00:00 2001 From: diubi Date: Sun, 18 Oct 2026 19:32:50 +0200 Subject: [PATCH 1/2] feat(audit-log): add audit log page and its route to nav --- src/components/shell/nav.ts | 38 ++++++++++++++++++++++-- src/routeTree.gen.ts | 21 +++++++++++++ src/routes/dashboard/audit-log/index.tsx | 24 +++++++++++++++ src/server/permissions.ts | 1 + 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/routes/dashboard/audit-log/index.tsx diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 747d07a..9839d77 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -17,6 +17,7 @@ import { MessagesSquare, MonitorDown, PenLine, + ScrollText, Send, ShieldCheck, Tags, @@ -35,7 +36,16 @@ import type { Permission } from "@/server/permissions" /** Every navigable dashboard route path, as TanStack `Link`'s `to` accepts it. */ export type DashboardPath = Extract -export type ServiceId = "overview" | "telegram" | "whatsapp" | "m365" | "web" | "esign" | "reports" | "account" +export type ServiceId = + | "overview" + | "telegram" + | "whatsapp" + | "m365" + | "web" + | "esign" + | "reports" + | "audit" + | "account" export type Section = { id: string @@ -91,6 +101,7 @@ export type PageMatch = | { kind: "esign-setup" } | { kind: "reports-open" } | { kind: "reports-closed" } + | { kind: "audit-log" } | { kind: "not-found" } export type PageKind = PageMatch["kind"] @@ -300,8 +311,25 @@ const reports: Service = { ], } +const audit: Service = { + id: "audit", + title: "Audit log", + short: "Audit", + icon: ScrollText, + path: "/dashboard/audit-log", + sections: [ + { + id: "audit-log", + title: "Audit Log", + icon: ScrollText, + path: "/dashboard/audit-log", + permission: "audit:read", + }, + ], +} + /** Rail order: Overview (the logo), then the services with sections, then Account. */ -export const services: Service[] = [overview, telegram, whatsapp, m365, web, esign, reports, account] +export const services: Service[] = [overview, telegram, whatsapp, m365, web, esign, reports, audit, account] /** The services that own a panel, in rail order. */ export const panelServices: Service[] = services.filter((service) => service.sections.length > 0) @@ -379,6 +407,8 @@ export function matchPath(pathname: string): PageMatch { if (second === "resolved") return { kind: "reports-closed" } } + if (head === "audit-log" && second === undefined) return { kind: "audit-log" } + return { kind: "not-found" } } @@ -419,6 +449,8 @@ export function serviceFor(match: PageMatch): Service { case "reports-open": case "reports-closed": return reports + case "audit-log": + return audit } } @@ -471,6 +503,8 @@ export function sectionFor(match: PageMatch): Section | null { return section(reports, "open") case "reports-closed": return section(reports, "closed") + case "audit-log": + return section(audit, "audit-log") } } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d33f17a..6f10281 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as DashboardAccountRouteImport } from './routes/dashboard/account import { Route as AuthLogoutRouteImport } from './routes/auth/logout' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' +import { Route as DashboardAuditLogIndexRouteImport } from './routes/dashboard/audit-log/index' import { Route as DashboardWhatsappGroupsRouteImport } from './routes/dashboard/whatsapp/groups' import { Route as DashboardWebProjectsRouteImport } from './routes/dashboard/web/projects' import { Route as DashboardWebGuidesRouteImport } from './routes/dashboard/web/guides' @@ -95,6 +96,11 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({ path: '/auth/callback', getParentRoute: () => rootRouteImport, } as any) +const DashboardAuditLogIndexRoute = DashboardAuditLogIndexRouteImport.update({ + id: '/audit-log/', + path: '/audit-log/', + getParentRoute: () => DashboardRoute, +} as any) const DashboardWhatsappGroupsRoute = DashboardWhatsappGroupsRouteImport.update({ id: '/whatsapp/groups', path: '/whatsapp/groups', @@ -258,6 +264,7 @@ export interface FileRoutesByFullPath { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/audit-log/': typeof DashboardAuditLogIndexRoute '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute @@ -294,6 +301,7 @@ export interface FileRoutesByTo { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/audit-log': typeof DashboardAuditLogIndexRoute '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute @@ -332,6 +340,7 @@ export interface FileRoutesById { '/dashboard/web/guides': typeof DashboardWebGuidesRoute '/dashboard/web/projects': typeof DashboardWebProjectsRoute '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute + '/dashboard/audit-log/': typeof DashboardAuditLogIndexRoute '/api/esign/agent/$': typeof ApiEsignAgentSplatRoute '/dashboard/esign/requests/$requestId': typeof DashboardEsignRequestsRequestIdRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute @@ -371,6 +380,7 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/audit-log/' | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/telegram/users/$userId' @@ -407,6 +417,7 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/audit-log' | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/telegram/users/$userId' @@ -444,6 +455,7 @@ export interface FileRouteTypes { | '/dashboard/web/guides' | '/dashboard/web/projects' | '/dashboard/whatsapp/groups' + | '/dashboard/audit-log/' | '/api/esign/agent/$' | '/dashboard/esign/requests/$requestId' | '/dashboard/telegram/users/$userId' @@ -537,6 +549,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCallbackRouteImport parentRoute: typeof rootRouteImport } + '/dashboard/audit-log/': { + id: '/dashboard/audit-log/' + path: '/audit-log' + fullPath: '/dashboard/audit-log/' + preLoaderRoute: typeof DashboardAuditLogIndexRouteImport + parentRoute: typeof DashboardRoute + } '/dashboard/whatsapp/groups': { id: '/dashboard/whatsapp/groups' path: '/whatsapp/groups' @@ -757,6 +776,7 @@ interface DashboardRouteChildren { DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute DashboardWhatsappGroupsRoute: typeof DashboardWhatsappGroupsRoute + DashboardAuditLogIndexRoute: typeof DashboardAuditLogIndexRoute DashboardEsignRequestsRequestIdRoute: typeof DashboardEsignRequestsRequestIdRoute DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute DashboardEsignRequestsIndexRoute: typeof DashboardEsignRequestsIndexRoute @@ -779,6 +799,7 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute, DashboardWhatsappGroupsRoute: DashboardWhatsappGroupsRoute, + DashboardAuditLogIndexRoute: DashboardAuditLogIndexRoute, DashboardEsignRequestsRequestIdRoute: DashboardEsignRequestsRequestIdRoute, DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute, DashboardEsignRequestsIndexRoute: DashboardEsignRequestsIndexRoute, diff --git a/src/routes/dashboard/audit-log/index.tsx b/src/routes/dashboard/audit-log/index.tsx new file mode 100644 index 0000000..923a43b --- /dev/null +++ b/src/routes/dashboard/audit-log/index.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router" +import { ScrollText } from "lucide-react" + +import { EmptyState } from "@/components/primitives" +import { PageBar, PageContent } from "@/components/shell" + +export const Route = createFileRoute("/dashboard/audit-log/")({ + component: AuditLogRoute, +}) + +function AuditLogRoute() { + return ( + <> + + + + + + ) +} diff --git a/src/server/permissions.ts b/src/server/permissions.ts index 3f6db7a..bf99778 100644 --- a/src/server/permissions.ts +++ b/src/server/permissions.ts @@ -21,6 +21,7 @@ export const PERMISSIONS = [ "esign:sign", "esign:requests:manage", "esign:drives:manage", + "audit:read", ] as const export type Permission = (typeof PERMISSIONS)[number] From 549b144638ac6bb0e2fbafdbd5bcf01626f2e1e6 Mon Sep 17 00:00:00 2001 From: diubi Date: Thu, 22 Oct 2026 01:37:51 +0200 Subject: [PATCH 2/2] feat(audit-log): list every change made across PoliNetwork The audit log page reads the backend's audit.list with audit:read: a table of action, outcome, target, author, reason and time, filtered by area, outcome and a search the backend runs, with older events loaded on demand. A row opens the event (?event=) with the fields that changed before and after and the request as sent; a linked event older than the loaded pages is fetched on its own. Co-Authored-By: Claude Opus 5.5 --- src/components/shell/nav.ts | 2 +- src/features/audit-log/audit-labels.ts | 151 +++++++++ src/features/audit-log/audit-log-page.tsx | 289 ++++++++++++++++++ src/features/audit-log/audit-log.functions.ts | 31 ++ src/features/audit-log/event-dialog.tsx | 218 +++++++++++++ src/lib/api/types.ts | 6 + src/routes/dashboard/audit-log/index.tsx | 48 ++- src/server/auth.middleware.ts | 8 + tests/server-security.test.mjs | 1 + 9 files changed, 738 insertions(+), 16 deletions(-) create mode 100644 src/features/audit-log/audit-labels.ts create mode 100644 src/features/audit-log/audit-log-page.tsx create mode 100644 src/features/audit-log/audit-log.functions.ts create mode 100644 src/features/audit-log/event-dialog.tsx diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 9839d77..ff31b2d 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -320,7 +320,7 @@ const audit: Service = { sections: [ { id: "audit-log", - title: "Audit Log", + title: "Audit log", icon: ScrollText, path: "/dashboard/audit-log", permission: "audit:read", diff --git a/src/features/audit-log/audit-labels.ts b/src/features/audit-log/audit-labels.ts new file mode 100644 index 0000000..7bd5b07 --- /dev/null +++ b/src/features/audit-log/audit-labels.ts @@ -0,0 +1,151 @@ +import type { StatusTone } from "@/components/primitives/status-badge" +import type { AuditArea, AuditEvent, AuditOutcome } from "@/lib/api/types" + +export const AREA_LABEL = { + tg: "Telegram", + wa: "WhatsApp", + web: "Web", + groups: "Labels", + azure: "Microsoft 365", + esign: "Signatures", + auth: "Accounts", +} satisfies Record + +export const OUTCOME = { + success: { label: "Done", tone: "success" }, + failure: { label: "Failed", tone: "danger" }, + denied: { label: "Denied", tone: "warning" }, +} satisfies Record + +const ACTIONS = new Map([ + ["tg.moderation.ban", "Banned"], + ["tg.moderation.unban", "Unbanned"], + ["tg.moderation.kick", "Kicked"], + ["tg.moderation.mute", "Muted"], + ["tg.moderation.unmute", "Unmuted"], + ["tg.moderation.ban_all", "Banned from every group"], + ["tg.moderation.unban_all", "Unbanned from every group"], + ["tg.grants.create", "Created grant"], + ["tg.grants.interrupt", "Interrupted grant"], + ["tg.groups.create", "Synced groups"], + ["tg.groups.delete", "Removed group"], + ["tg.groups.setHide", "Changed group visibility"], + ["tg.groups.leaveChat", "Left group"], + ["tg.permissions.addRole", "Added role"], + ["tg.permissions.removeRole", "Removed role"], + ["tg.permissions.addGroup", "Made group admin"], + ["tg.permissions.removeGroup", "Removed group admin"], + ["wa.groups.add", "Added group"], + ["wa.groups.modify", "Edited group"], + ["wa.groups.delete", "Deleted group"], + ["wa.groups.setHide", "Changed group visibility"], + ["web.faqs.addFaqs", "Added FAQ"], + ["web.faqs.addFaqsCategory", "Added FAQ category"], + ["web.faqs.editFaqs", "Edited FAQ"], + ["web.faqs.editFaqsCategory", "Edited FAQ category"], + ["web.faqs.deleteFaqs", "Deleted FAQ"], + ["web.faqs.deleteFaqsCategory", "Deleted FAQ category"], + ["web.projects.addProject", "Added project"], + ["web.projects.editProject", "Edited project"], + ["web.projects.reorderProjects", "Reordered projects"], + ["web.projects.deleteProject", "Deleted project"], + ["web.associations.addAssociation", "Added association"], + ["web.associations.editAssociation", "Edited association"], + ["web.associations.editAssociationLinks", "Edited association links"], + ["web.associations.deleteAssociation", "Deleted association"], + ["web.guides_matricole.addGuide", "Added freshman guide"], + ["web.guides_matricole.deleteGuide", "Deleted freshman guide"], + ["web.reports.create", "Reported group"], + ["web.reports.resolve", "Resolved reports"], + ["web.reports.dismiss", "Dismissed reports"], + ["groups.labels.create", "Created label"], + ["groups.labels.modify", "Edited label"], + ["groups.labels.delete", "Deleted label"], + ["groups.labels.tagGroup", "Labelled group"], + ["groups.labels.untagGroup", "Unlabelled group"], + ["azure.members.create", "Created member"], + ["azure.members.setAssocNumber", "Set member number"], + ["azure.groups.addMember", "Added group member"], + ["azure.groups.removeMember", "Removed group member"], + ["esign.signers.resetIdentity", "Reset signer's CIE"], + ["esign.drive.setAccess", "Changed drive access"], + ["esign.documents.upload", "Uploaded document"], + ["esign.documents.fromDrive", "Picked document from drive"], + ["esign.requests.create", "Created signature request"], + ["esign.requests.cancel", "Cancelled signature request"], + ["esign.requests.delete", "Deleted signature request"], + ["esign.requests.decline", "Declined signature request"], + ["esign.requests.uploadSigned", "Uploaded signed document"], + ["esign.requests.retryDelivery", "Retried delivery"], + ["esign.jobs.create", "Started signature"], + ["esign.jobs.cancel", "Cancelled signature"], + ["auth.updateProfilePic", "Changed profile picture"], +]) + +const TARGET_TYPES = new Map([ + ["tg.user", "Telegram user"], + ["tg.group", "Telegram group"], + ["wa.group", "WhatsApp group"], + ["web.faq", "FAQ"], + ["web.faqCategory", "FAQ category"], + ["web.project", "Project"], + ["web.projects", "Projects"], + ["web.association", "Association"], + ["web.guide", "Freshman guide"], + ["web.report", "Report"], + ["groups.label", "Label"], + ["azure.member", "Member"], + ["azure.group", "Microsoft 365 group"], + ["esign.signer", "Signer"], + ["esign.drive", "Drive"], + ["esign.document", "Document"], + ["esign.request", "Signature request"], + ["auth.user", "Account"], +]) + +/** "Edited FAQ"; actions without a name ("tg.groupLabels.create", the legacy mount) read from their path. */ +export function actionLabel(action: string) { + const known = ACTIONS.get(action) ?? ACTIONS.get(action.replace(/^tg\.groupLabels\./, "groups.labels.")) + if (known) return known + const words = (action.split(".").at(-1) ?? action) + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase() + return words.charAt(0).toUpperCase() + words.slice(1) +} + +function isArea(value: string): value is AuditArea { + return Object.hasOwn(AREA_LABEL, value) +} + +/** Where the change was made: "Telegram" for `tg.grants.create`. */ +export function areaLabel(action: string) { + const area = action.split(".")[0] ?? action + return isArea(area) ? AREA_LABEL[area] : area +} + +export function targetTypeLabel(type: string) { + return TARGET_TYPES.get(type) ?? type +} + +/** The target's name, else its type and ID ("Telegram user 5021007919"); null when the event names no target. */ +export function targetName(event: AuditEvent) { + if (event.targetType === null) return null + return event.targetLabel ?? `${targetTypeLabel(event.targetType)} ${event.targetId ?? ""}`.trim() +} + +/** Who acted: their name, else what identifies them. */ +export function actorName(event: AuditEvent) { + if (event.actorName) return event.actorName + if (event.actorKind === "service") return event.actorClient ?? "A service" + if (event.actorTgId !== null) return `User ${event.actorTgId}` + return event.actorSub ?? (event.actorKind === "legacy" ? "Unidentified legacy caller" : "Unknown") +} + +/** How they acted: through which client, or the legacy path. */ +export function actorVia(event: AuditEvent) { + if (event.actorKind === "legacy") return "Legacy call without a token" + if (event.actorKind === "telegram") return `On Telegram, through ${event.actorClient ?? "the bot"}` + if (event.actorKind === "service") return "Service" + return event.actorClient === "admin-dashboard" ? "Dashboard" : (event.actorClient ?? null) +} diff --git a/src/features/audit-log/audit-log-page.tsx b/src/features/audit-log/audit-log-page.tsx new file mode 100644 index 0000000..cdb2eea --- /dev/null +++ b/src/features/audit-log/audit-log-page.tsx @@ -0,0 +1,289 @@ +import { useNavigate, useRouter } from "@tanstack/react-router" +import { useServerFn } from "@tanstack/react-start" +import { History, ScrollText } from "lucide-react" +import { useEffect, useState } from "react" + +import { + buttonMotion, + DataTable, + type DataTableColumn, + EmptyState, + floatingMotion, + LoadingButton, + raisedSurface, + SegmentedControl, + StatusBadge, + Unset, +} from "@/components/primitives" +import { Hint } from "@/components/primitives/hint" +import { appToast, PageBar, PageContent, Toolbar } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { actionLabel, actorName, AREA_LABEL, areaLabel, OUTCOME, targetName } from "@/features/audit-log/audit-labels" +import { AUDIT_AREAS, getAuditEvent, getAuditEvents } from "@/features/audit-log/audit-log.functions" +import { AuditEventDialog } from "@/features/audit-log/event-dialog" +import type { AuditArea, AuditEvent, AuditEventPage, AuditOutcome } from "@/lib/api/types" +import { formatDateTime } from "@/lib/format" +import { cn } from "@/lib/utils" + +export type AuditFilters = { q: string; area: AuditArea | undefined; outcome: AuditOutcome | undefined } + +const ALL_AREAS = "all" +const SEARCH_DELAY_MS = 300 + +const columns: DataTableColumn[] = [ + { + id: "action", + label: "Action", + minWidth: 200, + mobile: "inline", + cell: (event) => ( + + {actionLabel(event.action)} + {areaLabel(event.action)} + + ), + }, + { + id: "outcome", + label: "Outcome", + priority: 2, + minWidth: 110, + mobile: "end", + cell: (event) => {OUTCOME[event.outcome].label}, + }, + { + id: "target", + label: "Target", + priority: 1, + minWidth: 160, + fill: true, + mobile: "text", + cell: (event) => { + const name = targetName(event) + return name ? ( + + {name} + + ) : ( + + ) + }, + }, + { + id: "by", + label: "By", + priority: 3, + minWidth: 150, + mobile: "meta", + cell: (event) => ( + + {actorName(event)} + + ), + }, + { + id: "reason", + label: "Reason", + priority: 5, + minWidth: 140, + mobile: "hidden", + cell: (event) => + event.reason ? ( + + {event.reason} + + ) : ( + + ), + }, + { + id: "when", + label: "When", + priority: 4, + minWidth: 150, + mobile: "meta", + className: "whitespace-nowrap tabular-nums", + cell: (event) => formatDateTime(event.createdAt), + }, +] + +type AuditLogPageProps = { + page: AuditEventPage + filters: AuditFilters + /** The event open in the dialog, from `?event=`. */ + eventId: number | undefined +} + +/** Every change made through the backend, newest first; filters reload from the backend, older pages append. */ +export function AuditLogPage({ page, filters, eventId }: AuditLogPageProps) { + const navigate = useNavigate({ from: "/dashboard/audit-log/" }) + const router = useRouter() + const fetchEvents = useServerFn(getAuditEvents) + const fetchEvent = useServerFn(getAuditEvent) + const [query, setQuery] = useState(filters.q) + const [older, setOlder] = useState([]) + const [cursor, setCursor] = useState(page.nextCursor) + const [loadingOlder, setLoadingOlder] = useState(false) + const [linked, setLinked] = useState(null) + + const events = [...page.events, ...older] + const filtered = filters.q !== "" || filters.area !== undefined || filters.outcome !== undefined + + // The search goes to the backend, so it waits for typing to pause. + useEffect(() => { + const q = query.trim() + if (q === filters.q) return + const timer = setTimeout( + () => void navigate({ search: (prev) => ({ ...prev, q: q || undefined }), replace: true }), + SEARCH_DELAY_MS + ) + return () => clearTimeout(timer) + }, [query, filters.q, navigate]) + + // An event opened from a link may be older than the pages loaded so far. + const listed = eventId === undefined ? undefined : events.find((event) => event.id === eventId) + useEffect(() => { + if (eventId === undefined || listed) return + let current = true + setLinked("loading") + fetchEvent({ data: { id: eventId } }) + .then((event) => current && setLinked(event)) + .catch((error: Error) => { + console.error(error) + if (current) setLinked(null) + appToast.error("Couldn't load the event.") + }) + return () => { + current = false + } + }, [eventId, listed, fetchEvent]) + const selected = eventId === undefined ? null : (listed ?? linked) + + function setFilters(next: Partial>) { + void navigate({ search: (prev) => ({ ...prev, ...next }), replace: true }) + } + + function clearFilters() { + setQuery("") + void navigate({ search: {}, replace: true }) + } + + async function loadOlder() { + if (cursor === null) return + setLoadingOlder(true) + try { + const next = await fetchEvents({ + data: { cursor, area: filters.area, outcome: filters.outcome, search: filters.q || undefined }, + }) + setOlder((loaded) => [...loaded, ...next.events]) + setCursor(next.nextCursor) + } catch (error) { + console.error(error) + appToast.error("Couldn't load older events.") + } finally { + setLoadingOlder(false) + } + } + + const eventHref = (event: AuditEvent) => + router.buildLocation({ to: "/dashboard/audit-log", search: (prev) => ({ ...prev, event: event.id }) }).href + + return ( + <> + + + setFilters({ outcome: value === "all" ? undefined : value })} + /> + + } + /> + } + /> + +
+ event.id} + onRowClick={(event) => void navigate({ search: (prev) => ({ ...prev, event: event.id }) })} + rowLabel={(event) => `${actionLabel(event.action)} ${targetName(event) ?? ""}`.trim()} + rowHref={eventHref} + selectedRowId={eventId} + empty={ + filtered ? ( + + Clear filters + + } + /> + ) : ( + + ) + } + /> + {cursor !== null && ( +
+ void loadOlder()}> + Load older events + +
+ )} +
+
+ void navigate({ search: ({ event: _event, ...rest }) => rest, replace: true })} + /> + + ) +} diff --git a/src/features/audit-log/audit-log.functions.ts b/src/features/audit-log/audit-log.functions.ts new file mode 100644 index 0000000..74a1315 --- /dev/null +++ b/src/features/audit-log/audit-log.functions.ts @@ -0,0 +1,31 @@ +import { createServerFn } from "@tanstack/react-start" +import { z } from "zod" + +import { auditReadMiddleware } from "@/server/auth.middleware" + +export const AUDIT_AREAS = ["tg", "wa", "web", "groups", "azure", "esign", "auth"] as const +export const AUDIT_OUTCOMES = ["success", "failure", "denied"] as const + +/** One page of the audit log, newest first; `cursor` is the previous page's `nextCursor`. */ +export const getAuditEvents = createServerFn({ method: "GET" }) + .middleware([auditReadMiddleware]) + .validator( + z.object({ + cursor: z.number().int().positive().optional(), + area: z.enum(AUDIT_AREAS).optional(), + outcome: z.enum(AUDIT_OUTCOMES).optional(), + search: z.string().trim().max(100).optional(), + }) + ) + .handler(({ data, context }) => context.backend.audit.list.query({ ...data, limit: 50 })) + +/** One event by ID, for a link to an event older than the pages loaded; null when it does not exist. */ +export const getAuditEvent = createServerFn({ method: "GET" }) + .middleware([auditReadMiddleware]) + .validator(z.object({ id: z.number().int().positive() })) + .handler(async ({ data, context }) => { + // The page before `id + 1` starts at `id`, if it exists. + const { events } = await context.backend.audit.list.query({ cursor: data.id + 1, limit: 1 }) + const [event] = events + return event?.id === data.id ? event : null + }) diff --git a/src/features/audit-log/event-dialog.tsx b/src/features/audit-log/event-dialog.tsx new file mode 100644 index 0000000..e9f4553 --- /dev/null +++ b/src/features/audit-log/event-dialog.tsx @@ -0,0 +1,218 @@ +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { Link } from "@tanstack/react-router" +import { X } from "lucide-react" +import { type ReactNode, useState } from "react" + +import { + dialogMotion, + dialogPanel, + IconButton, + KeyValueList, + scrimClasses, + SectionEmpty, + Spinner, + StatusBadge, + Unset, +} from "@/components/primitives" +import { dialogDescriptionText, dialogTitleText } from "@/components/primitives/confirm-dialog" +import { Dialog, DialogDescription, DialogOverlay, DialogPortal, DialogTitle } from "@/components/ui/dialog" +import { + actionLabel, + actorName, + actorVia, + areaLabel, + OUTCOME, + targetName, + targetTypeLabel, +} from "@/features/audit-log/audit-labels" +import type { AuditEvent, AuditJson } from "@/lib/api/types" +import { formatDateTime } from "@/lib/format" +import { cn } from "@/lib/utils" + +type JsonObject = Exclude, string | number | boolean | AuditJson[]> + +function isObject(value: AuditJson): value is JsonObject { + return value instanceof Object && !Array.isArray(value) +} + +type Change = { field: string; before: AuditJson | undefined; after: AuditJson | undefined } + +/** + * The fields that differ between the two states, or every field of a record that was only created or only removed. + * Null when the states are not records (a list of rows, a value), which are shown whole instead. + */ +function fieldChanges(before: AuditJson, after: AuditJson): Change[] | null { + if ((before !== null && !isObject(before)) || (after !== null && !isObject(after))) return null + const fields = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])] + return fields + .map((field) => ({ field, before: before?.[field], after: after?.[field] })) + .filter( + (change) => before === null || after === null || JSON.stringify(change.before) !== JSON.stringify(change.after) + ) +} + +function JsonValue({ value }: { value: AuditJson | undefined }) { + if (value === undefined || value === null) return + if (isObject(value) || Array.isArray(value)) + return ( +
+        {JSON.stringify(value, null, 2)}
+      
+ ) + return {String(value)} +} + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +const changeGrid = "grid grid-cols-[minmax(88px,140px)_1fr_1fr] gap-x-4 max-shell:grid-cols-1" + +function Changes({ event }: { event: AuditEvent }) { + if (event.before === null && event.after === null) + return + + const changes = fieldChanges(event.before, event.after) + if (changes === null) + return ( +
+ + +
+ ) + if (changes.length === 0) return + + return ( +
+
+ Field + Before + After +
+ {changes.map((change) => ( +
+ {change.field} + + Before: + + + + After: + + +
+ ))} +
+ ) +} + +function ChangeSide({ label, value }: { label: string; value: AuditJson }) { + return ( +
+ {label} +
+ +
+
+ ) +} + +function Target({ event }: { event: AuditEvent }) { + const name = targetName(event) + if (name === null || event.targetType === null) return null + // Without a label the name already is the type and ID. + const hint = event.targetLabel + ? `${targetTypeLabel(event.targetType)}${event.targetId ? ` · ${event.targetId}` : ""}` + : null + return ( + + {event.targetType === "tg.user" && event.targetId ? ( + + {name} + + ) : ( + name + )} + {hint && {hint}} + + ) +} + +type AuditEventDialogProps = { + /** The event, or `"loading"` while one opened from a link is fetched. */ + event: AuditEvent | "loading" | null + onClose: () => void +} + +/** One event: who did what to what and when, the outcome, the fields that changed and the request as sent. */ +export function AuditEventDialog({ event, onClose }: AuditEventDialogProps) { + // The last event shown stays while the dialog animates out. + const [shown, setShown] = useState(null) + if (event !== null && event !== "loading" && event !== shown) setShown(event) + const loaded = event === "loading" ? null : (event ?? shown) + return ( + !open && onClose()}> + + + +
+ {loaded ? actionLabel(loaded.action) : "Event"} + + {loaded ? `${areaLabel(loaded.action)} · ${formatDateTime(loaded.createdAt)}` : "Loading the event…"} + + +
+ + {loaded ? ( +
+ }, + { + key: "Outcome", + value: ( + {OUTCOME[loaded.outcome].label} + ), + hint: loaded.error, + }, + { key: "Reason", value: loaded.reason }, + { key: "Action", value: loaded.action, mono: true }, + ]} + /> +
+ +
+
+
+ +
+
+
+ ) : ( +
+ +
+ )} +
+
+
+ ) +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index ab41d1b..86bb084 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -33,3 +33,9 @@ export type EsignDriveListing = ApiOutput["esign"]["drive"]["list"] export type EsignDriveItem = EsignDriveListing["items"][number] export type EsignConfig = ApiOutput["esign"]["config"] export type EsignDrive = EsignConfig["drives"][number] + +export type AuditEventPage = ApiOutput["audit"]["list"] +export type AuditEvent = AuditEventPage["events"][number] +export type AuditArea = NonNullable +export type AuditOutcome = NonNullable +export type AuditJson = AuditEvent["input"] diff --git a/src/routes/dashboard/audit-log/index.tsx b/src/routes/dashboard/audit-log/index.tsx index 923a43b..2fef6f0 100644 --- a/src/routes/dashboard/audit-log/index.tsx +++ b/src/routes/dashboard/audit-log/index.tsx @@ -1,24 +1,42 @@ -import { createFileRoute } from "@tanstack/react-router" -import { ScrollText } from "lucide-react" +import { createFileRoute, redirect } from "@tanstack/react-router" +import { z } from "zod" -import { EmptyState } from "@/components/primitives" +import { TableSkeleton } from "@/components/primitives" import { PageBar, PageContent } from "@/components/shell" +import { AuditLogPage } from "@/features/audit-log/audit-log-page" +import { AUDIT_AREAS, AUDIT_OUTCOMES, getAuditEvents } from "@/features/audit-log/audit-log.functions" +import { hasPermission } from "@/server/permissions" export const Route = createFileRoute("/dashboard/audit-log/")({ - component: AuditLogRoute, -}) - -function AuditLogRoute() { - return ( + validateSearch: z.object({ + q: z.string().optional(), + area: z.enum(AUDIT_AREAS).optional(), + outcome: z.enum(AUDIT_OUTCOMES).optional(), + event: z.number().int().positive().optional(), + }), + beforeLoad: ({ context }) => { + if (!hasPermission(context.permissions, "audit:read")) throw redirect({ to: "/unauthorized" }) + }, + // Opening an event changes `event` only, which does not reload the list. + loaderDeps: ({ search }) => ({ q: search.q?.trim() ?? "", area: search.area, outcome: search.outcome }), + loader: async ({ deps }) => ({ + filters: deps, + page: await getAuditEvents({ data: { area: deps.area, outcome: deps.outcome, search: deps.q || undefined } }), + }), + pendingComponent: () => ( <> - + - + - ) + ), + component: AuditLogRoute, +}) + +function AuditLogRoute() { + const { page, filters } = Route.useLoaderData() + const { event } = Route.useSearch() + // A new search or filter starts over from the newest page. + return } diff --git a/src/server/auth.middleware.ts b/src/server/auth.middleware.ts index a7d8c5e..962fdc7 100644 --- a/src/server/auth.middleware.ts +++ b/src/server/auth.middleware.ts @@ -165,3 +165,11 @@ export const esignRequestsManageMiddleware = createMiddleware({ type: "function" requirePermission(context.permissions, "esign:requests:manage") return next() }) + +/** Audit log: every change made through the backend. */ +export const auditReadMiddleware = createMiddleware({ type: "function" }) + .middleware([adminMiddleware]) + .server(({ next, context }) => { + requirePermission(context.permissions, "audit:read") + return next() + }) diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 1944c39..08c117a 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -175,6 +175,7 @@ test("WhatsApp group links require a real HTTPS invite code", () => { test("dashboard server functions attach their scoped authorization middleware", async () => { const adminFunctionFiles = [ "src/features/dashboard/overview.functions.ts", + "src/features/audit-log/audit-log.functions.ts", "src/features/associations/associations.functions.ts", "src/features/azure/azure.functions.ts", "src/features/azure/groups.functions.ts",