From 6a5ecab013a68a9df73e573987cf1c4f46bfb19b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 21 Aug 2026 22:49:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20IP=20filtering=20for=20?= =?UTF-8?q?admin=20access=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routers/auditLogs/queryAccessAuditLog.ts | 25 ++++++++- src/app/[orgId]/settings/logs/access/page.tsx | 53 +++++++++++++++---- .../[orgId]/settings/logs/request/page.tsx | 9 +++- src/lib/queries.ts | 10 ++-- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 9e819db64..da03ebae3 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -88,7 +88,27 @@ export const queryAccessAuditLogsQuery = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + ip: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + // the array is returned as this + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, z.array(z.string())) + .optional() + .catch([]) + .openapi({ + type: "array", + description: "Filter by IP adresses" + }) }); export const queryAccessAuditLogsParams = z.object({ @@ -134,7 +154,8 @@ function getWhere(data: Q) { data.type ? eq(accessAuditLog.type, data.type) : undefined, data.action !== undefined ? eq(accessAuditLog.action, data.action) - : undefined + : undefined, + data.ip ? inArray(accessAuditLog.ip, data.ip) : undefined ); } diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 72661a15e..97dc5559e 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -12,6 +12,7 @@ import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { build } from "@server/build"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; @@ -42,12 +43,14 @@ export default function GeneralPage() { resourceId?: string; location?: string; actor?: string; + ip?: string[]; }>({ action: searchParams.get("action") || undefined, type: searchParams.get("type") || undefined, resourceId: searchParams.get("resourceId") || undefined, location: searchParams.get("location") || undefined, - actor: searchParams.get("actor") || undefined + actor: searchParams.get("actor") || undefined, + ip: searchParams.getAll("ip") || undefined }); const [currentPage, setCurrentPage] = useState(0); @@ -156,7 +159,7 @@ export default function GeneralPage() { const handleFilterChange = ( filterType: keyof typeof filters, - value: string | undefined + value: string | string[] | undefined ) => { const newFilters = { ...filters, [filterType]: value }; setFilters(newFilters); @@ -174,10 +177,13 @@ export default function GeneralPage() { ) => { const params = new URLSearchParams(searchParams); Object.entries(newFilters).forEach(([key, value]) => { - if (value) { + params.delete(key); + if (typeof value === "string") { params.set(key, value); - } else { - params.delete(key); + } else if (typeof value !== "undefined" && "length" in value) { + for (const element of value) { + params.append(key, element); + } } }); router.replace(`?${params.toString()}`, { scroll: false }); @@ -185,6 +191,7 @@ export default function GeneralPage() { const exportData = async () => { try { + const { ip, ...restFilters } = filters; const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() @@ -192,13 +199,20 @@ export default function GeneralPage() { timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() : undefined, - ...filters + ...restFilters }; - const response = await api.get(`/org/${orgId}/logs/access/export`, { - responseType: "blob", - params - }); + // axios serializes arrays as `ip[]=…`, which express's query + // parser does not read back as `ip`, so pass them in the URL + const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip])); + + const response = await api.get( + `/org/${orgId}/logs/access/export?${sp.toString()}`, + { + responseType: "blob", + params + } + ); const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); @@ -277,7 +291,24 @@ export default function GeneralPage() { }, { accessorKey: "ip", - header: () => {t("ip")} + header: () => ( + + ({ + label: ip, + value: ip + }))} + label={t("ip")} + allowArbitraryValues + searchPlaceholder={t("ipFilterSearchPlaceholder")} + emptyMessage={t("ipFilterEmptyMessage")} + selectedValues={filters.ip ?? []} + onSelectedValuesChange={(value) => + handleFilterChange("ip", value) + } + /> + + ) }, { accessorKey: "location", diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index ad3006db5..c3926e492 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -195,6 +195,7 @@ export default function GeneralPage() { const exportData = async () => { try { // Prepare query params for export + const { ip, ...restFilters } = filters; const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() @@ -202,11 +203,15 @@ export default function GeneralPage() { timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() : undefined, - ...filters + ...restFilters }; + // axios serializes arrays as `ip[]=…`, which express's query + // parser does not read back as `ip`, so pass them in the URL + const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip])); + const response = await api.get( - `/org/${orgId}/logs/request/export`, + `/org/${orgId}/logs/request/export?${sp.toString()}`, { responseType: "blob", params diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 912fa8f09..b376dc2e9 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -807,7 +807,8 @@ export const accessLogsFiltersSchema = z.object({ action: z.string().optional().catch(undefined), location: z.string().optional().catch(undefined), actor: z.string().optional().catch(undefined), - type: z.string().optional().catch(undefined) + type: z.string().optional().catch(undefined), + ip: z.array(z.string()).optional().catch(undefined) }); export type AccessLogFilters = z.output; @@ -932,10 +933,13 @@ export const logQueries = { queryOptions({ queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const, queryFn: async ({ signal, meta }) => { - const { page, pageSize, ...rest } = filters; + const { page, pageSize, ip, ...rest } = filters; + const sp = new URLSearchParams( + (ip ?? []).map((ip) => ["ip", ip]) + ); const res = await meta!.api.get< AxiosResponse - >(`/org/${orgId}/logs/access`, { + >(`/org/${orgId}/logs/access?${sp.toString()}`, { params: { ...rest, limit: pageSize,