[PULSE-13] Org Audit Log UI with advanced filtering #10

Merged
uz1mani merged 9 commits from staging into main 2026-02-05 13:53:56 +00:00
3 changed files with 285 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { import {
@@ -17,6 +17,7 @@ import {
Organization Organization
} from '@/lib/api/organization' } from '@/lib/api/organization'
import { getSubscription, createPortalSession, getInvoices, SubscriptionDetails, Invoice } from '@/lib/api/billing' 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 { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { getAuthErrorMessage } from '@/lib/utils/authErrors'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
@@ -30,7 +31,8 @@ import {
Captcha, Captcha,
BookOpenIcon, BookOpenIcon,
DownloadIcon, DownloadIcon,
ExternalLinkIcon ExternalLinkIcon,
LayoutDashboardIcon
} from '@ciphera-net/ui' } from '@ciphera-net/ui'
// @ts-ignore // @ts-ignore
import { Button, Input } from '@ciphera-net/ui' import { Button, Input } from '@ciphera-net/ui'
@@ -39,9 +41,9 @@ export default function OrganizationSettings() {
const { user } = useAuth() const { user } = useAuth()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing'>(() => { const [activeTab, setActiveTab] = useState<'general' | 'members' | 'billing' | 'audit'>(() => {
const tab = searchParams.get('tab') 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) const [showDeletePrompt, setShowDeletePrompt] = useState(false)
@@ -77,6 +79,34 @@ export default function OrganizationSettings() {
const [orgSlug, setOrgSlug] = useState('') const [orgSlug, setOrgSlug] = useState('')
greptile-apps[bot] commented 2026-02-05 13:36:33 +00:00 (Migrated from github.com)
Review

this useEffect is unnecessary - refs can be updated directly during render without causing issues

  // Update refs when state changes (no useEffect needed)
  filtersRef.current = {
    action: auditActionFilter,
    logId: auditLogIdFilter,
    startDate: auditStartDate,
    endDate: auditEndDate
  }
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/settings/OrganizationSettings.tsx
Line: 103:110

Comment:
this `useEffect` is unnecessary - refs can be updated directly during render without causing issues

```suggestion
  // Update refs when state changes (no useEffect needed)
  filtersRef.current = {
    action: auditActionFilter,
    logId: auditLogIdFilter,
    startDate: auditStartDate,
    endDate: auditEndDate
  }
```

How can I resolve this? If you propose a fix, please make it concise.
this `useEffect` is unnecessary - refs can be updated directly during render without causing issues ```suggestion // Update refs when state changes (no useEffect needed) filtersRef.current = { action: auditActionFilter, logId: auditLogIdFilter, startDate: auditStartDate, endDate: auditEndDate } ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/settings/OrganizationSettings.tsx Line: 103:110 Comment: this `useEffect` is unnecessary - refs can be updated directly during render without causing issues ```suggestion // Update refs when state changes (no useEffect needed) filtersRef.current = { action: auditActionFilter, logId: auditLogIdFilter, startDate: auditStartDate, endDate: auditEndDate } ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-05 13:39:46 +00:00 (Migrated from github.com)
Review

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.

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.
const [isSaving, setIsSaving] = useState(false) 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 [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 = () => { const getOrgIdFromToken = () => {
return user?.org_id || null return user?.org_id || null
} }
@@ -141,7 +171,7 @@ export default function OrganizationSettings() {
useEffect(() => { useEffect(() => {
const tab = searchParams.get('tab') const tab = searchParams.get('tab')
if ((tab === 'billing' || tab === 'members') && tab !== activeTab) { if ((tab === 'billing' || tab === 'members' || tab === 'audit') && tab !== activeTab) {
setActiveTab(tab) setActiveTab(tab)
} }
}, [searchParams, activeTab]) }, [searchParams, activeTab])
@@ -153,6 +183,46 @@ export default function OrganizationSettings() {
} }
}, [activeTab, currentOrgId, loadSubscription, loadInvoices]) }, [activeTab, currentOrgId, loadSubscription, loadInvoices])
greptile-apps[bot] commented 2026-02-05 13:28:16 +00:00 (Migrated from github.com)
Review

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:

  useEffect(() => {
    if (activeTab !== 'audit') return
    
    const timer = setTimeout(() => {
        setAuditPage(0)
        loadAudit()
    }, 500)
    return () => clearTimeout(timer)
  }, [auditActionFilter, auditLogIdFilter, auditStartDate, auditEndDate, activeTab])
