Skip to content

Commit 9318e8c

Browse files
AdrianMajkendelljoseph
authored andcommitted
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. <!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # -->
1 parent 5c67b47 commit 9318e8c

File tree

5 files changed

+626
-3
lines changed

5 files changed

+626
-3
lines changed

packages/payload/src/auth/operations/forgotPassword.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,17 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
138138
return null
139139
}
140140

141-
user.resetPasswordToken = token
142-
user.resetPasswordExpiration = new Date(
141+
const resetPasswordExpiration = new Date(
143142
Date.now() + (collectionConfig.auth?.forgotPassword?.expiration ?? expiration ?? 3600000),
144143
).toISOString()
145144

146145
user = await payload.update({
147146
id: user.id,
148147
collection: collectionConfig.slug,
149-
data: user,
148+
data: {
149+
resetPasswordExpiration,
150+
resetPasswordToken: token,
151+
},
150152
req,
151153
})
152154

payload-types.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
/* tslint:disable */
2+
/* eslint-disable */
3+
/**
4+
* This file was automatically generated by Payload.
5+
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
6+
* and re-run `payload generate:types` to regenerate this file.
7+
*/
8+
9+
/**
10+
* Supported timezones in IANA format.
11+
*
12+
* This interface was referenced by `Config`'s JSON-Schema
13+
* via the `definition` "supportedTimezones".
14+
*/
15+
export type SupportedTimezones =
16+
| 'Pacific/Midway'
17+
| 'Pacific/Niue'
18+
| 'Pacific/Honolulu'
19+
| 'Pacific/Rarotonga'
20+
| 'America/Anchorage'
21+
| 'Pacific/Gambier'
22+
| 'America/Los_Angeles'
23+
| 'America/Tijuana'
24+
| 'America/Denver'
25+
| 'America/Phoenix'
26+
| 'America/Chicago'
27+
| 'America/Guatemala'
28+
| 'America/New_York'
29+
| 'America/Bogota'
30+
| 'America/Caracas'
31+
| 'America/Santiago'
32+
| 'America/Buenos_Aires'
33+
| 'America/Sao_Paulo'
34+
| 'Atlantic/South_Georgia'
35+
| 'Atlantic/Azores'
36+
| 'Atlantic/Cape_Verde'
37+
| 'Europe/London'
38+
| 'Europe/Berlin'
39+
| 'Africa/Lagos'
40+
| 'Europe/Athens'
41+
| 'Africa/Cairo'
42+
| 'Europe/Moscow'
43+
| 'Asia/Riyadh'
44+
| 'Asia/Dubai'
45+
| 'Asia/Baku'
46+
| 'Asia/Karachi'
47+
| 'Asia/Tashkent'
48+
| 'Asia/Calcutta'
49+
| 'Asia/Dhaka'
50+
| 'Asia/Almaty'
51+
| 'Asia/Jakarta'
52+
| 'Asia/Bangkok'
53+
| 'Asia/Shanghai'
54+
| 'Asia/Singapore'
55+
| 'Asia/Tokyo'
56+
| 'Asia/Seoul'
57+
| 'Australia/Brisbane'
58+
| 'Australia/Sydney'
59+
| 'Pacific/Guam'
60+
| 'Pacific/Noumea'
61+
| 'Pacific/Auckland'
62+
| 'Pacific/Fiji';
63+
64+
export interface Config {
65+
auth: {
66+
users: UserAuthOperations;
67+
};
68+
blocks: {};
69+
collections: {
70+
users: User;
71+
'payload-locked-documents': PayloadLockedDocument;
72+
'payload-preferences': PayloadPreference;
73+
'payload-migrations': PayloadMigration;
74+
};
75+
collectionsJoins: {};
76+
collectionsSelect: {
77+
users: UsersSelect<false> | UsersSelect<true>;
78+
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
79+
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
80+
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
81+
};
82+
db: {
83+
defaultIDType: string;
84+
};
85+
globals: {};
86+
globalsSelect: {};
87+
locale: 'en' | 'pl';
88+
user: User & {
89+
collection: 'users';
90+
};
91+
jobs: {
92+
tasks: unknown;
93+
workflows: unknown;
94+
};
95+
}
96+
export interface UserAuthOperations {
97+
forgotPassword: {
98+
email: string;
99+
password: string;
100+
};
101+
login: {
102+
email: string;
103+
password: string;
104+
};
105+
registerFirstUser: {
106+
email: string;
107+
password: string;
108+
};
109+
unlock: {
110+
email: string;
111+
password: string;
112+
};
113+
}
114+
/**
115+
* This interface was referenced by `Config`'s JSON-Schema
116+
* via the `definition` "users".
117+
*/
118+
export interface User {
119+
id: string;
120+
localizedField: string;
121+
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
122+
updatedAt: string;
123+
createdAt: string;
124+
email: string;
125+
resetPasswordToken?: string | null;
126+
resetPasswordExpiration?: string | null;
127+
salt?: string | null;
128+
hash?: string | null;
129+
loginAttempts?: number | null;
130+
lockUntil?: string | null;
131+
password?: string | null;
132+
}
133+
/**
134+
* This interface was referenced by `Config`'s JSON-Schema
135+
* via the `definition` "payload-locked-documents".
136+
*/
137+
export interface PayloadLockedDocument {
138+
id: string;
139+
document?: {
140+
relationTo: 'users';
141+
value: string | User;
142+
} | null;
143+
globalSlug?: string | null;
144+
user: {
145+
relationTo: 'users';
146+
value: string | User;
147+
};
148+
updatedAt: string;
149+
createdAt: string;
150+
}
151+
/**
152+
* This interface was referenced by `Config`'s JSON-Schema
153+
* via the `definition` "payload-preferences".
154+
*/
155+
export interface PayloadPreference {
156+
id: string;
157+
user: {
158+
relationTo: 'users';
159+
value: string | User;
160+
};
161+
key?: string | null;
162+
value?:
163+
| {
164+
[k: string]: unknown;
165+
}
166+
| unknown[]
167+
| string
168+
| number
169+
| boolean
170+
| null;
171+
updatedAt: string;
172+
createdAt: string;
173+
}
174+
/**
175+
* This interface was referenced by `Config`'s JSON-Schema
176+
* via the `definition` "payload-migrations".
177+
*/
178+
export interface PayloadMigration {
179+
id: string;
180+
name?: string | null;
181+
batch?: number | null;
182+
updatedAt: string;
183+
createdAt: string;
184+
}
185+
/**
186+
* This interface was referenced by `Config`'s JSON-Schema
187+
* via the `definition` "users_select".
188+
*/
189+
export interface UsersSelect<T extends boolean = true> {
190+
localizedField?: T;
191+
roles?: T;
192+
updatedAt?: T;
193+
createdAt?: T;
194+
email?: T;
195+
resetPasswordToken?: T;
196+
resetPasswordExpiration?: T;
197+
salt?: T;
198+
hash?: T;
199+
loginAttempts?: T;
200+
lockUntil?: T;
201+
}
202+
/**
203+
* This interface was referenced by `Config`'s JSON-Schema
204+
* via the `definition` "payload-locked-documents_select".
205+
*/
206+
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
207+
document?: T;
208+
globalSlug?: T;
209+
user?: T;
210+
updatedAt?: T;
211+
createdAt?: T;
212+
}
213+
/**
214+
* This interface was referenced by `Config`'s JSON-Schema
215+
* via the `definition` "payload-preferences_select".
216+
*/
217+
export interface PayloadPreferencesSelect<T extends boolean = true> {
218+
user?: T;
219+
key?: T;
220+
value?: T;
221+
updatedAt?: T;
222+
createdAt?: T;
223+
}
224+
/**
225+
* This interface was referenced by `Config`'s JSON-Schema
226+
* via the `definition` "payload-migrations_select".
227+
*/
228+
export interface PayloadMigrationsSelect<T extends boolean = true> {
229+
name?: T;
230+
batch?: T;
231+
updatedAt?: T;
232+
createdAt?: T;
233+
}
234+
/**
235+
* This interface was referenced by `Config`'s JSON-Schema
236+
* via the `definition` "auth".
237+
*/
238+
export interface Auth {
239+
[k: string]: unknown;
240+
}
241+
242+
243+
declare module 'payload' {
244+
// @ts-ignore
245+
export interface GeneratedTypes extends Config {}
246+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { fileURLToPath } from 'node:url'
2+
import path from 'path'
3+
4+
import { buildConfigWithDefaults } from '../../buildConfigWithDefaults.js'
5+
6+
export const collectionSlug = 'users'
7+
8+
const filename = fileURLToPath(import.meta.url)
9+
const dirname = path.dirname(filename)
10+
11+
export default buildConfigWithDefaults({
12+
admin: {
13+
user: collectionSlug,
14+
importMap: {
15+
baseDir: path.resolve(dirname),
16+
},
17+
},
18+
localization: {
19+
locales: ['en', 'pl'],
20+
defaultLocale: 'en',
21+
},
22+
collections: [
23+
{
24+
slug: collectionSlug,
25+
auth: {
26+
forgotPassword: {
27+
// Default options
28+
},
29+
},
30+
fields: [
31+
{
32+
name: 'localizedField',
33+
type: 'text',
34+
localized: true, // This field is localized and will require locale during validation
35+
required: true,
36+
},
37+
{
38+
name: 'roles',
39+
type: 'select',
40+
defaultValue: ['user'],
41+
hasMany: true,
42+
label: 'Role',
43+
options: ['admin', 'editor', 'moderator', 'user', 'viewer'],
44+
required: true,
45+
saveToJWT: true,
46+
},
47+
],
48+
},
49+
],
50+
debug: true,
51+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Payload } from 'payload'
2+
3+
import path from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
import type { NextRESTClient } from '../../helpers/NextRESTClient.js'
7+
8+
import { devUser } from '../../credentials.js'
9+
import { initPayloadInt } from '../../helpers/initPayloadInt.js'
10+
import { collectionSlug } from './config.js'
11+
12+
let restClient: NextRESTClient | undefined
13+
let payload: Payload | undefined
14+
15+
const filename = fileURLToPath(import.meta.url)
16+
const dirname = path.dirname(filename)
17+
18+
describe('Forgot password operation with localized fields', () => {
19+
beforeAll(async () => {
20+
;({ payload, restClient } = await initPayloadInt(dirname, 'auth/forgot-password-localized'))
21+
22+
// Register a user with additional localized field
23+
const res = await restClient?.POST(`/${collectionSlug}/first-register?locale=en`, {
24+
body: JSON.stringify({
25+
...devUser,
26+
'confirm-password': devUser.password,
27+
localizedField: 'English content',
28+
}),
29+
})
30+
31+
if (!res) {
32+
throw new Error('Failed to register user')
33+
}
34+
35+
const { user } = await res.json()
36+
37+
// @ts-expect-error - Localized field is not in the general Payload type, but it is in mocked collection in this case.
38+
await payload?.update({
39+
collection: collectionSlug,
40+
id: user.id as string,
41+
locale: 'pl',
42+
data: {
43+
localizedField: 'Polish content',
44+
},
45+
})
46+
})
47+
48+
afterAll(async () => {
49+
if (typeof payload?.db.destroy === 'function') {
50+
await payload?.db.destroy()
51+
}
52+
})
53+
54+
it('should successfully process forgotPassword operation with localized fields', async () => {
55+
// Attempt to trigger forgotPassword operation
56+
const token = await payload?.forgotPassword({
57+
collection: collectionSlug,
58+
data: { email: devUser.email },
59+
disableEmail: true,
60+
})
61+
62+
// Verify token was generated successfully
63+
expect(token).toBeDefined()
64+
expect(typeof token).toBe('string')
65+
expect(token?.length).toBeGreaterThan(0)
66+
})
67+
68+
it('should not throw validation errors for localized fields', async () => {
69+
// We expect this not to throw an error
70+
await expect(
71+
payload?.forgotPassword({
72+
collection: collectionSlug,
73+
data: { email: devUser.email },
74+
disableEmail: true,
75+
}),
76+
).resolves.not.toThrow()
77+
})
78+
})

0 commit comments

Comments
 (0)