betterstart-cli 0.0.12 → 0.0.14
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/assets/adapters/next/plugins/blog/plugin.ts +1 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +155 -0
- package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
- package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +9 -3
- package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
- package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
- package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
- package/dist/assets/adapters/next/templates/init/data/navigation.ts +4 -1
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
- package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
- package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +122 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +19 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +77 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
- package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +117 -12
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +35 -1
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +104 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +15 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +565 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +7 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
- package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
- package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
- package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
- package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -32
- package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
- package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
- package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
- package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
- package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
- package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
- package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -11
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +24 -67
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +5 -12
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +2 -3
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
- package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
- package/dist/assets/shared-assets/react-admin/schema.json +136 -23
- package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
- package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +27 -3
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +9 -5
- package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +27 -12
- package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +4 -4
- package/dist/cli.js +5396 -2385
- package/dist/cli.js.map +1 -1
- package/package.json +10 -2
|
@@ -15,12 +15,13 @@ import * as React from 'react'
|
|
|
15
15
|
|
|
16
16
|
export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | null>) {
|
|
17
17
|
const [slashMenu, setSlashMenu] = React.useState<SlashMenuState | null>(null)
|
|
18
|
+
const [slashSearchQuery, setSlashSearchQuery] = React.useState('')
|
|
18
19
|
const [activeSlashIndexState, setActiveSlashIndexState] = React.useState(0)
|
|
19
20
|
const slashMenuRef = React.useRef<SlashMenuState | null>(null)
|
|
20
21
|
const activeSlashIndexRef = React.useRef(0)
|
|
21
22
|
const filteredSlashCommands = React.useMemo(
|
|
22
|
-
() => getFilteredSlashCommands(
|
|
23
|
-
[
|
|
23
|
+
() => getFilteredSlashCommands(slashSearchQuery),
|
|
24
|
+
[slashSearchQuery]
|
|
24
25
|
)
|
|
25
26
|
const filteredSlashCommandsRef = React.useRef(filteredSlashCommands)
|
|
26
27
|
const activeSlashIndex =
|
|
@@ -37,6 +38,23 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
|
|
|
37
38
|
isSameSlashMenuState(current, nextSlashMenu) ? current : nextSlashMenu
|
|
38
39
|
)
|
|
39
40
|
|
|
41
|
+
setSlashSearchQuery((current) => {
|
|
42
|
+
const currentSlashMenu = slashMenuRef.current
|
|
43
|
+
|
|
44
|
+
if (!nextSlashMenu) return ''
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
currentSlashMenu &&
|
|
48
|
+
currentSlashMenu.from === nextSlashMenu.from &&
|
|
49
|
+
currentSlashMenu.to === nextSlashMenu.to &&
|
|
50
|
+
currentSlashMenu.query === nextSlashMenu.query
|
|
51
|
+
) {
|
|
52
|
+
return current
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return nextSlashMenu.query
|
|
56
|
+
})
|
|
57
|
+
|
|
40
58
|
setActiveSlashIndexState((current) =>
|
|
41
59
|
slashMenuRef.current?.query === nextSlashMenu?.query && nextSlashMenu ? current : 0
|
|
42
60
|
)
|
|
@@ -44,6 +62,12 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
|
|
|
44
62
|
|
|
45
63
|
const closeSlashMenu = React.useCallback(() => {
|
|
46
64
|
setSlashMenu(null)
|
|
65
|
+
setSlashSearchQuery('')
|
|
66
|
+
setActiveSlashIndexState(0)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const setSlashSearch = React.useCallback((query: string) => {
|
|
70
|
+
setSlashSearchQuery(query)
|
|
47
71
|
setActiveSlashIndexState(0)
|
|
48
72
|
}, [])
|
|
49
73
|
|
|
@@ -110,7 +134,9 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
|
|
|
110
134
|
handleSlashKeyDown,
|
|
111
135
|
selectSlashCommand,
|
|
112
136
|
setActiveSlashIndex: setActiveSlashIndexState,
|
|
137
|
+
setSlashSearch,
|
|
113
138
|
slashMenu,
|
|
139
|
+
slashSearchQuery,
|
|
114
140
|
updateSlashMenu
|
|
115
141
|
}
|
|
116
142
|
}
|
|
@@ -66,6 +66,7 @@ export function useContentEditor({
|
|
|
66
66
|
const isInternalChange = React.useRef(false)
|
|
67
67
|
const isSyncingRichContent = React.useRef(false)
|
|
68
68
|
const editorRef = React.useRef<Editor | null>(null)
|
|
69
|
+
const isSlashMenuOpenRef = React.useRef(false)
|
|
69
70
|
const {
|
|
70
71
|
activeSlashIndex,
|
|
71
72
|
closeSlashMenu,
|
|
@@ -73,10 +74,14 @@ export function useContentEditor({
|
|
|
73
74
|
handleSlashKeyDown,
|
|
74
75
|
selectSlashCommand,
|
|
75
76
|
setActiveSlashIndex,
|
|
77
|
+
setSlashSearch,
|
|
76
78
|
slashMenu,
|
|
79
|
+
slashSearchQuery,
|
|
77
80
|
updateSlashMenu
|
|
78
81
|
} = useContentEditorSlashMenu(editorRef)
|
|
79
82
|
|
|
83
|
+
isSlashMenuOpenRef.current = slashMenu !== null
|
|
84
|
+
|
|
80
85
|
if (value !== lastValuePropRef.current) {
|
|
81
86
|
lastValuePropRef.current = value
|
|
82
87
|
latestMarkdownRef.current = value
|
|
@@ -158,7 +163,20 @@ export function useContentEditor({
|
|
|
158
163
|
updateSlashMenu(nextEditor)
|
|
159
164
|
},
|
|
160
165
|
onSelectionUpdate: ({ editor: nextEditor }) => updateSlashMenu(nextEditor),
|
|
161
|
-
onBlur:
|
|
166
|
+
onBlur: ({ event }) => {
|
|
167
|
+
const nextFocusTarget = event.relatedTarget
|
|
168
|
+
|
|
169
|
+
if (isSlashMenuOpenRef.current && !nextFocusTarget) return
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
nextFocusTarget instanceof Element &&
|
|
173
|
+
nextFocusTarget.closest('[data-content-editor-slash-menu]')
|
|
174
|
+
) {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
closeSlashMenu()
|
|
179
|
+
}
|
|
162
180
|
})
|
|
163
181
|
|
|
164
182
|
editorRef.current = editor
|
|
@@ -278,9 +296,12 @@ export function useContentEditor({
|
|
|
278
296
|
handleSourceChange,
|
|
279
297
|
handleSourceSelectionChange,
|
|
280
298
|
mode,
|
|
299
|
+
closeSlashMenu,
|
|
281
300
|
selectSlashCommand,
|
|
282
301
|
setActiveSlashIndex,
|
|
302
|
+
setSlashSearch,
|
|
283
303
|
slashMenu,
|
|
304
|
+
slashSearchQuery,
|
|
284
305
|
sourceValue: latestMarkdownRef.current,
|
|
285
306
|
sourceSelectionRequest,
|
|
286
307
|
syncRichContent
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { normalizeCount } from '@admin/utils/editor/table-input'
|
|
4
|
+
import { insertContentEditorTable } from '@admin/utils/editor/tiptap'
|
|
5
|
+
import type { Editor } from '@tiptap/core'
|
|
6
|
+
import * as React from 'react'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TABLE_ROWS = 3
|
|
9
|
+
const DEFAULT_TABLE_COLS = 3
|
|
10
|
+
const MIN_TABLE_ROWS = 2
|
|
11
|
+
const MIN_TABLE_COLS = 1
|
|
12
|
+
|
|
13
|
+
interface UseTableButtonOptions {
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
editor?: Editor | null
|
|
16
|
+
onInsertTable?: (size: { cols: number; rows: number }) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useTableButton({ disabled, editor, onInsertTable }: UseTableButtonOptions) {
|
|
20
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
21
|
+
const [rows, setRows] = React.useState(String(DEFAULT_TABLE_ROWS))
|
|
22
|
+
const [cols, setCols] = React.useState(String(DEFAULT_TABLE_COLS))
|
|
23
|
+
const rowsId = React.useId()
|
|
24
|
+
const colsId = React.useId()
|
|
25
|
+
const canInsert = Boolean(onInsertTable || editor?.isEditable)
|
|
26
|
+
const isDisabled = disabled || !canInsert
|
|
27
|
+
|
|
28
|
+
const closePopover = React.useCallback(() => {
|
|
29
|
+
setIsOpen(false)
|
|
30
|
+
window.setTimeout(() => setIsOpen(false), 0)
|
|
31
|
+
}, [])
|
|
32
|
+
|
|
33
|
+
const insertTable = React.useCallback(() => {
|
|
34
|
+
if (isDisabled) return
|
|
35
|
+
|
|
36
|
+
const rowCount = normalizeCount(rows, DEFAULT_TABLE_ROWS, MIN_TABLE_ROWS)
|
|
37
|
+
const colCount = normalizeCount(cols, DEFAULT_TABLE_COLS, MIN_TABLE_COLS)
|
|
38
|
+
|
|
39
|
+
if (onInsertTable) {
|
|
40
|
+
onInsertTable({ cols: colCount, rows: rowCount })
|
|
41
|
+
closePopover()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!editor) return
|
|
46
|
+
|
|
47
|
+
insertContentEditorTable(editor, rowCount, colCount)
|
|
48
|
+
closePopover()
|
|
49
|
+
}, [closePopover, cols, editor, isDisabled, onInsertTable, rows])
|
|
50
|
+
|
|
51
|
+
const handleKeyDown = React.useCallback(
|
|
52
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
53
|
+
if (event.key !== 'Enter') return
|
|
54
|
+
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
event.stopPropagation()
|
|
57
|
+
insertTable()
|
|
58
|
+
},
|
|
59
|
+
[insertTable]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const handleRowsChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
63
|
+
setRows(event.target.value)
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
const handleColsChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
67
|
+
setCols(event.target.value)
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
cols,
|
|
72
|
+
colsId,
|
|
73
|
+
handleColsChange,
|
|
74
|
+
handleKeyDown,
|
|
75
|
+
handleRowsChange,
|
|
76
|
+
insertTable,
|
|
77
|
+
isDisabled,
|
|
78
|
+
isOpen,
|
|
79
|
+
minCols: MIN_TABLE_COLS,
|
|
80
|
+
minRows: MIN_TABLE_ROWS,
|
|
81
|
+
rows,
|
|
82
|
+
rowsId,
|
|
83
|
+
setIsOpen
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { AuditLogPage, AuditLogQuery, CleanupAuditLogsResult } from '@admin/actions/audit'
|
|
4
|
+
import { cleanupAuditLogs, getAuditLogs } from '@admin/actions/audit'
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
6
|
+
|
|
7
|
+
export function useAuditLogs(query: AuditLogQuery) {
|
|
8
|
+
return useQuery<AuditLogPage>({
|
|
9
|
+
queryKey: ['audit-log', query],
|
|
10
|
+
queryFn: () => getAuditLogs(query)
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useCleanupAuditLogs() {
|
|
15
|
+
const queryClient = useQueryClient()
|
|
16
|
+
|
|
17
|
+
return useMutation<CleanupAuditLogsResult>({
|
|
18
|
+
mutationFn: () => cleanupAuditLogs(),
|
|
19
|
+
onSuccess: () => {
|
|
20
|
+
void queryClient.invalidateQueries({ queryKey: ['audit-log'] })
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { getSetting } from '@admin/actions/settings'
|
|
4
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
5
|
+
import db from '@admin/db'
|
|
6
|
+
import { auditLog } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
8
|
+
import { lt } from 'drizzle-orm'
|
|
9
|
+
import { getAuditRequestMetadata } from './request-metadata'
|
|
10
|
+
import type { CleanupAuditLogsResult } from './types'
|
|
11
|
+
|
|
12
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
function getRetentionDays(value: unknown): number | null {
|
|
15
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null
|
|
16
|
+
const days = Math.floor(value)
|
|
17
|
+
return days > 0 ? days : null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function cleanupAuditLogs(): Promise<CleanupAuditLogsResult> {
|
|
21
|
+
const actor = await requireRole([UserRole.ADMIN])
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const settings = await getSetting()
|
|
25
|
+
const retentionEnabled = settings?.auditRetentionEnabled === true
|
|
26
|
+
const retentionDays = getRetentionDays(settings?.auditRetentionDays)
|
|
27
|
+
|
|
28
|
+
if (!retentionEnabled || !retentionDays) {
|
|
29
|
+
return { success: true, deletedCount: 0, skipped: true }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cutoff = new Date(Date.now() - retentionDays * MS_PER_DAY).toISOString()
|
|
33
|
+
const deletedRows = await db
|
|
34
|
+
.delete(auditLog)
|
|
35
|
+
.where(lt(auditLog.createdAt, cutoff))
|
|
36
|
+
.returning({ id: auditLog.id })
|
|
37
|
+
|
|
38
|
+
const requestMetadata = await getAuditRequestMetadata()
|
|
39
|
+
await runAdminEventHooks({
|
|
40
|
+
category: 'system',
|
|
41
|
+
action: 'cleanup',
|
|
42
|
+
targetType: 'audit-log',
|
|
43
|
+
targetLabel: 'Audit log retention cleanup',
|
|
44
|
+
user: actor,
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
sourceType: 'system_job',
|
|
47
|
+
changedFields: [],
|
|
48
|
+
metadata: {
|
|
49
|
+
cutoff,
|
|
50
|
+
deletedCount: deletedRows.length,
|
|
51
|
+
retentionDays
|
|
52
|
+
},
|
|
53
|
+
...requestMetadata
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return { success: true, deletedCount: deletedRows.length, cutoff }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
error: error instanceof Error ? error.message : 'Failed to clean up audit logs'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
|
+
import db from '@admin/db'
|
|
5
|
+
import { auditLog } from '@admin/db/schema'
|
|
6
|
+
import { and, asc, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
|
|
7
|
+
import type { AuditLogEntry, AuditLogPage, AuditLogQuery } from './types'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_AUDIT_LOG_PAGE = 0
|
|
10
|
+
const DEFAULT_AUDIT_LOG_PAGE_SIZE = 20
|
|
11
|
+
const MAX_AUDIT_LOG_PAGE_SIZE = 100
|
|
12
|
+
|
|
13
|
+
function clampPage(value: number | undefined): number {
|
|
14
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_AUDIT_LOG_PAGE
|
|
15
|
+
return Math.max(0, Math.floor(value))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizePageSize(value: number | undefined): number | null {
|
|
19
|
+
if (value === -1) return null
|
|
20
|
+
|
|
21
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
22
|
+
return DEFAULT_AUDIT_LOG_PAGE_SIZE
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Math.min(MAX_AUDIT_LOG_PAGE_SIZE, Math.max(1, Math.floor(value)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getAuditLogOrderBy(query: AuditLogQuery) {
|
|
29
|
+
const sortFn = query.dir === 'asc' ? asc : desc
|
|
30
|
+
|
|
31
|
+
switch (query.sort) {
|
|
32
|
+
case 'event':
|
|
33
|
+
return [sortFn(auditLog.category), sortFn(auditLog.action), desc(auditLog.createdAt)]
|
|
34
|
+
case 'target':
|
|
35
|
+
return [
|
|
36
|
+
sortFn(auditLog.targetType),
|
|
37
|
+
sortFn(auditLog.targetLabel),
|
|
38
|
+
sortFn(auditLog.targetId),
|
|
39
|
+
desc(auditLog.createdAt)
|
|
40
|
+
]
|
|
41
|
+
case 'actor':
|
|
42
|
+
return [sortFn(auditLog.actorEmail), sortFn(auditLog.actorName), desc(auditLog.createdAt)]
|
|
43
|
+
case 'sourceType':
|
|
44
|
+
return [sortFn(auditLog.sourceType), desc(auditLog.createdAt)]
|
|
45
|
+
default:
|
|
46
|
+
return [sortFn(auditLog.createdAt)]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getExclusiveDateUpperBound(value: string | undefined): string | null {
|
|
51
|
+
if (!value) return null
|
|
52
|
+
const date = new Date(value)
|
|
53
|
+
if (Number.isNaN(date.getTime())) return null
|
|
54
|
+
|
|
55
|
+
date.setUTCDate(date.getUTCDate() + 1)
|
|
56
|
+
return date.toISOString()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function queryAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
|
|
60
|
+
const page = clampPage(query.page)
|
|
61
|
+
const pageSize = normalizePageSize(query.pageSize)
|
|
62
|
+
const offset = pageSize ? page * pageSize : 0
|
|
63
|
+
const conditions = []
|
|
64
|
+
|
|
65
|
+
if (query.category) conditions.push(eq(auditLog.category, query.category))
|
|
66
|
+
if (query.action) conditions.push(eq(auditLog.action, query.action))
|
|
67
|
+
if (query.targetType) conditions.push(ilike(auditLog.targetType, `%${query.targetType}%`))
|
|
68
|
+
if (query.actor) {
|
|
69
|
+
const actorPattern = `%${query.actor}%`
|
|
70
|
+
conditions.push(
|
|
71
|
+
or(ilike(auditLog.actorEmail, actorPattern), ilike(auditLog.actorName, actorPattern))
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (query.createdAtFrom) {
|
|
75
|
+
conditions.push(gte(auditLog.createdAt, query.createdAtFrom))
|
|
76
|
+
}
|
|
77
|
+
const createdAtExclusiveTo = getExclusiveDateUpperBound(query.createdAtTo)
|
|
78
|
+
if (createdAtExclusiveTo) {
|
|
79
|
+
conditions.push(lt(auditLog.createdAt, createdAtExclusiveTo))
|
|
80
|
+
}
|
|
81
|
+
if (query.search) {
|
|
82
|
+
const searchPattern = `%${query.search}%`
|
|
83
|
+
conditions.push(
|
|
84
|
+
or(
|
|
85
|
+
ilike(auditLog.targetId, searchPattern),
|
|
86
|
+
ilike(auditLog.targetLabel, searchPattern),
|
|
87
|
+
ilike(auditLog.actorEmail, searchPattern),
|
|
88
|
+
ilike(auditLog.actorName, searchPattern),
|
|
89
|
+
ilike(auditLog.sourceType, searchPattern),
|
|
90
|
+
ilike(auditLog.ipAddress, searchPattern),
|
|
91
|
+
ilike(auditLog.userAgent, searchPattern)
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined
|
|
97
|
+
const orderBy = getAuditLogOrderBy(query)
|
|
98
|
+
const orderedQuery = db
|
|
99
|
+
.select()
|
|
100
|
+
.from(auditLog)
|
|
101
|
+
.where(where)
|
|
102
|
+
.orderBy(...orderBy)
|
|
103
|
+
const [rows, countResult] = await Promise.all([
|
|
104
|
+
pageSize ? orderedQuery.limit(pageSize).offset(offset) : orderedQuery,
|
|
105
|
+
db.select({ count: sql<number>`count(*)` }).from(auditLog).where(where)
|
|
106
|
+
])
|
|
107
|
+
const total = Number(countResult[0]?.count ?? 0)
|
|
108
|
+
const resolvedPageSize = pageSize ?? total
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
auditLogs: rows as AuditLogEntry[],
|
|
112
|
+
total,
|
|
113
|
+
page,
|
|
114
|
+
pageSize: resolvedPageSize,
|
|
115
|
+
totalPages: pageSize ? Math.max(1, Math.ceil(total / pageSize)) : 1
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function getAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
|
|
120
|
+
await requireRole([UserRole.ADMIN])
|
|
121
|
+
return queryAuditLogs(query)
|
|
122
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { cleanupAuditLogs } from './cleanup-audit-logs'
|
|
2
|
+
export { getAuditLogs } from './get-audit-logs'
|
|
3
|
+
export type {
|
|
4
|
+
AuditLogAction,
|
|
5
|
+
AuditLogCategory,
|
|
6
|
+
AuditLogEntry,
|
|
7
|
+
AuditLogPage,
|
|
8
|
+
AuditLogQuery,
|
|
9
|
+
AuditLogSortDirection,
|
|
10
|
+
AuditLogSortField,
|
|
11
|
+
AuditLogSourceType,
|
|
12
|
+
CleanupAuditLogsResult
|
|
13
|
+
} from './types'
|
|
14
|
+
export {
|
|
15
|
+
AUDIT_LOG_ACTIONS,
|
|
16
|
+
AUDIT_LOG_CATEGORIES,
|
|
17
|
+
AUDIT_LOG_SOURCE_TYPES,
|
|
18
|
+
auditLogCacheTags
|
|
19
|
+
} from './types'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import db from '@admin/db'
|
|
2
|
+
import { auditLog } from '@admin/db/schema'
|
|
3
|
+
import type { AdminEventContext } from '@admin/lib/lifecycle-hooks/types'
|
|
4
|
+
|
|
5
|
+
const MAX_TARGET_TYPE_LENGTH = 100
|
|
6
|
+
const MAX_SOURCE_TYPE_LENGTH = 50
|
|
7
|
+
const MAX_IP_LENGTH = 100
|
|
8
|
+
const MAX_USER_AGENT_LENGTH = 500
|
|
9
|
+
|
|
10
|
+
function truncate(value: string | null | undefined, maxLength: number): string | null {
|
|
11
|
+
if (!value) return null
|
|
12
|
+
return value.length > maxLength ? value.slice(0, maxLength) : value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeChangedFields(value: readonly string[] | undefined): string[] {
|
|
16
|
+
return [...new Set(value ?? [])].filter(Boolean).sort((a, b) => a.localeCompare(b))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function recordAuditEvent(event: AdminEventContext): Promise<void> {
|
|
20
|
+
await db.insert(auditLog).values({
|
|
21
|
+
category: event.category,
|
|
22
|
+
action: event.action,
|
|
23
|
+
targetType: truncate(event.targetType, MAX_TARGET_TYPE_LENGTH) ?? 'unknown',
|
|
24
|
+
targetId: event.targetId ?? null,
|
|
25
|
+
targetLabel: event.targetLabel ?? null,
|
|
26
|
+
actorUserId: event.user?.id ?? null,
|
|
27
|
+
actorEmail: event.user?.email ?? null,
|
|
28
|
+
actorName: event.user?.name ?? null,
|
|
29
|
+
actorRole: typeof event.user?.role === 'string' ? event.user.role : null,
|
|
30
|
+
sourceType: truncate(event.sourceType, MAX_SOURCE_TYPE_LENGTH) ?? 'server_action',
|
|
31
|
+
ipAddress: truncate(event.ipAddress, MAX_IP_LENGTH),
|
|
32
|
+
userAgent: truncate(event.userAgent, MAX_USER_AGENT_LENGTH),
|
|
33
|
+
changedFields: normalizeChangedFields(event.changedFields),
|
|
34
|
+
metadata: event.metadata ?? null,
|
|
35
|
+
createdAt: event.timestamp
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { registerAdminHook } from '@admin/lib/lifecycle-hooks/registry'
|
|
2
|
+
import { recordAuditEvent } from './record-audit-event'
|
|
3
|
+
|
|
4
|
+
let auditHooksRegistered = false
|
|
5
|
+
|
|
6
|
+
export function registerAuditHooks(): void {
|
|
7
|
+
if (auditHooksRegistered) return
|
|
8
|
+
auditHooksRegistered = true
|
|
9
|
+
|
|
10
|
+
registerAdminHook({}, async (event) => {
|
|
11
|
+
await recordAuditEvent(event)
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { headers } from 'next/headers'
|
|
2
|
+
|
|
3
|
+
const MAX_IP_LENGTH = 100
|
|
4
|
+
const MAX_USER_AGENT_LENGTH = 500
|
|
5
|
+
|
|
6
|
+
function truncate(value: string | null, maxLength: number): string | null {
|
|
7
|
+
if (!value) return null
|
|
8
|
+
return value.length > maxLength ? value.slice(0, maxLength) : value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function firstHeaderValue(value: string | null): string | null {
|
|
12
|
+
return value?.split(',')[0]?.trim() || null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function getAuditRequestMetadata(): Promise<{
|
|
16
|
+
ipAddress: string | null
|
|
17
|
+
userAgent: string | null
|
|
18
|
+
}> {
|
|
19
|
+
try {
|
|
20
|
+
const requestHeaders = await headers()
|
|
21
|
+
const ipAddress =
|
|
22
|
+
firstHeaderValue(requestHeaders.get('x-forwarded-for')) ??
|
|
23
|
+
firstHeaderValue(requestHeaders.get('x-real-ip')) ??
|
|
24
|
+
firstHeaderValue(requestHeaders.get('cf-connecting-ip'))
|
|
25
|
+
const userAgent = requestHeaders.get('user-agent')?.trim() || null
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ipAddress: truncate(ipAddress, MAX_IP_LENGTH),
|
|
29
|
+
userAgent: truncate(userAgent, MAX_USER_AGENT_LENGTH)
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
ipAddress: null,
|
|
34
|
+
userAgent: null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export const AUDIT_LOG_CATEGORIES = [
|
|
2
|
+
'entity',
|
|
3
|
+
'form',
|
|
4
|
+
'settings',
|
|
5
|
+
'user',
|
|
6
|
+
'auth',
|
|
7
|
+
'media',
|
|
8
|
+
'system'
|
|
9
|
+
] as const
|
|
10
|
+
export const AUDIT_LOG_ACTIONS = [
|
|
11
|
+
'create',
|
|
12
|
+
'update',
|
|
13
|
+
'delete',
|
|
14
|
+
'publish',
|
|
15
|
+
'unpublish',
|
|
16
|
+
'cleanup'
|
|
17
|
+
] as const
|
|
18
|
+
export const AUDIT_LOG_SOURCE_TYPES = ['admin_ui', 'server_action', 'system_job'] as const
|
|
19
|
+
|
|
20
|
+
export type AuditLogCategory = (typeof AUDIT_LOG_CATEGORIES)[number]
|
|
21
|
+
export type AuditLogAction = (typeof AUDIT_LOG_ACTIONS)[number]
|
|
22
|
+
export type AuditLogSourceType = (typeof AUDIT_LOG_SOURCE_TYPES)[number]
|
|
23
|
+
export type AuditLogSortField = 'createdAt' | 'event' | 'target' | 'actor' | 'sourceType'
|
|
24
|
+
export type AuditLogSortDirection = 'asc' | 'desc'
|
|
25
|
+
|
|
26
|
+
export interface AuditLogEntry {
|
|
27
|
+
id: string
|
|
28
|
+
category: AuditLogCategory
|
|
29
|
+
action: AuditLogAction
|
|
30
|
+
targetType: string
|
|
31
|
+
targetId: string | null
|
|
32
|
+
targetLabel: string | null
|
|
33
|
+
actorUserId: string | null
|
|
34
|
+
actorEmail: string | null
|
|
35
|
+
actorName: string | null
|
|
36
|
+
actorRole: string | null
|
|
37
|
+
sourceType: AuditLogSourceType
|
|
38
|
+
ipAddress: string | null
|
|
39
|
+
userAgent: string | null
|
|
40
|
+
changedFields: string[]
|
|
41
|
+
metadata: Record<string, unknown> | null
|
|
42
|
+
createdAt: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AuditLogQuery {
|
|
46
|
+
search?: string
|
|
47
|
+
category?: AuditLogCategory
|
|
48
|
+
action?: AuditLogAction
|
|
49
|
+
targetType?: string
|
|
50
|
+
actor?: string
|
|
51
|
+
createdAtFrom?: string
|
|
52
|
+
createdAtTo?: string
|
|
53
|
+
page?: number
|
|
54
|
+
pageSize?: number
|
|
55
|
+
sort?: AuditLogSortField
|
|
56
|
+
dir?: AuditLogSortDirection
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AuditLogPage {
|
|
60
|
+
auditLogs: AuditLogEntry[]
|
|
61
|
+
total: number
|
|
62
|
+
page: number
|
|
63
|
+
pageSize: number
|
|
64
|
+
totalPages: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CleanupAuditLogsResult {
|
|
68
|
+
success: boolean
|
|
69
|
+
error?: string
|
|
70
|
+
deletedCount?: number
|
|
71
|
+
cutoff?: string
|
|
72
|
+
skipped?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const auditLogCacheTags = {
|
|
76
|
+
all: 'audit-log:all'
|
|
77
|
+
} as const
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
3
4
|
import db from '@admin/db'
|
|
4
5
|
import { formSettings } from '@admin/db/schema'
|
|
5
6
|
import { updateTag } from 'next/cache'
|
|
@@ -10,6 +11,8 @@ export async function upsertFormSettings(
|
|
|
10
11
|
formName: string,
|
|
11
12
|
data: UpsertFormSettingsInput
|
|
12
13
|
): Promise<FormSettingsResult> {
|
|
14
|
+
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
15
|
+
|
|
13
16
|
try {
|
|
14
17
|
const [settings] = await db
|
|
15
18
|
.insert(formSettings)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
3
4
|
import { getSession } from '@admin/auth/middleware'
|
|
4
5
|
import db from '@admin/db'
|
|
5
6
|
import { adminMedia } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
8
|
+
import { getTouchedFieldNames } from '@admin/utils/audit/audit'
|
|
6
9
|
import { updateTag } from 'next/cache'
|
|
7
10
|
import { getMediaById } from './get-media-by-id'
|
|
8
11
|
import { type CreateMediaInput, type CreateMediaResult, mediaCacheTags } from './types'
|
|
@@ -12,6 +15,7 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
|
|
|
12
15
|
const session = await getSession()
|
|
13
16
|
const actorId = session?.user?.id ?? null
|
|
14
17
|
const now = new Date().toISOString()
|
|
18
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
15
19
|
const [row] = await db
|
|
16
20
|
.insert(adminMedia)
|
|
17
21
|
.values({
|
|
@@ -27,6 +31,21 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
|
|
|
27
31
|
updateTag(mediaCacheTags.byId(row.id))
|
|
28
32
|
}
|
|
29
33
|
const media = row ? await getMediaById(row.id) : null
|
|
34
|
+
if (media) {
|
|
35
|
+
runAdminEventHooks({
|
|
36
|
+
category: 'media',
|
|
37
|
+
action: 'create',
|
|
38
|
+
targetType: 'media',
|
|
39
|
+
targetId: media.id,
|
|
40
|
+
targetLabel: media.filename,
|
|
41
|
+
user: session?.user ?? null,
|
|
42
|
+
timestamp: now,
|
|
43
|
+
sourceType: 'admin_ui' as const,
|
|
44
|
+
changedFields: getTouchedFieldNames(data as unknown as Record<string, unknown>),
|
|
45
|
+
metadata: { contentType: media.contentType, size: media.size },
|
|
46
|
+
...auditRequestMetadata
|
|
47
|
+
})
|
|
48
|
+
}
|
|
30
49
|
return media
|
|
31
50
|
? { success: true, media }
|
|
32
51
|
: { success: false, error: 'Failed to create media record' }
|