betterstart-cli 0.0.11 → 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.
- package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
- package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +115 -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 +2 -2
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +2 -2
- package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/snippets-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-versions/entity-versions-drawer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/media/edit-media-dialog-content.tsx +2 -2
- package/dist/assets/adapters/next/templates/init/components/shared/media/media-url-importer.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
- package/dist/assets/adapters/next/templates/init/data/navigation.ts +2 -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/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 +97 -0
- package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +17 -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 +72 -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 +116 -12
- package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +33 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +96 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +17 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-filters.tsx +152 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +279 -0
- package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +19 -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/dashboard-page.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/forgot-password-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/login-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/pages/reset-password-page-skeleton.tsx +1 -1
- package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
- package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +0 -24
- 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-bubble-menu.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 -9
- 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 +9 -4
- 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 +2 -4
- 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 +1 -1
- 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 +3 -3
- package/dist/assets/shared-assets/react-admin/custom/gallery-field.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/custom/placeholder-card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/placeholder.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/custom/sortable-gallery-item.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/schema.json +0 -23
- package/dist/assets/shared-assets/react-admin/ui/accordion.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/alert.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
- package/dist/assets/shared-assets/react-admin/ui/card.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/ui/chart.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +29 -5
- package/dist/assets/shared-assets/react-admin/ui/command.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/context-menu.tsx +6 -6
- package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +7 -3
- package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +32 -17
- package/dist/assets/shared-assets/react-admin/ui/empty.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/field.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/hover-card.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/input-group.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/item.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/menubar.tsx +7 -7
- package/dist/assets/shared-assets/react-admin/ui/navigation-menu.tsx +4 -4
- package/dist/assets/shared-assets/react-admin/ui/popover.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/resizable.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/select.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +3 -3
- package/dist/assets/shared-assets/react-admin/ui/skeleton.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/tabs.tsx +2 -2
- package/dist/assets/shared-assets/react-admin/ui/textarea.tsx +1 -1
- package/dist/assets/shared-assets/react-admin/ui/tooltip.tsx +1 -1
- package/dist/cli.js +2779 -810
- package/dist/cli.js.map +1 -1
- 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:
|
|
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
|
+
}
|