- DetailModal: thread tz through resolveLabel(...) module helper + default export's 3 inline date/time calls. - IntegrationStatusTabs: thread tz through fmtDate helper + VeeamTab sub-component prop. - SyncScheduler: thread tz into closure-scoped formatDate helper. - audit-log-table, user-table, user-sessions, active-sessions: inline toLocale calls in component body. - analysis-view: useUserTimezone in AnalysisView; thread tz into 4 toLocaleString calls. - resolution-trend, volume-trend (recharts): module-scope fmtDate(iso) → fmtDate(iso, tz); useUserTimezone in named export; thread tz into axis tickFormatter + tooltip labelFormatter. - ticket-detail-modal: thread tz into formatDate arrow inside TicketDetailModal. - TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls (hour/day/month/event-time formatters). - ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the date-range latest call. - addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls. - activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz); useUserTimezone in ActivitySparkline; update 3 callsites in title/aria. - compliance-detail-table: thread tz from ComplianceDetailTable into ContractCoverageModal sub-component (2 inline date calls). - company-backup-detail: module-scope formatDate(d) → formatDate(d, tz); useUserTimezone in CompanyBackupDetail; update 3 callsites. Migrates 31 of 81 audit leak callsites.
267 lines
9.2 KiB
TypeScript
267 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { Loader2, Filter, Calendar } from "lucide-react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { useUserTimezone } from "@/lib/hooks/use-user-timezone";
|
|
|
|
interface AuditLog {
|
|
id: string;
|
|
timestamp: string;
|
|
user_id: string | null;
|
|
user_email: string | null;
|
|
user_name: string | null;
|
|
action: string;
|
|
resource: string;
|
|
resource_id: string | null;
|
|
details: Record<string, unknown> | null;
|
|
ip_address: string | null;
|
|
}
|
|
|
|
interface Pagination {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
}
|
|
|
|
interface Filters {
|
|
actions: string[];
|
|
resources: string[];
|
|
}
|
|
|
|
const actionColors: Record<string, string> = {
|
|
create: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
update: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
|
delete: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
sign_in: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
|
sign_out: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200",
|
|
sign_in_failed: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
|
ban: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
|
unban: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
|
role_change: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
|
};
|
|
|
|
export function AuditLogTable() {
|
|
const tz = useUserTimezone();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
|
const [pagination, setPagination] = useState<Pagination | null>(null);
|
|
const [filters, setFilters] = useState<Filters>({ actions: [], resources: [] });
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [actionFilter, setActionFilter] = useState(searchParams.get("action") || "all");
|
|
const [resourceFilter, setResourceFilter] = useState(searchParams.get("resource") || "all");
|
|
|
|
async function fetchLogs() {
|
|
setIsLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (actionFilter && actionFilter !== "all") params.set("action", actionFilter);
|
|
if (resourceFilter && resourceFilter !== "all") params.set("resource", resourceFilter);
|
|
params.set("page", searchParams.get("page") || "1");
|
|
|
|
const response = await fetch(`/api/admin/audit-log?${params.toString()}`);
|
|
if (!response.ok) throw new Error("Failed to fetch audit logs");
|
|
|
|
const data = await response.json();
|
|
setLogs(data.logs);
|
|
setPagination(data.pagination);
|
|
setFilters(data.filters);
|
|
} catch (error) {
|
|
console.error("Error fetching audit logs:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchLogs();
|
|
}, [searchParams]);
|
|
|
|
function handleFilterChange(type: "action" | "resource", value: string) {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
if (value && value !== "all") {
|
|
params.set(type, value);
|
|
} else {
|
|
params.delete(type);
|
|
}
|
|
params.set("page", "1");
|
|
|
|
if (type === "action") setActionFilter(value);
|
|
if (type === "resource") setResourceFilter(value);
|
|
|
|
router.push(`/admin/audit-log?${params.toString()}`);
|
|
}
|
|
|
|
function formatAction(action: string): string {
|
|
return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filters */}
|
|
<div className="flex gap-4">
|
|
<Select value={actionFilter} onValueChange={(v) => handleFilterChange("action", v)}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Filter by action" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Actions</SelectItem>
|
|
{filters.actions.map((action) => (
|
|
<SelectItem key={action} value={action}>
|
|
{formatAction(action)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={resourceFilter} onValueChange={(v) => handleFilterChange("resource", v)}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Filter by resource" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Resources</SelectItem>
|
|
{filters.resources.map((resource) => (
|
|
<SelectItem key={resource} value={resource}>
|
|
{resource.charAt(0).toUpperCase() + resource.slice(1)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Timestamp</TableHead>
|
|
<TableHead>User</TableHead>
|
|
<TableHead>Action</TableHead>
|
|
<TableHead>Resource</TableHead>
|
|
<TableHead>Details</TableHead>
|
|
<TableHead>IP Address</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : logs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
|
No audit logs found
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
logs.map((log) => (
|
|
<TableRow key={log.id}>
|
|
<TableCell className="text-muted-foreground whitespace-nowrap">
|
|
{new Date(log.timestamp).toLocaleString(undefined, { timeZone: tz })}
|
|
</TableCell>
|
|
<TableCell>
|
|
{log.user_name || log.user_email || "System"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge className={actionColors[log.action] || "bg-gray-100 text-gray-800"}>
|
|
{formatAction(log.action)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="capitalize">{log.resource}</TableCell>
|
|
<TableCell className="max-w-[200px] truncate">
|
|
{log.details ? (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-auto p-1">
|
|
<code className="text-xs">
|
|
{JSON.stringify(log.details).slice(0, 50)}...
|
|
</code>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-80">
|
|
<pre className="text-xs overflow-auto max-h-60">
|
|
{JSON.stringify(log.details, null, 2)}
|
|
</pre>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
"-"
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{log.ip_address || "-"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.totalPages > 1 && (
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
|
|
{Math.min(pagination.page * pagination.limit, pagination.total)} of{" "}
|
|
{pagination.total} entries
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={pagination.page === 1}
|
|
onClick={() => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.set("page", String(pagination.page - 1));
|
|
router.push(`/admin/audit-log?${params.toString()}`);
|
|
}}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={pagination.page === pagination.totalPages}
|
|
onClick={() => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.set("page", String(pagination.page + 1));
|
|
router.push(`/admin/audit-log?${params.toString()}`);
|
|
}}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|