@modern-admin/react 0.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.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,580 @@
1
+ import * as React from 'react'
2
+ import { diffSnapshots } from '@modern-admin/core'
3
+ import {
4
+ Button,
5
+ Card,
6
+ CardContent,
7
+ CardHeader,
8
+ CardTitle,
9
+ DateRangeInput,
10
+ DiffView,
11
+ Input,
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ Skeleton,
18
+ cn,
19
+ } from '@modern-admin/ui'
20
+ import { ChevronDown, ChevronUp, ExternalLink, FilePlus, FileText, Key, KeyRound, Loader2, LogIn, Pencil, Trash2 } from 'lucide-react'
21
+ import { useInfiniteAuditLog, useRecord, useRecordHistory, useResource, useResources } from '../hooks.js'
22
+ import { useI18n } from '../i18n.js'
23
+ import { Link } from '../router.js'
24
+ import { USERS_RESOURCE_ID, useUserDirectory, userLabelOf } from '../user-directory.js'
25
+ import type {
26
+ AuditLogEntry,
27
+ AuditLogQuery,
28
+ HistoryDiffEntry,
29
+ HistoryRevision,
30
+ } from '../client.js'
31
+ import type { RecordJSON, ResourceJSON } from '../types.js'
32
+
33
+ const ALL = '__all__'
34
+ const ACTIONS = ['new', 'edit', 'delete', 'bulkDelete', 'login', 'apiKey.create', 'apiKey.update', 'apiKey.delete']
35
+ /** Virtual resource IDs that don't map to ORM resources. */
36
+ const VIRTUAL_RESOURCE_LABELS: Record<string, string> = {
37
+ __auth__: 'audit:virtualResource.auth',
38
+ __api_keys__: 'audit:virtualResource.apiKeys',
39
+ }
40
+ interface ActionStyle {
41
+ Icon: React.ComponentType<{ className?: string }>
42
+ iconClass: string
43
+ bgClass: string
44
+ titleKey: string
45
+ }
46
+
47
+ const FALLBACK_STYLE: ActionStyle = {
48
+ Icon: FileText,
49
+ iconClass: 'text-muted-foreground',
50
+ bgClass: 'bg-muted',
51
+ titleKey: '',
52
+ }
53
+
54
+ const ACTION_STYLES: Record<string, ActionStyle> = {
55
+ new: {
56
+ Icon: FilePlus,
57
+ iconClass: 'text-blue-600 dark:text-blue-300',
58
+ bgClass: 'bg-blue-100 dark:bg-blue-950/40',
59
+ titleKey: 'audit:action.new',
60
+ },
61
+ edit: {
62
+ Icon: Pencil,
63
+ iconClass: 'text-emerald-600 dark:text-emerald-300',
64
+ bgClass: 'bg-emerald-100 dark:bg-emerald-950/40',
65
+ titleKey: 'audit:action.edit',
66
+ },
67
+ delete: {
68
+ Icon: Trash2,
69
+ iconClass: 'text-rose-600 dark:text-rose-300',
70
+ bgClass: 'bg-rose-100 dark:bg-rose-950/40',
71
+ titleKey: 'audit:action.delete',
72
+ },
73
+ bulkDelete: {
74
+ Icon: Trash2,
75
+ iconClass: 'text-rose-600 dark:text-rose-300',
76
+ bgClass: 'bg-rose-100 dark:bg-rose-950/40',
77
+ titleKey: 'audit:action.bulkDelete',
78
+ },
79
+ login: {
80
+ Icon: LogIn,
81
+ iconClass: 'text-violet-600 dark:text-violet-300',
82
+ bgClass: 'bg-violet-100 dark:bg-violet-950/40',
83
+ titleKey: 'audit:action.login',
84
+ },
85
+ 'apiKey.create': {
86
+ Icon: Key,
87
+ iconClass: 'text-amber-600 dark:text-amber-300',
88
+ bgClass: 'bg-amber-100 dark:bg-amber-950/40',
89
+ titleKey: 'audit:action.apiKeyCreate',
90
+ },
91
+ 'apiKey.update': {
92
+ Icon: KeyRound,
93
+ iconClass: 'text-amber-600 dark:text-amber-300',
94
+ bgClass: 'bg-amber-100 dark:bg-amber-950/40',
95
+ titleKey: 'audit:action.apiKeyUpdate',
96
+ },
97
+ 'apiKey.delete': {
98
+ Icon: Trash2,
99
+ iconClass: 'text-rose-600 dark:text-rose-300',
100
+ bgClass: 'bg-rose-100 dark:bg-rose-950/40',
101
+ titleKey: 'audit:action.apiKeyDelete',
102
+ },
103
+ }
104
+
105
+ /** Format `entry.at` (unix-ms) as a relative phrase like "2m ago", falling
106
+ * back to absolute date for entries older than a week. Uses
107
+ * `Intl.RelativeTimeFormat` so output is locale-aware. */
108
+ function useRelativeTimeFormatter(
109
+ locale: string,
110
+ ): (atMs: number, nowMs: number) => string {
111
+ const rtf = React.useMemo(
112
+ () => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }),
113
+ [locale],
114
+ )
115
+ const dtf = React.useMemo(
116
+ () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
117
+ [locale],
118
+ )
119
+ return React.useCallback(
120
+ (atMs, nowMs) => {
121
+ const sec = Math.round((nowMs - atMs) / 1000)
122
+ if (sec < 45) return rtf.format(-Math.max(sec, 0), 'second')
123
+ if (sec < 3600) return rtf.format(-Math.round(sec / 60), 'minute')
124
+ if (sec < 86400) return rtf.format(-Math.round(sec / 3600), 'hour')
125
+ if (sec < 86400 * 7) return rtf.format(-Math.round(sec / 86400), 'day')
126
+ return dtf.format(new Date(atMs))
127
+ },
128
+ [dtf, rtf],
129
+ )
130
+ }
131
+
132
+ const initialsOf = (label: string): string => {
133
+ const parts = label.split(/\s+|[._@-]/).filter(Boolean)
134
+ if (parts.length === 0) return '?'
135
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()
136
+ return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
137
+ }
138
+
139
+ const PAGE_SIZE = 25
140
+
141
+ export function AuditLogPage(): React.ReactElement {
142
+ const { t, locale } = useI18n()
143
+ const resources = useResources()
144
+ const [filters, setFilters] = React.useState<Omit<AuditLogQuery, 'before' | 'limit' | 'offset'>>({})
145
+
146
+ const log = useInfiniteAuditLog(filters, PAGE_SIZE)
147
+
148
+ // Flatten all pages into one list, trimming the sentinel "+1" entry from each page
149
+ const events = React.useMemo(
150
+ () =>
151
+ (log.data?.pages ?? []).flatMap((page) => page.events.slice(0, PAGE_SIZE)),
152
+ [log.data],
153
+ )
154
+
155
+ const userIds = React.useMemo(
156
+ () => Array.from(new Set(events.map((e) => e.userId).filter((v): v is string => !!v))),
157
+ [events],
158
+ )
159
+ const users = useUserDirectory(userIds)
160
+
161
+ const resourceMap = React.useMemo(() => {
162
+ const map: Record<string, ResourceJSON> = {}
163
+ for (const r of resources) map[r.id] = r
164
+ return map
165
+ }, [resources])
166
+
167
+ const userResourceExists = resources.some((r) => r.id === USERS_RESOURCE_ID)
168
+
169
+ const formatRelative = useRelativeTimeFormatter(locale)
170
+ const formatAbsolute = React.useCallback(
171
+ (value: number) =>
172
+ new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)),
173
+ [locale],
174
+ )
175
+
176
+ // `now` is a snapshot for relative-time labels — we want it to refresh
177
+ // whenever a new page of events arrives, even though `events` is not read
178
+ // inside the callback.
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ const now = React.useMemo(() => Date.now(), [events])
181
+
182
+ // IntersectionObserver sentinel — triggers next page load when visible
183
+ const sentinelRef = React.useRef<HTMLDivElement>(null)
184
+ const { hasNextPage, isFetchingNextPage, fetchNextPage } = log
185
+ React.useEffect(() => {
186
+ const el = sentinelRef.current
187
+ if (!el) return
188
+ const observer = new IntersectionObserver(
189
+ (entries) => {
190
+ if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
191
+ void fetchNextPage()
192
+ }
193
+ },
194
+ { rootMargin: '200px' },
195
+ )
196
+ observer.observe(el)
197
+ return () => observer.disconnect()
198
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage])
199
+
200
+ const resetFilters = (patch: Partial<typeof filters>): void => {
201
+ setFilters((prev) => ({ ...prev, ...patch }))
202
+ }
203
+
204
+ return (
205
+ <div className="space-y-2 sm:space-y-4">
206
+ <Card>
207
+ <CardHeader>
208
+ <CardTitle>{t('audit:title')}</CardTitle>
209
+ </CardHeader>
210
+ <CardContent className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
211
+ <Select
212
+ value={filters.resourceId ?? ALL}
213
+ onValueChange={(v) => resetFilters({ resourceId: v === ALL ? undefined : v })}
214
+ >
215
+ <SelectTrigger aria-label={t('audit:resource')}>
216
+ <SelectValue />
217
+ </SelectTrigger>
218
+ <SelectContent>
219
+ <SelectItem value={ALL}>{t('audit:allResources')}</SelectItem>
220
+ {Object.entries(VIRTUAL_RESOURCE_LABELS).map(([id, key]) => (
221
+ <SelectItem key={id} value={id}>{t(key)}</SelectItem>
222
+ ))}
223
+ {resources.map((resource) => (
224
+ <SelectItem key={resource.id} value={resource.id}>
225
+ {resource.name}
226
+ {resource.name !== resource.id && (
227
+ <span className="ml-1.5 text-xs text-muted-foreground">({resource.id})</span>
228
+ )}
229
+ </SelectItem>
230
+ ))}
231
+ </SelectContent>
232
+ </Select>
233
+ <Select
234
+ value={filters.actions?.[0] ?? ALL}
235
+ onValueChange={(v) => resetFilters({ actions: v === ALL ? undefined : [v] })}
236
+ >
237
+ <SelectTrigger aria-label={t('audit:action')}>
238
+ <SelectValue />
239
+ </SelectTrigger>
240
+ <SelectContent>
241
+ <SelectItem value={ALL}>{t('audit:allActions')}</SelectItem>
242
+ {ACTIONS.map((action) => {
243
+ const style = ACTION_STYLES[action]
244
+ return (
245
+ <SelectItem key={action} value={action}>
246
+ {style?.titleKey ? t(style.titleKey) : action}
247
+ </SelectItem>
248
+ )
249
+ })}
250
+ </SelectContent>
251
+ </Select>
252
+ <Input
253
+ value={filters.recordId ?? ''}
254
+ placeholder={t('audit:recordId')}
255
+ onChange={(e) => resetFilters({ recordId: e.target.value || undefined })}
256
+ />
257
+ <Input
258
+ value={filters.userId ?? ''}
259
+ placeholder={t('audit:userId')}
260
+ onChange={(e) => resetFilters({ userId: e.target.value || undefined })}
261
+ />
262
+ <DateRangeInput
263
+ from={filters.from}
264
+ to={filters.to}
265
+ onChange={(from, to) =>
266
+ resetFilters({ from: from || undefined, to: to || undefined })
267
+ }
268
+ className="sm:col-span-2 md:col-span-4"
269
+ labels={{
270
+ placeholder: t('audit:dateRangePlaceholder'),
271
+ apply: t('common:apply'),
272
+ clear: t('common:clear'),
273
+ }}
274
+ />
275
+ </CardContent>
276
+ </Card>
277
+ <Card>
278
+ <CardContent>
279
+ {log.isLoading ? (
280
+ <div className="space-y-3">
281
+ <Skeleton className="h-20 w-full" />
282
+ <Skeleton className="h-20 w-full" />
283
+ <Skeleton className="h-20 w-full" />
284
+ </div>
285
+ ) : log.isError ? (
286
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
287
+ {t('audit:loadError')}
288
+ </div>
289
+ ) : events.length === 0 ? (
290
+ <div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
291
+ {t('audit:noEvents')}
292
+ </div>
293
+ ) : (
294
+ <ol className="space-y-3">
295
+ {events.map((entry, i) => (
296
+ <AuditEntryCard
297
+ key={entry.id ?? `${entry.at}:${i}`}
298
+ entry={entry}
299
+ resource={resourceMap[entry.resourceId]}
300
+ user={entry.userId ? users.get(entry.userId) ?? null : null}
301
+ userResourceId={userResourceExists ? USERS_RESOURCE_ID : undefined}
302
+ now={now}
303
+ formatRelative={formatRelative}
304
+ formatAbsolute={formatAbsolute}
305
+ />
306
+ ))}
307
+ </ol>
308
+ )}
309
+ {/* Sentinel div — observed by IntersectionObserver to trigger next page */}
310
+ <div ref={sentinelRef} className="h-1" aria-hidden />
311
+ {log.isFetchingNextPage && (
312
+ <div className="flex justify-center py-4">
313
+ <Loader2 className="size-5 animate-spin text-muted-foreground" />
314
+ </div>
315
+ )}
316
+ </CardContent>
317
+ </Card>
318
+ </div>
319
+ )
320
+ }
321
+
322
+ interface AuditEntryCardProps {
323
+ entry: AuditLogEntry
324
+ resource: ResourceJSON | undefined
325
+ user: RecordJSON | null | undefined
326
+ userResourceId?: string
327
+ now: number
328
+ formatRelative: (atMs: number, nowMs: number) => string
329
+ formatAbsolute: (value: number) => string
330
+ }
331
+
332
+ function AuditEntryCard({
333
+ entry,
334
+ resource,
335
+ user,
336
+ userResourceId,
337
+ now,
338
+ formatRelative,
339
+ formatAbsolute,
340
+ }: AuditEntryCardProps): React.ReactElement {
341
+ const { t } = useI18n()
342
+ const [expanded, setExpanded] = React.useState(false)
343
+
344
+ const style = ACTION_STYLES[entry.action] ?? FALLBACK_STYLE
345
+ const Icon = style.Icon
346
+ const title = style.titleKey ? t(style.titleKey) : entry.action
347
+
348
+ const userFallback = entry.userId ?? t('history:unknownUser')
349
+ const userLabel = userLabelOf(user, userFallback)
350
+
351
+ const byTpl = t('audit:by', { user: '\u0000' })
352
+ const byParts = byTpl.split('\u0000')
353
+ const byPrefix = byParts[0] ?? ''
354
+ const bySuffix = byParts[1] ?? ''
355
+
356
+ const virtualResourceKey = VIRTUAL_RESOURCE_LABELS[entry.resourceId]
357
+ const resourceLabel = virtualResourceKey
358
+ ? t(virtualResourceKey)
359
+ : (resource?.name ?? entry.resourceId)
360
+
361
+ // Diff drill-down only makes sense when we have a single recordId AND
362
+ // history exists for that resource (we'll discover that lazily on first
363
+ // expand). Bulk operations and `new`/`delete` actions skip the toggle.
364
+ const canExpand = entry.action === 'edit' && !!entry.recordId
365
+
366
+ return (
367
+ <li className="rounded-lg border border-border bg-card p-4 shadow-sm">
368
+ <div className="flex items-start gap-3">
369
+ <span
370
+ className={cn(
371
+ 'flex size-10 shrink-0 items-center justify-center rounded-full',
372
+ style.bgClass,
373
+ )}
374
+ >
375
+ <Icon className={cn('size-5', style.iconClass)} />
376
+ </span>
377
+ <div className="min-w-0 flex-1">
378
+ <div className="flex items-start justify-between gap-3">
379
+ <div className="min-w-0">
380
+ <p className="truncate text-sm font-semibold">{title}</p>
381
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
382
+ <span className="font-medium">{resourceLabel}</span>
383
+ {entry.recordId && (
384
+ <>
385
+ <span aria-hidden>·</span>
386
+ {resource ? (
387
+ <Link
388
+ to={{
389
+ name: 'show',
390
+ resourceId: entry.resourceId,
391
+ recordId: entry.recordId,
392
+ }}
393
+ className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] text-foreground hover:underline"
394
+ title={entry.recordId}
395
+ >
396
+ <span className="truncate max-w-[12rem]">
397
+ {entry.recordTitle ?? `#${entry.recordId}`}
398
+ </span>
399
+ <ExternalLink className="size-3 shrink-0" />
400
+ </Link>
401
+ ) : entry.recordTitle ? (
402
+ <span
403
+ className="truncate max-w-[12rem] rounded bg-muted px-1.5 py-0.5 text-[11px] text-foreground"
404
+ title={entry.recordId}
405
+ >
406
+ {entry.recordTitle}
407
+ </span>
408
+ ) : null}
409
+ </>
410
+ )}
411
+ {!entry.recordId && entry.recordIds?.length ? (
412
+ <>
413
+ <span aria-hidden>·</span>
414
+ <span>
415
+ {entry.recordIds.length} {t('audit:records')}
416
+ </span>
417
+ </>
418
+ ) : null}
419
+ </div>
420
+ <p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
421
+ <span
422
+ aria-hidden
423
+ className="inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[10px] font-semibold text-primary"
424
+ >
425
+ {initialsOf(userLabel)}
426
+ </span>
427
+ <span className="truncate">
428
+ {byPrefix}
429
+ {userResourceId && entry.userId ? (
430
+ <Link
431
+ to={{ name: 'show', resourceId: userResourceId, recordId: entry.userId }}
432
+ className="font-medium text-foreground hover:underline"
433
+ >
434
+ {userLabel}
435
+ </Link>
436
+ ) : (
437
+ userLabel
438
+ )}
439
+ {bySuffix}
440
+ </span>
441
+ </p>
442
+ </div>
443
+ <time
444
+ className="shrink-0 text-xs text-muted-foreground"
445
+ dateTime={new Date(entry.at).toISOString()}
446
+ title={formatAbsolute(entry.at)}
447
+ >
448
+ {formatRelative(entry.at, now)}
449
+ </time>
450
+ </div>
451
+ {canExpand && (
452
+ <div className="mt-3">
453
+ <Button
454
+ variant="ghost"
455
+ size="sm"
456
+ className="-ml-2 h-7 px-2 text-xs"
457
+ onClick={() => setExpanded((x) => !x)}
458
+ aria-expanded={expanded}
459
+ >
460
+ {expanded ? (
461
+ <ChevronUp className="size-3.5" />
462
+ ) : (
463
+ <ChevronDown className="size-3.5" />
464
+ )}
465
+ {expanded ? t('audit:hideChanges') : t('audit:viewChanges')}
466
+ </Button>
467
+ {expanded && (
468
+ <div className="mt-2">
469
+ <AuditEntryChanges
470
+ resourceId={entry.resourceId}
471
+ recordId={entry.recordId!}
472
+ atMs={entry.at}
473
+ />
474
+ </div>
475
+ )}
476
+ </div>
477
+ )}
478
+ </div>
479
+ </div>
480
+ </li>
481
+ )
482
+ }
483
+
484
+ /** Lazy-loaded diff for a single audit entry. Pulls the record's revision
485
+ * history (server returns the freshest 50) and picks the revision whose
486
+ * `createdAt` is closest to the entry's `at` timestamp. We require the
487
+ * match to be within a 60s window to avoid showing an unrelated revision
488
+ * when the matching one was pruned. */
489
+ function AuditEntryChanges({
490
+ resourceId,
491
+ recordId,
492
+ atMs,
493
+ }: {
494
+ resourceId: string
495
+ recordId: string
496
+ atMs: number
497
+ }): React.ReactElement {
498
+ const { t } = useI18n()
499
+ const resource = useResource(resourceId)
500
+ const history = useRecordHistory(resourceId, recordId, { limit: 50 })
501
+ // Pull the live record so we can show its title (e.g. user's email) at
502
+ // the top of the diff — helpful when the audit log lists many entries.
503
+ const record = useRecord(resourceId, recordId)
504
+
505
+ const labelByPath = React.useMemo(() => {
506
+ const map: Record<string, string> = {}
507
+ for (const p of resource?.properties ?? []) map[p.path] = p.label
508
+ return map
509
+ }, [resource])
510
+
511
+ if (history.isLoading) {
512
+ return <Skeleton className="h-16 w-full" />
513
+ }
514
+ if (history.isError) {
515
+ return (
516
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
517
+ {t('audit:loadDiffError')}
518
+ </div>
519
+ )
520
+ }
521
+
522
+ const revisions = history.data?.revisions ?? []
523
+ const target = findNearestRevision(revisions, atMs)
524
+
525
+ if (!target) {
526
+ return (
527
+ <div className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
528
+ {t('audit:noDiff')}
529
+ </div>
530
+ )
531
+ }
532
+
533
+ const fields: HistoryDiffEntry[] = diffSnapshots(
534
+ target.snapshotBefore ?? {},
535
+ target.snapshot,
536
+ ).map((f) => ({ ...f, label: labelByPath[f.path] }))
537
+ const recordTitle = record.data?.record?.title
538
+ const showTitle = recordTitle && recordTitle !== recordId
539
+
540
+ return (
541
+ <div className="space-y-2">
542
+ {showTitle && (
543
+ <p className="truncate text-xs text-muted-foreground" title={recordTitle}>
544
+ {recordTitle}
545
+ </p>
546
+ )}
547
+ <DiffView
548
+ fields={fields}
549
+ labels={{
550
+ added: t('diff:added'),
551
+ changed: t('diff:changed'),
552
+ removed: t('diff:removed'),
553
+ before: t('diff:before'),
554
+ after: t('diff:after'),
555
+ noChanges: t('diff:noChanges'),
556
+ }}
557
+ />
558
+ </div>
559
+ )
560
+ }
561
+
562
+ const TOLERANCE_MS = 60_000
563
+
564
+ const findNearestRevision = (
565
+ revisions: ReadonlyArray<HistoryRevision>,
566
+ atMs: number,
567
+ ): HistoryRevision | null => {
568
+ let best: HistoryRevision | null = null
569
+ let bestDiff = Infinity
570
+ for (const r of revisions) {
571
+ const t = new Date(r.createdAt).getTime()
572
+ if (Number.isNaN(t)) continue
573
+ const d = Math.abs(t - atMs)
574
+ if (d < bestDiff && d <= TOLERANCE_MS) {
575
+ bestDiff = d
576
+ best = r
577
+ }
578
+ }
579
+ return best
580
+ }