diff --git a/messages/en-US.json b/messages/en-US.json index 79042b7e..4a44ab33 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1893,5 +1893,12 @@ "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", "sidebarLogs": "Logs", - "request": "Request" + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs" } diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index de0b2d2b..1a1b1408 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -11,3 +11,4 @@ * This file is not licensed under the AGPLv3. */ +export * from "./queryActionAuditLog"; \ No newline at end of file diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 36a29788..7e81336b 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,8 +21,8 @@ import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; +import * as logs from "#private/routers/auditLogs"; -import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, @@ -345,3 +345,8 @@ authenticated.post( verifyUserIsServerAdmin, license.recheckStatus ); + +authenticated.get( + "/org/:orgId/logs/action", + logs.queryAccessAuditLogs +) \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 80e4e4a6..58479166 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -1,47 +1,15 @@ "use client"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - import { Button } from "@app/components/ui/button"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; - -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useRef, useEffect } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { formatAxiosError } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; -import { useRouter } from "next/navigation"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { build } from "@server/build"; +import { LogDataTable } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, User } from "lucide-react"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -49,6 +17,211 @@ export default function GeneralPage() { const api = createApiClient(useEnvContext()); const t = useTranslations(); const { env } = useEnvContext(); + const { orgId } = useParams(); - return

access

; + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday, + }, + endDate: { + date: now, + } + }; + }; + + const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime(defaultRange.startDate, defaultRange.endDate); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + queryDateTime(startDate, endDate); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + console.log("Date range changed:", { startDate, endDate }); + setIsRefreshing(true); + + try { + // Convert the date/time values to API parameters + let params: any = { + limit: 20, + offset: 0 + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/action`, { params }); + if (res.status === 200) { + setRows(res.data.data.log); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( + + ); + }, + // make the value capitalized + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + + ); + }, + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? : } + {row.original.actor} + + ); + } + } + ]; + + return ( + <> + + + ); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 6b453811..80ef2e2e 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs", + href: "/{orgId}/settings/logs/access", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..da6cb009 --- /dev/null +++ b/src/components/DateTimePicker.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { ChevronDownIcon, CalendarIcon } from "lucide-react"; + +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { ChangeEvent, useEffect, useState } from "react"; + +export interface DateTimeValue { + date?: Date; + time?: string; +} + +export interface DateTimePickerProps { + label?: string; + value?: DateTimeValue; + onChange?: (value: DateTimeValue) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + showTime?: boolean; +} + +export function DateTimePicker({ + label, + value, + onChange, + placeholder = "Select date & time", + className, + disabled = false, + showTime = true, +}: DateTimePickerProps) { + const [open, setOpen] = useState(false); + const [internalDate, setInternalDate] = useState(value?.date); + const [internalTime, setInternalTime] = useState(value?.time || ""); + + // Sync internal state with external value prop + useEffect(() => { + setInternalDate(value?.date); + setInternalTime(value?.time || ""); + }, [value?.date, value?.time]); + + const handleDateChange = (date: Date | undefined) => { + setInternalDate(date); + const newValue = { date, time: internalTime }; + setOpen(false); + onChange?.(newValue); + }; + + const handleTimeChange = (event: ChangeEvent) => { + const time = event.target.value; + setInternalTime(time); + const newValue = { date: internalDate, time }; + onChange?.(newValue); + }; + + const getDisplayText = () => { + if (!internalDate) return placeholder; + + const dateStr = internalDate.toLocaleDateString(); + if (!showTime || !internalTime) return dateStr; + + return `${dateStr} ${internalTime}`; + }; + + const hasValue = internalDate || (showTime && internalTime); + + return ( +
+
+ {label && ( + + )} +
+ + + + + +
+
+
+ + { + let dateValue = undefined; + if (e.target.value) { + // Create date in local timezone to avoid offset issues + const parts = e.target.value.split('-'); + dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + } + handleDateChange(dateValue); + }} + className="mt-1" + /> +
+ {showTime && ( +
+ + +
+ )} +
+
+
+
+
+
+
+ ); +} + +export interface DateRangePickerProps { + startLabel?: string; + endLabel?: string; + startValue?: DateTimeValue; + endValue?: DateTimeValue; + onStartChange?: (value: DateTimeValue) => void; + onEndChange?: (value: DateTimeValue) => void; + onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; + className?: string; + disabled?: boolean; + showTime?: boolean; +} + +export function DateRangePicker({ +// startLabel = "From", +// endLabel = "To", + startValue, + endValue, + onStartChange, + onEndChange, + onRangeChange, + className, + disabled = false, + showTime = true, +}: DateRangePickerProps) { + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; + + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx new file mode 100644 index 00000000..9fc3789b --- /dev/null +++ b/src/components/LogDataTable.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useEffect, useMemo, useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search, RefreshCw, Filter, X } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; +import { useTranslations } from "next-intl"; +import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker"; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId?: string) => + tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE +}; + +const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + // Validate that it's a reasonable page size + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +const setStoredPageSize = (pageSize: number, tableId?: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; + onDateRangeChange?: ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => void; + dateRange?: { + start: DateTimeValue; + end: DateTimeValue; + }; +}; + +export function LogDataTable({ + columns, + data, + title, + onRefresh, + isRefreshing, + searchPlaceholder = "Search...", + searchColumn = "name", + defaultSort, + tabs, + defaultTab, + persistPageSize = false, + defaultPageSize = 20, + onDateRangeChange, + dateRange +}: DataTableProps) { + const t = useTranslations(); + + // Determine table identifier for storage + const tableId = + typeof persistPageSize === "string" ? persistPageSize : undefined; + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + if (persistPageSize) { + return getStoredPageSize(tableId, defaultPageSize); + } + return defaultPageSize; + }); + + const [sorting, setSorting] = useState( + defaultSort ? [defaultSort] : [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + const [showDatePicker, setShowDatePicker] = useState(false); + const [startDate, setStartDate] = useState( + dateRange?.start || {} + ); + const [endDate, setEndDate] = useState(dateRange?.end || {}); + + // Sync internal date state with external dateRange prop + useEffect(() => { + if (dateRange?.start) { + setStartDate(dateRange.start); + } + if (dateRange?.end) { + setEndDate(dateRange.end); + } + }, [dateRange?.start, dateRange?.end]); + + // Apply tab filter to data + const filteredData = useMemo(() => { + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + initialState: { + pagination: { + pageSize: pageSize, + pageIndex: 0 + } + }, + state: { + sorting, + columnFilters, + globalFilter + } + }); + + useEffect(() => { + const currentPageSize = table.getState().pagination.pageSize; + if (currentPageSize !== pageSize) { + table.setPageSize(pageSize); + + // Persist to localStorage if enabled + if (persistPageSize) { + setStoredPageSize(pageSize, tableId); + } + } + }, [pageSize, table, persistPageSize, tableId]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + + // Enhanced pagination component that updates our local state + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + table.setPageSize(newPageSize); + + // Persist immediately when changed + if (persistPageSize) { + setStoredPageSize(newPageSize, tableId); + } + }; + + const handleDateRangeChange = ( + start: DateTimeValue, + end: DateTimeValue + ) => { + setStartDate(start); + setEndDate(end); + onDateRangeChange?.(start, end); + }; + + const clearDateFilter = () => { + const emptyStart = {}; + const emptyEnd = {}; + setStartDate(emptyStart); + setEndDate(emptyEnd); + onDateRangeChange?.(emptyStart, emptyEnd); + setShowDatePicker(false); + }; + + const hasDateFilter = startDate?.date || endDate?.date; + + return ( +
+ + +
+
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => ( + + {tab.label} ( + {data.filter(tab.filterFn).length}) + + ))} + + + )} + + +
+
+ {onRefresh && ( + + )} +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+
+ +
+
+
+
+ ); +}