From 54578d00cafe58f4b2e86ac2c40d6fa0ab2245ec Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 12:23:06 +0100 Subject: [PATCH 1/9] feat: add audit log functionality to OrganizationSettings, including filters and loading states for improved organization activity tracking --- components/settings/OrganizationSettings.tsx | 178 ++++++++++++++++++- lib/api/audit.ts | 69 +++++++ 2 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 lib/api/audit.ts diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 7ed2fe8..9c766ec 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -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,16 @@ 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 auditPageSize = 20 + const [auditActionFilter, setAuditActionFilter] = useState('') + const [auditStartDate, setAuditStartDate] = useState('') + const [auditEndDate, setAuditEndDate] = useState('') + const getOrgIdFromToken = () => { return user?.org_id || null } @@ -141,7 +153,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 +165,34 @@ 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 (auditActionFilter) params.action = auditActionFilter + if (auditStartDate) params.start_date = auditStartDate + if (auditEndDate) params.end_date = auditEndDate + const { entries, total } = await getAuditLog(params) + setAuditEntries(entries) + setAuditTotal(total) + } 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, auditActionFilter, auditStartDate, auditEndDate]) + + useEffect(() => { + if (activeTab === 'audit' && currentOrgId) { + loadAudit() + } + }, [activeTab, currentOrgId, loadAudit]) + // If no org ID, we are in personal workspace, so don't show org settings if (!currentOrgId) { return ( @@ -309,6 +349,17 @@ export default function OrganizationSettings() { Billing + {/* Content Area */} @@ -734,6 +785,125 @@ export default function OrganizationSettings() { )} )} + + {activeTab === 'audit' && ( +
+
+

Audit log

+

Who did what and when for this organization.

