'use client' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, Calendar, ChipCombobox, ChipInput, ChipSelect, type ComboboxOption, Download, Popover, PopoverAnchor, PopoverContent, RefreshCw, Search, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { formatDateTime } from '@sim/utils/formatting' import { isRecordLike } from '@sim/utils/object' import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters' import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils' import { ActivityLog, type ActivityLogEntry, } from '@/app/workspace/[workspaceId]/settings/components/activity-log' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants' import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs' import type { TimeRange } from '@/stores/logs/filters/types' const logger = createLogger('AuditLogs') const REFRESH_SPINNER_DURATION_MS = 1000 /** Trimmed to the most commonly used granularities so the menu fits without scrolling. */ const TIME_RANGE_OPTIONS: ComboboxOption[] = [ { value: 'All time', label: 'All time' }, { value: 'Past hour', label: 'Past hour' }, { value: 'Past 6 hours', label: 'Past 6 hours' }, { value: 'Past 24 hours', label: 'Past 24 hours' }, { value: 'Past 3 days', label: 'Past 3 days' }, { value: 'Past 7 days', label: 'Past 7 days' }, { value: 'Past 30 days', label: 'Past 30 days' }, { value: 'Custom range', label: 'Custom range' }, ] function formatResourceType(type: string): string { return type .split('_') .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } function formatAction(action: string): string { return action.replace(/[._]/g, ' ') } function formatMetadataLabel(key: string): string { return key .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/[_-]+/g, ' ') .replace(/\b\w/g, (char) => char.toUpperCase()) } function formatPrimitiveValue(value: string | number | boolean | null): string { if (value === null) return '-' if (typeof value === 'boolean') return value ? 'Yes' : 'No' if (typeof value === 'number') return value.toLocaleString() return value } function renderMetadataValue(value: unknown) { if (value == null) return - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return {formatPrimitiveValue(value)} } if (Array.isArray(value)) { if (value.length === 0) { return None } const hasComplexValues = value.some((item) => typeof item === 'object' && item !== null) if (!hasComplexValues) { return ( {value .map((item) => formatPrimitiveValue((item as string | number | boolean | null) ?? null)) .join(', ')} ) } return (
        {JSON.stringify(value, null, 2)}
      
) } if (isRecordLike(value)) { const entries = Object.entries(value).filter(([, nestedValue]) => nestedValue !== undefined) if (entries.length === 0) { return None } const hasComplexValues = entries.some(([, nestedValue]) => { return Array.isArray(nestedValue) || isRecordLike(nestedValue) }) if (!hasComplexValues) { return ( {entries .map(([nestedKey, nestedValue]) => { return `${formatMetadataLabel(nestedKey)}: ${formatPrimitiveValue((nestedValue as string | number | boolean | null) ?? null)}` }) .join(' ยท ')} ) } return (
        {JSON.stringify(value, null, 2)}
      
) } return (
      {JSON.stringify(value, null, 2)}
    
) } function getMetadataEntries(metadata: unknown) { if (!isRecordLike(metadata)) return [] return Object.entries(metadata).filter(([key, value]) => { if (value === undefined) return false return !['name', 'description'].includes(key) }) } interface ActionBadgeProps { action: string } function ActionBadge({ action }: ActionBadgeProps) { const [, verb] = action.split('.') const variant = verb === 'deleted' || verb === 'removed' || verb === 'revoked' ? 'red' : 'gray-secondary' return ( {formatAction(action)} ) } /** The expanded detail box content for one audit entry (resource, actor, metadata). */ function auditLogDetails(entry: EnterpriseAuditLogEntry): ReactNode { const metadataEntries = getMetadataEntries(entry.metadata) return ( <>
Resource {formatResourceType(entry.resourceType)} {entry.resourceId && ( ({entry.resourceId}) )}
{entry.resourceName && (
Name {entry.resourceName}
)}
Actor {entry.actorName || 'Unknown'} {entry.actorEmail && ( ({entry.actorEmail}) )}
{entry.description && (
Description {entry.description}
)} {metadataEntries.map(([key, value]) => (
{formatMetadataLabel(key)}
{renderMetadataValue(value)}
))} ) } /** Maps an audit entry to the shared {@link ActivityLog} row shape. */ function toActivityEntry(entry: EnterpriseAuditLogEntry): ActivityLogEntry { return { id: entry.id, timestamp: formatDateTime(new Date(entry.createdAt)), event: , description: entry.description || entry.resourceName || entry.resourceId || '-', actor: entry.actorEmail || entry.actorName || 'System', details: auditLogDetails(entry), } } export function AuditLogs() { const [selectedTypes, setSelectedTypes] = useState([]) const [timeRange, setTimeRange] = useState('Past 30 days') const [customStartDate, setCustomStartDate] = useState('') const [customEndDate, setCustomEndDate] = useState('') const [datePickerOpen, setDatePickerOpen] = useState(false) const previousTimeRangeRef = useRef('Past 30 days') const dateRangeAppliedRef = useRef(false) const [searchTerm, setSearchTerm] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const debounceRef = useRef | null>(null) const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const refreshTimersRef = useRef(new Set()) const [isExporting, setIsExporting] = useState(false) useEffect(() => { const trimmed = searchTerm.trim() if (trimmed === debouncedSearch) return debounceRef.current = setTimeout(() => { setDebouncedSearch(trimmed) }, 300) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [searchTerm, debouncedSearch]) useEffect(() => { const timers = refreshTimersRef.current return () => { for (const timerId of timers) window.clearTimeout(timerId) } }, []) const filters = useMemo(() => { return { search: debouncedSearch || undefined, resourceType: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined, startDate: getStartDateFromTimeRange(timeRange, customStartDate)?.toISOString(), endDate: getEndDateFromTimeRange(timeRange, customEndDate)?.toISOString(), } }, [debouncedSearch, selectedTypes, timeRange, customStartDate, customEndDate]) const { data, isLoading, isPlaceholderData, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, } = useAuditLogs(filters) const allEntries = useMemo(() => { if (!data?.pages) return [] return data.pages.flatMap((page) => page.data) }, [data]) const typeDisplayLabel = selectedTypes.length === 0 ? 'All types' : selectedTypes.length === 1 ? RESOURCE_TYPE_OPTIONS.find((t) => t.value === selectedTypes[0])?.label || '1 selected' : `${selectedTypes.length} types` const timeDisplayLabel = timeRange === 'Custom range' && customStartDate && customEndDate ? `${formatDateShort(customStartDate)} - ${formatDateShort(customEndDate)}` : timeRange const handleTimeRangeChange = (value: string) => { if (value === 'Custom range') { previousTimeRangeRef.current = timeRange setDatePickerOpen(true) } else { setCustomStartDate('') setCustomEndDate('') setTimeRange(value as TimeRange) } } const handleDateRangeApply = (start: string, end: string) => { dateRangeAppliedRef.current = true setCustomStartDate(start) setCustomEndDate(end) setTimeRange('Custom range') setDatePickerOpen(false) } const handleDatePickerCancel = () => { if (timeRange === 'Custom range' && !customStartDate) { setTimeRange(previousTimeRangeRef.current) } setDatePickerOpen(false) } const handleRefresh = useCallback(() => { setIsVisuallyRefreshing(true) const timerId = window.setTimeout(() => { setIsVisuallyRefreshing(false) refreshTimersRef.current.delete(timerId) }, REFRESH_SPINNER_DURATION_MS) refreshTimersRef.current.add(timerId) refetch().catch((error: unknown) => { logger.error('Failed to refresh audit logs', { error }) }) }, [refetch]) const handleLoadMore = useCallback(() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage().catch((error: unknown) => { logger.error('Failed to load more audit logs', { error }) }) } }, [hasNextPage, isFetchingNextPage, fetchNextPage]) const handleExportCsv = async () => { setIsExporting(true) try { const params = new URLSearchParams() if (filters.search) params.set('search', filters.search) if (filters.resourceType) params.set('resourceType', filters.resourceType) if (filters.startDate) params.set('startDate', filters.startDate) if (filters.endDate) params.set('endDate', filters.endDate) // boundary-raw-fetch: downloads a CSV blob and reads a response header before saving โ€” a plain anchor navigation can't do either const response = await fetch(`/api/audit-logs/export?${params.toString()}`) if (!response.ok) { toast.error('Failed to export audit logs') return } if (response.headers.get('X-Export-Truncated') === '1') { toast.info('Export truncated โ€” narrow the date range to see everything') } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv` document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(url) } finally { setIsExporting(false) } } return ( void handleExportCsv(), disabled: allEntries.length === 0 || isExporting || isPlaceholderData, }, ]} >
setSearchTerm(e.target.value)} />
{/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix DropdownMenu, modal by default) โ€” a modal trigger closing in the same tick that opens the Calendar popover below traps it behind the modal's focus lock, so "Custom range" silently did nothing. */} {timeDisplayLabel} } maxHeight={320} align='start' /> { if (!isOpen) { if (dateRangeAppliedRef.current) { dateRangeAppliedRef.current = false } else { handleDatePickerCancel() } } }} >
No results for "{debouncedSearch}" ) : ( No audit logs found ) } footer={ hasNextPage ? (
) : undefined } />
) }