diff --git a/messages/en-US.json b/messages/en-US.json index 62ed7f30a..1b68fa866 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1573,6 +1573,8 @@ "search": "Search…", "searchPlaceholder": "Search...", "emptySearchOptions": "No options found", + "ipFilterSearchPlaceholder": "Enter an IP address…", + "ipFilterEmptyMessage": "Enter an IP address to filter by", "create": "Create", "orgs": "Organizations", "loginError": "An unexpected error occurred. Please try again.", @@ -2596,6 +2598,7 @@ "createDomainType": "Type:", "createDomainName": "Name:", "createDomainValue": "Value:", + "multiSelectFilterCount": "{count} selected", "createDomainCnameRecords": "CNAME Records", "createDomainARecords": "A Records", "createDomainRecordNumber": "Record {number}", diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 9e819db64..96ae7711b 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,6 +154,9 @@ function getWhere(data: Q) { data.type ? eq(accessAuditLog.type, data.type) : undefined, data.action !== undefined ? eq(accessAuditLog.action, data.action) + : undefined, + data.ip && data.ip.length > 0 + ? inArray(accessAuditLog.ip, data.ip) : undefined ); } diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index c024fcca7..1911e1497 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -16,12 +16,10 @@ import { handleRemoteExitNodePingMessage } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; -import { - handleConnectionLogMessage, -} from "#private/routers/newt"; +import { handleConnectionLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage, - "newt/access-log": handleConnectionLogMessage, + "newt/access-log": handleConnectionLogMessage }; diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 01ce891ce..6c246a01c 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({ .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 queryRequestAuditLogsParams = z.object({ @@ -126,6 +146,9 @@ function getWhere(data: Q) { data.path ? eq(requestAuditLog.path, data.path) : undefined, data.action !== undefined ? eq(requestAuditLog.action, data.action) + : undefined, + data.ip && data.ip.length > 0 + ? inArray(requestAuditLog.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 48c5d11d1..89f36641e 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"; @@ -26,6 +27,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { logQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; export default function GeneralPage() { const router = useRouter(); @@ -45,12 +47,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); @@ -176,7 +180,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); @@ -194,10 +198,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 }); @@ -205,6 +212,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() @@ -212,13 +220,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"); @@ -297,7 +312,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) + } + /> + + ), cell: ({ row }) => { return row.original.ip ? ( row.original.ip @@ -315,7 +347,7 @@ export default function GeneralPage() { options={filterAttributes.locations.map( (location) => ({ value: location, - label: location + label: `${location} ${countryCodeToFlagEmoji(location)}` }) )} label={t("location")} @@ -334,7 +366,8 @@ export default function GeneralPage() { {row.original.location ? ( - {row.original.location} + {row.original.location}{" "} + {countryCodeToFlagEmoji(row.original.location)} ) : ( diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index d5ecdbfab..378cdcad5 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -23,6 +23,8 @@ import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; +import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton"; export default function GeneralPage() { const router = useRouter(); @@ -47,6 +49,7 @@ export default function GeneralPage() { method?: string; reason?: string; path?: string; + ip?: string[]; }>({ action: searchParams.get("action") || undefined, host: searchParams.get("host") || undefined, @@ -55,7 +58,8 @@ export default function GeneralPage() { actor: searchParams.get("actor") || undefined, method: searchParams.get("method") || undefined, reason: searchParams.get("reason") || undefined, - path: searchParams.get("path") || undefined + path: searchParams.get("path") || undefined, + ip: searchParams.getAll("ip") || undefined }); const getDefaultDateRange = () => { @@ -179,7 +183,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); @@ -197,10 +201,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 }); @@ -209,6 +216,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() @@ -216,11 +224,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 @@ -351,7 +363,24 @@ export default function GeneralPage() { }, { accessorKey: "ip", - header: ({ column }) => {t("ip")}, + header: ({ column }) => ( + + ({ + label: ip, + value: ip + }))} + label={t("ip")} + allowArbitraryValues + searchPlaceholder={t("ipFilterSearchPlaceholder")} + emptyMessage={t("ipFilterEmptyMessage")} + selectedValues={filters.ip ?? []} + onSelectedValuesChange={(value) => + handleFilterChange("ip", value) + } + /> + + ), cell: ({ row }) => { return row.original.ip ? ( row.original.ip @@ -369,7 +398,7 @@ export default function GeneralPage() { options={filterAttributes.locations.map( (location) => ({ value: location, - label: location + label: `${location} ${countryCodeToFlagEmoji(location)}` }) )} selectedValue={filters.location} @@ -389,7 +418,8 @@ export default function GeneralPage() { {row.original.location ? ( - {row.original.location} + {row.original.location}{" "} + {countryCodeToFlagEmoji(row.original.location)} ) : ( diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx index 5945dc887..8983ecf2c 100644 --- a/src/components/ColumnFilterButton.tsx +++ b/src/components/ColumnFilterButton.tsx @@ -21,7 +21,7 @@ import { useTranslations } from "next-intl"; interface FilterOption { value: string; - label: string; + label: React.ReactNode; } interface ColumnFilterButtonProps { @@ -32,6 +32,7 @@ interface ColumnFilterButtonProps { emptyMessage?: string; className?: string; label: string; + allowArbitraryValues?: boolean; } export function ColumnFilterButton({ @@ -41,7 +42,8 @@ export function ColumnFilterButton({ searchPlaceholder = "Search...", emptyMessage = "No options found", className, - label + label, + allowArbitraryValues }: ColumnFilterButtonProps) { const [open, setOpen] = useState(false); @@ -101,7 +103,7 @@ export function ColumnFilterButton({ {options.map((option) => ( { onValueChange( selectedValue === option.value diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx index d6ebaa482..7176bafff 100644 --- a/src/components/ColumnMultiFilterButton.tsx +++ b/src/components/ColumnMultiFilterButton.tsx @@ -35,6 +35,7 @@ type ColumnMultiFilterButtonProps = { emptyMessage?: string; className?: string; label: string; + allowArbitraryValues?: boolean; }; export function ColumnMultiFilterButton({ @@ -44,11 +45,26 @@ export function ColumnMultiFilterButton({ searchPlaceholder = "Search...", emptyMessage = "No options found", className, - label + label, + allowArbitraryValues }: ColumnMultiFilterButtonProps) { const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const t = useTranslations(); + const visibleOptions = useMemo(() => { + const newOptions = [...options]; + + if (allowArbitraryValues && searchQuery.trim().length > 0) { + newOptions.push({ + label: searchQuery, + value: searchQuery + }); + } + + return newOptions; + }, [options, allowArbitraryValues, searchQuery]); + const selectedSet = useMemo( () => new Set(selectedValues), [selectedValues] @@ -64,7 +80,7 @@ export function ColumnMultiFilterButton({ selectedValues[0] ); } - return t("accessUsersRoleFilterCount", { + return t("multiSelectFilterCount", { count: selectedValues.length }); }, [selectedValues, options, t]); @@ -108,7 +124,11 @@ export function ColumnMultiFilterButton({ align="start" > - + {emptyMessage} @@ -123,7 +143,7 @@ export function ColumnMultiFilterButton({ {t("accessFilterClear")} )} - {options.map((option) => ( + {visibleOptions.map((option) => ( ; @@ -1026,7 +1027,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; @@ -1139,10 +1141,13 @@ export const logQueries = { queryOptions({ queryKey: ["REQUEST_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/request`, { + >(`/org/${orgId}/logs/request?${sp.toString()}`, { params: { ...rest, limit: pageSize, @@ -1164,10 +1169,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,