+
+ + {/* Filters */} +
+
+ + setAuditActionFilter(e.target.value)} + className="w-40 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" + /> +
+
+ + setAuditStartDate(e.target.value)} + className="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" + /> +
+
+ + setAuditEndDate(e.target.value)} + className="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" + /> +
+ +
+ + {/* Table */} +
+ {isLoadingAudit ? ( +
+
+ Loading audit log... +
+ ) : auditEntries.length === 0 ? ( +
No audit events found.
+ ) : ( +
+ + + + + + + + + + + + {auditEntries.map((entry) => ( + + + + + + + + ))} + +
TimeActorActionResourceID
+ {new Date(entry.occurred_at).toLocaleString()} + + {entry.actor_id ? entry.actor_id : 'System'} + {entry.action}{entry.resource_type}{entry.resource_id || '—'}
+
+ )} + + {/* 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..7335543 --- /dev/null +++ b/lib/api/audit.ts @@ -0,0 +1,69 @@ +/** + * Audit log API client (org-scoped; requires org admin role) + */ + +import { API_URL } from './client' + +export interface AuditLogEntry { + id: string + org_id: string + actor_id?: string + action: string + resource_type: string + resource_id?: string + occurred_at: string + payload?: Record +} + +export interface GetAuditLogParams { + limit?: number + offset?: number + action?: string + start_date?: string + end_date?: string +} + +export interface GetAuditLogResponse { + entries: AuditLogEntry[] + total: number +} + +async function auditFetch(endpoint: string, options: RequestInit = {}): Promise { + const url = `${API_URL}${endpoint}` + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }) + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({ + error: 'Unknown error', + message: `HTTP ${response.status}: ${response.statusText}`, + })) + throw new Error(errorBody.message || errorBody.error || 'Request failed') + } + + return response.json() +} + +/** + * Fetches paginated audit log entries for the current org (org from JWT; admin-only on backend). + */ +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.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' + return await auditFetch(url, { method: 'GET' }) +} From bcb221eb41a24c4edbab16e85c598d31974ca9d9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 12:30:01 +0100 Subject: [PATCH 2/9] fix: normalize audit log response to ensure entries are always an array and total is a number, improving data handling in OrganizationSettings --- components/settings/OrganizationSettings.tsx | 8 ++++---- lib/api/audit.ts | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 9c766ec..d017f44 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -177,8 +177,8 @@ export default function OrganizationSettings() { if (auditStartDate) params.start_date = auditStartDate if (auditEndDate) params.end_date = auditEndDate const { entries, total } = await getAuditLog(params) - setAuditEntries(entries) - setAuditTotal(total) + 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') @@ -842,7 +842,7 @@ export default function OrganizationSettings() {
Loading audit log... - ) : auditEntries.length === 0 ? ( + ) : (auditEntries ?? []).length === 0 ? (
No audit events found.
) : (
@@ -857,7 +857,7 @@ export default function OrganizationSettings() { - {auditEntries.map((entry) => ( + {(auditEntries ?? []).map((entry) => ( {new Date(entry.occurred_at).toLocaleString()} diff --git a/lib/api/audit.ts b/lib/api/audit.ts index 7335543..45671f1 100644 --- a/lib/api/audit.ts +++ b/lib/api/audit.ts @@ -55,6 +55,7 @@ async function auditFetch(endpoint: string, options: RequestInit = {}): Promi /** * Fetches paginated audit log entries for the current org (org from JWT; admin-only on backend). + * Normalizes response so entries is always an array (backend may return null when empty). */ export async function getAuditLog(params: GetAuditLogParams = {}): Promise { const search = new URLSearchParams() @@ -65,5 +66,9 @@ export async function getAuditLog(params: GetAuditLogParams = {}): Promise(url, { method: 'GET' }) + const data = await auditFetch(url, { method: 'GET' }) + return { + entries: Array.isArray(data?.entries) ? data.entries : [], + total: typeof data?.total === 'number' ? data.total : 0, + } } From 01af2124eb173c2e2b774513b15f2e7507bfa150 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 13:04:51 +0100 Subject: [PATCH 3/9] feat: enhance audit log display in OrganizationSettings by adding Log ID and improving actor identification with email fallback --- components/settings/OrganizationSettings.tsx | 8 ++++++-- lib/api/audit.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index d017f44..7989164 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -849,21 +849,25 @@ export default function OrganizationSettings() { + - + {(auditEntries ?? []).map((entry) => ( + diff --git a/lib/api/audit.ts b/lib/api/audit.ts index 45671f1..cbb27fc 100644 --- a/lib/api/audit.ts +++ b/lib/api/audit.ts @@ -8,6 +8,7 @@ export interface AuditLogEntry { id: string org_id: string actor_id?: string + actor_email?: string action: string resource_type: string resource_id?: string From 508b110adf9d2794f51598e0523735d0406656ca Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 13:31:59 +0100 Subject: [PATCH 4/9] feat: add Log ID filter to audit log in OrganizationSettings, enhancing filtering capabilities for improved data retrieval --- components/settings/OrganizationSettings.tsx | 99 ++++++++++++-------- lib/api/audit.ts | 2 + 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 7989164..e864dcd 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -86,6 +86,7 @@ export default function OrganizationSettings() { const [auditPage, setAuditPage] = useState(0) const auditPageSize = 20 const [auditActionFilter, setAuditActionFilter] = useState('') + const [auditLogIdFilter, setAuditLogIdFilter] = useState('') const [auditStartDate, setAuditStartDate] = useState('') const [auditEndDate, setAuditEndDate] = useState('') @@ -174,6 +175,7 @@ export default function OrganizationSettings() { offset: auditPage * auditPageSize, } if (auditActionFilter) params.action = auditActionFilter + if (auditLogIdFilter) params.log_id = auditLogIdFilter if (auditStartDate) params.start_date = auditStartDate if (auditEndDate) params.end_date = auditEndDate const { entries, total } = await getAuditLog(params) @@ -185,7 +187,7 @@ export default function OrganizationSettings() { } finally { setIsLoadingAudit(false) } - }, [currentOrgId, auditPage, auditActionFilter, auditStartDate, auditEndDate]) + }, [currentOrgId, auditPage, auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate]) useEffect(() => { if (activeTab === 'audit' && currentOrgId) { @@ -793,46 +795,63 @@ export default function OrganizationSettings() {

Who did what and when for this organization.

- {/* Filters */} -
-
- - setAuditActionFilter(e.target.value)} - className="w-40 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" - /> + {/* 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" + /> +
-
- - setAuditStartDate(e.target.value)} - className="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" - /> +
+
-
- - setAuditEndDate(e.target.value)} - className="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" - /> -
-
{/* Table */} @@ -854,7 +873,6 @@ export default function OrganizationSettings() {
- @@ -871,7 +889,6 @@ export default function OrganizationSettings() { - ))} diff --git a/lib/api/audit.ts b/lib/api/audit.ts index cbb27fc..2dd76e8 100644 --- a/lib/api/audit.ts +++ b/lib/api/audit.ts @@ -20,6 +20,7 @@ export interface GetAuditLogParams { limit?: number offset?: number action?: string + log_id?: string start_date?: string end_date?: string } @@ -63,6 +64,7 @@ export async function getAuditLog(params: GetAuditLogParams = {}): Promise Date: Thu, 5 Feb 2026 13:38:31 +0100 Subject: [PATCH 5/9] refactor: remove optional resource_id from AuditLogEntry interface to streamline audit log structure --- lib/api/audit.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/api/audit.ts b/lib/api/audit.ts index 2dd76e8..4167cb5 100644 --- a/lib/api/audit.ts +++ b/lib/api/audit.ts @@ -11,7 +11,6 @@ export interface AuditLogEntry { actor_email?: string action: string resource_type: string - resource_id?: string occurred_at: string payload?: Record } From cbe2769faaf4ea67d4af65d0d9d6da625e661e5d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 13:41:33 +0100 Subject: [PATCH 6/9] fix: update Log ID placeholder and display in OrganizationSettings for improved clarity and consistency --- components/settings/OrganizationSettings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index e864dcd..38e971c 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -802,7 +802,7 @@ export default function OrganizationSettings() { 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" @@ -879,7 +879,7 @@ export default function OrganizationSettings() { {(auditEntries ?? []).map((entry) => (
Log ID Time Actor Action ResourceIDResource ID
+ {entry.id.slice(0, 8)}… + {new Date(entry.occurred_at).toLocaleString()} - {entry.actor_id ? entry.actor_id : 'System'} + {entry.actor_email || entry.actor_id || 'System'} {entry.action} {entry.resource_type}Actor Action ResourceResource ID
{entry.action} {entry.resource_type}{entry.resource_id || '—'}
- {entry.id.slice(0, 8)}… + {entry.id} {new Date(entry.occurred_at).toLocaleString()} From e581d5212f340262b28d69ac0162d87c6f31e6ce Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 5 Feb 2026 14:03:37 +0100 Subject: [PATCH 7/9] refactor: optimize audit log filtering in OrganizationSettings by using refs to stabilize state updates and improve performance --- components/settings/OrganizationSettings.tsx | 54 ++++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 38e971c..29c4d19 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 { @@ -90,6 +90,24 @@ export default function OrganizationSettings() { 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 + useEffect(() => { + filtersRef.current = { + action: auditActionFilter, + logId: auditLogIdFilter, + startDate: auditStartDate, + endDate: auditEndDate + } + }, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate]) + const getOrgIdFromToken = () => { return user?.org_id || null } @@ -174,10 +192,10 @@ export default function OrganizationSettings() { limit: auditPageSize, offset: auditPage * auditPageSize, } - if (auditActionFilter) params.action = auditActionFilter - if (auditLogIdFilter) params.log_id = auditLogIdFilter - if (auditStartDate) params.start_date = auditStartDate - if (auditEndDate) params.end_date = auditEndDate + 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) @@ -187,7 +205,18 @@ export default function OrganizationSettings() { } finally { setIsLoadingAudit(false) } - }, [currentOrgId, auditPage, auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate]) + }, [currentOrgId, auditPage]) + + // Debounced filter change handler + useEffect(() => { + if (activeTab !== 'audit') return + + const timer = setTimeout(() => { + setAuditPage(0) // Reset page on filter change + loadAudit() + }, 500) + return () => clearTimeout(timer) + }, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate, loadAudit]) useEffect(() => { if (activeTab === 'audit' && currentOrgId) { @@ -306,6 +335,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 (
@@ -319,7 +353,7 @@ export default function OrganizationSettings() { {/* Sidebar Navigation */}