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 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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.
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}