diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 7ed2fe8..a140c24 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { @@ -17,6 +17,7 @@ import { Organization } from '@/lib/api/organization' import { getSubscription, createPortalSession, getInvoices, SubscriptionDetails, Invoice } from '@/lib/api/billing' +import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { motion, AnimatePresence } from 'framer-motion' @@ -30,7 +31,8 @@ import { Captcha, BookOpenIcon, DownloadIcon, - ExternalLinkIcon + ExternalLinkIcon, + LayoutDashboardIcon } from '@ciphera-net/ui' // @ts-ignore import { Button, Input } from '@ciphera-net/ui' @@ -39,9 +41,9 @@ export default function OrganizationSettings() { const { user } = useAuth() const router = useRouter() const searchParams = useSearchParams() - const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing'>(() => { + const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'audit'>(() => { const tab = searchParams.get('tab') - return tab === 'billing' || tab === 'members' ? tab : 'general' + return tab === 'billing' || tab === 'members' || tab === 'audit' ? tab : 'general' }) const [showDeletePrompt, setShowDeletePrompt] = useState(false) @@ -77,6 +79,34 @@ export default function OrganizationSettings() { const [orgSlug, setOrgSlug] = useState('') const [isSaving, setIsSaving] = useState(false) + // Audit log State + const [auditEntries, setAuditEntries] = useState([]) + const [auditTotal, setAuditTotal] = useState(0) + const [isLoadingAudit, setIsLoadingAudit] = useState(false) + const [auditPage, setAuditPage] = useState(0) + const [auditFetchTrigger, setAuditFetchTrigger] = useState(0) + const auditPageSize = 20 + const [auditActionFilter, setAuditActionFilter] = useState('') + const [auditLogIdFilter, setAuditLogIdFilter] = useState('') + const [auditStartDate, setAuditStartDate] = useState('') + const [auditEndDate, setAuditEndDate] = useState('') + + // Refs for filters to keep loadAudit stable and avoid rapid re-renders + const filtersRef = useRef({ + action: auditActionFilter, + logId: auditLogIdFilter, + startDate: auditStartDate, + endDate: auditEndDate + }) + + // Update refs when state changes (no useEffect needed) + filtersRef.current = { + action: auditActionFilter, + logId: auditLogIdFilter, + startDate: auditStartDate, + endDate: auditEndDate + } + const getOrgIdFromToken = () => { return user?.org_id || null } @@ -141,7 +171,7 @@ export default function OrganizationSettings() { useEffect(() => { const tab = searchParams.get('tab') - if ((tab === 'billing' || tab === 'members') && tab !== activeTab) { + if ((tab === 'billing' || tab === 'members' || tab === 'audit') && tab !== activeTab) { setActiveTab(tab) } }, [searchParams, activeTab]) @@ -153,6 +183,46 @@ export default function OrganizationSettings() { } }, [activeTab, currentOrgId, loadSubscription, loadInvoices]) + const loadAudit = useCallback(async () => { + if (!currentOrgId) return + setIsLoadingAudit(true) + try { + const params: GetAuditLogParams = { + limit: auditPageSize, + offset: auditPage * auditPageSize, + } + if (filtersRef.current.action) params.action = filtersRef.current.action + if (filtersRef.current.logId) params.log_id = filtersRef.current.logId + if (filtersRef.current.startDate) params.start_date = filtersRef.current.startDate + if (filtersRef.current.endDate) params.end_date = filtersRef.current.endDate + const { entries, total } = await getAuditLog(params) + setAuditEntries(Array.isArray(entries) ? entries : []) + setAuditTotal(typeof total === 'number' ? total : 0) + } catch (error) { + console.error('Failed to load audit log', error) + toast.error(getAuthErrorMessage(error as Error) || 'Failed to load audit log') + } finally { + setIsLoadingAudit(false) + } + }, [currentOrgId, auditPage]) + + // Debounced filter change handler + useEffect(() => { + if (activeTab !== 'audit') return + + const timer = setTimeout(() => { + setAuditPage(0) // Reset page on filter change + setAuditFetchTrigger(prev => prev + 1) // Trigger fetch + }, 500) + return () => clearTimeout(timer) + }, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate, activeTab]) + + useEffect(() => { + if (activeTab === 'audit' && currentOrgId) { + loadAudit() + } + }, [activeTab, currentOrgId, loadAudit, auditFetchTrigger]) + // If no org ID, we are in personal workspace, so don't show org settings if (!currentOrgId) { return ( @@ -264,6 +334,11 @@ export default function OrganizationSettings() { // We can find the current user's membership entry which has org name. const currentOrgName = members.find(m => m.user_id === user?.id)?.organization_name || 'Organization' + const handleTabChange = (tab: 'general' | 'members' | 'billing' | 'audit') => { + setActiveTab(tab) + router.push(`?tab=${tab}`) + } + return (
@@ -277,7 +352,7 @@ export default function OrganizationSettings() { {/* Sidebar Navigation */} {/* Content Area */} @@ -734,6 +820,145 @@ export default function OrganizationSettings() { )}
)} + + {activeTab === 'audit' && ( +
+
+

Audit log

+

Who did what and when for this organization.

+
+ + {/* Advanced Filters */} +
+
+
+ + setAuditLogIdFilter(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" + /> +
+
+ + setAuditActionFilter(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" + /> +
+
+ + setAuditStartDate(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" + /> +
+
+ + setAuditEndDate(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-sm text-neutral-900 dark:text-white focus:ring-2 focus:ring-brand-orange outline-none transition-all" + /> +
+
+
+ +
+
+ + {/* Table */} +
+ {isLoadingAudit ? ( +
+
+ Loading audit log... +
+ ) : (auditEntries ?? []).length === 0 ? ( +
No audit events found.
+ ) : ( +
+ + + + + + + + + + + + {(auditEntries ?? []).map((entry) => ( + + + + + + + + ))} + +
Log IDTimeActorActionResource
+ {entry.id} + + {new Date(entry.occurred_at).toLocaleString()} + + {entry.actor_email || entry.actor_id || 'System'} + {entry.action}{entry.resource_type}
+
+ )} + + {/* Pagination */} + {auditTotal > auditPageSize && ( +
+ + {auditPage * auditPageSize + 1}–{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal} + +
+ + +
+
+ )} +
+
+ )}
diff --git a/lib/api/audit.ts b/lib/api/audit.ts new file mode 100644 index 0000000..2037fc0 --- /dev/null +++ b/lib/api/audit.ts @@ -0,0 +1,47 @@ +/** + * Audit log API client (org-scoped; requires org admin role) + */ + +import apiRequest from './client' + +export interface AuditLogEntry { + id: string + org_id: string + actor_id?: string + actor_email?: string + action: string + resource_type: string + occurred_at: string + payload?: Record +} + +export interface GetAuditLogParams { + limit?: number + offset?: number + action?: string + log_id?: string + start_date?: string + end_date?: string +} + +export interface GetAuditLogResponse { + entries: AuditLogEntry[] + total: number +} + +export async function getAuditLog(params: GetAuditLogParams = {}): Promise { + const search = new URLSearchParams() + if (params.limit != null) search.set('limit', String(params.limit)) + if (params.offset != null) search.set('offset', String(params.offset)) + if (params.action) search.set('action', params.action) + if (params.log_id) search.set('log_id', params.log_id) + if (params.start_date) search.set('start_date', params.start_date) + if (params.end_date) search.set('end_date', params.end_date) + const qs = search.toString() + const url = qs ? `/api/audit?${qs}` : '/api/audit' + const data = await apiRequest(url, { method: 'GET' }) + return { + entries: Array.isArray(data?.entries) ? data.entries : [], + total: typeof data?.total === 'number' ? data.total : 0, + } +} diff --git a/lib/api/client.ts b/lib/api/client.ts index 27068aa..a3fcac5 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -68,7 +68,11 @@ async function apiRequest( // * Determine base URL const isAuthRequest = endpoint.startsWith('/auth') const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL - const url = `${baseUrl}/api/v1${endpoint}` + + // * Handle legacy endpoints that already include /api/ prefix + const url = endpoint.startsWith('/api/') + ? `${baseUrl}${endpoint}` + : `${baseUrl}/api/v1${endpoint}` const headers: HeadersInit = { 'Content-Type': 'application/json',