Basic log table there

This commit is contained in:
Owen
2025-10-21 15:26:03 -07:00
parent 65364d6b0f
commit 9a64f45815
7 changed files with 804 additions and 42 deletions

View File

@@ -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"
}

View File

@@ -11,3 +11,4 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./queryActionAuditLog";

View File

@@ -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
)

View File

@@ -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 <p>access</p>;
const [rows, setRows] = useState<any[]>([]);
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<any>[] = [
{
accessorKey: "timestamp",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="hidden md:flex whitespace-nowrap"
>
{t("timestamp")}
</Button>
);
},
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
{new Date(
row.original.timestamp * 1000
).toLocaleString()}
</div>
);
}
},
{
accessorKey: "action",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("action")}
</Button>
);
},
// make the value capitalized
cell: ({ row }) => {
return (
<span className="hitespace-nowrap">
{row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)}
</span>
);
},
},
{
accessorKey: "actor",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("actor")}
</Button>
);
},
cell: ({ row }) => {
return (
<span className="flex items-center gap-1">
{row.original.actorType == "user" ? <User className="h-4 w-4" /> : <Key className="h-4 w-4" />}
{row.original.actor}
</span>
);
}
}
];
return (
<>
<LogDataTable
columns={columns}
data={rows}
persistPageSize="access-logs-table"
title={t("accessLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="action"
onRefresh={refreshData}
isRefreshing={isRefreshing}
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
end: dateRange.endDate
}}
defaultSort={{
id: "timestamp",
desc: false
}}
/>
</>
);
}

View File

@@ -141,7 +141,7 @@ export const orgNavSections = (
: []),
{
title: "sidebarLogs",
href: "/{orgId}/settings/logs",
href: "/{orgId}/settings/logs/access",
icon: <Logs className="h-4 w-4" />
},
{

View File

@@ -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<Date | undefined>(value?.date);
const [internalTime, setInternalTime] = useState<string>(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<HTMLInputElement>) => {
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 (
<div className={cn("flex gap-4", className)}>
<div className="flex flex-col gap-3">
{label && (
<Label htmlFor="date-picker" className="px-1">
{label}
</Label>
)}
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
id="date-picker"
disabled={disabled}
className={cn(
"justify-between font-normal",
showTime ? "w-48" : "w-32",
!hasValue && "text-muted-foreground"
)}
>
{getDisplayText()}
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<div className="p-3">
<div className="space-y-3">
<div>
<Label htmlFor="date-input" className="text-sm font-medium">
Date
</Label>
<Input
id="date-input"
type="date"
value={internalDate ?
`${internalDate.getFullYear()}-${String(internalDate.getMonth() + 1).padStart(2, '0')}-${String(internalDate.getDate()).padStart(2, '0')}`
: ''}
onChange={(e) => {
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"
/>
</div>
{showTime && (
<div>
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
<Input
id="time-input"
type="time"
step="1"
value={internalTime}
onChange={handleTimeChange}
className="mt-1 bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
}
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 (
<div className={cn("flex gap-4", className)}>
<DateTimePicker
// label={startLabel}
value={startValue}
onChange={handleStartChange}
placeholder="Start date & time"
disabled={disabled}
showTime={showTime}
/>
<DateTimePicker
// label={endLabel}
value={endValue}
onChange={handleEndChange}
placeholder="End date & time"
disabled={disabled}
showTime={showTime}
/>
</div>
);
}

View File

@@ -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<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
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<TData, TValue>({
columns,
data,
title,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
searchColumn = "name",
defaultSort,
tabs,
defaultTab,
persistPageSize = false,
defaultPageSize = 20,
onDateRangeChange,
dateRange
}: DataTableProps<TData, TValue>) {
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<number>(() => {
if (persistPageSize) {
return getStoredPageSize(tableId, defaultPageSize);
}
return defaultPageSize;
});
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const [showDatePicker, setShowDatePicker] = useState(false);
const [startDate, setStartDate] = useState<DateTimeValue>(
dateRange?.start || {}
);
const [endDate, setEndDate] = useState<DateTimeValue>(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 (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
table.setGlobalFilter(
String(e.target.value)
)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
{tabs && tabs.length > 0 && (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
>
{tab.label} (
{data.filter(tab.filterFn).length})
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<DateRangePicker
startValue={startDate}
endValue={endDate}
onRangeChange={handleDateRangeChange}
className="flex-wrap gap-2"
/>
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("refresh")}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() && "selected"
}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="mt-4">
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</CardContent>
</Card>
</div>
);
}