[PULSE-13] Org Audit Log UI with advanced filtering #10
@@ -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<AuditLogEntry[]>([])
|
||||
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])
|
||||
|
Filters don't trigger reload In the Audit Log tab, changing Prompt To Fix With AI**Filters don't trigger reload**
In the Audit Log tab, changing `auditActionFilter`, `auditLogIdFilter`, `auditStartDate`, or `auditEndDate` does not actually refetch data because `loadAudit()` is only invoked by the `useEffect` that depends on `activeTab/currentOrgId/loadAudit` (and `loadAudit`’s identity doesn’t change when its deps change). As a result, the UI shows updated filter inputs but still displays stale table data until the user changes tabs or pagination. Consider invoking `loadAudit()` when any filter changes (and resetting `auditPage` as needed).
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 169:196
Comment:
**Filters don't trigger reload**
In the Audit Log tab, changing `auditActionFilter`, `auditLogIdFilter`, `auditStartDate`, or `auditEndDate` does not actually refetch data because `loadAudit()` is only invoked by the `useEffect` that depends on `activeTab/currentOrgId/loadAudit` (and `loadAudit`’s identity doesn’t change when its deps change). As a result, the UI shows updated filter inputs but still displays stale table data until the user changes tabs or pagination. Consider invoking `loadAudit()` when any filter changes (and resetting `auditPage` as needed).
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Introduced a debounced useEffect that listens to changes in auditActionFilter, auditLogIdFilter, auditStartDate, and auditEndDate. Introduced a debounced useEffect that listens to changes in auditActionFilter, auditLogIdFilter, auditStartDate, and auditEndDate.
When any filter changes, it now waits 500ms (debounce) and then automatically triggers loadAudit() and resets the page to 0.
Refactored loadAudit to use a useRef for filter values, ensuring the function identity remains stable while always accessing the latest state. This prevents excessive re-renders while ensuring data freshness.
|
||||
|
||||
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')
|
||||
|
infinite loop risk: since remove Prompt To Fix With AIinfinite loop risk: `loadAudit` in dependency array
since `loadAudit` depends on `auditPage` (line 208), changing filters triggers the debounced effect, which calls `loadAudit()` and resets `auditPage` to 0 (line 215). this causes `loadAudit` to be recreated, potentially re-triggering the effect
remove `loadAudit` from dependencies since you're already using `filtersRef` to access latest values:
```suggestion
useEffect(() => {
if (activeTab !== 'audit') return
const timer = setTimeout(() => {
setAuditPage(0)
loadAudit()
}, 500)
return () => clearTimeout(timer)
}, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate, activeTab])
```
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 211:219
Comment:
infinite loop risk: `loadAudit` in dependency array
since `loadAudit` depends on `auditPage` (line 208), changing filters triggers the debounced effect, which calls `loadAudit()` and resets `auditPage` to 0 (line 215). this causes `loadAudit` to be recreated, potentially re-triggering the effect
remove `loadAudit` from dependencies since you're already using `filtersRef` to access latest values:
```suggestion
useEffect(() => {
if (activeTab !== 'audit') return
const timer = setTimeout(() => {
setAuditPage(0)
loadAudit()
}, 500)
return () => clearTimeout(timer)
}, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate, activeTab])
```
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
double API call when page changes when user clicks pagination, add Prompt To Fix With AIdouble API call when page changes
when user clicks pagination, `setAuditPage` triggers this effect AND the debounced filter effect (211-219) since `loadAudit` is in both dependency arrays. causes 2 API calls
add `auditPage` to the debounced effect's dependencies and remove this separate effect
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 221:225
Comment:
double API call when page changes
when user clicks pagination, `setAuditPage` triggers this effect AND the debounced filter effect (211-219) since `loadAudit` is in both dependency arrays. causes 2 API calls
add `auditPage` to the debounced effect's dependencies and remove this separate effect
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Fix: Fix:
Introduced a auditFetchTrigger state to reliably trigger fetches when filters change, even if the page number remains 0.
Debounced Effect: Removed loadAudit from dependencies (fixing the loop). Now it only resets the page and increments the trigger.
Fetch Effect: Merged the pagination logic. It now listens to loadAudit (which changes on page change) AND auditFetchTrigger (which changes on filter change).
Fix: Fix:
Introduced a auditFetchTrigger state to reliably trigger fetches when filters change, even if the page number remains 0.
Debounced Effect: Removed loadAudit from dependencies (fixing the loop). Now it only resets the page and increments the trigger.
Fetch Effect: Merged the pagination logic. It now listens to loadAudit (which changes on page change) AND auditFetchTrigger (which changes on filter change).
|
||||
} 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() {
|
||||
<BoxIcon className="w-5 h-5" />
|
||||
Billing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('audit')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
|
||||
activeTab === 'audit'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboardIcon className="w-5 h-5" />
|
||||
Audit log
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
@@ -734,6 +785,125 @@ export default function OrganizationSettings() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-12">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Audit log</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Who did what and when for this organization.</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase">Action</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. site_created"
|
||||
value={auditActionFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase">From date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auditStartDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-neutral-500 uppercase">To date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auditEndDate}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setAuditPage(0)
|
||||
loadAudit()
|
||||
}}
|
||||
disabled={isLoadingAudit}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
|
||||
{isLoadingAudit ? (
|
||||
<div className="p-12 text-center text-neutral-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-neutral-400 border-t-transparent rounded-full mx-auto mb-3"></div>
|
||||
Loading audit log...
|
||||
</div>
|
||||
) : auditEntries.length === 0 ? (
|
||||
<div className="p-8 text-center text-neutral-500">No audit events found.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-700 dark:text-neutral-300">Time</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-700 dark:text-neutral-300">Actor</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-700 dark:text-neutral-300">Action</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-700 dark:text-neutral-300">Resource</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-neutral-700 dark:text-neutral-300">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditEntries.map((entry) => (
|
||||
<tr key={entry.id} className="border-b border-neutral-100 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/30">
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
||||
{new Date(entry.occurred_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-900 dark:text-white">
|
||||
{entry.actor_id ? entry.actor_id : 'System'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-white">{entry.action}</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400">{entry.resource_type}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 dark:text-neutral-500 font-mono text-xs">{entry.resource_id || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{auditTotal > auditPageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm text-neutral-500">
|
||||
{auditPage * auditPageSize + 1}–{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setAuditPage((p) => Math.max(0, p - 1))}
|
||||
disabled={auditPage === 0 || isLoadingAudit}
|
||||
className="text-sm py-2 px-3"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setAuditPage((p) => p + 1)}
|
||||
disabled={(auditPage + 1) * auditPageSize >= auditTotal || isLoadingAudit}
|
||||
className="text-sm py-2 px-3"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
69
lib/api/audit.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
|
use Prompt To Fix With AI`auditFetch` bypasses the standard client's token refresh
`lib/api/client.ts` has automatic token refresh on 401 (lines 103-173). this custom fetch duplicates error handling but lacks that retry mechanism. if the access token expires, audit log calls fail instead of auto-refreshing
use `apiRequest` from `client.ts` instead (note: may need to adjust endpoint format to match)
<details><summary>Prompt To Fix With AI</summary>
`````markdown
This is a comment left during a code review.
Path: lib/api/audit.ts
Line: 32:55
Comment:
`auditFetch` bypasses the standard client's token refresh
`lib/api/client.ts` has automatic token refresh on 401 (lines 103-173). this custom fetch duplicates error handling but lacks that retry mechanism. if the access token expires, audit log calls fail instead of auto-refreshing
use `apiRequest` from `client.ts` instead (note: may need to adjust endpoint format to match)
How can I resolve this? If you propose a fix, please make it concise.
`````
</details>
Replaced the custom auditFetch with the standard apiRequest from lib/api/client.ts, which includes automatic token refresh logic. Replaced the custom auditFetch with the standard apiRequest from lib/api/client.ts, which includes automatic token refresh logic.
Crucial Update in client.ts: Modified apiRequest to support legacy endpoints starting with /api/ (like /api/audit) without double-prepending /api/v1, ensuring compatibility with the backend.
|
||||
* 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<string, unknown>
|
||||
}
|
||||
|
||||
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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<GetAuditLogResponse> {
|
||||
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<GetAuditLogResponse>(url, { method: 'GET' })
|
||||
}
|
||||
this
useEffectis unnecessary - refs can be updated directly during render without causing issuesPrompt To Fix With AI
Issue: Unnecessary useEffect for updating filtersRef.
Fix: Removed the useEffect and now updating filtersRef.current synchronously during the render cycle.
Why: Refs can be safely updated during render for this use case, avoiding an extra effect hook and simplifying the component lifecycle.