Prompt To Fix With AI
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.
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]) ``` <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>
greptile-apps[bot] commented 2026-02-05 13:28:17 +00:00 (Migrated from github.com)
Review

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

Prompt To Fix With AI
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.
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 <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>
uz1mani commented 2026-02-05 13:32:49 +00:00 (Migrated from github.com)
Review

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: 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).
uz1mani commented 2026-02-05 13:32:58 +00:00 (Migrated from github.com)
Review

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: 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).
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 no org ID, we are in personal workspace, so don't show org settings
if (!currentOrgId) { if (!currentOrgId) {
return ( return (
@@ -264,6 +334,11 @@ export default function OrganizationSettings() {
// We can find the current user's membership entry which has org name. // 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 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 ( return (
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
<div> <div>
@@ -277,7 +352,7 @@ export default function OrganizationSettings() {
{/* Sidebar Navigation */} {/* Sidebar Navigation */}
<nav className="w-full md:w-64 flex-shrink-0 space-y-1"> <nav className="w-full md:w-64 flex-shrink-0 space-y-1">
<button <button
onClick={() => setActiveTab('general')} onClick={() => handleTabChange('general')}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
activeTab === 'general' activeTab === 'general'
? 'bg-brand-orange/10 text-brand-orange' ? 'bg-brand-orange/10 text-brand-orange'
@@ -288,7 +363,7 @@ export default function OrganizationSettings() {
General General
</button> </button>
<button <button
onClick={() => setActiveTab('members')} onClick={() => handleTabChange('members')}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
activeTab === 'members' activeTab === 'members'
? 'bg-brand-orange/10 text-brand-orange' ? 'bg-brand-orange/10 text-brand-orange'
@@ -299,7 +374,7 @@ export default function OrganizationSettings() {
Members Members
</button> </button>
<button <button
onClick={() => setActiveTab('billing')} onClick={() => handleTabChange('billing')}
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
activeTab === 'billing' activeTab === 'billing'
? 'bg-brand-orange/10 text-brand-orange' ? 'bg-brand-orange/10 text-brand-orange'
@@ -309,6 +384,17 @@ export default function OrganizationSettings() {
<BoxIcon className="w-5 h-5" /> <BoxIcon className="w-5 h-5" />
Billing Billing
</button> </button>
<button
onClick={() => handleTabChange('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> </nav>
{/* Content Area */} {/* Content Area */}
@@ -734,6 +820,145 @@ export default function OrganizationSettings() {
)} )}
</div> </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>
{/* Advanced Filters */}
<div className="bg-neutral-50 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-800 rounded-xl p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-1">
<label className="block text-xs font-medium text-neutral-500 uppercase">Log ID</label>
<input
type="text"
placeholder="e.g. 8a2b3c"
value={auditLogIdFilter}
onChange={(e) => 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"
/>
</div>
<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-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"
/>
</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="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"
/>
</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="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"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setAuditLogIdFilter('')
setAuditActionFilter('')
setAuditStartDate('')
setAuditEndDate('')
setAuditPage(0)
setAuditFetchTrigger(prev => prev + 1)
}}
disabled={isLoadingAudit}
>
Clear Filters
</Button>
</div>
</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">Log ID</th>
<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>
</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-500 dark:text-neutral-500 font-mono text-xs" title={entry.id}>
{entry.id}
</td>
<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_email || 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>
</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> </motion.div>
</div> </div>
</div> </div>

47
lib/api/audit.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
greptile-apps[bot] commented 2026-02-05 13:28:18 +00:00 (Migrated from github.com)
Review

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)

Prompt To Fix With AI
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.
`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>
uz1mani commented 2026-02-05 13:32:36 +00:00 (Migrated from github.com)
Review

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.

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 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<string, unknown>
}
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<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.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<GetAuditLogResponse>(url, { method: 'GET' })
return {
entries: Array.isArray(data?.entries) ? data.entries : [],
total: typeof data?.total === 'number' ? data.total : 0,
}
}

View File

@@ -68,7 +68,11 @@ async function apiRequest<T>(
// * Determine base URL // * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth') const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL 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 = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',