diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx index 7ed2fe8..9c766ec 100644 --- a/components/settings/OrganizationSettings.tsx +++ b/components/settings/OrganizationSettings.tsx @@ -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([]) + 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() { Billing + {/* Content Area */} @@ -734,6 +785,125 @@ export default function OrganizationSettings() { )} )} + + {activeTab === 'audit' && ( +
+
+

Audit log

+

Who did what and when for this organization.

+
+ + {/* Filters */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+ + {/* Table */} +
+ {isLoadingAudit ? ( +
+
+ Loading audit log... +
+ ) : auditEntries.length === 0 ? ( +
No audit events found.
+ ) : ( +
+ + + + + + + + + + + + {auditEntries.map((entry) => ( + + + + + + + + ))} + +
TimeActorActionResourceID
+ {new Date(entry.occurred_at).toLocaleString()} + + {entry.actor_id ? entry.actor_id : 'System'} + {entry.action}{entry.resource_type}{entry.resource_id || '—'}
+
+ )} + + {/* Pagination */} + {auditTotal > auditPageSize && ( +
+ + {auditPage * auditPageSize + 1}–{Math.min((auditPage + 1) * auditPageSize, auditTotal)} of {auditTotal} + +
+ + +
+
+ )} +
+
+ )} diff --git a/lib/api/audit.ts b/lib/api/audit.ts new file mode 100644 index 0000000..7335543 --- /dev/null +++ b/lib/api/audit.ts @@ -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 +} + +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(endpoint: string, options: RequestInit = {}): Promise { + 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 { + 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(url, { method: 'GET' }) +}