betterstart-cli 0.0.12 → 0.0.13

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 (117) 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 +115 -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 +2 -2
  5. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +2 -2
  6. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-tab.tsx +1 -1
  7. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  8. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +2 -2
  10. package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
  12. package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -1
  13. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
  14. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
  15. package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
  16. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
  17. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +97 -0
  18. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +17 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +72 -0
  23. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
  28. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
  31. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
  32. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
  33. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
  34. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
  35. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
  36. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
  37. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +116 -12
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +33 -0
  40. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +96 -0
  41. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +17 -0
  42. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-filters.tsx +152 -0
  43. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +279 -0
  44. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +19 -0
  45. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
  46. package/dist/assets/adapters/next/templates/init/pages/dashboard-page.tsx +1 -1
  47. package/dist/assets/adapters/next/templates/init/pages/forgot-password-page-skeleton.tsx +1 -1
  48. package/dist/assets/adapters/next/templates/init/pages/login-page-skeleton.tsx +1 -1
  49. package/dist/assets/adapters/next/templates/init/pages/reset-password-page-skeleton.tsx +1 -1
  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 +0 -24
  52. package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
  53. package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
  54. package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
  55. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-bubble-menu.tsx +1 -1
  56. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
  57. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
  58. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
  59. package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
  60. package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
  61. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
  62. package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
  63. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
  64. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -9
  65. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
  66. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +9 -4
  67. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
  68. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
  69. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
  72. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
  73. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
  74. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
  75. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
  76. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +2 -4
  77. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
  78. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +1 -1
  79. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
  80. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
  81. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
  82. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
  83. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +3 -3
  84. package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +2 -2
  85. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +2 -2
  86. package/dist/assets/shared-assets/react-admin/custom/placeholder-card.tsx +1 -1
  87. package/dist/assets/shared-assets/react-admin/custom/placeholder.tsx +1 -1
  88. package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +1 -1
  89. package/dist/assets/shared-assets/react-admin/schema.json +0 -23
  90. package/dist/assets/shared-assets/react-admin/ui/accordion.tsx +1 -1
  91. package/dist/assets/shared-assets/react-admin/ui/alert.tsx +1 -1
  92. package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
  93. package/dist/assets/shared-assets/react-admin/ui/card.tsx +3 -3
  94. package/dist/assets/shared-assets/react-admin/ui/chart.tsx +1 -1
  95. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +29 -5
  96. package/dist/assets/shared-assets/react-admin/ui/command.tsx +1 -1
  97. package/dist/assets/shared-assets/react-admin/ui/context-menu.tsx +6 -6
  98. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +7 -3
  99. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +32 -17
  100. package/dist/assets/shared-assets/react-admin/ui/empty.tsx +2 -2
  101. package/dist/assets/shared-assets/react-admin/ui/field.tsx +1 -1
  102. package/dist/assets/shared-assets/react-admin/ui/hover-card.tsx +1 -1
  103. package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +1 -1
  104. package/dist/assets/shared-assets/react-admin/ui/item.tsx +2 -2
  105. package/dist/assets/shared-assets/react-admin/ui/menubar.tsx +7 -7
  106. package/dist/assets/shared-assets/react-admin/ui/navigation-menu.tsx +4 -4
  107. package/dist/assets/shared-assets/react-admin/ui/popover.tsx +1 -1
  108. package/dist/assets/shared-assets/react-admin/ui/resizable.tsx +1 -1
  109. package/dist/assets/shared-assets/react-admin/ui/select.tsx +2 -2
  110. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +3 -3
  111. package/dist/assets/shared-assets/react-admin/ui/skeleton.tsx +1 -1
  112. package/dist/assets/shared-assets/react-admin/ui/tabs.tsx +2 -2
  113. package/dist/assets/shared-assets/react-admin/ui/textarea.tsx +1 -1
  114. package/dist/assets/shared-assets/react-admin/ui/tooltip.tsx +1 -1
  115. package/dist/cli.js +2769 -802
  116. package/dist/cli.js.map +1 -1
  117. package/package.json +1 -1
@@ -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,72 @@
1
+ export const AUDIT_LOG_CATEGORIES = [
2
+ 'entity',
3
+ 'settings',
4
+ 'user',
5
+ 'auth',
6
+ 'media',
7
+ 'system'
8
+ ] as const
9
+ export const AUDIT_LOG_ACTIONS = [
10
+ 'create',
11
+ 'update',
12
+ 'delete',
13
+ 'publish',
14
+ 'unpublish',
15
+ 'cleanup'
16
+ ] as const
17
+ export const AUDIT_LOG_SOURCE_TYPES = ['admin_ui', 'server_action', 'system_job'] as const
18
+
19
+ export type AuditLogCategory = (typeof AUDIT_LOG_CATEGORIES)[number]
20
+ export type AuditLogAction = (typeof AUDIT_LOG_ACTIONS)[number]
21
+ export type AuditLogSourceType = (typeof AUDIT_LOG_SOURCE_TYPES)[number]
22
+
23
+ export interface AuditLogEntry {
24
+ id: string
25
+ category: AuditLogCategory
26
+ action: AuditLogAction
27
+ targetType: string
28
+ targetId: string | null
29
+ targetLabel: string | null
30
+ actorUserId: string | null
31
+ actorEmail: string | null
32
+ actorName: string | null
33
+ actorRole: string | null
34
+ sourceType: AuditLogSourceType
35
+ ipAddress: string | null
36
+ userAgent: string | null
37
+ changedFields: string[]
38
+ metadata: Record<string, unknown> | null
39
+ createdAt: string
40
+ }
41
+
42
+ export interface AuditLogQuery {
43
+ search?: string
44
+ category?: AuditLogCategory
45
+ action?: AuditLogAction
46
+ targetType?: string
47
+ actor?: string
48
+ createdAtFrom?: string
49
+ createdAtTo?: string
50
+ page?: number
51
+ pageSize?: number
52
+ }
53
+
54
+ export interface AuditLogPage {
55
+ auditLogs: AuditLogEntry[]
56
+ total: number
57
+ page: number
58
+ pageSize: number
59
+ totalPages: number
60
+ }
61
+
62
+ export interface CleanupAuditLogsResult {
63
+ success: boolean
64
+ error?: string
65
+ deletedCount?: number
66
+ cutoff?: string
67
+ skipped?: boolean
68
+ }
69
+
70
+ export const auditLogCacheTags = {
71
+ all: 'audit-log:all'
72
+ } 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' }
@@ -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
+ }