@wakastellar/ui 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/list.d.ts +5 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/index.cjs +4844 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/utils/config.d.ts +29 -0
- package/dist/cli/utils/logger.d.ts +20 -0
- package/dist/cli/utils/registry.d.ts +23 -0
- package/package.json +14 -3
- package/src/blocks/activity-timeline/index.tsx +586 -0
- package/src/blocks/calendar-view/index.tsx +756 -0
- package/src/blocks/chat/index.tsx +1018 -0
- package/src/blocks/chat/widget.tsx +504 -0
- package/src/blocks/dashboard/index.tsx +522 -0
- package/src/blocks/empty-states/index.tsx +452 -0
- package/src/blocks/error-pages/index.tsx +426 -0
- package/src/blocks/faq/index.tsx +479 -0
- package/src/blocks/file-manager/index.tsx +890 -0
- package/src/blocks/footer/index.tsx +133 -0
- package/src/blocks/header/index.tsx +357 -0
- package/src/blocks/headtab/index.tsx +139 -0
- package/src/blocks/i18n-editor/index.tsx +1016 -0
- package/src/blocks/index.ts +80 -0
- package/src/blocks/kanban-board/index.tsx +779 -0
- package/src/blocks/landing/index.tsx +677 -0
- package/src/blocks/language-selector/index.tsx +88 -0
- package/src/blocks/layout/index.tsx +159 -0
- package/src/blocks/login/index.tsx +339 -0
- package/src/blocks/login/types.ts +131 -0
- package/src/blocks/pricing/index.tsx +564 -0
- package/src/blocks/profile/index.tsx +746 -0
- package/src/blocks/settings/index.tsx +558 -0
- package/src/blocks/sidebar/index.tsx +713 -0
- package/src/blocks/theme-creator-block/index.tsx +835 -0
- package/src/blocks/user-management/index.tsx +1037 -0
- package/src/blocks/wizard/index.tsx +719 -0
- package/src/components/DataTable/DataTable.tsx +406 -0
- package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
- package/src/components/DataTable/DataTableBody.tsx +216 -0
- package/src/components/DataTable/DataTableCell.tsx +172 -0
- package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
- package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
- package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
- package/src/components/DataTable/DataTableEditCell.tsx +279 -0
- package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
- package/src/components/DataTable/DataTableFilters.tsx +535 -0
- package/src/components/DataTable/DataTableGrouping.tsx +147 -0
- package/src/components/DataTable/DataTableHeader.tsx +172 -0
- package/src/components/DataTable/DataTablePagination.tsx +125 -0
- package/src/components/DataTable/DataTableSelection.tsx +269 -0
- package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
- package/src/components/DataTable/DataTableToolbar.tsx +262 -0
- package/src/components/DataTable/README.md +446 -0
- package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
- package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
- package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
- package/src/components/DataTable/examples/EditExample.tsx +166 -0
- package/src/components/DataTable/formatters/index.ts +335 -0
- package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
- package/src/components/DataTable/hooks/useDataTable.ts +145 -0
- package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
- package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
- package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
- package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
- package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
- package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
- package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
- package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
- package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
- package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
- package/src/components/DataTable/index.ts +81 -0
- package/src/components/DataTable/services/IndexedDBService.ts +504 -0
- package/src/components/DataTable/templates/index.tsx +803 -0
- package/src/components/DataTable/types.ts +504 -0
- package/src/components/DataTable/utils.ts +164 -0
- package/src/components/DataTable/workers/exportWorker.ts +213 -0
- package/src/components/accordion/index.tsx +61 -0
- package/src/components/alert/index.tsx +61 -0
- package/src/components/alert-dialog/index.tsx +146 -0
- package/src/components/aspect-ratio/index.tsx +12 -0
- package/src/components/avatar/index.tsx +54 -0
- package/src/components/badge/Badge.stories.tsx +64 -0
- package/src/components/badge/index.tsx +38 -0
- package/src/components/button/Button.stories.tsx +173 -0
- package/src/components/button/index.tsx +56 -0
- package/src/components/calendar/index.tsx +73 -0
- package/src/components/card/index.tsx +78 -0
- package/src/components/checkbox/index.tsx +34 -0
- package/src/components/code/index.tsx +229 -0
- package/src/components/collapsible/index.tsx +16 -0
- package/src/components/command/index.tsx +162 -0
- package/src/components/context-menu/index.tsx +204 -0
- package/src/components/dialog/index.tsx +126 -0
- package/src/components/dropdown-menu/index.tsx +204 -0
- package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
- package/src/components/error-boundary/index.ts +7 -0
- package/src/components/form/index.tsx +183 -0
- package/src/components/hover-card/index.tsx +33 -0
- package/src/components/index.ts +368 -0
- package/src/components/input/Input.stories.tsx +100 -0
- package/src/components/input/index.tsx +27 -0
- package/src/components/input-otp/index.tsx +277 -0
- package/src/components/label/index.tsx +30 -0
- package/src/components/language-selector/index.tsx +341 -0
- package/src/components/menubar/index.tsx +240 -0
- package/src/components/navigation-menu/index.tsx +134 -0
- package/src/components/popover/index.tsx +35 -0
- package/src/components/progress/index.tsx +32 -0
- package/src/components/radio-group/index.tsx +48 -0
- package/src/components/scroll-area/index.tsx +52 -0
- package/src/components/select/index.tsx +164 -0
- package/src/components/separator/index.tsx +35 -0
- package/src/components/sheet/index.tsx +147 -0
- package/src/components/skeleton/index.tsx +22 -0
- package/src/components/slider/index.tsx +32 -0
- package/src/components/switch/index.tsx +33 -0
- package/src/components/table/index.tsx +117 -0
- package/src/components/tabs/index.tsx +59 -0
- package/src/components/textarea/index.tsx +30 -0
- package/src/components/theme-selector/index.tsx +327 -0
- package/src/components/toast/index.tsx +133 -0
- package/src/components/toaster/index.tsx +34 -0
- package/src/components/toggle/index.tsx +49 -0
- package/src/components/tooltip/index.tsx +34 -0
- package/src/components/typography/index.tsx +276 -0
- package/src/components/waka-3d-pie-chart/index.tsx +486 -0
- package/src/components/waka-achievement-unlock/index.tsx +716 -0
- package/src/components/waka-activity-feed/index.tsx +686 -0
- package/src/components/waka-address-autocomplete/index.tsx +1202 -0
- package/src/components/waka-admincrumb/index.tsx +349 -0
- package/src/components/waka-alert-stack/index.tsx +827 -0
- package/src/components/waka-allocation-matrix/index.tsx +1278 -0
- package/src/components/waka-approval-chain/index.tsx +766 -0
- package/src/components/waka-audit-log/index.tsx +1475 -0
- package/src/components/waka-autocomplete/index.tsx +358 -0
- package/src/components/waka-badge-showcase/index.tsx +704 -0
- package/src/components/waka-barcode/index.tsx +260 -0
- package/src/components/waka-biometric-prompt/index.tsx +765 -0
- package/src/components/waka-bottom-sheet/index.tsx +495 -0
- package/src/components/waka-breadcrumb/index.tsx +376 -0
- package/src/components/waka-breadcrumb-path/index.tsx +513 -0
- package/src/components/waka-budget-burn/index.tsx +1234 -0
- package/src/components/waka-capacity-planner/index.tsx +1107 -0
- package/src/components/waka-carousel/index.tsx +893 -0
- package/src/components/waka-cart-summary/index.tsx +1055 -0
- package/src/components/waka-challenge-timer/index.tsx +1044 -0
- package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
- package/src/components/waka-charts/WakaBarChart.tsx +222 -0
- package/src/components/waka-charts/WakaChart.tsx +124 -0
- package/src/components/waka-charts/WakaLineChart.tsx +219 -0
- package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
- package/src/components/waka-charts/WakaPieChart.tsx +214 -0
- package/src/components/waka-charts/WakaSparkline.tsx +229 -0
- package/src/components/waka-charts/dataTableHelpers.ts +109 -0
- package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
- package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
- package/src/components/waka-charts/index.ts +90 -0
- package/src/components/waka-charts/types.ts +330 -0
- package/src/components/waka-chat-bubble/index.tsx +1060 -0
- package/src/components/waka-checklist/index.tsx +1067 -0
- package/src/components/waka-checkout-stepper/index.tsx +976 -0
- package/src/components/waka-cohort-table/index.tsx +1011 -0
- package/src/components/waka-color-picker/index.tsx +447 -0
- package/src/components/waka-combo-counter/index.tsx +864 -0
- package/src/components/waka-combobox/index.tsx +497 -0
- package/src/components/waka-command-bar/index.tsx +403 -0
- package/src/components/waka-compare-period/index.tsx +1230 -0
- package/src/components/waka-connection-matrix/index.tsx +1053 -0
- package/src/components/waka-contribution-graph/index.tsx +552 -0
- package/src/components/waka-cost-breakdown/index.tsx +1065 -0
- package/src/components/waka-coupon-input/index.tsx +592 -0
- package/src/components/waka-credit-card-input/index.tsx +982 -0
- package/src/components/waka-daily-reward/index.tsx +762 -0
- package/src/components/waka-date-range-picker/index.tsx +378 -0
- package/src/components/waka-datetime-picker/index.tsx +793 -0
- package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
- package/src/components/waka-deployment-lane/index.tsx +673 -0
- package/src/components/waka-device-trust/index.tsx +1259 -0
- package/src/components/waka-dock/index.tsx +285 -0
- package/src/components/waka-drawer/index.tsx +319 -0
- package/src/components/waka-empty-state/index.tsx +545 -0
- package/src/components/waka-error-shake/index.tsx +398 -0
- package/src/components/waka-feature-announcement/index.tsx +991 -0
- package/src/components/waka-file-upload/index.tsx +437 -0
- package/src/components/waka-floating-nav/index.tsx +413 -0
- package/src/components/waka-flow-diagram/index.tsx +508 -0
- package/src/components/waka-funnel-chart/index.tsx +823 -0
- package/src/components/waka-glow-card/index.tsx +246 -0
- package/src/components/waka-goal-progress/index.tsx +1025 -0
- package/src/components/waka-haptic-button/index.tsx +388 -0
- package/src/components/waka-health-pulse/index.tsx +451 -0
- package/src/components/waka-heatmap/index.tsx +1026 -0
- package/src/components/waka-hotspot/index.tsx +682 -0
- package/src/components/waka-image/index.tsx +373 -0
- package/src/components/waka-incident-timeline/index.tsx +686 -0
- package/src/components/waka-invoice-preview/index.tsx +829 -0
- package/src/components/waka-kanban/index.tsx +646 -0
- package/src/components/waka-kpi-dashboard/index.tsx +755 -0
- package/src/components/waka-leaderboard/index.tsx +746 -0
- package/src/components/waka-level-progress/index.tsx +665 -0
- package/src/components/waka-liquid-button/index.tsx +520 -0
- package/src/components/waka-loading-orbit/index.tsx +478 -0
- package/src/components/waka-loot-box/index.tsx +1091 -0
- package/src/components/waka-magic-link/index.tsx +321 -0
- package/src/components/waka-magnetic-button/index.tsx +567 -0
- package/src/components/waka-mention-input/index.tsx +953 -0
- package/src/components/waka-metric-sparkline/index.tsx +627 -0
- package/src/components/waka-milestone-road/index.tsx +1064 -0
- package/src/components/waka-modal/index.tsx +374 -0
- package/src/components/waka-morph-button/index.tsx +495 -0
- package/src/components/waka-network-topology/index.tsx +801 -0
- package/src/components/waka-notifications/index.tsx +414 -0
- package/src/components/waka-number-input/index.tsx +373 -0
- package/src/components/waka-orbital-menu/index.tsx +445 -0
- package/src/components/waka-order-tracker/index.tsx +1041 -0
- package/src/components/waka-pagination/index.tsx +393 -0
- package/src/components/waka-password-strength/index.tsx +824 -0
- package/src/components/waka-payment-method-picker/index.tsx +715 -0
- package/src/components/waka-permission-matrix/index.tsx +1302 -0
- package/src/components/waka-phone-input/index.tsx +801 -0
- package/src/components/waka-pipeline-view/index.tsx +604 -0
- package/src/components/waka-player-card/index.tsx +691 -0
- package/src/components/waka-points-popup/index.tsx +366 -0
- package/src/components/waka-power-up/index.tsx +1155 -0
- package/src/components/waka-presence-indicator/index.tsx +1181 -0
- package/src/components/waka-pricing-table/index.tsx +755 -0
- package/src/components/waka-product-card/index.tsx +786 -0
- package/src/components/waka-progress-onboarding/index.tsx +878 -0
- package/src/components/waka-pull-to-refresh/index.tsx +451 -0
- package/src/components/waka-qrcode/index.tsx +232 -0
- package/src/components/waka-quest-card/index.tsx +1275 -0
- package/src/components/waka-quota-bar/index.tsx +693 -0
- package/src/components/waka-radar-score/index.tsx +512 -0
- package/src/components/waka-rank-badge/index.tsx +813 -0
- package/src/components/waka-rating-input/index.tsx +560 -0
- package/src/components/waka-reaction-picker/index.tsx +1062 -0
- package/src/components/waka-region-map/index.tsx +730 -0
- package/src/components/waka-resource-gauge/index.tsx +654 -0
- package/src/components/waka-resource-pool/index.tsx +1035 -0
- package/src/components/waka-rich-text-editor/index.tsx +594 -0
- package/src/components/waka-rollback-slider/index.tsx +891 -0
- package/src/components/waka-sankey-diagram/index.tsx +1032 -0
- package/src/components/waka-schedule-picker/index.tsx +1060 -0
- package/src/components/waka-scratch-card/index.tsx +914 -0
- package/src/components/waka-season-pass/index.tsx +886 -0
- package/src/components/waka-security-score/index.tsx +1126 -0
- package/src/components/waka-segmented-control/index.tsx +238 -0
- package/src/components/waka-server-rack/index.tsx +764 -0
- package/src/components/waka-session-manager/index.tsx +815 -0
- package/src/components/waka-signature-pad/index.tsx +744 -0
- package/src/components/waka-skeleton-wave/index.tsx +454 -0
- package/src/components/waka-skill-tree/index.tsx +1031 -0
- package/src/components/waka-sla-tracker/index.tsx +798 -0
- package/src/components/waka-slider-range/index.tsx +765 -0
- package/src/components/waka-spin-wheel/index.tsx +671 -0
- package/src/components/waka-spinner/index.tsx +284 -0
- package/src/components/waka-spotlight/index.tsx +410 -0
- package/src/components/waka-stat/index.tsx +428 -0
- package/src/components/waka-stats-hexagon/index.tsx +824 -0
- package/src/components/waka-status-matrix/index.tsx +565 -0
- package/src/components/waka-stepper/index.tsx +489 -0
- package/src/components/waka-streak-counter/index.tsx +334 -0
- package/src/components/waka-success-explosion/index.tsx +453 -0
- package/src/components/waka-swipe-card/index.tsx +574 -0
- package/src/components/waka-tabs-morph/index.tsx +509 -0
- package/src/components/waka-tag-input/index.tsx +877 -0
- package/src/components/waka-team-banner/index.tsx +1183 -0
- package/src/components/waka-terminal-output/index.tsx +836 -0
- package/src/components/waka-theme-creator/index.tsx +762 -0
- package/src/components/waka-theme-manager/index.tsx +654 -0
- package/src/components/waka-thread-view/index.tsx +874 -0
- package/src/components/waka-tilt-card/index.tsx +250 -0
- package/src/components/waka-time-picker/index.tsx +479 -0
- package/src/components/waka-timeline/index.tsx +385 -0
- package/src/components/waka-tooltip-tour/index.tsx +855 -0
- package/src/components/waka-tour-guide/index.tsx +920 -0
- package/src/components/waka-tournament-bracket/index.tsx +1276 -0
- package/src/components/waka-tree/index.tsx +557 -0
- package/src/components/waka-treemap-chart/index.tsx +1031 -0
- package/src/components/waka-two-factor-setup/index.tsx +995 -0
- package/src/components/waka-typewriter/index.tsx +566 -0
- package/src/components/waka-typing-indicator/index.tsx +649 -0
- package/src/components/waka-versus-card/index.tsx +1026 -0
- package/src/components/waka-video/index.tsx +557 -0
- package/src/components/waka-video-call/index.tsx +1087 -0
- package/src/components/waka-virtual-list/index.tsx +327 -0
- package/src/components/waka-voice-message/index.tsx +1019 -0
- package/src/components/waka-welcome-modal/index.tsx +790 -0
- package/src/components/waka-xp-bar/index.tsx +799 -0
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import {
|
|
6
|
+
Activity,
|
|
7
|
+
LogIn,
|
|
8
|
+
LogOut,
|
|
9
|
+
Edit,
|
|
10
|
+
Trash2,
|
|
11
|
+
Plus,
|
|
12
|
+
Filter,
|
|
13
|
+
Search,
|
|
14
|
+
ChevronDown,
|
|
15
|
+
ChevronUp,
|
|
16
|
+
Download,
|
|
17
|
+
Eye,
|
|
18
|
+
Settings,
|
|
19
|
+
Shield,
|
|
20
|
+
Key,
|
|
21
|
+
UserPlus,
|
|
22
|
+
UserMinus,
|
|
23
|
+
RefreshCw,
|
|
24
|
+
AlertTriangle,
|
|
25
|
+
Info,
|
|
26
|
+
AlertCircle,
|
|
27
|
+
X,
|
|
28
|
+
Calendar,
|
|
29
|
+
MapPin,
|
|
30
|
+
Globe,
|
|
31
|
+
Loader2,
|
|
32
|
+
FileText,
|
|
33
|
+
Lock,
|
|
34
|
+
Unlock,
|
|
35
|
+
Mail,
|
|
36
|
+
CreditCard,
|
|
37
|
+
Upload,
|
|
38
|
+
Database,
|
|
39
|
+
Server,
|
|
40
|
+
} from "lucide-react"
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Types
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
export type AuditEventType =
|
|
47
|
+
| "login"
|
|
48
|
+
| "logout"
|
|
49
|
+
| "create"
|
|
50
|
+
| "update"
|
|
51
|
+
| "delete"
|
|
52
|
+
| "view"
|
|
53
|
+
| "export"
|
|
54
|
+
| "import"
|
|
55
|
+
| "settings_change"
|
|
56
|
+
| "permission_change"
|
|
57
|
+
| "password_change"
|
|
58
|
+
| "user_invite"
|
|
59
|
+
| "user_remove"
|
|
60
|
+
| "api_key_create"
|
|
61
|
+
| "api_key_revoke"
|
|
62
|
+
| "mfa_enable"
|
|
63
|
+
| "mfa_disable"
|
|
64
|
+
| "session_revoke"
|
|
65
|
+
| "file_upload"
|
|
66
|
+
| "file_download"
|
|
67
|
+
| "payment"
|
|
68
|
+
| "subscription_change"
|
|
69
|
+
| "data_export"
|
|
70
|
+
| "backup"
|
|
71
|
+
| "restore"
|
|
72
|
+
|
|
73
|
+
export type AuditSeverity = "info" | "warning" | "critical"
|
|
74
|
+
|
|
75
|
+
export interface AuditUser {
|
|
76
|
+
/** Unique user identifier */
|
|
77
|
+
id: string
|
|
78
|
+
/** Display name */
|
|
79
|
+
name: string
|
|
80
|
+
/** Email address */
|
|
81
|
+
email?: string
|
|
82
|
+
/** Avatar URL */
|
|
83
|
+
avatar?: string
|
|
84
|
+
/** User role */
|
|
85
|
+
role?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface AuditLocation {
|
|
89
|
+
/** City name */
|
|
90
|
+
city?: string
|
|
91
|
+
/** Country name */
|
|
92
|
+
country?: string
|
|
93
|
+
/** Country code */
|
|
94
|
+
countryCode?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AuditEvent {
|
|
98
|
+
/** Unique event identifier */
|
|
99
|
+
id: string
|
|
100
|
+
/** Event type */
|
|
101
|
+
type: AuditEventType
|
|
102
|
+
/** Severity level */
|
|
103
|
+
severity: AuditSeverity
|
|
104
|
+
/** User who performed the action */
|
|
105
|
+
user: AuditUser
|
|
106
|
+
/** Event description */
|
|
107
|
+
description: string
|
|
108
|
+
/** Detailed action information */
|
|
109
|
+
details?: Record<string, any>
|
|
110
|
+
/** IP address */
|
|
111
|
+
ipAddress?: string
|
|
112
|
+
/** User agent string */
|
|
113
|
+
userAgent?: string
|
|
114
|
+
/** Location information */
|
|
115
|
+
location?: AuditLocation
|
|
116
|
+
/** Resource affected (e.g., "User: john@example.com") */
|
|
117
|
+
resource?: string
|
|
118
|
+
/** When the event occurred */
|
|
119
|
+
timestamp: Date
|
|
120
|
+
/** Session ID */
|
|
121
|
+
sessionId?: string
|
|
122
|
+
/** Request ID for tracing */
|
|
123
|
+
requestId?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface AuditFilters {
|
|
127
|
+
/** Filter by event types */
|
|
128
|
+
eventTypes?: AuditEventType[]
|
|
129
|
+
/** Filter by severity levels */
|
|
130
|
+
severities?: AuditSeverity[]
|
|
131
|
+
/** Filter by user IDs */
|
|
132
|
+
userIds?: string[]
|
|
133
|
+
/** Search query */
|
|
134
|
+
search?: string
|
|
135
|
+
/** Start date */
|
|
136
|
+
startDate?: Date
|
|
137
|
+
/** End date */
|
|
138
|
+
endDate?: Date
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface WakaAuditLogProps {
|
|
142
|
+
/** List of audit events */
|
|
143
|
+
events: AuditEvent[]
|
|
144
|
+
/** Callback when an event is clicked */
|
|
145
|
+
onEventClick?: (event: AuditEvent) => void
|
|
146
|
+
/** Callback when filters change */
|
|
147
|
+
onFilterChange?: (filters: AuditFilters) => void
|
|
148
|
+
/** Callback when load more is triggered (pagination/infinite scroll) */
|
|
149
|
+
onLoadMore?: () => void
|
|
150
|
+
/** Callback when export is requested */
|
|
151
|
+
onExport?: (format: "csv" | "json") => void
|
|
152
|
+
/** Whether more events can be loaded */
|
|
153
|
+
hasMore?: boolean
|
|
154
|
+
/** Whether events are currently loading */
|
|
155
|
+
loading?: boolean
|
|
156
|
+
/** Whether to show filter controls */
|
|
157
|
+
showFilters?: boolean
|
|
158
|
+
/** Whether to show search */
|
|
159
|
+
showSearch?: boolean
|
|
160
|
+
/** Whether to show export button */
|
|
161
|
+
showExport?: boolean
|
|
162
|
+
/** Whether to use infinite scroll instead of pagination */
|
|
163
|
+
infiniteScroll?: boolean
|
|
164
|
+
/** Maximum number of events to display */
|
|
165
|
+
maxItems?: number
|
|
166
|
+
/** Available users for filtering */
|
|
167
|
+
users?: AuditUser[]
|
|
168
|
+
/** Custom className */
|
|
169
|
+
className?: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Event Type Configuration
|
|
174
|
+
// ============================================================================
|
|
175
|
+
|
|
176
|
+
const eventTypeConfig: Record<
|
|
177
|
+
AuditEventType,
|
|
178
|
+
{ icon: React.ElementType; color: string; bgColor: string; label: string }
|
|
179
|
+
> = {
|
|
180
|
+
login: {
|
|
181
|
+
icon: LogIn,
|
|
182
|
+
color: "text-green-600 dark:text-green-400",
|
|
183
|
+
bgColor: "bg-green-100 dark:bg-green-900/30",
|
|
184
|
+
label: "Login",
|
|
185
|
+
},
|
|
186
|
+
logout: {
|
|
187
|
+
icon: LogOut,
|
|
188
|
+
color: "text-gray-600 dark:text-gray-400",
|
|
189
|
+
bgColor: "bg-gray-100 dark:bg-gray-900/30",
|
|
190
|
+
label: "Logout",
|
|
191
|
+
},
|
|
192
|
+
create: {
|
|
193
|
+
icon: Plus,
|
|
194
|
+
color: "text-blue-600 dark:text-blue-400",
|
|
195
|
+
bgColor: "bg-blue-100 dark:bg-blue-900/30",
|
|
196
|
+
label: "Create",
|
|
197
|
+
},
|
|
198
|
+
update: {
|
|
199
|
+
icon: Edit,
|
|
200
|
+
color: "text-amber-600 dark:text-amber-400",
|
|
201
|
+
bgColor: "bg-amber-100 dark:bg-amber-900/30",
|
|
202
|
+
label: "Update",
|
|
203
|
+
},
|
|
204
|
+
delete: {
|
|
205
|
+
icon: Trash2,
|
|
206
|
+
color: "text-red-600 dark:text-red-400",
|
|
207
|
+
bgColor: "bg-red-100 dark:bg-red-900/30",
|
|
208
|
+
label: "Delete",
|
|
209
|
+
},
|
|
210
|
+
view: {
|
|
211
|
+
icon: Eye,
|
|
212
|
+
color: "text-purple-600 dark:text-purple-400",
|
|
213
|
+
bgColor: "bg-purple-100 dark:bg-purple-900/30",
|
|
214
|
+
label: "View",
|
|
215
|
+
},
|
|
216
|
+
export: {
|
|
217
|
+
icon: Download,
|
|
218
|
+
color: "text-indigo-600 dark:text-indigo-400",
|
|
219
|
+
bgColor: "bg-indigo-100 dark:bg-indigo-900/30",
|
|
220
|
+
label: "Export",
|
|
221
|
+
},
|
|
222
|
+
import: {
|
|
223
|
+
icon: Upload,
|
|
224
|
+
color: "text-indigo-600 dark:text-indigo-400",
|
|
225
|
+
bgColor: "bg-indigo-100 dark:bg-indigo-900/30",
|
|
226
|
+
label: "Import",
|
|
227
|
+
},
|
|
228
|
+
settings_change: {
|
|
229
|
+
icon: Settings,
|
|
230
|
+
color: "text-slate-600 dark:text-slate-400",
|
|
231
|
+
bgColor: "bg-slate-100 dark:bg-slate-900/30",
|
|
232
|
+
label: "Settings",
|
|
233
|
+
},
|
|
234
|
+
permission_change: {
|
|
235
|
+
icon: Shield,
|
|
236
|
+
color: "text-orange-600 dark:text-orange-400",
|
|
237
|
+
bgColor: "bg-orange-100 dark:bg-orange-900/30",
|
|
238
|
+
label: "Permission",
|
|
239
|
+
},
|
|
240
|
+
password_change: {
|
|
241
|
+
icon: Key,
|
|
242
|
+
color: "text-yellow-600 dark:text-yellow-400",
|
|
243
|
+
bgColor: "bg-yellow-100 dark:bg-yellow-900/30",
|
|
244
|
+
label: "Password",
|
|
245
|
+
},
|
|
246
|
+
user_invite: {
|
|
247
|
+
icon: UserPlus,
|
|
248
|
+
color: "text-teal-600 dark:text-teal-400",
|
|
249
|
+
bgColor: "bg-teal-100 dark:bg-teal-900/30",
|
|
250
|
+
label: "Invite",
|
|
251
|
+
},
|
|
252
|
+
user_remove: {
|
|
253
|
+
icon: UserMinus,
|
|
254
|
+
color: "text-rose-600 dark:text-rose-400",
|
|
255
|
+
bgColor: "bg-rose-100 dark:bg-rose-900/30",
|
|
256
|
+
label: "Remove",
|
|
257
|
+
},
|
|
258
|
+
api_key_create: {
|
|
259
|
+
icon: Key,
|
|
260
|
+
color: "text-cyan-600 dark:text-cyan-400",
|
|
261
|
+
bgColor: "bg-cyan-100 dark:bg-cyan-900/30",
|
|
262
|
+
label: "API Key Create",
|
|
263
|
+
},
|
|
264
|
+
api_key_revoke: {
|
|
265
|
+
icon: Key,
|
|
266
|
+
color: "text-red-600 dark:text-red-400",
|
|
267
|
+
bgColor: "bg-red-100 dark:bg-red-900/30",
|
|
268
|
+
label: "API Key Revoke",
|
|
269
|
+
},
|
|
270
|
+
mfa_enable: {
|
|
271
|
+
icon: Lock,
|
|
272
|
+
color: "text-emerald-600 dark:text-emerald-400",
|
|
273
|
+
bgColor: "bg-emerald-100 dark:bg-emerald-900/30",
|
|
274
|
+
label: "MFA Enable",
|
|
275
|
+
},
|
|
276
|
+
mfa_disable: {
|
|
277
|
+
icon: Unlock,
|
|
278
|
+
color: "text-orange-600 dark:text-orange-400",
|
|
279
|
+
bgColor: "bg-orange-100 dark:bg-orange-900/30",
|
|
280
|
+
label: "MFA Disable",
|
|
281
|
+
},
|
|
282
|
+
session_revoke: {
|
|
283
|
+
icon: RefreshCw,
|
|
284
|
+
color: "text-pink-600 dark:text-pink-400",
|
|
285
|
+
bgColor: "bg-pink-100 dark:bg-pink-900/30",
|
|
286
|
+
label: "Session Revoke",
|
|
287
|
+
},
|
|
288
|
+
file_upload: {
|
|
289
|
+
icon: Upload,
|
|
290
|
+
color: "text-violet-600 dark:text-violet-400",
|
|
291
|
+
bgColor: "bg-violet-100 dark:bg-violet-900/30",
|
|
292
|
+
label: "Upload",
|
|
293
|
+
},
|
|
294
|
+
file_download: {
|
|
295
|
+
icon: Download,
|
|
296
|
+
color: "text-violet-600 dark:text-violet-400",
|
|
297
|
+
bgColor: "bg-violet-100 dark:bg-violet-900/30",
|
|
298
|
+
label: "Download",
|
|
299
|
+
},
|
|
300
|
+
payment: {
|
|
301
|
+
icon: CreditCard,
|
|
302
|
+
color: "text-green-600 dark:text-green-400",
|
|
303
|
+
bgColor: "bg-green-100 dark:bg-green-900/30",
|
|
304
|
+
label: "Payment",
|
|
305
|
+
},
|
|
306
|
+
subscription_change: {
|
|
307
|
+
icon: CreditCard,
|
|
308
|
+
color: "text-blue-600 dark:text-blue-400",
|
|
309
|
+
bgColor: "bg-blue-100 dark:bg-blue-900/30",
|
|
310
|
+
label: "Subscription",
|
|
311
|
+
},
|
|
312
|
+
data_export: {
|
|
313
|
+
icon: Database,
|
|
314
|
+
color: "text-fuchsia-600 dark:text-fuchsia-400",
|
|
315
|
+
bgColor: "bg-fuchsia-100 dark:bg-fuchsia-900/30",
|
|
316
|
+
label: "Data Export",
|
|
317
|
+
},
|
|
318
|
+
backup: {
|
|
319
|
+
icon: Server,
|
|
320
|
+
color: "text-sky-600 dark:text-sky-400",
|
|
321
|
+
bgColor: "bg-sky-100 dark:bg-sky-900/30",
|
|
322
|
+
label: "Backup",
|
|
323
|
+
},
|
|
324
|
+
restore: {
|
|
325
|
+
icon: RefreshCw,
|
|
326
|
+
color: "text-sky-600 dark:text-sky-400",
|
|
327
|
+
bgColor: "bg-sky-100 dark:bg-sky-900/30",
|
|
328
|
+
label: "Restore",
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Severity Configuration
|
|
334
|
+
// ============================================================================
|
|
335
|
+
|
|
336
|
+
const severityConfig: Record<
|
|
337
|
+
AuditSeverity,
|
|
338
|
+
{ icon: React.ElementType; color: string; bgColor: string; borderColor: string; label: string }
|
|
339
|
+
> = {
|
|
340
|
+
info: {
|
|
341
|
+
icon: Info,
|
|
342
|
+
color: "text-blue-600 dark:text-blue-400",
|
|
343
|
+
bgColor: "bg-blue-100 dark:bg-blue-900/30",
|
|
344
|
+
borderColor: "border-blue-200 dark:border-blue-800",
|
|
345
|
+
label: "Info",
|
|
346
|
+
},
|
|
347
|
+
warning: {
|
|
348
|
+
icon: AlertTriangle,
|
|
349
|
+
color: "text-amber-600 dark:text-amber-400",
|
|
350
|
+
bgColor: "bg-amber-100 dark:bg-amber-900/30",
|
|
351
|
+
borderColor: "border-amber-200 dark:border-amber-800",
|
|
352
|
+
label: "Warning",
|
|
353
|
+
},
|
|
354
|
+
critical: {
|
|
355
|
+
icon: AlertCircle,
|
|
356
|
+
color: "text-red-600 dark:text-red-400",
|
|
357
|
+
bgColor: "bg-red-100 dark:bg-red-900/30",
|
|
358
|
+
borderColor: "border-red-200 dark:border-red-800",
|
|
359
|
+
label: "Critical",
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ============================================================================
|
|
364
|
+
// Helper Functions
|
|
365
|
+
// ============================================================================
|
|
366
|
+
|
|
367
|
+
function formatRelativeTime(date: Date): string {
|
|
368
|
+
const now = new Date()
|
|
369
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
370
|
+
|
|
371
|
+
if (diffInSeconds < 60) {
|
|
372
|
+
return "just now"
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const diffInMinutes = Math.floor(diffInSeconds / 60)
|
|
376
|
+
if (diffInMinutes < 60) {
|
|
377
|
+
return `${diffInMinutes}m ago`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const diffInHours = Math.floor(diffInMinutes / 60)
|
|
381
|
+
if (diffInHours < 24) {
|
|
382
|
+
return `${diffInHours}h ago`
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const diffInDays = Math.floor(diffInHours / 24)
|
|
386
|
+
if (diffInDays < 7) {
|
|
387
|
+
return `${diffInDays}d ago`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const diffInWeeks = Math.floor(diffInDays / 7)
|
|
391
|
+
if (diffInWeeks < 4) {
|
|
392
|
+
return `${diffInWeeks}w ago`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return date.toLocaleDateString()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function formatFullTimestamp(date: Date): string {
|
|
399
|
+
return date.toLocaleString(undefined, {
|
|
400
|
+
year: "numeric",
|
|
401
|
+
month: "short",
|
|
402
|
+
day: "numeric",
|
|
403
|
+
hour: "2-digit",
|
|
404
|
+
minute: "2-digit",
|
|
405
|
+
second: "2-digit",
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatLocation(location?: AuditLocation): string | null {
|
|
410
|
+
if (!location) return null
|
|
411
|
+
const parts = []
|
|
412
|
+
if (location.city) parts.push(location.city)
|
|
413
|
+
if (location.country) parts.push(location.country)
|
|
414
|
+
return parts.length > 0 ? parts.join(", ") : null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ============================================================================
|
|
418
|
+
// Avatar Component
|
|
419
|
+
// ============================================================================
|
|
420
|
+
|
|
421
|
+
interface AvatarProps {
|
|
422
|
+
user: AuditUser
|
|
423
|
+
size?: "sm" | "md" | "lg"
|
|
424
|
+
className?: string
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function Avatar({ user, size = "md", className }: AvatarProps) {
|
|
428
|
+
const sizeClasses = {
|
|
429
|
+
sm: "h-6 w-6 text-[10px]",
|
|
430
|
+
md: "h-10 w-10 text-sm",
|
|
431
|
+
lg: "h-12 w-12 text-base",
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const getInitials = (name: string) => {
|
|
435
|
+
return name
|
|
436
|
+
.split(" ")
|
|
437
|
+
.map((n) => n[0])
|
|
438
|
+
.join("")
|
|
439
|
+
.toUpperCase()
|
|
440
|
+
.slice(0, 2)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<div
|
|
445
|
+
className={cn(
|
|
446
|
+
"relative flex items-center justify-center rounded-full font-semibold flex-shrink-0",
|
|
447
|
+
sizeClasses[size],
|
|
448
|
+
user.avatar ? "" : "bg-muted text-muted-foreground",
|
|
449
|
+
className
|
|
450
|
+
)}
|
|
451
|
+
>
|
|
452
|
+
{user.avatar ? (
|
|
453
|
+
<img
|
|
454
|
+
src={user.avatar}
|
|
455
|
+
alt={user.name}
|
|
456
|
+
className="h-full w-full rounded-full object-cover"
|
|
457
|
+
/>
|
|
458
|
+
) : (
|
|
459
|
+
getInitials(user.name)
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============================================================================
|
|
466
|
+
// Event Icon Component
|
|
467
|
+
// ============================================================================
|
|
468
|
+
|
|
469
|
+
interface EventIconProps {
|
|
470
|
+
type: AuditEventType
|
|
471
|
+
severity: AuditSeverity
|
|
472
|
+
className?: string
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function EventIcon({ type, severity, className }: EventIconProps) {
|
|
476
|
+
const config = eventTypeConfig[type]
|
|
477
|
+
const Icon = config.icon
|
|
478
|
+
|
|
479
|
+
// Use severity color for warning/critical events
|
|
480
|
+
const colorClass = severity === "info" ? config.color : severityConfig[severity].color
|
|
481
|
+
const bgColorClass = severity === "info" ? config.bgColor : severityConfig[severity].bgColor
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div
|
|
485
|
+
className={cn(
|
|
486
|
+
"flex h-9 w-9 items-center justify-center rounded-full flex-shrink-0",
|
|
487
|
+
bgColorClass,
|
|
488
|
+
className
|
|
489
|
+
)}
|
|
490
|
+
>
|
|
491
|
+
<Icon className={cn("h-4 w-4", colorClass)} />
|
|
492
|
+
</div>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ============================================================================
|
|
497
|
+
// Severity Badge Component
|
|
498
|
+
// ============================================================================
|
|
499
|
+
|
|
500
|
+
interface SeverityBadgeProps {
|
|
501
|
+
severity: AuditSeverity
|
|
502
|
+
className?: string
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function SeverityBadge({ severity, className }: SeverityBadgeProps) {
|
|
506
|
+
const config = severityConfig[severity]
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<span
|
|
510
|
+
className={cn(
|
|
511
|
+
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
|
512
|
+
config.bgColor,
|
|
513
|
+
config.color,
|
|
514
|
+
className
|
|
515
|
+
)}
|
|
516
|
+
>
|
|
517
|
+
{config.label}
|
|
518
|
+
</span>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ============================================================================
|
|
523
|
+
// Event Details Component
|
|
524
|
+
// ============================================================================
|
|
525
|
+
|
|
526
|
+
interface EventDetailsProps {
|
|
527
|
+
event: AuditEvent
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function EventDetails({ event }: EventDetailsProps) {
|
|
531
|
+
const locationText = formatLocation(event.location)
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<div className="mt-3 p-3 bg-muted/50 rounded-lg text-sm space-y-2 animate-in slide-in-from-top-2 fade-in duration-200">
|
|
535
|
+
{/* Full timestamp */}
|
|
536
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
537
|
+
<Calendar className="h-3.5 w-3.5" />
|
|
538
|
+
<span>{formatFullTimestamp(new Date(event.timestamp))}</span>
|
|
539
|
+
</div>
|
|
540
|
+
|
|
541
|
+
{/* IP Address */}
|
|
542
|
+
{event.ipAddress && (
|
|
543
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
544
|
+
<Globe className="h-3.5 w-3.5" />
|
|
545
|
+
<span>IP: {event.ipAddress}</span>
|
|
546
|
+
</div>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{/* Location */}
|
|
550
|
+
{locationText && (
|
|
551
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
552
|
+
<MapPin className="h-3.5 w-3.5" />
|
|
553
|
+
<span>{locationText}</span>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
|
|
557
|
+
{/* Resource */}
|
|
558
|
+
{event.resource && (
|
|
559
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
560
|
+
<FileText className="h-3.5 w-3.5" />
|
|
561
|
+
<span>Resource: {event.resource}</span>
|
|
562
|
+
</div>
|
|
563
|
+
)}
|
|
564
|
+
|
|
565
|
+
{/* Session ID */}
|
|
566
|
+
{event.sessionId && (
|
|
567
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
568
|
+
<Key className="h-3.5 w-3.5" />
|
|
569
|
+
<span className="font-mono text-xs">Session: {event.sessionId}</span>
|
|
570
|
+
</div>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{/* Request ID */}
|
|
574
|
+
{event.requestId && (
|
|
575
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
576
|
+
<Activity className="h-3.5 w-3.5" />
|
|
577
|
+
<span className="font-mono text-xs">Request: {event.requestId}</span>
|
|
578
|
+
</div>
|
|
579
|
+
)}
|
|
580
|
+
|
|
581
|
+
{/* User Agent */}
|
|
582
|
+
{event.userAgent && (
|
|
583
|
+
<div className="text-muted-foreground">
|
|
584
|
+
<span className="text-xs break-all">{event.userAgent}</span>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
|
|
588
|
+
{/* Additional Details */}
|
|
589
|
+
{event.details && Object.keys(event.details).length > 0 && (
|
|
590
|
+
<div className="pt-2 border-t border-border">
|
|
591
|
+
<div className="text-xs font-medium text-muted-foreground mb-2">Details</div>
|
|
592
|
+
<div className="space-y-1">
|
|
593
|
+
{Object.entries(event.details).map(([key, value]) => (
|
|
594
|
+
<div key={key} className="flex items-start gap-2 text-xs">
|
|
595
|
+
<span className="text-muted-foreground font-medium min-w-[80px]">{key}:</span>
|
|
596
|
+
<span className="text-foreground break-all">
|
|
597
|
+
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
|
598
|
+
</span>
|
|
599
|
+
</div>
|
|
600
|
+
))}
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ============================================================================
|
|
609
|
+
// Event Item Component
|
|
610
|
+
// ============================================================================
|
|
611
|
+
|
|
612
|
+
interface EventItemProps {
|
|
613
|
+
event: AuditEvent
|
|
614
|
+
onEventClick?: (event: AuditEvent) => void
|
|
615
|
+
isNew?: boolean
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function EventItem({ event, onEventClick, isNew }: EventItemProps) {
|
|
619
|
+
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
620
|
+
const config = eventTypeConfig[event.type]
|
|
621
|
+
const severityConf = severityConfig[event.severity]
|
|
622
|
+
const locationText = formatLocation(event.location)
|
|
623
|
+
|
|
624
|
+
const hasDetails =
|
|
625
|
+
event.details ||
|
|
626
|
+
event.ipAddress ||
|
|
627
|
+
event.userAgent ||
|
|
628
|
+
event.sessionId ||
|
|
629
|
+
event.requestId ||
|
|
630
|
+
event.location
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div
|
|
634
|
+
className={cn(
|
|
635
|
+
"relative flex gap-3 p-4 rounded-lg border bg-card transition-all duration-300",
|
|
636
|
+
"hover:bg-accent/50 hover:shadow-sm",
|
|
637
|
+
event.severity === "critical" && "border-l-4 border-l-red-500",
|
|
638
|
+
event.severity === "warning" && "border-l-4 border-l-amber-500",
|
|
639
|
+
isNew && "animate-in slide-in-from-left-5 fade-in duration-500"
|
|
640
|
+
)}
|
|
641
|
+
>
|
|
642
|
+
{/* Timeline indicator */}
|
|
643
|
+
<div className="flex flex-col items-center gap-2">
|
|
644
|
+
<EventIcon type={event.type} severity={event.severity} />
|
|
645
|
+
<div className="flex-1 w-px bg-border" />
|
|
646
|
+
</div>
|
|
647
|
+
|
|
648
|
+
{/* Content */}
|
|
649
|
+
<div className="flex-1 min-w-0">
|
|
650
|
+
{/* Header */}
|
|
651
|
+
<div className="flex items-start justify-between gap-2 mb-1">
|
|
652
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
653
|
+
<span
|
|
654
|
+
className={cn(
|
|
655
|
+
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
|
|
656
|
+
config.bgColor,
|
|
657
|
+
config.color
|
|
658
|
+
)}
|
|
659
|
+
>
|
|
660
|
+
{config.label}
|
|
661
|
+
</span>
|
|
662
|
+
{event.severity !== "info" && <SeverityBadge severity={event.severity} />}
|
|
663
|
+
</div>
|
|
664
|
+
<span className="flex-shrink-0 text-xs text-muted-foreground">
|
|
665
|
+
{formatRelativeTime(new Date(event.timestamp))}
|
|
666
|
+
</span>
|
|
667
|
+
</div>
|
|
668
|
+
|
|
669
|
+
{/* Description */}
|
|
670
|
+
<p className="text-sm text-foreground mb-2">{event.description}</p>
|
|
671
|
+
|
|
672
|
+
{/* User info */}
|
|
673
|
+
<div className="flex items-center gap-2 mb-2">
|
|
674
|
+
<Avatar user={event.user} size="sm" />
|
|
675
|
+
<div className="flex flex-col">
|
|
676
|
+
<span className="text-sm font-medium">{event.user.name}</span>
|
|
677
|
+
{event.user.email && (
|
|
678
|
+
<span className="text-xs text-muted-foreground">{event.user.email}</span>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
{event.user.role && (
|
|
682
|
+
<span className="ml-2 text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
|
683
|
+
{event.user.role}
|
|
684
|
+
</span>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
|
|
688
|
+
{/* Quick info row */}
|
|
689
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
|
690
|
+
{event.ipAddress && (
|
|
691
|
+
<span className="inline-flex items-center gap-1">
|
|
692
|
+
<Globe className="h-3 w-3" />
|
|
693
|
+
{event.ipAddress}
|
|
694
|
+
</span>
|
|
695
|
+
)}
|
|
696
|
+
{locationText && (
|
|
697
|
+
<span className="inline-flex items-center gap-1">
|
|
698
|
+
<MapPin className="h-3 w-3" />
|
|
699
|
+
{locationText}
|
|
700
|
+
</span>
|
|
701
|
+
)}
|
|
702
|
+
{event.resource && (
|
|
703
|
+
<span className="inline-flex items-center gap-1">
|
|
704
|
+
<FileText className="h-3 w-3" />
|
|
705
|
+
{event.resource}
|
|
706
|
+
</span>
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
709
|
+
|
|
710
|
+
{/* Expandable details */}
|
|
711
|
+
{hasDetails && (
|
|
712
|
+
<div className="mt-2">
|
|
713
|
+
<button
|
|
714
|
+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
715
|
+
onClick={(e) => {
|
|
716
|
+
e.stopPropagation()
|
|
717
|
+
setIsExpanded(!isExpanded)
|
|
718
|
+
}}
|
|
719
|
+
>
|
|
720
|
+
{isExpanded ? (
|
|
721
|
+
<ChevronUp className="h-3 w-3" />
|
|
722
|
+
) : (
|
|
723
|
+
<ChevronDown className="h-3 w-3" />
|
|
724
|
+
)}
|
|
725
|
+
{isExpanded ? "Hide details" : "Show details"}
|
|
726
|
+
</button>
|
|
727
|
+
|
|
728
|
+
{isExpanded && <EventDetails event={event} />}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
731
|
+
</div>
|
|
732
|
+
|
|
733
|
+
{/* Click handler */}
|
|
734
|
+
{onEventClick && (
|
|
735
|
+
<button
|
|
736
|
+
className="absolute inset-0 opacity-0"
|
|
737
|
+
onClick={() => onEventClick(event)}
|
|
738
|
+
aria-label={`View details for ${event.description}`}
|
|
739
|
+
/>
|
|
740
|
+
)}
|
|
741
|
+
</div>
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ============================================================================
|
|
746
|
+
// Filter Bar Component
|
|
747
|
+
// ============================================================================
|
|
748
|
+
|
|
749
|
+
interface FilterBarProps {
|
|
750
|
+
filters: AuditFilters
|
|
751
|
+
onFilterChange: (filters: AuditFilters) => void
|
|
752
|
+
users?: AuditUser[]
|
|
753
|
+
showSearch?: boolean
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function FilterBar({ filters, onFilterChange, users, showSearch }: FilterBarProps) {
|
|
757
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
758
|
+
const [searchValue, setSearchValue] = React.useState(filters.search || "")
|
|
759
|
+
|
|
760
|
+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
761
|
+
const value = e.target.value
|
|
762
|
+
setSearchValue(value)
|
|
763
|
+
onFilterChange({ ...filters, search: value || undefined })
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const toggleEventType = (type: AuditEventType) => {
|
|
767
|
+
const currentTypes = filters.eventTypes || []
|
|
768
|
+
const newTypes = currentTypes.includes(type)
|
|
769
|
+
? currentTypes.filter((t) => t !== type)
|
|
770
|
+
: [...currentTypes, type]
|
|
771
|
+
onFilterChange({ ...filters, eventTypes: newTypes.length > 0 ? newTypes : undefined })
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const toggleSeverity = (severity: AuditSeverity) => {
|
|
775
|
+
const currentSeverities = filters.severities || []
|
|
776
|
+
const newSeverities = currentSeverities.includes(severity)
|
|
777
|
+
? currentSeverities.filter((s) => s !== severity)
|
|
778
|
+
: [...currentSeverities, severity]
|
|
779
|
+
onFilterChange({
|
|
780
|
+
...filters,
|
|
781
|
+
severities: newSeverities.length > 0 ? newSeverities : undefined,
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const toggleUser = (userId: string) => {
|
|
786
|
+
const currentUsers = filters.userIds || []
|
|
787
|
+
const newUsers = currentUsers.includes(userId)
|
|
788
|
+
? currentUsers.filter((u) => u !== userId)
|
|
789
|
+
: [...currentUsers, userId]
|
|
790
|
+
onFilterChange({ ...filters, userIds: newUsers.length > 0 ? newUsers : undefined })
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const clearFilters = () => {
|
|
794
|
+
setSearchValue("")
|
|
795
|
+
onFilterChange({})
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const hasActiveFilters =
|
|
799
|
+
(filters.eventTypes && filters.eventTypes.length > 0) ||
|
|
800
|
+
(filters.severities && filters.severities.length > 0) ||
|
|
801
|
+
(filters.userIds && filters.userIds.length > 0) ||
|
|
802
|
+
filters.search ||
|
|
803
|
+
filters.startDate ||
|
|
804
|
+
filters.endDate
|
|
805
|
+
|
|
806
|
+
const eventTypeGroups = [
|
|
807
|
+
{
|
|
808
|
+
label: "Authentication",
|
|
809
|
+
types: ["login", "logout", "password_change", "mfa_enable", "mfa_disable", "session_revoke"] as AuditEventType[],
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
label: "Data",
|
|
813
|
+
types: ["create", "update", "delete", "view", "export", "import", "data_export"] as AuditEventType[],
|
|
814
|
+
},
|
|
815
|
+
{
|
|
816
|
+
label: "Users",
|
|
817
|
+
types: ["user_invite", "user_remove", "permission_change"] as AuditEventType[],
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
label: "System",
|
|
821
|
+
types: ["settings_change", "api_key_create", "api_key_revoke", "backup", "restore"] as AuditEventType[],
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
label: "Files",
|
|
825
|
+
types: ["file_upload", "file_download"] as AuditEventType[],
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
label: "Billing",
|
|
829
|
+
types: ["payment", "subscription_change"] as AuditEventType[],
|
|
830
|
+
},
|
|
831
|
+
]
|
|
832
|
+
|
|
833
|
+
return (
|
|
834
|
+
<div className="space-y-3 mb-4">
|
|
835
|
+
{/* Search and filter toggle */}
|
|
836
|
+
<div className="flex items-center gap-2">
|
|
837
|
+
{showSearch && (
|
|
838
|
+
<div className="relative flex-1">
|
|
839
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
840
|
+
<input
|
|
841
|
+
type="text"
|
|
842
|
+
placeholder="Search audit logs..."
|
|
843
|
+
value={searchValue}
|
|
844
|
+
onChange={handleSearchChange}
|
|
845
|
+
className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
|
846
|
+
/>
|
|
847
|
+
</div>
|
|
848
|
+
)}
|
|
849
|
+
|
|
850
|
+
<button
|
|
851
|
+
className={cn(
|
|
852
|
+
"inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border transition-colors",
|
|
853
|
+
isOpen || hasActiveFilters
|
|
854
|
+
? "bg-primary text-primary-foreground"
|
|
855
|
+
: "bg-background hover:bg-muted"
|
|
856
|
+
)}
|
|
857
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
858
|
+
>
|
|
859
|
+
<Filter className="h-4 w-4" />
|
|
860
|
+
Filters
|
|
861
|
+
{hasActiveFilters && (
|
|
862
|
+
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary-foreground text-primary text-xs">
|
|
863
|
+
{(filters.eventTypes?.length || 0) +
|
|
864
|
+
(filters.severities?.length || 0) +
|
|
865
|
+
(filters.userIds?.length || 0)}
|
|
866
|
+
</span>
|
|
867
|
+
)}
|
|
868
|
+
</button>
|
|
869
|
+
|
|
870
|
+
{hasActiveFilters && (
|
|
871
|
+
<button
|
|
872
|
+
className="inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg border bg-background hover:bg-muted transition-colors"
|
|
873
|
+
onClick={clearFilters}
|
|
874
|
+
>
|
|
875
|
+
<X className="h-4 w-4" />
|
|
876
|
+
Clear
|
|
877
|
+
</button>
|
|
878
|
+
)}
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
{/* Filter panel */}
|
|
882
|
+
{isOpen && (
|
|
883
|
+
<div className="p-4 bg-muted/50 rounded-lg border space-y-4 animate-in slide-in-from-top-2 fade-in duration-200">
|
|
884
|
+
{/* Severity filters */}
|
|
885
|
+
<div>
|
|
886
|
+
<div className="text-xs font-medium text-muted-foreground mb-2">Severity</div>
|
|
887
|
+
<div className="flex flex-wrap gap-2">
|
|
888
|
+
{(Object.keys(severityConfig) as AuditSeverity[]).map((severity) => {
|
|
889
|
+
const config = severityConfig[severity]
|
|
890
|
+
const isActive = filters.severities?.includes(severity)
|
|
891
|
+
|
|
892
|
+
return (
|
|
893
|
+
<button
|
|
894
|
+
key={severity}
|
|
895
|
+
className={cn(
|
|
896
|
+
"px-3 py-1.5 rounded-lg text-xs font-medium transition-all",
|
|
897
|
+
isActive
|
|
898
|
+
? cn(config.bgColor, config.color, "ring-2 ring-offset-1", config.borderColor)
|
|
899
|
+
: "bg-background hover:bg-muted text-muted-foreground border"
|
|
900
|
+
)}
|
|
901
|
+
onClick={() => toggleSeverity(severity)}
|
|
902
|
+
>
|
|
903
|
+
{config.label}
|
|
904
|
+
</button>
|
|
905
|
+
)
|
|
906
|
+
})}
|
|
907
|
+
</div>
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
{/* Event type filters */}
|
|
911
|
+
<div>
|
|
912
|
+
<div className="text-xs font-medium text-muted-foreground mb-2">Event Types</div>
|
|
913
|
+
<div className="space-y-3">
|
|
914
|
+
{eventTypeGroups.map((group) => (
|
|
915
|
+
<div key={group.label}>
|
|
916
|
+
<div className="text-xs text-muted-foreground mb-1.5">{group.label}</div>
|
|
917
|
+
<div className="flex flex-wrap gap-1.5">
|
|
918
|
+
{group.types.map((type) => {
|
|
919
|
+
const config = eventTypeConfig[type]
|
|
920
|
+
const isActive = filters.eventTypes?.includes(type)
|
|
921
|
+
|
|
922
|
+
return (
|
|
923
|
+
<button
|
|
924
|
+
key={type}
|
|
925
|
+
className={cn(
|
|
926
|
+
"px-2 py-1 rounded text-xs font-medium transition-all",
|
|
927
|
+
isActive
|
|
928
|
+
? cn(config.bgColor, config.color)
|
|
929
|
+
: "bg-background hover:bg-muted text-muted-foreground border"
|
|
930
|
+
)}
|
|
931
|
+
onClick={() => toggleEventType(type)}
|
|
932
|
+
>
|
|
933
|
+
{config.label}
|
|
934
|
+
</button>
|
|
935
|
+
)
|
|
936
|
+
})}
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
))}
|
|
940
|
+
</div>
|
|
941
|
+
</div>
|
|
942
|
+
|
|
943
|
+
{/* User filters */}
|
|
944
|
+
{users && users.length > 0 && (
|
|
945
|
+
<div>
|
|
946
|
+
<div className="text-xs font-medium text-muted-foreground mb-2">Users</div>
|
|
947
|
+
<div className="flex flex-wrap gap-2">
|
|
948
|
+
{users.map((user) => {
|
|
949
|
+
const isActive = filters.userIds?.includes(user.id)
|
|
950
|
+
|
|
951
|
+
return (
|
|
952
|
+
<button
|
|
953
|
+
key={user.id}
|
|
954
|
+
className={cn(
|
|
955
|
+
"inline-flex items-center gap-2 px-2 py-1 rounded-lg text-xs font-medium transition-all",
|
|
956
|
+
isActive
|
|
957
|
+
? "bg-primary text-primary-foreground"
|
|
958
|
+
: "bg-background hover:bg-muted text-muted-foreground border"
|
|
959
|
+
)}
|
|
960
|
+
onClick={() => toggleUser(user.id)}
|
|
961
|
+
>
|
|
962
|
+
<Avatar user={user} size="sm" className="h-4 w-4 text-[8px]" />
|
|
963
|
+
{user.name}
|
|
964
|
+
</button>
|
|
965
|
+
)
|
|
966
|
+
})}
|
|
967
|
+
</div>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
|
|
971
|
+
{/* Date range filters */}
|
|
972
|
+
<div>
|
|
973
|
+
<div className="text-xs font-medium text-muted-foreground mb-2">Date Range</div>
|
|
974
|
+
<div className="flex items-center gap-2">
|
|
975
|
+
<input
|
|
976
|
+
type="date"
|
|
977
|
+
value={filters.startDate?.toISOString().split("T")[0] || ""}
|
|
978
|
+
onChange={(e) =>
|
|
979
|
+
onFilterChange({
|
|
980
|
+
...filters,
|
|
981
|
+
startDate: e.target.value ? new Date(e.target.value) : undefined,
|
|
982
|
+
})
|
|
983
|
+
}
|
|
984
|
+
className="px-3 py-1.5 text-xs rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
|
985
|
+
/>
|
|
986
|
+
<span className="text-xs text-muted-foreground">to</span>
|
|
987
|
+
<input
|
|
988
|
+
type="date"
|
|
989
|
+
value={filters.endDate?.toISOString().split("T")[0] || ""}
|
|
990
|
+
onChange={(e) =>
|
|
991
|
+
onFilterChange({
|
|
992
|
+
...filters,
|
|
993
|
+
endDate: e.target.value ? new Date(e.target.value) : undefined,
|
|
994
|
+
})
|
|
995
|
+
}
|
|
996
|
+
className="px-3 py-1.5 text-xs rounded-lg border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
|
997
|
+
/>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
)}
|
|
1002
|
+
</div>
|
|
1003
|
+
)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ============================================================================
|
|
1007
|
+
// Export Menu Component
|
|
1008
|
+
// ============================================================================
|
|
1009
|
+
|
|
1010
|
+
interface ExportMenuProps {
|
|
1011
|
+
onExport: (format: "csv" | "json") => void
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function ExportMenu({ onExport }: ExportMenuProps) {
|
|
1015
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
1016
|
+
|
|
1017
|
+
return (
|
|
1018
|
+
<div className="relative">
|
|
1019
|
+
<button
|
|
1020
|
+
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border bg-background hover:bg-muted transition-colors"
|
|
1021
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
1022
|
+
>
|
|
1023
|
+
<Download className="h-4 w-4" />
|
|
1024
|
+
Export
|
|
1025
|
+
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
|
|
1026
|
+
</button>
|
|
1027
|
+
|
|
1028
|
+
{isOpen && (
|
|
1029
|
+
<>
|
|
1030
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
|
|
1031
|
+
<div className="absolute right-0 top-full mt-1 z-20 w-40 bg-popover border rounded-lg shadow-lg overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
|
|
1032
|
+
<button
|
|
1033
|
+
className="w-full px-3 py-2 text-sm text-left hover:bg-muted transition-colors flex items-center gap-2"
|
|
1034
|
+
onClick={() => {
|
|
1035
|
+
onExport("csv")
|
|
1036
|
+
setIsOpen(false)
|
|
1037
|
+
}}
|
|
1038
|
+
>
|
|
1039
|
+
<FileText className="h-4 w-4" />
|
|
1040
|
+
Export as CSV
|
|
1041
|
+
</button>
|
|
1042
|
+
<button
|
|
1043
|
+
className="w-full px-3 py-2 text-sm text-left hover:bg-muted transition-colors flex items-center gap-2"
|
|
1044
|
+
onClick={() => {
|
|
1045
|
+
onExport("json")
|
|
1046
|
+
setIsOpen(false)
|
|
1047
|
+
}}
|
|
1048
|
+
>
|
|
1049
|
+
<FileText className="h-4 w-4" />
|
|
1050
|
+
Export as JSON
|
|
1051
|
+
</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
</>
|
|
1054
|
+
)}
|
|
1055
|
+
</div>
|
|
1056
|
+
)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ============================================================================
|
|
1060
|
+
// Main Component
|
|
1061
|
+
// ============================================================================
|
|
1062
|
+
|
|
1063
|
+
export function WakaAuditLog({
|
|
1064
|
+
events,
|
|
1065
|
+
onEventClick,
|
|
1066
|
+
onFilterChange,
|
|
1067
|
+
onLoadMore,
|
|
1068
|
+
onExport,
|
|
1069
|
+
hasMore = false,
|
|
1070
|
+
loading = false,
|
|
1071
|
+
showFilters = true,
|
|
1072
|
+
showSearch = true,
|
|
1073
|
+
showExport = true,
|
|
1074
|
+
infiniteScroll = false,
|
|
1075
|
+
maxItems,
|
|
1076
|
+
users,
|
|
1077
|
+
className,
|
|
1078
|
+
}: WakaAuditLogProps) {
|
|
1079
|
+
const [filters, setFilters] = React.useState<AuditFilters>({})
|
|
1080
|
+
const [newEventIds, setNewEventIds] = React.useState<Set<string>>(new Set())
|
|
1081
|
+
const prevEventIdsRef = React.useRef<Set<string>>(new Set())
|
|
1082
|
+
const observerRef = React.useRef<IntersectionObserver | null>(null)
|
|
1083
|
+
const loadMoreRef = React.useRef<HTMLDivElement>(null)
|
|
1084
|
+
|
|
1085
|
+
// Track new events for animation
|
|
1086
|
+
React.useEffect(() => {
|
|
1087
|
+
const currentIds = new Set(events.map((e) => e.id))
|
|
1088
|
+
const newIds = new Set<string>()
|
|
1089
|
+
|
|
1090
|
+
currentIds.forEach((id) => {
|
|
1091
|
+
if (!prevEventIdsRef.current.has(id)) {
|
|
1092
|
+
newIds.add(id)
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
if (newIds.size > 0 && prevEventIdsRef.current.size > 0) {
|
|
1097
|
+
setNewEventIds(newIds)
|
|
1098
|
+
const timer = setTimeout(() => setNewEventIds(new Set()), 500)
|
|
1099
|
+
return () => clearTimeout(timer)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
prevEventIdsRef.current = currentIds
|
|
1103
|
+
}, [events])
|
|
1104
|
+
|
|
1105
|
+
// Handle filter changes
|
|
1106
|
+
const handleFilterChange = React.useCallback(
|
|
1107
|
+
(newFilters: AuditFilters) => {
|
|
1108
|
+
setFilters(newFilters)
|
|
1109
|
+
onFilterChange?.(newFilters)
|
|
1110
|
+
},
|
|
1111
|
+
[onFilterChange]
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
// Filter events
|
|
1115
|
+
const filteredEvents = React.useMemo(() => {
|
|
1116
|
+
let result = events
|
|
1117
|
+
|
|
1118
|
+
if (filters.eventTypes && filters.eventTypes.length > 0) {
|
|
1119
|
+
result = result.filter((e) => filters.eventTypes!.includes(e.type))
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (filters.severities && filters.severities.length > 0) {
|
|
1123
|
+
result = result.filter((e) => filters.severities!.includes(e.severity))
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (filters.userIds && filters.userIds.length > 0) {
|
|
1127
|
+
result = result.filter((e) => filters.userIds!.includes(e.user.id))
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (filters.search) {
|
|
1131
|
+
const searchLower = filters.search.toLowerCase()
|
|
1132
|
+
result = result.filter(
|
|
1133
|
+
(e) =>
|
|
1134
|
+
e.description.toLowerCase().includes(searchLower) ||
|
|
1135
|
+
e.user.name.toLowerCase().includes(searchLower) ||
|
|
1136
|
+
e.user.email?.toLowerCase().includes(searchLower) ||
|
|
1137
|
+
e.resource?.toLowerCase().includes(searchLower) ||
|
|
1138
|
+
e.ipAddress?.includes(searchLower)
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (filters.startDate) {
|
|
1143
|
+
result = result.filter((e) => new Date(e.timestamp) >= filters.startDate!)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (filters.endDate) {
|
|
1147
|
+
const endOfDay = new Date(filters.endDate)
|
|
1148
|
+
endOfDay.setHours(23, 59, 59, 999)
|
|
1149
|
+
result = result.filter((e) => new Date(e.timestamp) <= endOfDay)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (maxItems && maxItems > 0) {
|
|
1153
|
+
result = result.slice(0, maxItems)
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return result
|
|
1157
|
+
}, [events, filters, maxItems])
|
|
1158
|
+
|
|
1159
|
+
// Infinite scroll observer
|
|
1160
|
+
React.useEffect(() => {
|
|
1161
|
+
if (!infiniteScroll || !onLoadMore || !hasMore) return
|
|
1162
|
+
|
|
1163
|
+
observerRef.current = new IntersectionObserver(
|
|
1164
|
+
(entries) => {
|
|
1165
|
+
if (entries[0].isIntersecting && !loading) {
|
|
1166
|
+
onLoadMore()
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
{ threshold: 0.1 }
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
if (loadMoreRef.current) {
|
|
1173
|
+
observerRef.current.observe(loadMoreRef.current)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return () => {
|
|
1177
|
+
observerRef.current?.disconnect()
|
|
1178
|
+
}
|
|
1179
|
+
}, [infiniteScroll, onLoadMore, hasMore, loading])
|
|
1180
|
+
|
|
1181
|
+
if (events.length === 0 && !loading) {
|
|
1182
|
+
return (
|
|
1183
|
+
<div
|
|
1184
|
+
className={cn(
|
|
1185
|
+
"flex flex-col items-center justify-center py-12 text-center",
|
|
1186
|
+
className
|
|
1187
|
+
)}
|
|
1188
|
+
>
|
|
1189
|
+
<Activity className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
|
1190
|
+
<h3 className="text-lg font-medium text-muted-foreground">No audit events</h3>
|
|
1191
|
+
<p className="text-sm text-muted-foreground/70 mt-1">
|
|
1192
|
+
Activity will appear here when events are logged.
|
|
1193
|
+
</p>
|
|
1194
|
+
</div>
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return (
|
|
1199
|
+
<div className={cn("w-full", className)}>
|
|
1200
|
+
{/* Header with filters and export */}
|
|
1201
|
+
<div className="flex items-start justify-between gap-4 mb-4">
|
|
1202
|
+
<div className="flex-1">
|
|
1203
|
+
{(showFilters || showSearch) && (
|
|
1204
|
+
<FilterBar
|
|
1205
|
+
filters={filters}
|
|
1206
|
+
onFilterChange={handleFilterChange}
|
|
1207
|
+
users={users}
|
|
1208
|
+
showSearch={showSearch}
|
|
1209
|
+
/>
|
|
1210
|
+
)}
|
|
1211
|
+
</div>
|
|
1212
|
+
|
|
1213
|
+
{showExport && onExport && <ExportMenu onExport={onExport} />}
|
|
1214
|
+
</div>
|
|
1215
|
+
|
|
1216
|
+
{/* Event List */}
|
|
1217
|
+
<div className="space-y-3">
|
|
1218
|
+
{filteredEvents.map((event) => (
|
|
1219
|
+
<EventItem
|
|
1220
|
+
key={event.id}
|
|
1221
|
+
event={event}
|
|
1222
|
+
onEventClick={onEventClick}
|
|
1223
|
+
isNew={newEventIds.has(event.id)}
|
|
1224
|
+
/>
|
|
1225
|
+
))}
|
|
1226
|
+
</div>
|
|
1227
|
+
|
|
1228
|
+
{/* Loading State */}
|
|
1229
|
+
{loading && (
|
|
1230
|
+
<div className="flex items-center justify-center py-6">
|
|
1231
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
1232
|
+
</div>
|
|
1233
|
+
)}
|
|
1234
|
+
|
|
1235
|
+
{/* Load More / Infinite Scroll Target */}
|
|
1236
|
+
{!loading && hasMore && (
|
|
1237
|
+
<>
|
|
1238
|
+
{infiniteScroll ? (
|
|
1239
|
+
<div ref={loadMoreRef} className="h-1" />
|
|
1240
|
+
) : (
|
|
1241
|
+
<div className="flex justify-center pt-4">
|
|
1242
|
+
<button
|
|
1243
|
+
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border bg-background hover:bg-muted transition-colors"
|
|
1244
|
+
onClick={onLoadMore}
|
|
1245
|
+
>
|
|
1246
|
+
<ChevronDown className="h-4 w-4" />
|
|
1247
|
+
Load more
|
|
1248
|
+
</button>
|
|
1249
|
+
</div>
|
|
1250
|
+
)}
|
|
1251
|
+
</>
|
|
1252
|
+
)}
|
|
1253
|
+
|
|
1254
|
+
{/* No results after filtering */}
|
|
1255
|
+
{filteredEvents.length === 0 && events.length > 0 && !loading && (
|
|
1256
|
+
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
1257
|
+
<Filter className="h-8 w-8 text-muted-foreground mb-3" />
|
|
1258
|
+
<p className="text-sm text-muted-foreground">No events match the current filters</p>
|
|
1259
|
+
</div>
|
|
1260
|
+
)}
|
|
1261
|
+
</div>
|
|
1262
|
+
)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ============================================================================
|
|
1266
|
+
// Hook for managing audit log state
|
|
1267
|
+
// ============================================================================
|
|
1268
|
+
|
|
1269
|
+
export interface UseAuditLogOptions {
|
|
1270
|
+
/** Initial events */
|
|
1271
|
+
initialEvents?: AuditEvent[]
|
|
1272
|
+
/** Maximum events to keep in memory */
|
|
1273
|
+
maxEvents?: number
|
|
1274
|
+
/** Initial filters */
|
|
1275
|
+
initialFilters?: AuditFilters
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
export interface UseAuditLogReturn {
|
|
1279
|
+
/** Current audit events */
|
|
1280
|
+
events: AuditEvent[]
|
|
1281
|
+
/** Current filters */
|
|
1282
|
+
filters: AuditFilters
|
|
1283
|
+
/** Loading state */
|
|
1284
|
+
loading: boolean
|
|
1285
|
+
/** Whether more events can be loaded */
|
|
1286
|
+
hasMore: boolean
|
|
1287
|
+
/** Add a new event */
|
|
1288
|
+
addEvent: (event: AuditEvent) => void
|
|
1289
|
+
/** Add multiple events */
|
|
1290
|
+
addEvents: (events: AuditEvent[]) => void
|
|
1291
|
+
/** Remove an event */
|
|
1292
|
+
removeEvent: (eventId: string) => void
|
|
1293
|
+
/** Set filters */
|
|
1294
|
+
setFilters: (filters: AuditFilters) => void
|
|
1295
|
+
/** Clear all filters */
|
|
1296
|
+
clearFilters: () => void
|
|
1297
|
+
/** Load more events */
|
|
1298
|
+
loadMore: (fetcher: () => Promise<{ events: AuditEvent[]; hasMore: boolean }>) => Promise<void>
|
|
1299
|
+
/** Refresh all events */
|
|
1300
|
+
refresh: (events: AuditEvent[]) => void
|
|
1301
|
+
/** Clear all events */
|
|
1302
|
+
clear: () => void
|
|
1303
|
+
/** Set loading state */
|
|
1304
|
+
setLoading: (loading: boolean) => void
|
|
1305
|
+
/** Set has more state */
|
|
1306
|
+
setHasMore: (hasMore: boolean) => void
|
|
1307
|
+
/** Export events to format */
|
|
1308
|
+
exportEvents: (format: "csv" | "json") => string
|
|
1309
|
+
/** Get filtered events */
|
|
1310
|
+
getFilteredEvents: () => AuditEvent[]
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
export function useAuditLog({
|
|
1314
|
+
initialEvents = [],
|
|
1315
|
+
maxEvents = 1000,
|
|
1316
|
+
initialFilters = {},
|
|
1317
|
+
}: UseAuditLogOptions = {}): UseAuditLogReturn {
|
|
1318
|
+
const [events, setEvents] = React.useState<AuditEvent[]>(initialEvents)
|
|
1319
|
+
const [filters, setFilters] = React.useState<AuditFilters>(initialFilters)
|
|
1320
|
+
const [loading, setLoading] = React.useState(false)
|
|
1321
|
+
const [hasMore, setHasMore] = React.useState(false)
|
|
1322
|
+
|
|
1323
|
+
const addEvent = React.useCallback(
|
|
1324
|
+
(event: AuditEvent) => {
|
|
1325
|
+
setEvents((prev) => {
|
|
1326
|
+
const updated = [event, ...prev]
|
|
1327
|
+
return updated.slice(0, maxEvents)
|
|
1328
|
+
})
|
|
1329
|
+
},
|
|
1330
|
+
[maxEvents]
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
const addEvents = React.useCallback(
|
|
1334
|
+
(newEvents: AuditEvent[]) => {
|
|
1335
|
+
setEvents((prev) => {
|
|
1336
|
+
const updated = [...newEvents, ...prev]
|
|
1337
|
+
return updated.slice(0, maxEvents)
|
|
1338
|
+
})
|
|
1339
|
+
},
|
|
1340
|
+
[maxEvents]
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
const removeEvent = React.useCallback((eventId: string) => {
|
|
1344
|
+
setEvents((prev) => prev.filter((e) => e.id !== eventId))
|
|
1345
|
+
}, [])
|
|
1346
|
+
|
|
1347
|
+
const clearFilters = React.useCallback(() => {
|
|
1348
|
+
setFilters({})
|
|
1349
|
+
}, [])
|
|
1350
|
+
|
|
1351
|
+
const loadMore = React.useCallback(
|
|
1352
|
+
async (fetcher: () => Promise<{ events: AuditEvent[]; hasMore: boolean }>) => {
|
|
1353
|
+
setLoading(true)
|
|
1354
|
+
try {
|
|
1355
|
+
const result = await fetcher()
|
|
1356
|
+
setEvents((prev) => {
|
|
1357
|
+
const existingIds = new Set(prev.map((e) => e.id))
|
|
1358
|
+
const newEvents = result.events.filter((e) => !existingIds.has(e.id))
|
|
1359
|
+
return [...prev, ...newEvents].slice(0, maxEvents)
|
|
1360
|
+
})
|
|
1361
|
+
setHasMore(result.hasMore)
|
|
1362
|
+
} finally {
|
|
1363
|
+
setLoading(false)
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
[maxEvents]
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
const refresh = React.useCallback((newEvents: AuditEvent[]) => {
|
|
1370
|
+
setEvents(newEvents)
|
|
1371
|
+
}, [])
|
|
1372
|
+
|
|
1373
|
+
const clear = React.useCallback(() => {
|
|
1374
|
+
setEvents([])
|
|
1375
|
+
}, [])
|
|
1376
|
+
|
|
1377
|
+
const getFilteredEvents = React.useCallback(() => {
|
|
1378
|
+
let result = events
|
|
1379
|
+
|
|
1380
|
+
if (filters.eventTypes && filters.eventTypes.length > 0) {
|
|
1381
|
+
result = result.filter((e) => filters.eventTypes!.includes(e.type))
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (filters.severities && filters.severities.length > 0) {
|
|
1385
|
+
result = result.filter((e) => filters.severities!.includes(e.severity))
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (filters.userIds && filters.userIds.length > 0) {
|
|
1389
|
+
result = result.filter((e) => filters.userIds!.includes(e.user.id))
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (filters.search) {
|
|
1393
|
+
const searchLower = filters.search.toLowerCase()
|
|
1394
|
+
result = result.filter(
|
|
1395
|
+
(e) =>
|
|
1396
|
+
e.description.toLowerCase().includes(searchLower) ||
|
|
1397
|
+
e.user.name.toLowerCase().includes(searchLower) ||
|
|
1398
|
+
e.user.email?.toLowerCase().includes(searchLower)
|
|
1399
|
+
)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (filters.startDate) {
|
|
1403
|
+
result = result.filter((e) => new Date(e.timestamp) >= filters.startDate!)
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
if (filters.endDate) {
|
|
1407
|
+
const endOfDay = new Date(filters.endDate)
|
|
1408
|
+
endOfDay.setHours(23, 59, 59, 999)
|
|
1409
|
+
result = result.filter((e) => new Date(e.timestamp) <= endOfDay)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
return result
|
|
1413
|
+
}, [events, filters])
|
|
1414
|
+
|
|
1415
|
+
const exportEvents = React.useCallback(
|
|
1416
|
+
(format: "csv" | "json"): string => {
|
|
1417
|
+
const eventsToExport = getFilteredEvents()
|
|
1418
|
+
|
|
1419
|
+
if (format === "json") {
|
|
1420
|
+
return JSON.stringify(eventsToExport, null, 2)
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// CSV format
|
|
1424
|
+
const headers = [
|
|
1425
|
+
"ID",
|
|
1426
|
+
"Type",
|
|
1427
|
+
"Severity",
|
|
1428
|
+
"Description",
|
|
1429
|
+
"User Name",
|
|
1430
|
+
"User Email",
|
|
1431
|
+
"IP Address",
|
|
1432
|
+
"Location",
|
|
1433
|
+
"Resource",
|
|
1434
|
+
"Timestamp",
|
|
1435
|
+
]
|
|
1436
|
+
|
|
1437
|
+
const rows = eventsToExport.map((e) => [
|
|
1438
|
+
e.id,
|
|
1439
|
+
e.type,
|
|
1440
|
+
e.severity,
|
|
1441
|
+
`"${e.description.replace(/"/g, '""')}"`,
|
|
1442
|
+
e.user.name,
|
|
1443
|
+
e.user.email || "",
|
|
1444
|
+
e.ipAddress || "",
|
|
1445
|
+
formatLocation(e.location) || "",
|
|
1446
|
+
e.resource || "",
|
|
1447
|
+
new Date(e.timestamp).toISOString(),
|
|
1448
|
+
])
|
|
1449
|
+
|
|
1450
|
+
return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n")
|
|
1451
|
+
},
|
|
1452
|
+
[getFilteredEvents]
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
return {
|
|
1456
|
+
events,
|
|
1457
|
+
filters,
|
|
1458
|
+
loading,
|
|
1459
|
+
hasMore,
|
|
1460
|
+
addEvent,
|
|
1461
|
+
addEvents,
|
|
1462
|
+
removeEvent,
|
|
1463
|
+
setFilters,
|
|
1464
|
+
clearFilters,
|
|
1465
|
+
loadMore,
|
|
1466
|
+
refresh,
|
|
1467
|
+
clear,
|
|
1468
|
+
setLoading,
|
|
1469
|
+
setHasMore,
|
|
1470
|
+
exportEvents,
|
|
1471
|
+
getFilteredEvents,
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
export default WakaAuditLog
|