diff --git a/packages/db-mongodb/src/create.ts b/packages/db-mongodb/src/create.ts index 948d20ef1..cc71697cc 100644 --- a/packages/db-mongodb/src/create.ts +++ b/packages/db-mongodb/src/create.ts @@ -2,7 +2,7 @@ import type { Create, Document, PayloadRequestWithData } from 'payload' import type { MongooseAdapter } from './index.js' -import handleError from './utilities/handleError.js' +import { handleError } from './utilities/handleError.js' import { withSession } from './withSession.js' export const create: Create = async function create( @@ -15,7 +15,7 @@ export const create: Create = async function create( try { ;[doc] = await Model.create([data], options) } catch (error) { - handleError(error, req) + handleError({ collection, error, req }) } // doc.toJSON does not do stuff like converting ObjectIds to string, or date strings to date objects. That's why we use JSON.parse/stringify here diff --git a/packages/db-mongodb/src/updateOne.ts b/packages/db-mongodb/src/updateOne.ts index fc48e4908..64484a2fd 100644 --- a/packages/db-mongodb/src/updateOne.ts +++ b/packages/db-mongodb/src/updateOne.ts @@ -2,7 +2,7 @@ import type { PayloadRequestWithData, UpdateOne } from 'payload' import type { MongooseAdapter } from './index.js' -import handleError from './utilities/handleError.js' +import { handleError } from './utilities/handleError.js' import sanitizeInternalFields from './utilities/sanitizeInternalFields.js' import { withSession } from './withSession.js' @@ -29,7 +29,7 @@ export const updateOne: UpdateOne = async function updateOne( try { result = await Model.findOneAndUpdate(query, data, options) } catch (error) { - handleError(error, req) + handleError({ collection, error, req }) } result = JSON.parse(JSON.stringify(result)) diff --git a/packages/db-mongodb/src/utilities/handleError.ts b/packages/db-mongodb/src/utilities/handleError.ts index dbf95b986..d28d19af6 100644 --- a/packages/db-mongodb/src/utilities/handleError.ts +++ b/packages/db-mongodb/src/utilities/handleError.ts @@ -1,16 +1,30 @@ import httpStatus from 'http-status' import { APIError, ValidationError } from 'payload' -const handleError = (error, req) => { +export const handleError = ({ + collection, + error, + global, + req, +}: { + collection?: string + error + global?: string + req +}) => { // Handle uniqueness error from MongoDB if (error.code === 11000 && error.keyValue) { throw new ValidationError( - [ - { - field: Object.keys(error.keyValue)[0], - message: req.t('error:valueMustBeUnique'), - }, - ], + { + collection, + errors: [ + { + field: Object.keys(error.keyValue)[0], + message: req.t('error:valueMustBeUnique'), + }, + ], + global, + }, req.t, ) } else if (error.code === 11000) { @@ -19,5 +33,3 @@ const handleError = (error, req) => { throw error } } - -export default handleError diff --git a/packages/db-postgres/src/upsertRow/index.ts b/packages/db-postgres/src/upsertRow/index.ts index 760b3e1e5..59e51fb0f 100644 --- a/packages/db-postgres/src/upsertRow/index.ts +++ b/packages/db-postgres/src/upsertRow/index.ts @@ -313,12 +313,14 @@ export const upsertRow = async | TypeWithID>( } catch (error) { throw error.code === '23505' ? new ValidationError( - [ - { - field: adapter.fieldConstraints[tableName][error.constraint], - message: req.t('error:valueMustBeUnique'), - }, - ], + { + errors: [ + { + field: adapter.fieldConstraints[tableName][error.constraint], + message: req.t('error:valueMustBeUnique'), + }, + ], + }, req.t, ) : error diff --git a/packages/payload/src/auth/operations/login.ts b/packages/payload/src/auth/operations/login.ts index 7fff495cf..2302300a7 100644 --- a/packages/payload/src/auth/operations/login.ts +++ b/packages/payload/src/auth/operations/login.ts @@ -83,10 +83,16 @@ export const loginOperation = async ( const { email: unsanitizedEmail, password } = data if (typeof unsanitizedEmail !== 'string' || unsanitizedEmail.trim() === '') { - throw new ValidationError([{ field: 'email', message: req.i18n.t('validation:required') }]) + throw new ValidationError({ + collection: collectionConfig.slug, + errors: [{ field: 'email', message: req.i18n.t('validation:required') }], + }) } if (typeof password !== 'string' || password.trim() === '') { - throw new ValidationError([{ field: 'password', message: req.i18n.t('validation:required') }]) + throw new ValidationError({ + collection: collectionConfig.slug, + errors: [{ field: 'password', message: req.i18n.t('validation:required') }], + }) } const email = unsanitizedEmail ? unsanitizedEmail.toLowerCase().trim() : null diff --git a/packages/payload/src/auth/operations/resetPassword.ts b/packages/payload/src/auth/operations/resetPassword.ts index 2c4df4b04..3e7161a50 100644 --- a/packages/payload/src/auth/operations/resetPassword.ts +++ b/packages/payload/src/auth/operations/resetPassword.ts @@ -67,7 +67,10 @@ export const resetPasswordOperation = async (args: Arguments): Promise = if (!user) throw new APIError('Token is either invalid or has expired.', httpStatus.FORBIDDEN) // TODO: replace this method - const { hash, salt } = await generatePasswordSaltHash({ password: data.password }) + const { hash, salt } = await generatePasswordSaltHash({ + collection: collectionConfig, + password: data.password, + }) user.salt = salt user.hash = hash diff --git a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts index 967d97d7b..29eab536c 100644 --- a/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts +++ b/packages/payload/src/auth/strategies/local/generatePasswordSaltHash.ts @@ -1,5 +1,7 @@ import crypto from 'crypto' +import type { SanitizedCollectionConfig } from '../../../collections/config/types.js' + import { ValidationError } from '../../../errors/index.js' const defaultPasswordValidator = (password: string): string | true => { @@ -24,16 +26,21 @@ function pbkdf2Promisified(password: string, salt: string): Promise { } type Args = { + collection: SanitizedCollectionConfig password: string } export const generatePasswordSaltHash = async ({ + collection, password, }: Args): Promise<{ hash: string; salt: string }> => { const validationResult = defaultPasswordValidator(password) if (typeof validationResult === 'string') { - throw new ValidationError([{ field: 'password', message: validationResult }]) + throw new ValidationError({ + collection: collection?.slug, + errors: [{ field: 'password', message: validationResult }], + }) } const saltBuffer = await randomBytes() diff --git a/packages/payload/src/auth/strategies/local/register.ts b/packages/payload/src/auth/strategies/local/register.ts index 30603b60f..2ea1932de 100644 --- a/packages/payload/src/auth/strategies/local/register.ts +++ b/packages/payload/src/auth/strategies/local/register.ts @@ -34,12 +34,13 @@ export const registerLocalStrategy = async ({ }) if (existingUser.docs.length > 0) { - throw new ValidationError([ - { field: 'email', message: req.t('error:userEmailAlreadyRegistered') }, - ]) + throw new ValidationError({ + collection: collection.slug, + errors: [{ field: 'email', message: req.t('error:userEmailAlreadyRegistered') }], + }) } - const { hash, salt } = await generatePasswordSaltHash({ password }) + const { hash, salt } = await generatePasswordSaltHash({ collection, password }) const sanitizedDoc = { ...doc } if (sanitizedDoc.password) delete sanitizedDoc.password diff --git a/packages/payload/src/collections/operations/updateByID.ts b/packages/payload/src/collections/operations/updateByID.ts index 753379e93..0a8253d80 100644 --- a/packages/payload/src/collections/operations/updateByID.ts +++ b/packages/payload/src/collections/operations/updateByID.ts @@ -260,7 +260,10 @@ export const updateByIDOperation = async ( const dataToUpdate: Record = { ...result } if (shouldSavePassword && typeof password === 'string') { - const { hash, salt } = await generatePasswordSaltHash({ password }) + const { hash, salt } = await generatePasswordSaltHash({ + collection: collectionConfig, + password, + }) dataToUpdate.salt = salt dataToUpdate.hash = hash delete dataToUpdate.password diff --git a/packages/payload/src/errors/APIError.ts b/packages/payload/src/errors/APIError.ts index efa8cccc0..eb77ec156 100644 --- a/packages/payload/src/errors/APIError.ts +++ b/packages/payload/src/errors/APIError.ts @@ -11,7 +11,10 @@ class ExtendableError extends status: number constructor(message: string, status: number, data: TData, isPublic: boolean) { - super(message) + super(message, { + // show data in cause + cause: data, + }) this.name = this.constructor.name this.message = message this.status = status diff --git a/packages/payload/src/errors/ValidationError.ts b/packages/payload/src/errors/ValidationError.ts index b54aa443f..13752a778 100644 --- a/packages/payload/src/errors/ValidationError.ts +++ b/packages/payload/src/errors/ValidationError.ts @@ -5,14 +5,25 @@ import httpStatus from 'http-status' import { APIError } from './APIError.js' -export class ValidationError extends APIError<{ field: string; message: string }[]> { - constructor(results: { field: string; message: string }[], t?: TFunction) { +export class ValidationError extends APIError<{ + collection?: string + errors: { field: string; message: string }[] + global?: string +}> { + constructor( + results: { collection?: string; errors: { field: string; message: string }[]; global?: string }, + t?: TFunction, + ) { const message = t - ? t('error:followingFieldsInvalid', { count: results.length }) - : results.length === 1 + ? t('error:followingFieldsInvalid', { count: results.errors.length }) + : results.errors.length === 1 ? en.translations.error.followingFieldsInvalid_one : en.translations.error.followingFieldsInvalid_other - super(`${message} ${results.map((f) => f.field).join(', ')}`, httpStatus.BAD_REQUEST, results) + super( + `${message} ${results.errors.map((f) => f.field).join(', ')}`, + httpStatus.BAD_REQUEST, + results, + ) } } diff --git a/packages/payload/src/fields/hooks/beforeChange/index.ts b/packages/payload/src/fields/hooks/beforeChange/index.ts index 435e1c5ff..0a0f1ff8a 100644 --- a/packages/payload/src/fields/hooks/beforeChange/index.ts +++ b/packages/payload/src/fields/hooks/beforeChange/index.ts @@ -69,7 +69,14 @@ export const beforeChange = async >({ }) if (errors.length > 0) { - throw new ValidationError(errors, req.t) + throw new ValidationError( + { + collection: collection?.slug, + errors, + global: global?.slug, + }, + req.t, + ) } await mergeLocaleActions.reduce(async (priorAction, action) => { diff --git a/packages/payload/src/utilities/logger.ts b/packages/payload/src/utilities/logger.ts index d62fccc05..3f6ecf9c0 100644 --- a/packages/payload/src/utilities/logger.ts +++ b/packages/payload/src/utilities/logger.ts @@ -7,7 +7,7 @@ const pinoPretty = (pinoPrettyImport.default || export type PayloadLogger = pinoImport.default.Logger -const prettyOptions = { +const prettyOptions: pinoPrettyImport.PrettyOptions = { colorize: true, ignore: 'pid,hostname', translateTime: 'SYS:HH:MM:ss', diff --git a/packages/ui/src/forms/Form/index.tsx b/packages/ui/src/forms/Form/index.tsx index d33fcc82c..528abc73a 100644 --- a/packages/ui/src/forms/Form/index.tsx +++ b/packages/ui/src/forms/Form/index.tsx @@ -339,8 +339,8 @@ export const Form: React.FC = (props) => { newNonFieldErrs.push(err) } - if (Array.isArray(err?.data)) { - err.data.forEach((dataError) => { + if (Array.isArray(err?.data?.errors)) { + err.data?.errors.forEach((dataError) => { if (dataError?.field) { newFieldErrs.push(dataError) } else { diff --git a/test/collections-graphql/int.spec.ts b/test/collections-graphql/int.spec.ts index ecc5103ea..671ff70dc 100644 --- a/test/collections-graphql/int.spec.ts +++ b/test/collections-graphql/int.spec.ts @@ -1185,24 +1185,26 @@ describe('collections-graphql', () => { expect(errors[0].message).toEqual('The following field is invalid: password') expect(errors[0].path[0]).toEqual('test2') expect(errors[0].extensions.name).toEqual('ValidationError') - expect(errors[0].extensions.data[0].message).toEqual('No password was given') - expect(errors[0].extensions.data[0].field).toEqual('password') + expect(errors[0].extensions.data.errors[0].message).toEqual('No password was given') + expect(errors[0].extensions.data.errors[0].field).toEqual('password') expect(Array.isArray(errors[1].locations)).toEqual(true) expect(errors[1].message).toEqual('The following field is invalid: email') expect(errors[1].path[0]).toEqual('test3') expect(errors[1].extensions.name).toEqual('ValidationError') - expect(errors[1].extensions.data[0].message).toEqual( + expect(errors[1].extensions.data.errors[0].message).toEqual( 'A user with the given email is already registered.', ) - expect(errors[1].extensions.data[0].field).toEqual('email') + expect(errors[1].extensions.data.errors[0].field).toEqual('email') expect(Array.isArray(errors[2].locations)).toEqual(true) expect(errors[2].message).toEqual('The following field is invalid: email') expect(errors[2].path[0]).toEqual('test4') expect(errors[2].extensions.name).toEqual('ValidationError') - expect(errors[2].extensions.data[0].message).toEqual('Please enter a valid email address.') - expect(errors[2].extensions.data[0].field).toEqual('email') + expect(errors[2].extensions.data.errors[0].message).toEqual( + 'Please enter a valid email address.', + ) + expect(errors[2].extensions.data.errors[0].field).toEqual('email') }) it('should return the minimum allowed information about internal errors', async () => {