feat: add audit log functionality to OrganizationSettings, including filters and loading states for improved organization activity tracking

This commit is contained in:
Usman Baig
2026-02-05 12:23:06 +01:00
parent 7072049b5f
commit 54578d00ca
2 changed files with 243 additions and 4 deletions

View File

@@ -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>