@@ -127,6 +125,7 @@ export const DefaultTemplate: React.FC
= ({
? RenderServerComponent({
Component: avatar.Component,
importMap: payload.importMap,
+ serverProps,
})
: undefined
}
@@ -135,6 +134,7 @@ export const DefaultTemplate: React.FC = ({
? RenderServerComponent({
Component: components.graphics.Icon,
importMap: payload.importMap,
+ serverProps,
})
: undefined
}
diff --git a/packages/next/src/views/Document/renderDocumentSlots.tsx b/packages/next/src/views/Document/renderDocumentSlots.tsx
index 459cba81a81..e86cd134584 100644
--- a/packages/next/src/views/Document/renderDocumentSlots.tsx
+++ b/packages/next/src/views/Document/renderDocumentSlots.tsx
@@ -5,12 +5,12 @@ import type {
SanitizedCollectionConfig,
SanitizedDocumentPermissions,
SanitizedGlobalConfig,
+ ServerProps,
StaticDescription,
} from 'payload'
import { ViewDescription } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
-import React from 'react'
import { getDocumentPermissions } from './getDocumentPermissions.js'
@@ -29,6 +29,13 @@ export const renderDocumentSlots: (args: {
const isPreviewEnabled = collectionConfig?.admin?.preview || globalConfig?.admin?.preview
+ const serverProps: ServerProps = {
+ i18n: req.i18n,
+ payload: req.payload,
+ user: req.user,
+ // TODO: Add remaining serverProps
+ }
+
const CustomPreviewButton =
collectionConfig?.admin?.components?.edit?.PreviewButton ||
globalConfig?.admin?.components?.elements?.PreviewButton
@@ -37,6 +44,7 @@ export const renderDocumentSlots: (args: {
components.PreviewButton = RenderServerComponent({
Component: CustomPreviewButton,
importMap: req.payload.importMap,
+ serverProps,
})
}
@@ -60,6 +68,7 @@ export const renderDocumentSlots: (args: {
Component: CustomDescription,
Fallback: ViewDescription,
importMap: req.payload.importMap,
+ serverProps,
})
}
@@ -73,6 +82,7 @@ export const renderDocumentSlots: (args: {
components.PublishButton = RenderServerComponent({
Component: CustomPublishButton,
importMap: req.payload.importMap,
+ serverProps,
})
}
const CustomSaveDraftButton =
@@ -87,6 +97,7 @@ export const renderDocumentSlots: (args: {
components.SaveDraftButton = RenderServerComponent({
Component: CustomSaveDraftButton,
importMap: req.payload.importMap,
+ serverProps,
})
}
} else {
@@ -98,6 +109,7 @@ export const renderDocumentSlots: (args: {
components.SaveButton = RenderServerComponent({
Component: CustomSaveButton,
importMap: req.payload.importMap,
+ serverProps,
})
}
}
diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts
index 36df4979173..b30bdee6c5d 100644
--- a/packages/payload/src/auth/operations/forgotPassword.ts
+++ b/packages/payload/src/auth/operations/forgotPassword.ts
@@ -137,8 +137,8 @@ export const forgotPasswordOperation = async (
user.resetPasswordToken = token
user.resetPasswordExpiration = new Date(
- collectionConfig.auth?.forgotPassword?.expiration || expiration || Date.now() + 3600000,
- ).toISOString() // 1 hour
+ Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000),
+ ).toISOString()
user = await payload.update({
id: user.id,
diff --git a/packages/payload/src/collections/operations/create.ts b/packages/payload/src/collections/operations/create.ts
index a5580200f0b..9af12e1ed6d 100644
--- a/packages/payload/src/collections/operations/create.ts
+++ b/packages/payload/src/collections/operations/create.ts
@@ -13,6 +13,7 @@ import type {
BeforeOperationHook,
BeforeValidateHook,
Collection,
+ DataFromCollectionSlug,
RequiredDataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../config/types.js'
@@ -21,6 +22,7 @@ import { ensureUsernameOrEmail } from '../../auth/ensureUsernameOrEmail.js'
import executeAccess from '../../auth/executeAccess.js'
import { sendVerificationEmail } from '../../auth/sendVerificationEmail.js'
import { registerLocalStrategy } from '../../auth/strategies/local/register.js'
+import { getDuplicateDocumentData } from '../../duplicateDocument/index.js'
import { afterChange } from '../../fields/hooks/afterChange/index.js'
import { afterRead } from '../../fields/hooks/afterRead/index.js'
import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
@@ -43,6 +45,7 @@ export type Arguments = {
disableTransaction?: boolean
disableVerificationEmail?: boolean
draft?: boolean
+ duplicateFromID?: DataFromCollectionSlug['id']
overrideAccess?: boolean
overwriteExistingFiles?: boolean
populate?: PopulateType
@@ -97,6 +100,7 @@ export const createOperation = async <
depth,
disableVerificationEmail,
draft = false,
+ duplicateFromID,
overrideAccess,
overwriteExistingFiles = false,
populate,
@@ -115,6 +119,23 @@ export const createOperation = async <
const shouldSaveDraft = Boolean(draft && collectionConfig.versions.drafts)
+ let duplicatedFromDocWithLocales: JsonObject = {}
+ let duplicatedFromDoc: JsonObject = {}
+
+ if (duplicateFromID) {
+ const duplicateResult = await getDuplicateDocumentData({
+ id: duplicateFromID,
+ collectionConfig,
+ draftArg: shouldSaveDraft,
+ overrideAccess,
+ req,
+ shouldSaveDraft,
+ })
+
+ duplicatedFromDoc = duplicateResult.duplicatedFromDoc
+ duplicatedFromDocWithLocales = duplicateResult.duplicatedFromDocWithLocales
+ }
+
// /////////////////////////////////////
// Access
// /////////////////////////////////////
@@ -131,7 +152,9 @@ export const createOperation = async <
collection,
config,
data,
+ isDuplicating: Boolean(duplicateFromID),
operation: 'create',
+ originalDoc: duplicatedFromDoc,
overwriteExistingFiles,
req,
throwOnMissingFile:
@@ -148,7 +171,7 @@ export const createOperation = async <
collection: collectionConfig,
context: req.context,
data,
- doc: {},
+ doc: duplicatedFromDoc,
global: null,
operation: 'create',
overrideAccess,
@@ -169,6 +192,7 @@ export const createOperation = async <
context: req.context,
data,
operation: 'create',
+ originalDoc: duplicatedFromDoc,
req,
})) || data
},
@@ -188,6 +212,7 @@ export const createOperation = async <
context: req.context,
data,
operation: 'create',
+ originalDoc: duplicatedFromDoc,
req,
})) || data
}, Promise.resolve())
@@ -200,8 +225,8 @@ export const createOperation = async <
collection: collectionConfig,
context: req.context,
data,
- doc: {},
- docWithLocales: {},
+ doc: duplicatedFromDoc,
+ docWithLocales: duplicatedFromDocWithLocales,
global: null,
operation: 'create',
req,
diff --git a/packages/payload/src/collections/operations/duplicate.ts b/packages/payload/src/collections/operations/duplicate.ts
index 386318af202..888bea0a6f1 100644
--- a/packages/payload/src/collections/operations/duplicate.ts
+++ b/packages/payload/src/collections/operations/duplicate.ts
@@ -1,391 +1,26 @@
import type { DeepPartial } from 'ts-essentials'
-import httpStatus from 'http-status'
-
-import type { FindOneArgs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
-import type {
- PayloadRequest,
- PopulateType,
- SelectType,
- TransformCollectionWithSelect,
-} from '../../types/index.js'
-import type {
- Collection,
- DataFromCollectionSlug,
- SelectFromCollectionSlug,
-} from '../config/types.js'
+import type { TransformCollectionWithSelect } from '../../types/index.js'
+import type { RequiredDataFromCollectionSlug, SelectFromCollectionSlug } from '../config/types.js'
-import executeAccess from '../../auth/executeAccess.js'
-import { hasWhereAccessResult } from '../../auth/types.js'
-import { combineQueries } from '../../database/combineQueries.js'
-import { APIError, Forbidden, NotFound } from '../../errors/index.js'
-import { afterChange } from '../../fields/hooks/afterChange/index.js'
-import { afterRead } from '../../fields/hooks/afterRead/index.js'
-import { beforeChange } from '../../fields/hooks/beforeChange/index.js'
-import { beforeDuplicate } from '../../fields/hooks/beforeDuplicate/index.js'
-import { beforeValidate } from '../../fields/hooks/beforeValidate/index.js'
-import { generateFileData } from '../../uploads/generateFileData.js'
-import { uploadFiles } from '../../uploads/uploadFiles.js'
-import { commitTransaction } from '../../utilities/commitTransaction.js'
-import { initTransaction } from '../../utilities/initTransaction.js'
-import { killTransaction } from '../../utilities/killTransaction.js'
-import sanitizeInternalFields from '../../utilities/sanitizeInternalFields.js'
-import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion.js'
-import { saveVersion } from '../../versions/saveVersion.js'
-import { buildAfterOperation } from './utils.js'
+import { type Arguments as CreateArguments, createOperation } from './create.js'
-export type Arguments = {
- collection: Collection
- depth?: number
- disableTransaction?: boolean
- draft?: boolean
+export type Arguments = {
+ data?: DeepPartial>
id: number | string
- overrideAccess?: boolean
- populate?: PopulateType
- req: PayloadRequest
- select?: SelectType
- showHiddenFields?: boolean
-}
+} & Omit, 'data' | 'duplicateFromID'>
export const duplicateOperation = async <
TSlug extends CollectionSlug,
TSelect extends SelectFromCollectionSlug,
>(
- incomingArgs: Arguments,
+ incomingArgs: Arguments,
): Promise> => {
- let args = incomingArgs
- const operation = 'create'
-
- try {
- const shouldCommit = !args.disableTransaction && (await initTransaction(args.req))
-
- // /////////////////////////////////////
- // beforeOperation - Collection
- // /////////////////////////////////////
-
- await args.collection.config.hooks.beforeOperation.reduce(async (priorHook, hook) => {
- await priorHook
-
- args =
- (await hook({
- args,
- collection: args.collection.config,
- context: args.req.context,
- operation,
- req: args.req,
- })) || args
- }, Promise.resolve())
-
- const {
- id,
- collection: { config: collectionConfig },
- depth,
- draft: draftArg = true,
- overrideAccess,
- populate,
- req: { fallbackLocale, locale: localeArg, payload },
- req,
- select,
- showHiddenFields,
- } = args
-
- if (!id) {
- throw new APIError('Missing ID of document to duplicate.', httpStatus.BAD_REQUEST)
- }
- const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts)
-
- // /////////////////////////////////////
- // Read Access
- // /////////////////////////////////////
-
- const accessResults = !overrideAccess
- ? await executeAccess({ id, req }, collectionConfig.access.read)
- : true
- const hasWherePolicy = hasWhereAccessResult(accessResults)
-
- // /////////////////////////////////////
- // Retrieve document
- // /////////////////////////////////////
- const findOneArgs: FindOneArgs = {
- collection: collectionConfig.slug,
- locale: req.locale,
- req,
- where: combineQueries({ id: { equals: id } }, accessResults),
- }
-
- let docWithLocales = await getLatestCollectionVersion({
- id,
- config: collectionConfig,
- payload,
- query: findOneArgs,
- req,
- })
-
- if (!docWithLocales && !hasWherePolicy) {
- throw new NotFound(req.t)
- }
- if (!docWithLocales && hasWherePolicy) {
- throw new Forbidden(req.t)
- }
-
- // remove the createdAt timestamp and id to rely on the db to set the default it
- delete docWithLocales.createdAt
- delete docWithLocales.id
-
- docWithLocales = await beforeDuplicate({
- id,
- collection: collectionConfig,
- context: req.context,
- doc: docWithLocales,
- overrideAccess,
- req,
- })
-
- // for version enabled collections, override the current status with draft, unless draft is explicitly set to false
- if (shouldSaveDraft) {
- docWithLocales._status = 'draft'
- }
-
- let result
-
- let originalDoc = await afterRead({
- collection: collectionConfig,
- context: req.context,
- depth: 0,
- doc: docWithLocales,
- draft: draftArg,
- fallbackLocale: null,
- global: null,
- locale: req.locale,
- overrideAccess: true,
- req,
- showHiddenFields: true,
- })
-
- const { data: newFileData, files: filesToUpload } = await generateFileData({
- collection: args.collection,
- config: req.payload.config,
- data: originalDoc,
- operation: 'create',
- overwriteExistingFiles: 'forceDisable',
- req,
- throwOnMissingFile: true,
- })
-
- originalDoc = newFileData
-
- // /////////////////////////////////////
- // Create Access
- // /////////////////////////////////////
-
- if (!overrideAccess) {
- await executeAccess({ data: originalDoc, req }, collectionConfig.access.create)
- }
-
- // /////////////////////////////////////
- // beforeValidate - Fields
- // /////////////////////////////////////
-
- let data = await beforeValidate>>({
- id,
- collection: collectionConfig,
- context: req.context,
- data: originalDoc,
- doc: originalDoc,
- duplicate: true,
- global: null,
- operation,
- overrideAccess,
- req,
- })
-
- // /////////////////////////////////////
- // beforeValidate - Collection
- // /////////////////////////////////////
-
- await collectionConfig.hooks.beforeValidate.reduce(async (priorHook, hook) => {
- await priorHook
-
- data =
- (await hook({
- collection: collectionConfig,
- context: req.context,
- data,
- operation,
- originalDoc,
- req,
- })) || result
- }, Promise.resolve())
-
- // /////////////////////////////////////
- // beforeChange - Collection
- // /////////////////////////////////////
-
- await collectionConfig.hooks.beforeChange.reduce(async (priorHook, hook) => {
- await priorHook
-
- data =
- (await hook({
- collection: collectionConfig,
- context: req.context,
- data,
- operation,
- originalDoc: result,
- req,
- })) || result
- }, Promise.resolve())
-
- // /////////////////////////////////////
- // beforeChange - Fields
- // /////////////////////////////////////
-
- result = await beforeChange({
- id,
- collection: collectionConfig,
- context: req.context,
- data,
- doc: originalDoc,
- docWithLocales,
- global: null,
- operation,
- req,
- skipValidation:
- shouldSaveDraft &&
- collectionConfig.versions.drafts &&
- !collectionConfig.versions.drafts.validate,
- })
-
- // set req.locale back to the original locale
- req.locale = localeArg
-
- // /////////////////////////////////////
- // Create / Update
- // /////////////////////////////////////
-
- // /////////////////////////////////////
- // Write files to local storage
- // /////////////////////////////////////
-
- if (!collectionConfig.upload.disableLocalStorage) {
- await uploadFiles(payload, filesToUpload, req)
- }
-
- let versionDoc = await payload.db.create({
- collection: collectionConfig.slug,
- data: result,
- req,
- select,
- })
-
- versionDoc = sanitizeInternalFields(versionDoc)
-
- // /////////////////////////////////////
- // Create version
- // /////////////////////////////////////
-
- if (collectionConfig.versions) {
- result = await saveVersion({
- id: versionDoc.id,
- collection: collectionConfig,
- docWithLocales: versionDoc,
- draft: shouldSaveDraft,
- payload,
- req,
- })
- }
-
- // /////////////////////////////////////
- // afterRead - Fields
- // /////////////////////////////////////
-
- result = await afterRead({
- collection: collectionConfig,
- context: req.context,
- depth,
- doc: versionDoc,
- draft: draftArg,
- fallbackLocale,
- global: null,
- locale: localeArg,
- overrideAccess,
- populate,
- req,
- select,
- showHiddenFields,
- })
-
- // /////////////////////////////////////
- // afterRead - Collection
- // /////////////////////////////////////
-
- await collectionConfig.hooks.afterRead.reduce(async (priorHook, hook) => {
- await priorHook
-
- result =
- (await hook({
- collection: collectionConfig,
- context: req.context,
- doc: result,
- req,
- })) || result
- }, Promise.resolve())
-
- // /////////////////////////////////////
- // afterChange - Fields
- // /////////////////////////////////////
-
- result = await afterChange({
- collection: collectionConfig,
- context: req.context,
- data: versionDoc,
- doc: result,
- global: null,
- operation,
- previousDoc: {},
- req,
- })
-
- // /////////////////////////////////////
- // afterChange - Collection
- // /////////////////////////////////////
-
- await collectionConfig.hooks.afterChange.reduce(async (priorHook, hook) => {
- await priorHook
-
- result =
- (await hook({
- collection: collectionConfig,
- context: req.context,
- doc: result,
- operation,
- previousDoc: {},
- req,
- })) || result
- }, Promise.resolve())
-
- // /////////////////////////////////////
- // afterOperation - Collection
- // /////////////////////////////////////
-
- result = await buildAfterOperation({
- args,
- collection: collectionConfig,
- operation,
- result,
- })
-
- // /////////////////////////////////////
- // Return results
- // /////////////////////////////////////
-
- if (shouldCommit) {
- await commitTransaction(req)
- }
-
- return result
- } catch (error: unknown) {
- await killTransaction(args.req)
- throw error
- }
+ const { id, ...args } = incomingArgs
+ return createOperation({
+ ...args,
+ data: incomingArgs?.data || {},
+ duplicateFromID: id,
+ })
}
diff --git a/packages/payload/src/collections/operations/local/create.ts b/packages/payload/src/collections/operations/local/create.ts
index 36c171a1dba..e680ea13920 100644
--- a/packages/payload/src/collections/operations/local/create.ts
+++ b/packages/payload/src/collections/operations/local/create.ts
@@ -8,6 +8,7 @@ import type {
} from '../../../types/index.js'
import type { File } from '../../../uploads/types.js'
import type {
+ DataFromCollectionSlug,
RequiredDataFromCollectionSlug,
SelectFromCollectionSlug,
} from '../../config/types.js'
@@ -28,6 +29,7 @@ export type Options =
disableTransaction?: boolean
disableVerificationEmail?: boolean
draft?: boolean
+ duplicateFromID?: DataFromCollectionSlug['id']
fallbackLocale?: false | TypedLocale
file?: File
filePath?: string
@@ -56,6 +58,7 @@ export default async function createLocal<
disableTransaction,
disableVerificationEmail,
draft,
+ duplicateFromID,
file,
filePath,
overrideAccess = true,
@@ -82,6 +85,7 @@ export default async function createLocal<
disableTransaction,
disableVerificationEmail,
draft,
+ duplicateFromID,
overrideAccess,
overwriteExistingFiles,
populate,
diff --git a/packages/payload/src/collections/operations/local/duplicate.ts b/packages/payload/src/collections/operations/local/duplicate.ts
index 63c1b87796f..747bb679da3 100644
--- a/packages/payload/src/collections/operations/local/duplicate.ts
+++ b/packages/payload/src/collections/operations/local/duplicate.ts
@@ -1,3 +1,5 @@
+import type { DeepPartial } from 'ts-essentials'
+
import type { CollectionSlug, TypedLocale } from '../../..//index.js'
import type { Payload, RequestContext } from '../../../index.js'
import type {
@@ -7,7 +9,10 @@ import type {
SelectType,
TransformCollectionWithSelect,
} from '../../../types/index.js'
-import type { SelectFromCollectionSlug } from '../../config/types.js'
+import type {
+ RequiredDataFromCollectionSlug,
+ SelectFromCollectionSlug,
+} from '../../config/types.js'
import { APIError } from '../../../errors/index.js'
import { createLocalReq } from '../../../utilities/createLocalReq.js'
@@ -19,6 +24,7 @@ export type Options =
* context, which will then be passed to req.context, which can be read by hooks
*/
context?: RequestContext
+ data?: DeepPartial>
depth?: number
disableTransaction?: boolean
draft?: boolean
@@ -43,6 +49,7 @@ export async function duplicate<
const {
id,
collection: collectionSlug,
+ data,
depth,
disableTransaction,
draft,
@@ -71,6 +78,7 @@ export async function duplicate<
return duplicateOperation({
id,
collection,
+ data,
depth,
disableTransaction,
draft,
diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts
index c61e68ecd1e..1eee12794c2 100644
--- a/packages/payload/src/config/types.ts
+++ b/packages/payload/src/config/types.ts
@@ -704,7 +704,7 @@ export type Config = {
| 'default'
| 'gravatar'
| {
- Component: PayloadComponent
+ Component: PayloadComponent
}
/**
* Add extra and/or replace built-in components with custom components
@@ -841,6 +841,12 @@ export type Config = {
/** The route for the unauthorized page. */
unauthorized?: string
}
+ /**
+ * Suppresses React hydration mismatch warnings during the hydration of the root tag.
+ * Useful in scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM.
+ * @default false
+ */
+ suppressHydrationWarning?: boolean
/**
* Restrict the Admin Panel theme to use only one of your choice
*
diff --git a/packages/payload/src/database/migrations/migrate.ts b/packages/payload/src/database/migrations/migrate.ts
index 94d105155ca..ecf12340629 100644
--- a/packages/payload/src/database/migrations/migrate.ts
+++ b/packages/payload/src/database/migrations/migrate.ts
@@ -35,7 +35,8 @@ export const migrate: BaseDatabaseAdapter['migrate'] = async function migrate(
try {
await initTransaction(req)
- await migration.up({ payload, req })
+ const session = payload.db.sessions?.[await req.transactionID]
+ await migration.up({ payload, req, session })
payload.logger.info({ msg: `Migrated: ${migration.name} (${Date.now() - start}ms)` })
await payload.create({
collection: 'payload-migrations',
diff --git a/packages/payload/src/database/migrations/migrateDown.ts b/packages/payload/src/database/migrations/migrateDown.ts
index 352a765b326..40ae1915e11 100644
--- a/packages/payload/src/database/migrations/migrateDown.ts
+++ b/packages/payload/src/database/migrations/migrateDown.ts
@@ -38,7 +38,8 @@ export async function migrateDown(this: BaseDatabaseAdapter): Promise {
try {
payload.logger.info({ msg: `Migrating down: ${migrationFile.name}` })
await initTransaction(req)
- await migrationFile.down({ payload, req })
+ const session = payload.db.sessions?.[await req.transactionID]
+ await migrationFile.down({ payload, req, session })
payload.logger.info({
msg: `Migrated down: ${migrationFile.name} (${Date.now() - start}ms)`,
})
diff --git a/packages/payload/src/database/migrations/migrateRefresh.ts b/packages/payload/src/database/migrations/migrateRefresh.ts
index 0b53eca724a..f07d464d091 100644
--- a/packages/payload/src/database/migrations/migrateRefresh.ts
+++ b/packages/payload/src/database/migrations/migrateRefresh.ts
@@ -37,7 +37,8 @@ export async function migrateRefresh(this: BaseDatabaseAdapter) {
payload.logger.info({ msg: `Migrating down: ${migration.name}` })
const start = Date.now()
await initTransaction(req)
- await migrationFile.down({ payload, req })
+ const session = payload.db.sessions?.[await req.transactionID]
+ await migrationFile.down({ payload, req, session })
payload.logger.info({
msg: `Migrated down: ${migration.name} (${Date.now() - start}ms)`,
})
diff --git a/packages/payload/src/database/migrations/migrateReset.ts b/packages/payload/src/database/migrations/migrateReset.ts
index 7cd1b0b0acf..bb63d14ab34 100644
--- a/packages/payload/src/database/migrations/migrateReset.ts
+++ b/packages/payload/src/database/migrations/migrateReset.ts
@@ -31,7 +31,8 @@ export async function migrateReset(this: BaseDatabaseAdapter): Promise {
try {
const start = Date.now()
await initTransaction(req)
- await migration.down({ payload, req })
+ const session = payload.db.sessions?.[await req.transactionID]
+ await migration.down({ payload, req, session })
await payload.delete({
collection: 'payload-migrations',
req,
diff --git a/packages/payload/src/database/types.ts b/packages/payload/src/database/types.ts
index d7697e0cdbd..36a54bc14a4 100644
--- a/packages/payload/src/database/types.ts
+++ b/packages/payload/src/database/types.ts
@@ -161,8 +161,6 @@ export type Connect = (args?: ConnectArgs) => Promise
export type Destroy = () => Promise
export type CreateMigration = (args: {
- /** dirname of the package, required in drizzle */
- dirname?: string
file?: string
forceAcceptWarning?: boolean
migrationName?: string
diff --git a/packages/payload/src/duplicateDocument/index.ts b/packages/payload/src/duplicateDocument/index.ts
new file mode 100644
index 00000000000..206c4349e72
--- /dev/null
+++ b/packages/payload/src/duplicateDocument/index.ts
@@ -0,0 +1,106 @@
+import type { SanitizedCollectionConfig } from '../collections/config/types.js'
+import type { FindOneArgs } from '../database/types.js'
+import type { JsonObject, PayloadRequest } from '../types/index.js'
+
+import executeAccess from '../auth/executeAccess.js'
+import { hasWhereAccessResult } from '../auth/types.js'
+import { combineQueries } from '../database/combineQueries.js'
+import { Forbidden } from '../errors/Forbidden.js'
+import { NotFound } from '../errors/NotFound.js'
+import { afterRead } from '../fields/hooks/afterRead/index.js'
+import { beforeDuplicate } from '../fields/hooks/beforeDuplicate/index.js'
+import { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js'
+
+type GetDuplicateDocumentArgs = {
+ collectionConfig: SanitizedCollectionConfig
+ draftArg?: boolean
+ id: number | string
+ overrideAccess?: boolean
+ req: PayloadRequest
+ shouldSaveDraft?: boolean
+}
+export const getDuplicateDocumentData = async ({
+ id,
+ collectionConfig,
+ draftArg,
+ overrideAccess,
+ req,
+ shouldSaveDraft,
+}: GetDuplicateDocumentArgs): Promise<{
+ duplicatedFromDoc: JsonObject
+ duplicatedFromDocWithLocales: JsonObject
+}> => {
+ const { payload } = req
+ // /////////////////////////////////////
+ // Read Access
+ // /////////////////////////////////////
+
+ const accessResults = !overrideAccess
+ ? await executeAccess({ id, req }, collectionConfig.access.read)
+ : true
+ const hasWherePolicy = hasWhereAccessResult(accessResults)
+
+ // /////////////////////////////////////
+ // Retrieve document
+ // /////////////////////////////////////
+ const findOneArgs: FindOneArgs = {
+ collection: collectionConfig.slug,
+ locale: req.locale,
+ req,
+ where: combineQueries({ id: { equals: id } }, accessResults),
+ }
+
+ let duplicatedFromDocWithLocales = await getLatestCollectionVersion({
+ id,
+ config: collectionConfig,
+ payload,
+ query: findOneArgs,
+ req,
+ })
+
+ if (!duplicatedFromDocWithLocales && !hasWherePolicy) {
+ throw new NotFound(req.t)
+ }
+ if (!duplicatedFromDocWithLocales && hasWherePolicy) {
+ throw new Forbidden(req.t)
+ }
+
+ // remove the createdAt timestamp and rely on the db to set it
+ if ('createdAt' in duplicatedFromDocWithLocales) {
+ delete duplicatedFromDocWithLocales.createdAt
+ }
+ // remove the id and rely on the db to set it
+ if ('id' in duplicatedFromDocWithLocales) {
+ delete duplicatedFromDocWithLocales.id
+ }
+
+ duplicatedFromDocWithLocales = await beforeDuplicate({
+ id,
+ collection: collectionConfig,
+ context: req.context,
+ doc: duplicatedFromDocWithLocales,
+ overrideAccess,
+ req,
+ })
+
+ // for version enabled collections, override the current status with draft, unless draft is explicitly set to false
+ if (shouldSaveDraft) {
+ duplicatedFromDocWithLocales._status = 'draft'
+ }
+
+ const duplicatedFromDoc = await afterRead({
+ collection: collectionConfig,
+ context: req.context,
+ depth: 0,
+ doc: duplicatedFromDocWithLocales,
+ draft: draftArg,
+ fallbackLocale: null,
+ global: null,
+ locale: req.locale,
+ overrideAccess: true,
+ req,
+ showHiddenFields: true,
+ })
+
+ return { duplicatedFromDoc, duplicatedFromDocWithLocales }
+}
diff --git a/packages/payload/src/fields/config/client.ts b/packages/payload/src/fields/config/client.ts
index b8f4140a5c2..d11779fcdde 100644
--- a/packages/payload/src/fields/config/client.ts
+++ b/packages/payload/src/fields/config/client.ts
@@ -37,7 +37,10 @@ export type ServerOnlyFieldProperties =
| 'validate'
| keyof Pick
-export type ServerOnlyFieldAdminProperties = keyof Pick
+export type ServerOnlyFieldAdminProperties = keyof Pick<
+ FieldBase['admin'],
+ 'components' | 'condition'
+>
const serverOnlyFieldProperties: Partial[] = [
'hooks',
@@ -57,7 +60,10 @@ const serverOnlyFieldProperties: Partial[] = [
// `tabs`
// `admin`
]
-const serverOnlyFieldAdminProperties: Partial[] = ['condition']
+const serverOnlyFieldAdminProperties: Partial[] = [
+ 'condition',
+ 'components',
+]
type FieldWithDescription = {
admin: AdminClient
} & ClientField
diff --git a/packages/payload/src/fields/hooks/beforeChange/promise.ts b/packages/payload/src/fields/hooks/beforeChange/promise.ts
index 3d38bb86041..c37f05011c2 100644
--- a/packages/payload/src/fields/hooks/beforeChange/promise.ts
+++ b/packages/payload/src/fields/hooks/beforeChange/promise.ts
@@ -286,7 +286,6 @@ export const promise = async ({
}
case 'collapsible':
-
case 'row': {
await traverseFields({
id,
diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts
index ed7acdb5429..01f0f80a52f 100644
--- a/packages/payload/src/uploads/generateFileData.ts
+++ b/packages/payload/src/uploads/generateFileData.ts
@@ -25,10 +25,10 @@ type Args = {
collection: Collection
config: SanitizedConfig
data: T
+ isDuplicating?: boolean
operation: 'create' | 'update'
originalDoc?: T
- /** pass forceDisable to not overwrite existing files even if they already exist in `data` */
- overwriteExistingFiles?: 'forceDisable' | boolean
+ overwriteExistingFiles?: boolean
req: PayloadRequest
throwOnMissingFile?: boolean
}
@@ -41,6 +41,7 @@ type Result = Promise<{
export const generateFileData = async ({
collection: { config: collectionConfig },
data,
+ isDuplicating,
operation,
originalDoc,
overwriteExistingFiles,
@@ -60,6 +61,7 @@ export const generateFileData = async ({
const uploadEdits = parseUploadEditsFromReqOrIncomingData({
data,
+ isDuplicating,
operation,
originalDoc,
req,
@@ -78,33 +80,31 @@ export const generateFileData = async ({
const staticPath = staticDir
- if (!file && uploadEdits && data) {
- const { filename, url } = data as FileData
+ const incomingFileData = isDuplicating ? originalDoc : data
+
+ if (!file && uploadEdits && incomingFileData) {
+ const { filename, url } = incomingFileData as FileData
try {
if (url && url.startsWith('/') && !disableLocalStorage) {
const filePath = `${staticPath}/${filename}`
const response = await getFileByPath(filePath)
file = response
- if (overwriteExistingFiles !== 'forceDisable') {
- overwriteExistingFiles = true
- }
+ overwriteExistingFiles = true
} else if (filename && url) {
file = await getExternalFile({
- data: data as FileData,
+ data: incomingFileData as FileData,
req,
uploadConfig: collectionConfig.upload,
})
- if (overwriteExistingFiles !== 'forceDisable') {
- overwriteExistingFiles = true
- }
+ overwriteExistingFiles = true
}
} catch (err: unknown) {
throw new FileRetrievalError(req.t, err instanceof Error ? err.message : undefined)
}
}
- if (overwriteExistingFiles === 'forceDisable') {
+ if (isDuplicating) {
overwriteExistingFiles = false
}
@@ -362,11 +362,12 @@ export const generateFileData = async ({
*/
function parseUploadEditsFromReqOrIncomingData(args: {
data: unknown
+ isDuplicating?: boolean
operation: 'create' | 'update'
originalDoc: unknown
req: PayloadRequest
}): UploadEdits {
- const { data, operation, originalDoc, req } = args
+ const { data, isDuplicating, operation, originalDoc, req } = args
// Get intended focal point change from query string or incoming data
const uploadEdits =
@@ -381,10 +382,19 @@ function parseUploadEditsFromReqOrIncomingData(args: {
const incomingData = data as FileData
const origDoc = originalDoc as FileData
- // If no change in focal point, return undefined.
- // This prevents a refocal operation triggered from admin, because it always sends the focal point.
- if (origDoc && incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
- return undefined
+ if (origDoc && 'focalX' in origDoc && 'focalY' in origDoc) {
+ // If no change in focal point, return undefined.
+ // This prevents a refocal operation triggered from admin, because it always sends the focal point.
+ if (incomingData.focalX === origDoc.focalX && incomingData.focalY === origDoc.focalY) {
+ return undefined
+ }
+
+ if (isDuplicating) {
+ uploadEdits.focalPoint = {
+ x: incomingData?.focalX || origDoc.focalX,
+ y: incomingData?.focalY || origDoc.focalX,
+ }
+ }
}
if (incomingData?.focalX && incomingData?.focalY) {
@@ -402,5 +412,6 @@ function parseUploadEditsFromReqOrIncomingData(args: {
y: 50,
}
}
+
return uploadEdits
}
diff --git a/packages/richtext-lexical/src/exports/client/index.ts b/packages/richtext-lexical/src/exports/client/index.ts
index ac81a0f6594..2f82846cc5e 100644
--- a/packages/richtext-lexical/src/exports/client/index.ts
+++ b/packages/richtext-lexical/src/exports/client/index.ts
@@ -136,6 +136,8 @@ export { InlineBlockEditButton } from '../../features/blocks/client/componentInl
export { InlineBlockRemoveButton } from '../../features/blocks/client/componentInline/components/InlineBlockRemoveButton.js'
export { InlineBlockLabel } from '../../features/blocks/client/componentInline/components/InlineBlockLabel.js'
export { InlineBlockContainer } from '../../features/blocks/client/componentInline/components/InlineBlockContainer.js'
+export { useInlineBlockComponentContext } from '../../features/blocks/client/componentInline/index.js'
export { BlockCollapsible } from '../../features/blocks/client/component/components/BlockCollapsible.js'
export { BlockEditButton } from '../../features/blocks/client/component/components/BlockEditButton.js'
export { BlockRemoveButton } from '../../features/blocks/client/component/components/BlockRemoveButton.js'
+export { useBlockComponentContext } from '../../features/blocks/client/component/BlockContent.js'
diff --git a/packages/ui/src/elements/Autosave/index.tsx b/packages/ui/src/elements/Autosave/index.tsx
index 38ec81d20dc..2c540a05533 100644
--- a/packages/ui/src/elements/Autosave/index.tsx
+++ b/packages/ui/src/elements/Autosave/index.tsx
@@ -3,7 +3,7 @@
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload'
import { versionDefaults } from 'payload/shared'
-import React, { useEffect, useRef, useState } from 'react'
+import React, { useRef, useState } from 'react'
import { toast } from 'sonner'
import {
@@ -49,6 +49,9 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc })
setLastUpdateTime,
setMostRecentVersionIsAutosaved,
} = useDocumentInfo()
+ const queueRef = useRef([])
+ const isProcessingRef = useRef(false)
+
const { reportUpdate } = useDocumentEvents()
const { dispatchFields, setSubmitted } = useForm()
const submitted = useFormSubmitted()
@@ -88,6 +91,25 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc })
// can always retrieve the most to date locale
localeRef.current = locale
+ const processQueue = React.useCallback(async () => {
+ if (isProcessingRef.current || queueRef.current.length === 0) {
+ return
+ }
+
+ isProcessingRef.current = true
+ const latestAction = queueRef.current[queueRef.current.length - 1]
+ queueRef.current = []
+
+ try {
+ await latestAction()
+ } finally {
+ isProcessingRef.current = false
+ if (queueRef.current.length > 0) {
+ await processQueue()
+ }
+ }
+ }, [])
+
// When debounced fields change, autosave
useIgnoredEffect(
() => {
@@ -97,7 +119,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc })
let startTimestamp = undefined
let endTimestamp = undefined
- const autosave = () => {
+ const autosave = async () => {
if (modified) {
startTimestamp = new Date().getTime()
@@ -129,7 +151,7 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc })
submitted && !valid && versionsConfig?.drafts && versionsConfig?.drafts?.validate
if (!skipSubmission) {
- void fetch(url, {
+ await fetch(url, {
body: JSON.stringify(data),
credentials: 'include',
headers: {
@@ -229,7 +251,8 @@ export const Autosave: React.FC = ({ id, collection, global: globalDoc })
}
}
- void autosave()
+ queueRef.current.push(autosave)
+ void processQueue()
return () => {
if (autosaveTimeout) {
diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts
index b4180b73902..df3055ffcd0 100644
--- a/test/access-control/int.spec.ts
+++ b/test/access-control/int.spec.ts
@@ -605,6 +605,43 @@ describe('Access Control', () => {
expect(res).toBeTruthy()
})
})
+
+ describe('Auth - Local API', () => {
+ it('should not allow reset password if forgotPassword expiration token is expired', async () => {
+ // Mock Date.now() to simulate the forgotPassword call happening 1 hour ago (default is 1 hour)
+ const originalDateNow = Date.now
+ const mockDateNow = jest.spyOn(Date, 'now').mockImplementation(() => {
+ // Move the current time back by 1 hour
+ return originalDateNow() - 60 * 60 * 1000
+ })
+
+ let forgot
+ try {
+ // Call forgotPassword while the mocked Date.now() is active
+ forgot = await payload.forgotPassword({
+ collection: 'users',
+ data: {
+ email: 'dev@payloadcms.com',
+ },
+ })
+ } finally {
+ // Restore the original Date.now() after the forgotPassword call
+ mockDateNow.mockRestore()
+ }
+
+ // Attempt to reset password, which should fail because the token is expired
+ await expect(
+ payload.resetPassword({
+ collection: 'users',
+ data: {
+ password: 'test',
+ token: forgot,
+ },
+ overrideAccess: true,
+ }),
+ ).rejects.toThrow('Token is either invalid or has expired.')
+ })
+ })
})
async function createDoc(
diff --git a/test/auth/config.ts b/test/auth/config.ts
index 66b349fabb8..66d5ddceffc 100644
--- a/test/auth/config.ts
+++ b/test/auth/config.ts
@@ -44,6 +44,9 @@ export default buildConfigWithDefaults({
tokenExpiration: 7200, // 2 hours
useAPIKey: true,
verify: false,
+ forgotPassword: {
+ expiration: 300000, // 5 minutes
+ },
},
fields: [
{
diff --git a/test/auth/int.spec.ts b/test/auth/int.spec.ts
index 9d969dc797f..88d886287d2 100644
--- a/test/auth/int.spec.ts
+++ b/test/auth/int.spec.ts
@@ -932,5 +932,40 @@ describe('Auth', () => {
expect(reset.user.email).toStrictEqual('dev@payloadcms.com')
})
+
+ it('should not allow reset password if forgotPassword expiration token is expired', async () => {
+ // Mock Date.now() to simulate the forgotPassword call happening 6 minutes ago (current expiration is set to 5 minutes)
+ const originalDateNow = Date.now
+ const mockDateNow = jest.spyOn(Date, 'now').mockImplementation(() => {
+ // Move the current time back by 6 minutes (360,000 ms)
+ return originalDateNow() - 6 * 60 * 1000
+ })
+
+ let forgot
+ try {
+ // Call forgotPassword while the mocked Date.now() is active
+ forgot = await payload.forgotPassword({
+ collection: 'users',
+ data: {
+ email: 'dev@payloadcms.com',
+ },
+ })
+ } finally {
+ // Restore the original Date.now() after the forgotPassword call
+ mockDateNow.mockRestore()
+ }
+
+ // Attempt to reset password, which should fail because the token is expired
+ await expect(
+ payload.resetPassword({
+ collection: 'users',
+ data: {
+ password: 'test',
+ token: forgot,
+ },
+ overrideAccess: true,
+ }),
+ ).rejects.toThrow('Token is either invalid or has expired.')
+ })
})
})
diff --git a/test/fields/collections/Array/index.ts b/test/fields/collections/Array/index.ts
index 1fe4aec1bcf..dad95be90d6 100644
--- a/test/fields/collections/Array/index.ts
+++ b/test/fields/collections/Array/index.ts
@@ -32,7 +32,13 @@ const ArrayFields: CollectionConfig = {
type: 'ui',
admin: {
components: {
- Field: './collections/Array/LabelComponent.js#ArrayRowLabel',
+ Field: {
+ path: './collections/Array/LabelComponent.js#ArrayRowLabel',
+ serverProps: {
+ // While this doesn't do anything, this will reproduce a bug where having server-only props in here will throw a "Functions cannot be passed directly to Client Components" error
+ someFn: () => 'Hello',
+ },
+ },
},
},
},