Skip to content

Commit e3a419b

Browse files
authored
Merge branch 'alpha' into test/includeall
2 parents b12bb1e + 9ed9af4 commit e3a419b

File tree

7 files changed

+257
-13
lines changed

7 files changed

+257
-13
lines changed

changelogs/CHANGELOG_alpha.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# [8.5.0-alpha.12](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.11...8.5.0-alpha.12) (2025-11-19)
2+
3+
4+
### Features
5+
6+
* Add `beforePasswordResetRequest` hook ([#9906](https://github.com/parse-community/parse-server/issues/9906)) ([94cee5b](https://github.com/parse-community/parse-server/commit/94cee5bfafca10c914c73cf17fcdb627a9f0837b))
7+
18
# [8.5.0-alpha.11](https://github.com/parse-community/parse-server/compare/8.5.0-alpha.10...8.5.0-alpha.11) (2025-11-17)
29

310

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "parse-server",
3-
"version": "8.5.0-alpha.11",
3+
"version": "8.5.0-alpha.12",
44
"description": "An express module providing a Parse-compatible API server",
55
"main": "lib/index.js",
66
"repository": {

spec/CloudCode.spec.js

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3307,19 +3307,19 @@ describe('afterFind hooks', () => {
33073307
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
33083308
expect(() => {
33093309
Parse.Cloud.beforeLogin('SomeClass', () => { });
3310-
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
3310+
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
33113311
expect(() => {
33123312
Parse.Cloud.afterLogin(() => { });
3313-
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
3313+
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
33143314
expect(() => {
33153315
Parse.Cloud.afterLogin('_User', () => { });
3316-
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
3316+
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
33173317
expect(() => {
33183318
Parse.Cloud.afterLogin(Parse.User, () => { });
3319-
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
3319+
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
33203320
expect(() => {
33213321
Parse.Cloud.afterLogin('SomeClass', () => { });
3322-
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
3322+
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
33233323
expect(() => {
33243324
Parse.Cloud.afterLogout(() => { });
33253325
}).not.toThrow();
@@ -4656,3 +4656,157 @@ describe('sendEmail', () => {
46564656
);
46574657
});
46584658
});
4659+
4660+
describe('beforePasswordResetRequest hook', () => {
4661+
it('should run beforePasswordResetRequest with valid user', async () => {
4662+
let hit = 0;
4663+
let sendPasswordResetEmailCalled = false;
4664+
const emailAdapter = {
4665+
sendVerificationEmail: () => Promise.resolve(),
4666+
sendPasswordResetEmail: () => {
4667+
sendPasswordResetEmailCalled = true;
4668+
},
4669+
sendMail: () => {},
4670+
};
4671+
4672+
await reconfigureServer({
4673+
appName: 'test',
4674+
emailAdapter: emailAdapter,
4675+
publicServerURL: 'http://localhost:8378/1',
4676+
});
4677+
4678+
Parse.Cloud.beforePasswordResetRequest(req => {
4679+
hit++;
4680+
expect(req.object).toBeDefined();
4681+
expect(req.object.get('email')).toEqual('[email protected]');
4682+
expect(req.object.get('username')).toEqual('testuser');
4683+
});
4684+
4685+
const user = new Parse.User();
4686+
user.setUsername('testuser');
4687+
user.setPassword('password');
4688+
user.set('email', '[email protected]');
4689+
await user.signUp();
4690+
4691+
await Parse.User.requestPasswordReset('[email protected]');
4692+
expect(hit).toBe(1);
4693+
expect(sendPasswordResetEmailCalled).toBe(true);
4694+
});
4695+
4696+
it('should be able to block password reset request if an error is thrown', async () => {
4697+
let hit = 0;
4698+
let sendPasswordResetEmailCalled = false;
4699+
const emailAdapter = {
4700+
sendVerificationEmail: () => Promise.resolve(),
4701+
sendPasswordResetEmail: () => {
4702+
sendPasswordResetEmailCalled = true;
4703+
},
4704+
sendMail: () => {},
4705+
};
4706+
4707+
await reconfigureServer({
4708+
appName: 'test',
4709+
emailAdapter: emailAdapter,
4710+
publicServerURL: 'http://localhost:8378/1',
4711+
});
4712+
4713+
Parse.Cloud.beforePasswordResetRequest(req => {
4714+
hit++;
4715+
throw new Error('password reset blocked');
4716+
});
4717+
4718+
const user = new Parse.User();
4719+
user.setUsername('testuser');
4720+
user.setPassword('password');
4721+
user.set('email', '[email protected]');
4722+
await user.signUp();
4723+
4724+
try {
4725+
await Parse.User.requestPasswordReset('[email protected]');
4726+
throw new Error('should not have sent password reset email.');
4727+
} catch (e) {
4728+
expect(e.message).toBe('password reset blocked');
4729+
}
4730+
expect(hit).toBe(1);
4731+
expect(sendPasswordResetEmailCalled).toBe(false);
4732+
});
4733+
4734+
it('should not run beforePasswordResetRequest if email does not exist', async () => {
4735+
let hit = 0;
4736+
const emailAdapter = {
4737+
sendVerificationEmail: () => Promise.resolve(),
4738+
sendPasswordResetEmail: () => {},
4739+
sendMail: () => {},
4740+
};
4741+
4742+
await reconfigureServer({
4743+
appName: 'test',
4744+
emailAdapter: emailAdapter,
4745+
publicServerURL: 'http://localhost:8378/1',
4746+
});
4747+
4748+
Parse.Cloud.beforePasswordResetRequest(req => {
4749+
hit++;
4750+
});
4751+
4752+
await Parse.User.requestPasswordReset('[email protected]');
4753+
4754+
expect(hit).toBe(0);
4755+
});
4756+
4757+
it('should have expected data in request in beforePasswordResetRequest', async () => {
4758+
const emailAdapter = {
4759+
sendVerificationEmail: () => Promise.resolve(),
4760+
sendPasswordResetEmail: () => {},
4761+
sendMail: () => {},
4762+
};
4763+
4764+
await reconfigureServer({
4765+
appName: 'test',
4766+
emailAdapter: emailAdapter,
4767+
publicServerURL: 'http://localhost:8378/1',
4768+
});
4769+
4770+
const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=';
4771+
const file = new Parse.File('myfile.txt', { base64 });
4772+
await file.save();
4773+
4774+
Parse.Cloud.beforePasswordResetRequest(req => {
4775+
expect(req.object).toBeDefined();
4776+
expect(req.object.get('email')).toBeDefined();
4777+
expect(req.object.get('email')).toBe('[email protected]');
4778+
expect(req.object.get('file')).toBeDefined();
4779+
expect(req.object.get('file')).toBeInstanceOf(Parse.File);
4780+
expect(req.object.get('file').name()).toContain('myfile.txt');
4781+
expect(req.headers).toBeDefined();
4782+
expect(req.ip).toBeDefined();
4783+
expect(req.installationId).toBeDefined();
4784+
expect(req.context).toBeDefined();
4785+
expect(req.config).toBeDefined();
4786+
});
4787+
4788+
const user = new Parse.User();
4789+
user.setUsername('testuser2');
4790+
user.setPassword('password');
4791+
user.set('email', '[email protected]');
4792+
user.set('file', file);
4793+
await user.signUp();
4794+
4795+
await Parse.User.requestPasswordReset('[email protected]');
4796+
});
4797+
4798+
it('should validate that only _User class is allowed for beforePasswordResetRequest', () => {
4799+
expect(() => {
4800+
Parse.Cloud.beforePasswordResetRequest('SomeClass', () => { });
4801+
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
4802+
expect(() => {
4803+
Parse.Cloud.beforePasswordResetRequest(() => { });
4804+
}).not.toThrow();
4805+
expect(() => {
4806+
Parse.Cloud.beforePasswordResetRequest('_User', () => { });
4807+
}).not.toThrow();
4808+
expect(() => {
4809+
Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { });
4810+
}).not.toThrow();
4811+
});
4812+
});

src/Routers/UsersRouter.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Types as TriggerTypes,
1313
getRequestObject,
1414
resolveError,
15+
inflate,
1516
} from '../triggers';
1617
import { promiseEnsureIdempotency } from '../middlewares';
1718
import RestWrite from '../RestWrite';
@@ -444,21 +445,59 @@ export class UsersRouter extends ClassesRouter {
444445
if (!email && !token) {
445446
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
446447
}
448+
449+
let userResults = null;
450+
let userData = null;
451+
452+
// We can find the user using token
447453
if (token) {
448-
const results = await req.config.database.find('_User', {
454+
userResults = await req.config.database.find('_User', {
449455
_perishable_token: token,
450456
_perishable_token_expires_at: { $lt: Parse._encode(new Date()) },
451457
});
452-
if (results && results[0] && results[0].email) {
453-
email = results[0].email;
458+
if (userResults?.length > 0) {
459+
userData = userResults[0];
460+
if (userData.email) {
461+
email = userData.email;
462+
}
463+
}
464+
// Or using email if no token provided
465+
} else if (typeof email === 'string') {
466+
userResults = await req.config.database.find(
467+
'_User',
468+
{ $or: [{ email }, { username: email, email: { $exists: false } }] },
469+
{ limit: 1 },
470+
Auth.maintenance(req.config)
471+
);
472+
if (userResults?.length > 0) {
473+
userData = userResults[0];
454474
}
455475
}
476+
456477
if (typeof email !== 'string') {
457478
throw new Parse.Error(
458479
Parse.Error.INVALID_EMAIL_ADDRESS,
459480
'you must provide a valid email string'
460481
);
461482
}
483+
484+
if (userData) {
485+
this._sanitizeAuthData(userData);
486+
// Get files attached to user
487+
await req.config.filesController.expandFilesInObject(req.config, userData);
488+
489+
const user = inflate('_User', userData);
490+
491+
await maybeRunTrigger(
492+
TriggerTypes.beforePasswordResetRequest,
493+
req.auth,
494+
user,
495+
null,
496+
req.config,
497+
req.info.context
498+
);
499+
}
500+
462501
const userController = req.config.userController;
463502
try {
464503
await userController.sendPasswordResetEmail(email);

src/cloud-code/Parse.Cloud.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,48 @@ ParseCloud.afterLogout = function (handler) {
349349
triggers.addTrigger(triggers.Types.afterLogout, className, handler, Parse.applicationId);
350350
};
351351

352+
/**
353+
* Registers the before password reset request function.
354+
*
355+
* **Available in Cloud Code only.**
356+
*
357+
* This function provides control in validating a password reset request
358+
* before the reset email is sent. It is triggered after the user is found
359+
* by email, but before the reset token is generated and the email is sent.
360+
*
361+
* Code example:
362+
*
363+
* ```
364+
* Parse.Cloud.beforePasswordResetRequest(request => {
365+
* if (request.object.get('banned')) {
366+
* throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User is banned.');
367+
* }
368+
* });
369+
* ```
370+
*
371+
* @method beforePasswordResetRequest
372+
* @name Parse.Cloud.beforePasswordResetRequest
373+
* @param {Function} func The function to run before a password reset request. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest};
374+
*/
375+
ParseCloud.beforePasswordResetRequest = function (handler, validationHandler) {
376+
let className = '_User';
377+
if (typeof handler === 'string' || isParseObjectConstructor(handler)) {
378+
// validation will occur downstream, this is to maintain internal
379+
// code consistency with the other hook types.
380+
className = triggers.getClassName(handler);
381+
handler = arguments[1];
382+
validationHandler = arguments.length >= 2 ? arguments[2] : null;
383+
}
384+
triggers.addTrigger(triggers.Types.beforePasswordResetRequest, className, handler, Parse.applicationId);
385+
if (validationHandler && validationHandler.rateLimit) {
386+
addRateLimit(
387+
{ requestPath: `/requestPasswordReset`, requestMethods: 'POST', ...validationHandler.rateLimit },
388+
Parse.applicationId,
389+
true
390+
);
391+
}
392+
};
393+
352394
/**
353395
* Registers an after save function.
354396
*

src/triggers.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const Types = {
66
beforeLogin: 'beforeLogin',
77
afterLogin: 'afterLogin',
88
afterLogout: 'afterLogout',
9+
beforePasswordResetRequest: 'beforePasswordResetRequest',
910
beforeSave: 'beforeSave',
1011
afterSave: 'afterSave',
1112
beforeDelete: 'beforeDelete',
@@ -58,10 +59,10 @@ function validateClassNameForTriggers(className, type) {
5859
// TODO: Allow proper documented way of using nested increment ops
5960
throw 'Only afterSave is allowed on _PushStatus';
6061
}
61-
if ((type === Types.beforeLogin || type === Types.afterLogin) && className !== '_User') {
62+
if ((type === Types.beforeLogin || type === Types.afterLogin || type === Types.beforePasswordResetRequest) && className !== '_User') {
6263
// TODO: check if upstream code will handle `Error` instance rather
6364
// than this anti-pattern of throwing strings
64-
throw 'Only the _User class is allowed for the beforeLogin and afterLogin triggers';
65+
throw 'Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers';
6566
}
6667
if (type === Types.afterLogout && className !== '_Session') {
6768
// TODO: check if upstream code will handle `Error` instance rather
@@ -287,6 +288,7 @@ export function getRequestObject(
287288
triggerType === Types.afterDelete ||
288289
triggerType === Types.beforeLogin ||
289290
triggerType === Types.afterLogin ||
291+
triggerType === Types.beforePasswordResetRequest ||
290292
triggerType === Types.afterFind
291293
) {
292294
// Set a copy of the context on the request object.

0 commit comments

Comments
 (0)