@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.
- package/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- 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
|
+
}
|