From c85fb808b96deccb0c83fcecf4a966c76cc5644b Mon Sep 17 00:00:00 2001 From: Adrian Maj Date: Mon, 28 Apr 2025 20:49:43 +0200 Subject: [PATCH] fix: user validation error inside the forgotPassword operation in the cases where user had localised fields (#12034) ### What? So, while resetting the password using the Local API, I encountered a validation error for localized fields. I jumped into the Payload repository, and saw that `payload.update` is being used in the process, with no locale specified/supported. This causes errors if the user has localized fields, but specifying a locale for the password reset operation would be silly, so I suggest turning this into a db operation, just like the user fetching operation before. ### How? I replaced this: ```TS user = await payload.update({ id: user.id, collection: collectionConfig.slug, data: user, req, }) ``` With this: ```TS user = await payload.db.updateOne({ id: user.id, collection: collectionConfig.slug, data: user, req, }) ``` So the validation of other fields would be skipped in this operation. ### Why? This is the error I encountered while trying to reset password, it blocks my project to go further :) ```bash Error [ValidationError]: The following field is invalid: Data > Name at async sendOfferEmail (src/collections/Offers/components/SendEmailButton/index.tsx:18:20) 16 | try { 17 | const payload = await getPayload({ config }); > 18 | const token = await payload.forgotPassword({ | ^ 19 | collection: "offers", 20 | data: { { data: [Object], isOperational: true, isPublic: false, status: 400, [cause]: [Object] } cause: { id: '67f4c1df8aa60189df9bdf5c', collection: 'offers', errors: [ { label: 'Data > Name', message: 'This field is required.', path: 'name' } ], global: undefined } ``` P.S The name field is totally fine, it is required and filled with values in both locales I use, in admin panel I can edit and save everything without any issues. --- .../src/auth/operations/forgotPassword.ts | 8 +- payload-types.ts | 246 ++++++++++++++++++ test/auth/forgot-password-localized/config.ts | 51 ++++ .../forgot-password-localized/int.spec.ts | 78 ++++++ .../payload-types.ts | 246 ++++++++++++++++++ 5 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 payload-types.ts create mode 100644 test/auth/forgot-password-localized/config.ts create mode 100644 test/auth/forgot-password-localized/int.spec.ts create mode 100644 test/auth/forgot-password-localized/payload-types.ts diff --git a/packages/payload/src/auth/operations/forgotPassword.ts b/packages/payload/src/auth/operations/forgotPassword.ts index a51f947b3..dad7df932 100644 --- a/packages/payload/src/auth/operations/forgotPassword.ts +++ b/packages/payload/src/auth/operations/forgotPassword.ts @@ -138,15 +138,17 @@ export const forgotPasswordOperation = async ( return null } - user.resetPasswordToken = token - user.resetPasswordExpiration = new Date( + const resetPasswordExpiration = new Date( Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000), ).toISOString() user = await payload.update({ id: user.id, collection: collectionConfig.slug, - data: user, + data: { + resetPasswordExpiration, + resetPasswordToken: token, + }, req, }) diff --git a/payload-types.ts b/payload-types.ts new file mode 100644 index 000000000..1b22b04b7 --- /dev/null +++ b/payload-types.ts @@ -0,0 +1,246 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: {}; + globalsSelect: {}; + locale: 'en' | 'pl'; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + localizedField: string; + roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: { + relationTo: 'users'; + value: string | User; + } | null; + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + localizedField?: T; + roles?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/auth/forgot-password-localized/config.ts b/test/auth/forgot-password-localized/config.ts new file mode 100644 index 000000000..7a6cdec9b --- /dev/null +++ b/test/auth/forgot-password-localized/config.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../../buildConfigWithDefaults.js' + +export const collectionSlug = 'users' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + admin: { + user: collectionSlug, + importMap: { + baseDir: path.resolve(dirname), + }, + }, + localization: { + locales: ['en', 'pl'], + defaultLocale: 'en', + }, + collections: [ + { + slug: collectionSlug, + auth: { + forgotPassword: { + // Default options + }, + }, + fields: [ + { + name: 'localizedField', + type: 'text', + localized: true, // This field is localized and will require locale during validation + required: true, + }, + { + name: 'roles', + type: 'select', + defaultValue: ['user'], + hasMany: true, + label: 'Role', + options: ['admin', 'editor', 'moderator', 'user', 'viewer'], + required: true, + saveToJWT: true, + }, + ], + }, + ], + debug: true, +}) diff --git a/test/auth/forgot-password-localized/int.spec.ts b/test/auth/forgot-password-localized/int.spec.ts new file mode 100644 index 000000000..0e5eaa308 --- /dev/null +++ b/test/auth/forgot-password-localized/int.spec.ts @@ -0,0 +1,78 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../../helpers/NextRESTClient.js' + +import { devUser } from '../../credentials.js' +import { initPayloadInt } from '../../helpers/initPayloadInt.js' +import { collectionSlug } from './config.js' + +let restClient: NextRESTClient | undefined +let payload: Payload | undefined + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('Forgot password operation with localized fields', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname, 'auth/forgot-password-localized')) + + // Register a user with additional localized field + const res = await restClient?.POST(`/${collectionSlug}/first-register?locale=en`, { + body: JSON.stringify({ + ...devUser, + 'confirm-password': devUser.password, + localizedField: 'English content', + }), + }) + + if (!res) { + throw new Error('Failed to register user') + } + + const { user } = await res.json() + + // @ts-expect-error - Localized field is not in the general Payload type, but it is in mocked collection in this case. + await payload?.update({ + collection: collectionSlug, + id: user.id as string, + locale: 'pl', + data: { + localizedField: 'Polish content', + }, + }) + }) + + afterAll(async () => { + if (typeof payload?.db.destroy === 'function') { + await payload?.db.destroy() + } + }) + + it('should successfully process forgotPassword operation with localized fields', async () => { + // Attempt to trigger forgotPassword operation + const token = await payload?.forgotPassword({ + collection: collectionSlug, + data: { email: devUser.email }, + disableEmail: true, + }) + + // Verify token was generated successfully + expect(token).toBeDefined() + expect(typeof token).toBe('string') + expect(token?.length).toBeGreaterThan(0) + }) + + it('should not throw validation errors for localized fields', async () => { + // We expect this not to throw an error + await expect( + payload?.forgotPassword({ + collection: collectionSlug, + data: { email: devUser.email }, + disableEmail: true, + }), + ).resolves.not.toThrow() + }) +}) diff --git a/test/auth/forgot-password-localized/payload-types.ts b/test/auth/forgot-password-localized/payload-types.ts new file mode 100644 index 000000000..1b22b04b7 --- /dev/null +++ b/test/auth/forgot-password-localized/payload-types.ts @@ -0,0 +1,246 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: {}; + globalsSelect: {}; + locale: 'en' | 'pl'; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + localizedField: string; + roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: { + relationTo: 'users'; + value: string | User; + } | null; + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + localizedField?: T; + roles?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file