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
|
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,16 @@ export default function OrganizationSettings() {
|
|||||||
const [orgSlug, setOrgSlug] = useState('')
|
const [orgSlug, setOrgSlug] = useState('')
|
||||||
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 auditPageSize = 20
|
||||||
|
const [auditActionFilter, setAuditActionFilter] = useState('')
|
||||||
|
const [auditStartDate, setAuditStartDate] = useState('')
|
||||||
|
const [auditEndDate, setAuditEndDate] = useState('')
|
||||||
|
|
||||||
const getOrgIdFromToken = () => {
|
const getOrgIdFromToken = () => {
|
||||||
return user?.org_id || null
|
return user?.org_id || null
|
||||||
}
|
}
|
||||||
@@ -141,7 +153,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 +165,34 @@ export default function OrganizationSettings() {
|
|||||||
}
|
}
|
||||||
}, [activeTab, currentOrgId, loadSubscription, loadInvoices])
|
}, [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 no org ID, we are in personal workspace, so don't show org settings
|
||||||
if (!currentOrgId) {
|
if (!currentOrgId) {
|
||||||
return (
|
return (
|
||||||
@@ -309,6 +349,17 @@ export default function OrganizationSettings() {
|
|||||||
<BoxIcon className="w-5 h-5" />
|
<BoxIcon className="w-5 h-5" />
|
||||||
Billing
|
Billing
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
@@ -734,6 +785,125 @@ 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>
|
||||||
|
|
||||||
|
{/* 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>
|
</motion.div>
|
||||||
</div>
|
</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