feat: add audit log functionality to OrganizationSettings, including filters and loading states for improved organization activity tracking
This commit is contained in:
@@ -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])
|
||||
|
||||
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() {
|
||||
<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
69
lib/api/audit.ts
Normal file
@@ -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<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' })
|
||||
}
|
||||
Reference in New Issue
Block a user