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.
Files changed (99) hide show
  1. package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +155 -0
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
  4. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +9 -3
  5. package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
  6. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
  7. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
  8. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  10. package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  12. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
  13. package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
  14. package/dist/assets/adapters/next/templates/init/data/navigation.ts +4 -1
  15. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
  16. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
  17. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
  18. package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +122 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +19 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
  23. package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +77 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
  31. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
  35. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
  36. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
  37. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
  41. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +117 -12
  42. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +35 -1
  43. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +104 -0
  44. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +15 -0
  45. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +565 -0
  46. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +7 -0
  47. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
  48. package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
  49. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
  50. package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
  51. package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -32
  52. package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
  53. package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
  54. package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
  55. package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
  56. package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
  57. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
  58. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
  59. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
  60. package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
  61. package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
  62. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
  63. package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
  64. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
  65. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -11
  66. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
  67. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +24 -67
  68. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
  69. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
  72. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
  73. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
  74. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
  75. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
  76. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
  77. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +5 -12
  78. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
  79. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +2 -3
  80. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
  81. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
  82. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
  83. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
  84. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +2 -2
  85. package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
  86. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +1 -1
  87. package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
  88. package/dist/assets/shared-assets/react-admin/schema.json +136 -23
  89. package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
  90. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  91. package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
  92. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +27 -3
  93. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +9 -5
  94. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +27 -12
  95. package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
  96. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +4 -4
  97. package/dist/cli.js +5396 -2385
  98. package/dist/cli.js.map +1 -1
  99. 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: new Date().toISOString(),
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'
@@ -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,
@@ -7,3 +7,6 @@
7
7
  // registerHook('posts', 'afterCreate', async ({ user, entityId, data }) => {
8
8
  // console.log('Post created', { userId: user.id, entityId, data })
9
9
  // })
10
+ import { registerAuditHooks } from '@admin/actions/audit/register'
11
+
12
+ registerAuditHooks()
@@ -1,8 +1,17 @@
1
- import type { LifecycleHookEvent, LifecycleHookHandler } from './types'
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
- export function runAfterHooks(event: AfterLifecycleHookEvent, context: LifecycleHookContext): void {
37
- const handlers = getRegisteredHooks(context.entityType, event)
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(context))).then((results) => {
41
- for (const result of results) {
42
- if (result.status === 'rejected') {
43
- console.error('Lifecycle hook failed:', {
44
- entityType: context.entityType,
45
- event,
46
- error: result.reason
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>