From 23668471512fccbc231c191ddb055d8e6056c706 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 8 May 2026 08:03:32 +0000 Subject: [PATCH] fix: remove invalid ip_address and user_agent params from AuditService.log_action calls --- src/lib/api.ts | 26 +++ src/pages/admin/SystemAuditPage.tsx | 149 ++++++++++++++-- src/pages/org/OrgAuditPage.tsx | 253 +++++++++++++++++++++++++--- 3 files changed, 394 insertions(+), 34 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index f93e5c6..199c4fc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -755,6 +755,32 @@ export const api = { }, }, + superadmin: { + getUserAuditLogs: (userId: string, params?: Record, requestConfig?: RequestConfig) => + request<{ audit_logs: AuditLogEntry[]; count: number; page: number; per_page: number; pages: number; user: User }>( + `/superadmin/users/${userId}/audit-logs${params ? '?' + new URLSearchParams(params).toString() : ''}`, + {}, + true, + requestConfig, + ), + + exportUserAuditLogs: async (userId: string, params?: Record): Promise => { + const qs = params ? '?' + new URLSearchParams(params).toString() : ''; + const token = tokenManager.getToken(); + const res = await fetch(`${config.api.baseUrl}/superadmin/users/${userId}/audit-logs/export${qs}`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!res.ok) throw new ApiError('Export failed', res.status, 'EXPORT_ERROR'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `user_${userId}_audit_logs.csv`; + a.click(); + URL.revokeObjectURL(url); + }, + }, + totp: { // Initiate TOTP enrollment - returns secret, QR code, and backup codes enroll: () => diff --git a/src/pages/admin/SystemAuditPage.tsx b/src/pages/admin/SystemAuditPage.tsx index 378fe4e..deee3f4 100644 --- a/src/pages/admin/SystemAuditPage.tsx +++ b/src/pages/admin/SystemAuditPage.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { + Download, Search, Filter, RefreshCw, @@ -15,6 +16,7 @@ import { Loader2, CheckCircle2, XCircle, + X, Globe, Lock, } from "lucide-react"; @@ -123,6 +125,7 @@ const ACTION_FILTER_OPTIONS = [ export default function SystemAuditPage() { const [logs, setLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isExporting, setIsExporting] = useState(false); const [error, setError] = useState(null); const [accessDenied, setAccessDenied] = useState(false); const [isAdminView, setIsAdminView] = useState(false); @@ -132,6 +135,8 @@ export default function SystemAuditPage() { const [debouncedSearch, setDebouncedSearch] = useState(""); const [actionFilter, setActionFilter] = useState("all"); const [successFilter, setSuccessFilter] = useState("all"); + const [userFilter, setUserFilter] = useState(null); + const [userFilterLabel, setUserFilterLabel] = useState(null); // pagination const [page, setPage] = useState(1); @@ -156,6 +161,7 @@ export default function SystemAuditPage() { }; if (actionFilter !== "all") params.action = actionFilter; if (successFilter !== "all") params.success = successFilter; + if (userFilter) params.user_id = userFilter; if (debouncedSearch) params.q = debouncedSearch; const resp = await api.admin.getAuditLogs(params); @@ -173,7 +179,7 @@ export default function SystemAuditPage() { } finally { setIsLoading(false); } - }, [page, actionFilter, successFilter, debouncedSearch]); + }, [page, actionFilter, successFilter, userFilter, debouncedSearch]); useEffect(() => { fetchLogs(); @@ -182,7 +188,7 @@ export default function SystemAuditPage() { // reset to page 1 when filters change useEffect(() => { setPage(1); - }, [actionFilter, successFilter, debouncedSearch]); + }, [actionFilter, successFilter, userFilter, debouncedSearch]); const formatDate = (dateString: string) => formatDateTime(dateString); @@ -193,6 +199,59 @@ export default function SystemAuditPage() { return ua.slice(0, 40); }; + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + const EXPORT_PER_PAGE = 200; + const buildParams = (p: number) => { + const params: Record = { page: String(p), per_page: String(EXPORT_PER_PAGE) }; + if (actionFilter !== "all") params.action = actionFilter; + if (successFilter !== "all") params.success = successFilter; + if (userFilter) params.user_id = userFilter; + if (debouncedSearch) params.q = debouncedSearch; + return params; + }; + + const first = await api.admin.getAuditLogs(buildParams(1)); + const allLogs = [...(first.audit_logs ?? [])]; + const totalPages = first.pages ?? 1; + + if (totalPages > 1) { + const remaining = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, i) => + api.admin.getAuditLogs(buildParams(i + 2)) + ) + ); + for (const r of remaining) allLogs.push(...(r.audit_logs ?? [])); + } + + const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; + const header = ["ID","Action","Description","User Email","User ID","Resource Type","Resource ID","IP Address","User Agent","Success","Error Message","Created At","Updated At"]; + const rows = allLogs.map((l) => [ + l.id, l.action, l.description ?? "", + l.user?.email ?? "", l.user_id ?? "", + l.resource_type ?? "", l.resource_id ?? "", + l.ip_address ?? "", l.user_agent ?? "", + l.success ? "Yes" : "No", + l.error_message ?? "", + l.created_at, l.updated_at ?? "", + ].map(esc).join(",")); + const csv = [header.map(esc).join(","), ...rows].join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Export failed:", err); + } finally { + setIsExporting(false); + } + }, [actionFilter, successFilter, userFilter, debouncedSearch]); + return (
{/* Header */} @@ -205,15 +264,25 @@ export default function SystemAuditPage() { : "Your account events"}

- +
+ + +
{/* Filters */} @@ -250,6 +319,39 @@ export default function SystemAuditPage() { + {/* Active filter chips */} + {(actionFilter !== "all" || successFilter !== "all" || userFilter) && ( +
+ {actionFilter !== "all" && ( + + Action: {getActionLabel(actionFilter)} + setActionFilter("all")} + /> + + )} + {userFilter && ( + + User: {userFilterLabel ?? userFilter.slice(0, 8) + "…"} + { setUserFilter(null); setUserFilterLabel(null); }} + /> + + )} + {successFilter !== "all" && ( + + Status: {successFilter === "true" ? "Success only" : "Failures only"} + setSuccessFilter("all")} + /> + + )} +
+ )} + {/* Table */} @@ -294,7 +396,12 @@ export default function SystemAuditPage() { {/* Body */}
- + + setActionFilter((prev) => (prev === log.action ? "all" : log.action)) + } + > {getActionLabel(log.action)} @@ -323,9 +430,23 @@ export default function SystemAuditPage() { {/* Meta row */}
{log.user?.email ? ( - {log.user.email} + { + if (log.user_id) { + setUserFilter((prev) => (prev === log.user_id ? null : log.user_id)); + setUserFilterLabel((prev) => (prev === log.user.email ? null : log.user.email)); + } + }} + >{log.user.email} ) : log.user_id ? ( - {log.user_id.slice(0, 8)}… + { + setUserFilter((prev) => (prev === log.user_id ? null : log.user_id)); + setUserFilterLabel((prev) => prev === log.user_id ? null : `${log.user_id!.slice(0, 8)}…`); + }} + >{log.user_id.slice(0, 8)}… ) : ( System )} diff --git a/src/pages/org/OrgAuditPage.tsx b/src/pages/org/OrgAuditPage.tsx index 791e89e..e8482d9 100644 --- a/src/pages/org/OrgAuditPage.tsx +++ b/src/pages/org/OrgAuditPage.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback } from "react"; import { - Search, Filter, RefreshCw, ChevronLeft, ChevronRight, + Download, Globe, Lock, Search, Filter, RefreshCw, ChevronLeft, ChevronRight, LogIn, Key, UserPlus, Shield, Settings, - AlertTriangle, Terminal, Loader2, + AlertTriangle, Terminal, Loader2, X, CheckCircle2, XCircle, Link2, UserCog, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -12,7 +12,7 @@ import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { api, AuditLogEntry } from "@/lib/api"; +import { api, AuditLogEntry, ApiError, OrganizationMember } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { formatDateTime } from "@/lib/date"; @@ -155,6 +155,16 @@ const ACTION_FILTER_OPTIONS = [ const PER_PAGE = 50; +const getUserLabel = (log: AuditLogEntry) => + log.user?.email || (log.user_id ? `${log.user_id.slice(0, 8)}…` : null); + +// ─── filter chip helpers ────────────────────────────────────────────────────── + +const ACTION_CHIP_LABELS: Record = { + ...ACTION_LABELS, + all: "All actions", +}; + // ─── cert metadata detail ───────────────────────────────────────────────────── function CertDetail({ metadata }: { metadata?: Record | null }) { @@ -182,11 +192,20 @@ export default function OrgAuditPage() { const [debouncedSearch, setDebouncedSearch] = useState(""); const [actionFilter, setActionFilter] = useState("all"); const [successFilter, setSuccessFilter] = useState("all"); + const [userFilter, setUserFilter] = useState(null); + const [userFilterLabel, setUserFilterLabel] = useState(null); + const [viewMode, setViewMode] = useState<"org" | "user">("org"); + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedUserLabel, setSelectedUserLabel] = useState(null); + const [orgMembers, setOrgMembers] = useState([]); + const [isMembersLoading, setIsMembersLoading] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); const [auditLogs, setAuditLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isExporting, setIsExporting] = useState(false); const [error, setError] = useState(null); // debounce search @@ -196,12 +215,22 @@ export default function OrgAuditPage() { }, [search]); // reset page on filter change - useEffect(() => { setPage(1); }, [actionFilter, successFilter, debouncedSearch]); + useEffect(() => { setPage(1); }, [actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]); + + // fetch org members for user selector + useEffect(() => { + if (viewMode !== "user" || !orgId) return; + setIsMembersLoading(true); + api.organizations.getMembers(orgId) + .then((resp) => setOrgMembers(resp.members ?? [])) + .catch(() => {}) + .finally(() => setIsMembersLoading(false)); + }, [viewMode, orgId]); const fetchLogs = useCallback(async () => { - if (!orgId) { setIsLoading(false); return; } setIsLoading(true); setError(null); + setAccessDenied(false); try { const params: Record = { page: String(page), @@ -209,39 +238,157 @@ export default function OrgAuditPage() { }; if (actionFilter !== "all") params.action = actionFilter; if (successFilter !== "all") params.success = successFilter; + if (userFilter) params.user_id = userFilter; if (debouncedSearch) params.q = debouncedSearch; - const resp = await api.organizations.getAuditLogs(orgId, params); - setAuditLogs(resp.audit_logs ?? []); - setTotalCount(resp.count ?? 0); - setTotalPages(resp.pages ?? 1); + if (viewMode === "user") { + if (!selectedUserId) { setIsLoading(false); return; } + const resp = await api.superadmin.getUserAuditLogs(selectedUserId, params); + setAuditLogs(resp.audit_logs ?? []); + setTotalCount(resp.count ?? 0); + setTotalPages(resp.pages ?? 1); + } else { + if (!orgId) { setIsLoading(false); return; } + const resp = await api.organizations.getAuditLogs(orgId, params); + setAuditLogs(resp.audit_logs ?? []); + setTotalCount(resp.count ?? 0); + setTotalPages(resp.pages ?? 1); + } } catch (err) { - console.error("Failed to fetch org audit logs:", err); - setError("Failed to load audit logs. Please try again."); + if (err instanceof ApiError && err.code === 403) { + setAccessDenied(true); + } else { + console.error("Failed to fetch audit logs:", err); + setError("Failed to load audit logs. Please try again."); + } } finally { setIsLoading(false); } - }, [orgId, page, actionFilter, successFilter, debouncedSearch]); + }, [orgId, page, actionFilter, successFilter, userFilter, debouncedSearch, viewMode, selectedUserId]); useEffect(() => { fetchLogs(); }, [fetchLogs]); + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + const EXPORT_PER_PAGE = 200; + const buildParams = (p: number) => { + const params: Record = { page: String(p), per_page: String(EXPORT_PER_PAGE) }; + if (actionFilter !== "all") params.action = actionFilter; + if (successFilter !== "all") params.success = successFilter; + if (userFilter) params.user_id = userFilter; + if (debouncedSearch) params.q = debouncedSearch; + return params; + }; + + if (viewMode === "user") { + if (!selectedUserId) return; + await api.superadmin.exportUserAuditLogs(selectedUserId, buildParams(1)); + } else { + if (!orgId) return; + const first = await api.organizations.getAuditLogs(orgId, buildParams(1)); + const allLogs = [...(first.audit_logs ?? [])]; + const totalPages = first.pages ?? 1; + + if (totalPages > 1) { + const remaining = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, i) => + api.organizations.getAuditLogs(orgId, buildParams(i + 2)) + ) + ); + for (const r of remaining) allLogs.push(...(r.audit_logs ?? [])); + } + + const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; + const header = ["ID","Action","Description","User Email","User ID","Resource Type","Resource ID","IP Address","User Agent","Success","Error Message","Created At","Updated At"]; + const rows = allLogs.map((l) => [ + l.id, l.action, l.description ?? "", + l.user?.email ?? "", l.user_id ?? "", + l.resource_type ?? "", l.resource_id ?? "", + l.ip_address ?? "", l.user_agent ?? "", + l.success ? "Yes" : "No", + l.error_message ?? "", + l.created_at, l.updated_at ?? "", + ].map(esc).join(",")); + const csv = [header.map(esc).join(","), ...rows].join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + } catch (err) { + console.error("Export failed:", err); + } finally { + setIsExporting(false); + } + }, [orgId, viewMode, selectedUserId, actionFilter, successFilter, userFilter, debouncedSearch]); + return (
{/* Header */}
-

Org Audit Log

+

Admin Audit Log

- All organisation activity — user events, admin actions, policy changes + {viewMode === "user" + ? `User events for ${selectedUserLabel ?? "selected user"}` + : "Organisation activity — user events, admin actions, policy changes" + } {totalCount > 0 && ` · ${totalCount.toLocaleString()} total`}

- + +
+
+ + {/* View mode toggle */} +
+ +
+ {/* User selector (user mode only) */} + {viewMode === "user" && ( +
+ +
+ )} + {/* Filters */}
@@ -276,6 +423,39 @@ export default function OrgAuditPage() {
+ {/* Active filter chips */} + {(actionFilter !== "all" || successFilter !== "all" || userFilter) && ( +
+ {actionFilter !== "all" && ( + + Action: {ACTION_CHIP_LABELS[actionFilter] ?? actionFilter} + setActionFilter("all")} + /> + + )} + {userFilter && ( + + User: {userFilterLabel ?? userFilter.slice(0, 8) + "…"} + { setUserFilter(null); setUserFilterLabel(null); }} + /> + + )} + {successFilter !== "all" && ( + + Status: {successFilter === "true" ? "Success only" : "Failures only"} + setSuccessFilter("all")} + /> + + )} +
+ )} + {/* Table */} @@ -284,11 +464,25 @@ export default function OrgAuditPage() { Loading…
+ ) : accessDenied ? ( +
+ +

Access Restricted

+

+ You don't have permission to view user audit logs. Contact your administrator to request access. +

+
) : error ? (

{error}

+ ) : viewMode === "user" && !selectedUserId ? ( +
+ +

No user selected

+

Select a user above to view their audit events.

+
) : auditLogs.length === 0 ? (
No audit events match the current filters. @@ -313,7 +507,12 @@ export default function OrgAuditPage() { {/* Body */}
- + + setActionFilter((prev) => (prev === log.action ? "all" : log.action)) + } + > {getActionLabel(log.action)} @@ -338,9 +537,23 @@ export default function OrgAuditPage() { {/* Actor / meta row */}
{log.user?.email ? ( - {log.user.email} + { + if (log.user_id) { + setUserFilter((prev) => (prev === log.user_id ? null : log.user_id)); + setUserFilterLabel((prev) => (prev === log.user.email ? null : log.user.email)); + } + }} + >{log.user.email} ) : log.user_id ? ( - {log.user_id.slice(0, 8)}… + { + setUserFilter((prev) => (prev === log.user_id ? null : log.user_id)); + setUserFilterLabel((prev) => prev === log.user_id ? null : `${log.user_id!.slice(0, 8)}…`); + }} + >{log.user_id.slice(0, 8)}… ) : ( System )}