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
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
4
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
3
5
|
import db from '@admin/db'
|
|
4
6
|
import { adminMedia } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
5
8
|
import { inArray } from 'drizzle-orm'
|
|
6
9
|
import { updateTag } from 'next/cache'
|
|
7
10
|
import type { DeleteMediaBulkResult } from './types'
|
|
8
11
|
import { mediaCacheTags } from './types'
|
|
9
12
|
|
|
10
13
|
export async function deleteMediaBulk(ids: string[]): Promise<DeleteMediaBulkResult> {
|
|
14
|
+
const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
15
|
+
|
|
11
16
|
if (ids.length === 0) return { success: true, deletedCount: 0 }
|
|
12
17
|
|
|
13
18
|
try {
|
|
19
|
+
const now = new Date().toISOString()
|
|
20
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
21
|
+
const mediaRows = await db.select().from(adminMedia).where(inArray(adminMedia.id, ids))
|
|
14
22
|
const deletedRows = await db
|
|
15
23
|
.delete(adminMedia)
|
|
16
24
|
.where(inArray(adminMedia.id, ids))
|
|
@@ -19,6 +27,21 @@ export async function deleteMediaBulk(ids: string[]): Promise<DeleteMediaBulkRes
|
|
|
19
27
|
for (const id of ids) {
|
|
20
28
|
updateTag(mediaCacheTags.byId(id))
|
|
21
29
|
}
|
|
30
|
+
for (const media of mediaRows) {
|
|
31
|
+
runAdminEventHooks({
|
|
32
|
+
category: 'media',
|
|
33
|
+
action: 'delete',
|
|
34
|
+
targetType: 'media',
|
|
35
|
+
targetId: media.id,
|
|
36
|
+
targetLabel: media.filename,
|
|
37
|
+
user: actor,
|
|
38
|
+
timestamp: now,
|
|
39
|
+
sourceType: 'admin_ui' as const,
|
|
40
|
+
changedFields: [],
|
|
41
|
+
metadata: { contentType: media.contentType, bulk: true },
|
|
42
|
+
...auditRequestMetadata
|
|
43
|
+
})
|
|
44
|
+
}
|
|
22
45
|
return { success: true, deletedCount: deletedRows.length }
|
|
23
46
|
} catch (error) {
|
|
24
47
|
return {
|
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
4
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
3
5
|
import db from '@admin/db'
|
|
4
6
|
import { adminMedia } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
5
8
|
import { eq } from 'drizzle-orm'
|
|
6
9
|
import { updateTag } from 'next/cache'
|
|
7
10
|
import type { DeleteMediaResult } from './types'
|
|
8
11
|
import { mediaCacheTags } from './types'
|
|
9
12
|
|
|
10
13
|
export async function deleteMedia(id: string): Promise<DeleteMediaResult> {
|
|
14
|
+
const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
15
|
+
|
|
11
16
|
try {
|
|
17
|
+
const now = new Date().toISOString()
|
|
18
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
19
|
+
const [deletedMedia] = await db.select().from(adminMedia).where(eq(adminMedia.id, id)).limit(1)
|
|
12
20
|
await db.delete(adminMedia).where(eq(adminMedia.id, id))
|
|
13
21
|
updateTag(mediaCacheTags.all)
|
|
14
22
|
updateTag(mediaCacheTags.byId(id))
|
|
23
|
+
runAdminEventHooks({
|
|
24
|
+
category: 'media',
|
|
25
|
+
action: 'delete',
|
|
26
|
+
targetType: 'media',
|
|
27
|
+
targetId: id,
|
|
28
|
+
targetLabel: deletedMedia?.filename ?? id,
|
|
29
|
+
user: actor,
|
|
30
|
+
timestamp: now,
|
|
31
|
+
sourceType: 'admin_ui' as const,
|
|
32
|
+
changedFields: [],
|
|
33
|
+
metadata: { contentType: deletedMedia?.contentType ?? null },
|
|
34
|
+
...auditRequestMetadata
|
|
35
|
+
})
|
|
15
36
|
return { success: true }
|
|
16
37
|
} catch (error) {
|
|
17
38
|
return {
|
|
@@ -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 { getChangedFieldNames } from '@admin/utils/audit/audit'
|
|
6
9
|
import { eq } from 'drizzle-orm'
|
|
7
10
|
import { updateTag } from 'next/cache'
|
|
8
11
|
import type { UpdateMediaResult } from './types'
|
|
@@ -15,16 +18,35 @@ export async function updateMedia(
|
|
|
15
18
|
try {
|
|
16
19
|
const session = await getSession()
|
|
17
20
|
const actorId = session?.user?.id ?? null
|
|
21
|
+
const now = new Date().toISOString()
|
|
22
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
23
|
+
const [previousMedia] = await db.select().from(adminMedia).where(eq(adminMedia.id, id)).limit(1)
|
|
18
24
|
await db
|
|
19
25
|
.update(adminMedia)
|
|
20
26
|
.set({
|
|
21
27
|
...data,
|
|
22
|
-
updatedAt:
|
|
28
|
+
updatedAt: now,
|
|
23
29
|
updatedBy: actorId
|
|
24
30
|
})
|
|
25
31
|
.where(eq(adminMedia.id, id))
|
|
26
32
|
updateTag(mediaCacheTags.all)
|
|
27
33
|
updateTag(mediaCacheTags.byId(id))
|
|
34
|
+
runAdminEventHooks({
|
|
35
|
+
category: 'media',
|
|
36
|
+
action: 'update',
|
|
37
|
+
targetType: 'media',
|
|
38
|
+
targetId: id,
|
|
39
|
+
targetLabel: previousMedia?.filename ?? id,
|
|
40
|
+
user: session?.user ?? null,
|
|
41
|
+
timestamp: now,
|
|
42
|
+
sourceType: 'admin_ui' as const,
|
|
43
|
+
changedFields: getChangedFieldNames(
|
|
44
|
+
previousMedia as unknown as Record<string, unknown>,
|
|
45
|
+
data as Record<string, unknown>
|
|
46
|
+
),
|
|
47
|
+
metadata: { contentType: previousMedia?.contentType ?? null },
|
|
48
|
+
...auditRequestMetadata
|
|
49
|
+
})
|
|
28
50
|
return { success: true }
|
|
29
51
|
} catch (error) {
|
|
30
52
|
return {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
3
4
|
import { auth } from '@admin/auth'
|
|
4
5
|
import { getSession } from '@admin/auth/middleware'
|
|
5
6
|
import db from '@admin/db'
|
|
6
7
|
import { user } from '@admin/db/schema'
|
|
8
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
7
9
|
import { eq } from 'drizzle-orm'
|
|
8
10
|
import { headers } from 'next/headers'
|
|
9
11
|
import { invalidateUsersCache } from './invalidate-users-cache'
|
|
@@ -19,6 +21,8 @@ export async function updateEmail(
|
|
|
19
21
|
return { success: false, error: 'Not authenticated' }
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
const now = new Date().toISOString()
|
|
25
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
22
26
|
const verification = await auth.api
|
|
23
27
|
.verifyPassword({
|
|
24
28
|
body: { password: currentPassword },
|
|
@@ -46,6 +50,19 @@ export async function updateEmail(
|
|
|
46
50
|
.where(eq(user.id, session.user.id))
|
|
47
51
|
|
|
48
52
|
await invalidateUsersCache()
|
|
53
|
+
runAdminEventHooks({
|
|
54
|
+
category: 'auth',
|
|
55
|
+
action: 'update',
|
|
56
|
+
targetType: 'user',
|
|
57
|
+
targetId: session.user.id,
|
|
58
|
+
targetLabel: session.user.email,
|
|
59
|
+
user: session.user,
|
|
60
|
+
timestamp: now,
|
|
61
|
+
sourceType: 'admin_ui' as const,
|
|
62
|
+
changedFields: ['email'],
|
|
63
|
+
metadata: { operation: 'email_change' },
|
|
64
|
+
...auditRequestMetadata
|
|
65
|
+
})
|
|
49
66
|
|
|
50
67
|
return { success: true }
|
|
51
68
|
} catch (error) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createMedia } from '@admin/actions/media'
|
|
4
4
|
import { saveUpload } from '@admin/actions/storage'
|
|
5
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
5
6
|
import type { UploadedFile } from '@admin/types'
|
|
6
7
|
import type { FileValidationConfig, UploadResult } from './types'
|
|
7
8
|
|
|
@@ -9,6 +10,8 @@ export async function uploadFiles(
|
|
|
9
10
|
formData: FormData,
|
|
10
11
|
config: FileValidationConfig = {}
|
|
11
12
|
): Promise<UploadResult> {
|
|
13
|
+
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
14
|
+
|
|
12
15
|
try {
|
|
13
16
|
const files: File[] = []
|
|
14
17
|
const prefix = formData.get('prefix')?.toString() || 'uploads'
|
package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createMedia } from '@admin/actions/media'
|
|
4
4
|
import { saveUpload } from '@admin/actions/storage'
|
|
5
|
+
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
5
6
|
import {
|
|
6
7
|
DEFAULT_REMOTE_ACCEPT,
|
|
7
8
|
getRemoteFilename,
|
|
@@ -14,6 +15,8 @@ export async function uploadMediaFromUrl(
|
|
|
14
15
|
mediaUrl: string,
|
|
15
16
|
{ prefix = 'media', accept = DEFAULT_REMOTE_ACCEPT, maxSizeInBytes }: RemoteUploadOptions = {}
|
|
16
17
|
): Promise<UploadResult> {
|
|
18
|
+
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
19
|
+
|
|
17
20
|
try {
|
|
18
21
|
const url = new URL(mediaUrl)
|
|
19
22
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
3
4
|
import { auth } from '@admin/auth'
|
|
4
5
|
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
5
6
|
import db from '@admin/db'
|
|
6
7
|
import { user } from '@admin/db/schema'
|
|
8
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
9
|
+
import { getTouchedFieldNames } from '@admin/utils/audit/audit'
|
|
7
10
|
import { eq } from 'drizzle-orm'
|
|
8
11
|
import { updateTag } from 'next/cache'
|
|
9
12
|
import type { CreateUserInput, CreateUserResult } from './types'
|
|
10
13
|
import { usersCacheTags } from './types'
|
|
11
14
|
|
|
12
15
|
export async function createUser(input: CreateUserInput): Promise<CreateUserResult> {
|
|
13
|
-
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
16
|
+
const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
14
17
|
|
|
15
18
|
try {
|
|
19
|
+
const now = new Date().toISOString()
|
|
20
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
16
21
|
const result = await auth.api.signUpEmail({
|
|
17
22
|
body: {
|
|
18
23
|
email: input.email,
|
|
@@ -33,6 +38,19 @@ export async function createUser(input: CreateUserInput): Promise<CreateUserResu
|
|
|
33
38
|
})
|
|
34
39
|
.where(eq(user.id, result.user.id))
|
|
35
40
|
updateTag(usersCacheTags.all)
|
|
41
|
+
runAdminEventHooks({
|
|
42
|
+
category: 'user',
|
|
43
|
+
action: 'create',
|
|
44
|
+
targetType: 'user',
|
|
45
|
+
targetId: result.user.id,
|
|
46
|
+
targetLabel: result.user.email,
|
|
47
|
+
user: actor,
|
|
48
|
+
timestamp: now,
|
|
49
|
+
sourceType: 'admin_ui' as const,
|
|
50
|
+
changedFields: getTouchedFieldNames(input as unknown as Record<string, unknown>),
|
|
51
|
+
metadata: { role: input.role },
|
|
52
|
+
...auditRequestMetadata
|
|
53
|
+
})
|
|
36
54
|
|
|
37
55
|
return {
|
|
38
56
|
success: true,
|
|
@@ -1,19 +1,41 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
3
4
|
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
5
|
import db from '@admin/db'
|
|
5
6
|
import { user } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
6
8
|
import { eq } from 'drizzle-orm'
|
|
7
9
|
import { updateTag } from 'next/cache'
|
|
8
10
|
import type { DeleteUserResult } from './types'
|
|
9
11
|
import { usersCacheTags } from './types'
|
|
10
12
|
|
|
11
13
|
export async function deleteUser(userId: string): Promise<DeleteUserResult> {
|
|
12
|
-
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
14
|
+
const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
13
15
|
|
|
14
16
|
try {
|
|
17
|
+
const now = new Date().toISOString()
|
|
18
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
19
|
+
const [deletedUser] = await db
|
|
20
|
+
.select({ email: user.email, name: user.name, role: user.role })
|
|
21
|
+
.from(user)
|
|
22
|
+
.where(eq(user.id, userId))
|
|
23
|
+
.limit(1)
|
|
15
24
|
await db.delete(user).where(eq(user.id, userId))
|
|
16
25
|
updateTag(usersCacheTags.all)
|
|
26
|
+
runAdminEventHooks({
|
|
27
|
+
category: 'user',
|
|
28
|
+
action: 'delete',
|
|
29
|
+
targetType: 'user',
|
|
30
|
+
targetId: userId,
|
|
31
|
+
targetLabel: deletedUser?.email ?? userId,
|
|
32
|
+
user: actor,
|
|
33
|
+
timestamp: now,
|
|
34
|
+
sourceType: 'admin_ui' as const,
|
|
35
|
+
changedFields: [],
|
|
36
|
+
metadata: { role: deletedUser?.role ?? null },
|
|
37
|
+
...auditRequestMetadata
|
|
38
|
+
})
|
|
17
39
|
return { success: true }
|
|
18
40
|
} catch (error) {
|
|
19
41
|
return {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use server'
|
|
2
2
|
|
|
3
|
+
import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
|
|
3
4
|
import { requireRole, UserRole } from '@admin/auth/middleware'
|
|
4
5
|
import db from '@admin/db'
|
|
5
6
|
import { user } from '@admin/db/schema'
|
|
7
|
+
import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
|
|
6
8
|
import { eq } from 'drizzle-orm'
|
|
7
9
|
import { updateTag } from 'next/cache'
|
|
8
10
|
import type { UpdateUserRoleResult } from './types'
|
|
@@ -12,11 +14,31 @@ export async function updateUserRole(
|
|
|
12
14
|
userId: string,
|
|
13
15
|
role: UserRole
|
|
14
16
|
): Promise<UpdateUserRoleResult> {
|
|
15
|
-
await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
17
|
+
const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
|
|
16
18
|
|
|
17
19
|
try {
|
|
20
|
+
const now = new Date().toISOString()
|
|
21
|
+
const auditRequestMetadata = await getAuditRequestMetadata()
|
|
22
|
+
const [previousUser] = await db
|
|
23
|
+
.select({ email: user.email, name: user.name, role: user.role })
|
|
24
|
+
.from(user)
|
|
25
|
+
.where(eq(user.id, userId))
|
|
26
|
+
.limit(1)
|
|
18
27
|
await db.update(user).set({ role, updatedAt: new Date() }).where(eq(user.id, userId))
|
|
19
28
|
updateTag(usersCacheTags.all)
|
|
29
|
+
runAdminEventHooks({
|
|
30
|
+
category: 'user',
|
|
31
|
+
action: 'update',
|
|
32
|
+
targetType: 'user',
|
|
33
|
+
targetId: userId,
|
|
34
|
+
targetLabel: previousUser?.email ?? userId,
|
|
35
|
+
user: actor,
|
|
36
|
+
timestamp: now,
|
|
37
|
+
sourceType: 'admin_ui' as const,
|
|
38
|
+
changedFields: previousUser?.role === role ? [] : ['role'],
|
|
39
|
+
metadata: { operation: 'role_update' },
|
|
40
|
+
...auditRequestMetadata
|
|
41
|
+
})
|
|
20
42
|
|
|
21
43
|
return { success: true }
|
|
22
44
|
} catch (error) {
|
|
@@ -129,6 +129,55 @@ export const entityVersions = pgTable(
|
|
|
129
129
|
]
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Audit Log
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
export const auditLog = pgTable(
|
|
137
|
+
'AuditLog',
|
|
138
|
+
{
|
|
139
|
+
id: uuid().defaultRandom().primaryKey().notNull(),
|
|
140
|
+
category: varchar('category', { length: 50 }).notNull(),
|
|
141
|
+
action: varchar('action', { length: 50 }).notNull(),
|
|
142
|
+
targetType: varchar('target_type', { length: 100 }).notNull(),
|
|
143
|
+
targetId: text('target_id'),
|
|
144
|
+
targetLabel: text('target_label'),
|
|
145
|
+
actorUserId: text('actor_user_id').references(() => user.id, {
|
|
146
|
+
onDelete: 'set null'
|
|
147
|
+
}),
|
|
148
|
+
actorEmail: text('actor_email'),
|
|
149
|
+
actorName: text('actor_name'),
|
|
150
|
+
actorRole: text('actor_role'),
|
|
151
|
+
sourceType: varchar('source_type', { length: 50 }).notNull().default('server_action'),
|
|
152
|
+
ipAddress: varchar('ip_address', { length: 100 }),
|
|
153
|
+
userAgent: varchar('user_agent', { length: 500 }),
|
|
154
|
+
changedFields: text('changed_fields').array().notNull().default(sql`ARRAY[]::text[]`),
|
|
155
|
+
metadata: jsonb('metadata'),
|
|
156
|
+
createdAt: timestamp('created_at', { precision: 3, mode: 'string' })
|
|
157
|
+
.default(sql`CURRENT_TIMESTAMP`)
|
|
158
|
+
.notNull()
|
|
159
|
+
},
|
|
160
|
+
(table) => [
|
|
161
|
+
index('AuditLog_createdAt_idx').using('btree', table.createdAt.desc().nullsLast()),
|
|
162
|
+
index('AuditLog_category_action_createdAt_idx').using(
|
|
163
|
+
'btree',
|
|
164
|
+
table.category.asc().nullsLast().op('text_ops'),
|
|
165
|
+
table.action.asc().nullsLast().op('text_ops'),
|
|
166
|
+
table.createdAt.desc().nullsLast()
|
|
167
|
+
),
|
|
168
|
+
index('AuditLog_target_idx').using(
|
|
169
|
+
'btree',
|
|
170
|
+
table.targetType.asc().nullsLast().op('text_ops'),
|
|
171
|
+
table.targetId.asc().nullsLast().op('text_ops')
|
|
172
|
+
),
|
|
173
|
+
index('AuditLog_actor_createdAt_idx').using(
|
|
174
|
+
'btree',
|
|
175
|
+
table.actorUserId.asc().nullsLast().op('text_ops'),
|
|
176
|
+
table.createdAt.desc().nullsLast()
|
|
177
|
+
)
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
|
|
132
181
|
// ============================================================================
|
|
133
182
|
// Media Library
|
|
134
183
|
// ============================================================================
|
|
@@ -3,11 +3,16 @@ import './register.local'
|
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
5
|
getLifecycleHookSummary,
|
|
6
|
+
getRegisteredAdminHooks,
|
|
6
7
|
getRegisteredHooks,
|
|
8
|
+
registerAdminHook,
|
|
7
9
|
registerHook
|
|
8
10
|
} from './registry'
|
|
9
|
-
export { runAfterHooks, runBeforeHooks } from './runner'
|
|
11
|
+
export { runAdminEventHooks, runAfterHooks, runBeforeHooks } from './runner'
|
|
10
12
|
export type {
|
|
13
|
+
AdminEventContext,
|
|
14
|
+
AdminEventDescriptor,
|
|
15
|
+
AdminEventHookHandler,
|
|
11
16
|
AfterLifecycleHookEvent,
|
|
12
17
|
AfterLifecycleHookHandler,
|
|
13
18
|
BeforeLifecycleHookEvent,
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AdminEventDescriptor,
|
|
3
|
+
AdminEventHookHandler,
|
|
4
|
+
LifecycleHookEvent,
|
|
5
|
+
LifecycleHookHandler
|
|
6
|
+
} from './types'
|
|
2
7
|
|
|
3
8
|
type HookBuckets = Map<LifecycleHookEvent, LifecycleHookHandler<LifecycleHookEvent>[]>
|
|
4
9
|
|
|
5
10
|
const lifecycleHooks = new Map<string, HookBuckets>()
|
|
11
|
+
const adminEventHooks: Array<{
|
|
12
|
+
descriptor: AdminEventDescriptor
|
|
13
|
+
handler: AdminEventHookHandler
|
|
14
|
+
}> = []
|
|
6
15
|
|
|
7
16
|
export function registerHook<Event extends LifecycleHookEvent>(
|
|
8
17
|
entityType: string,
|
|
@@ -53,3 +62,29 @@ export function getLifecycleHookSummary(): Record<
|
|
|
53
62
|
|
|
54
63
|
return summary
|
|
55
64
|
}
|
|
65
|
+
|
|
66
|
+
export function registerAdminHook(
|
|
67
|
+
descriptor: AdminEventDescriptor,
|
|
68
|
+
handler: AdminEventHookHandler
|
|
69
|
+
): () => void {
|
|
70
|
+
adminEventHooks.push({ descriptor, handler })
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
const index = adminEventHooks.findIndex((entry) => entry.handler === handler)
|
|
74
|
+
if (index >= 0) {
|
|
75
|
+
adminEventHooks.splice(index, 1)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getRegisteredAdminHooks(
|
|
81
|
+
event: Required<AdminEventDescriptor>
|
|
82
|
+
): AdminEventHookHandler[] {
|
|
83
|
+
return adminEventHooks
|
|
84
|
+
.filter(({ descriptor }) => {
|
|
85
|
+
if (descriptor.category && descriptor.category !== event.category) return false
|
|
86
|
+
if (descriptor.action && descriptor.action !== event.action) return false
|
|
87
|
+
return true
|
|
88
|
+
})
|
|
89
|
+
.map(({ handler }) => handler)
|
|
90
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { getRegisteredHooks } from './registry'
|
|
1
|
+
import { getRegisteredAdminHooks, getRegisteredHooks } from './registry'
|
|
2
2
|
import type {
|
|
3
|
+
AdminEventContext,
|
|
3
4
|
AfterLifecycleHookEvent,
|
|
4
5
|
BeforeLifecycleHookEvent,
|
|
5
6
|
LifecycleHookAbort,
|
|
@@ -33,19 +34,123 @@ export async function runBeforeHooks(
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
function getAdminActionFromLifecycleEvent(
|
|
38
|
+
event: AfterLifecycleHookEvent
|
|
39
|
+
): AdminEventContext['action'] | null {
|
|
40
|
+
if (event === 'afterCreate') return 'create'
|
|
41
|
+
if (event === 'afterUpdate') return 'update'
|
|
42
|
+
if (event === 'afterDelete') return 'delete'
|
|
43
|
+
if (event === 'afterPublish') return 'publish'
|
|
44
|
+
if (event === 'afterUnpublish') return 'unpublish'
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getTargetLabel(data: Record<string, unknown> | undefined): string | null {
|
|
49
|
+
if (!data) return null
|
|
50
|
+
|
|
51
|
+
for (const fieldName of ['title', 'name', 'label', 'slug', 'email', 'filename']) {
|
|
52
|
+
const value = data[fieldName]
|
|
53
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getChangedFieldsFromLifecycleContext(
|
|
60
|
+
event: AfterLifecycleHookEvent,
|
|
61
|
+
context: LifecycleHookContext
|
|
62
|
+
): string[] {
|
|
63
|
+
if (context.changedFields) return context.changedFields
|
|
64
|
+
if (event === 'afterDelete') return []
|
|
65
|
+
|
|
66
|
+
return Object.keys(context.input ?? {})
|
|
67
|
+
.filter((fieldName) => fieldName !== 'id')
|
|
68
|
+
.sort((a, b) => a.localeCompare(b))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toAdminEventContext(
|
|
72
|
+
event: AfterLifecycleHookEvent,
|
|
73
|
+
context: LifecycleHookContext
|
|
74
|
+
): AdminEventContext | null {
|
|
75
|
+
const action = getAdminActionFromLifecycleEvent(event)
|
|
76
|
+
if (!action) return null
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
category: context.schemaType === 'form' ? 'form' : 'entity',
|
|
80
|
+
action,
|
|
81
|
+
targetType: context.entityType,
|
|
82
|
+
targetId: context.entityId ?? null,
|
|
83
|
+
targetLabel: context.targetLabel ?? getTargetLabel(context.data),
|
|
84
|
+
user: context.user,
|
|
85
|
+
timestamp: context.timestamp,
|
|
86
|
+
sourceType: context.sourceType ?? 'admin_ui',
|
|
87
|
+
ipAddress: context.ipAddress ?? null,
|
|
88
|
+
userAgent: context.userAgent ?? null,
|
|
89
|
+
changedFields: getChangedFieldsFromLifecycleContext(event, context),
|
|
90
|
+
metadata: {
|
|
91
|
+
entityType: context.entityType,
|
|
92
|
+
schemaType: context.schemaType ?? 'entity',
|
|
93
|
+
...(context.metadata ?? {})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function runSettledHook<Context>(
|
|
99
|
+
handler: (context: Context) => void | Promise<void>,
|
|
100
|
+
context: Context
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
try {
|
|
103
|
+
return Promise.resolve(handler(context))
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return Promise.reject(error)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function runAdminEventHooks(context: AdminEventContext): void {
|
|
110
|
+
const handlers = getRegisteredAdminHooks({
|
|
111
|
+
category: context.category,
|
|
112
|
+
action: context.action
|
|
113
|
+
})
|
|
38
114
|
if (handlers.length === 0) return
|
|
39
115
|
|
|
40
|
-
void Promise.allSettled(handlers.map((handler) => handler
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
116
|
+
void Promise.allSettled(handlers.map((handler) => runSettledHook(handler, context))).then(
|
|
117
|
+
(results) => {
|
|
118
|
+
for (const result of results) {
|
|
119
|
+
if (result.status === 'rejected') {
|
|
120
|
+
console.error('Admin event hook failed:', {
|
|
121
|
+
category: context.category,
|
|
122
|
+
action: context.action,
|
|
123
|
+
targetType: context.targetType,
|
|
124
|
+
targetId: context.targetId,
|
|
125
|
+
error: result.reason
|
|
126
|
+
})
|
|
127
|
+
}
|
|
48
128
|
}
|
|
49
129
|
}
|
|
50
|
-
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function runAfterHooks(event: AfterLifecycleHookEvent, context: LifecycleHookContext): void {
|
|
134
|
+
const handlers = getRegisteredHooks(context.entityType, event)
|
|
135
|
+
|
|
136
|
+
if (handlers.length > 0) {
|
|
137
|
+
void Promise.allSettled(handlers.map((handler) => runSettledHook(handler, context))).then(
|
|
138
|
+
(results) => {
|
|
139
|
+
for (const result of results) {
|
|
140
|
+
if (result.status === 'rejected') {
|
|
141
|
+
console.error('Lifecycle hook failed:', {
|
|
142
|
+
entityType: context.entityType,
|
|
143
|
+
event,
|
|
144
|
+
error: result.reason
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const adminEvent = toAdminEventContext(event, context)
|
|
153
|
+
if (adminEvent) {
|
|
154
|
+
runAdminEventHooks(adminEvent)
|
|
155
|
+
}
|
|
51
156
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditLogAction,
|
|
3
|
+
AuditLogCategory,
|
|
4
|
+
AuditLogSourceType
|
|
5
|
+
} from '@admin/actions/audit/types'
|
|
1
6
|
import type { User } from '@admin/auth'
|
|
2
7
|
|
|
3
8
|
export type LifecycleHookEvent =
|
|
@@ -16,12 +21,39 @@ export type BeforeLifecycleHookEvent = Extract<LifecycleHookEvent, `before${stri
|
|
|
16
21
|
export type AfterLifecycleHookEvent = Extract<LifecycleHookEvent, `after${string}`>
|
|
17
22
|
|
|
18
23
|
export interface LifecycleHookContext {
|
|
19
|
-
user: User
|
|
24
|
+
user: User | null
|
|
20
25
|
entityType: string
|
|
26
|
+
schemaType?: 'entity' | 'form' | undefined
|
|
21
27
|
timestamp: string
|
|
22
28
|
entityId?: string | undefined
|
|
23
29
|
input?: Record<string, unknown> | undefined
|
|
24
30
|
data?: Record<string, unknown> | undefined
|
|
31
|
+
changedFields?: string[] | undefined
|
|
32
|
+
sourceType?: AuditLogSourceType | undefined
|
|
33
|
+
ipAddress?: string | null | undefined
|
|
34
|
+
userAgent?: string | null | undefined
|
|
35
|
+
targetLabel?: string | null | undefined
|
|
36
|
+
metadata?: Record<string, unknown> | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AdminEventDescriptor {
|
|
40
|
+
category?: AuditLogCategory
|
|
41
|
+
action?: AuditLogAction
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AdminEventContext {
|
|
45
|
+
category: AuditLogCategory
|
|
46
|
+
action: AuditLogAction
|
|
47
|
+
targetType: string
|
|
48
|
+
targetId?: string | null | undefined
|
|
49
|
+
targetLabel?: string | null | undefined
|
|
50
|
+
user?: User | null | undefined
|
|
51
|
+
timestamp: string
|
|
52
|
+
sourceType?: AuditLogSourceType | undefined
|
|
53
|
+
ipAddress?: string | null | undefined
|
|
54
|
+
userAgent?: string | null | undefined
|
|
55
|
+
changedFields?: string[] | undefined
|
|
56
|
+
metadata?: Record<string, unknown> | undefined
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
export interface LifecycleHookAbort {
|
|
@@ -37,3 +69,5 @@ export type AfterLifecycleHookHandler = (context: LifecycleHookContext) => void
|
|
|
37
69
|
|
|
38
70
|
export type LifecycleHookHandler<Event extends LifecycleHookEvent> =
|
|
39
71
|
Event extends BeforeLifecycleHookEvent ? BeforeLifecycleHookHandler : AfterLifecycleHookHandler
|
|
72
|
+
|
|
73
|
+
export type AdminEventHookHandler = (context: AdminEventContext) => void | Promise<void>
|