From bd79dcbc861bf440f5231e1c905c512cdb4b0af3 Mon Sep 17 00:00:00 2001 From: Jackson Tran Date: Tue, 20 Aug 2024 19:07:33 -0700 Subject: [PATCH] new API to request password reset --- server/emails/passwordReset/html.ejs | 14 +++++ server/emails/passwordReset/subject.ejs | 1 + server/emails/passwordReset/text.ejs | 15 +++++ server/models/user.js | 19 ++++++ .../migration.sql | 5 ++ server/prisma/schema.prisma | 2 + .../api/v1/users/request-password-reset.js | 58 +++++++++++++++++++ server/test/routes/api/v1/users.test.js | 44 ++++++++++++++ 8 files changed, 158 insertions(+) create mode 100644 server/emails/passwordReset/html.ejs create mode 100644 server/emails/passwordReset/subject.ejs create mode 100644 server/emails/passwordReset/text.ejs rename server/prisma/migrations/{20240602204712_initial_data_model => 20240820234151_}/migration.sql (97%) create mode 100644 server/routes/api/v1/users/request-password-reset.js diff --git a/server/emails/passwordReset/html.ejs b/server/emails/passwordReset/html.ejs new file mode 100644 index 00000000..65ff1777 --- /dev/null +++ b/server/emails/passwordReset/html.ejs @@ -0,0 +1,14 @@ +<%- include('../_header.html.ejs') %> + +

Dear <%= firstName %>,

+ +

We have received a request to reset your password for your SF Lifeline account.

+ +<%- include('../_button.html.ejs', { label: 'Reset Password', url }) %> + +

This link will expire in 30 minutes. If you did not request a password reset, please ignore this email or contact + our support team if you have any concerns.

+ +

Sincerely,
The SF Life Line Team

+ +<%- include('../_footer.html.ejs') %> \ No newline at end of file diff --git a/server/emails/passwordReset/subject.ejs b/server/emails/passwordReset/subject.ejs new file mode 100644 index 00000000..b5df8f69 --- /dev/null +++ b/server/emails/passwordReset/subject.ejs @@ -0,0 +1 @@ +Reset your password for your SF Lifeline account \ No newline at end of file diff --git a/server/emails/passwordReset/text.ejs b/server/emails/passwordReset/text.ejs new file mode 100644 index 00000000..353bff91 --- /dev/null +++ b/server/emails/passwordReset/text.ejs @@ -0,0 +1,15 @@ +Dear <%= firstName %>, + +We have received a request to reset your password for your SF Lifeline account. + +Click on the following link to reset your: + +<%= url %> + +This link will expire in 30 minutes. If you did not request a password reset, please ignore this email or contact +our support team if you have any concerns. + +Sincerely, +The SF Life Line Team + +<%- include('../_footer.text.ejs') %> \ No newline at end of file diff --git a/server/models/user.js b/server/models/user.js index 41a325c1..82263ef8 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -92,6 +92,25 @@ class User extends Base { }); } + generatePasswordResetToken() { + this.passwordResetToken = crypto.randomUUID(); + } + + async sendPasswordResetEmail() { + const { firstName } = this; + const url = `${process.env.BASE_URL}/verify/${this.passwordResetToken}`; + return mailer.send({ + message: { + to: this.fullNameAndEmail, + }, + template: 'passwordReset', + locals: { + firstName, + url, + }, + }); + } + async setPassword(password) { this.hashedPassword = await bcrypt.hash(password, 10); } diff --git a/server/prisma/migrations/20240602204712_initial_data_model/migration.sql b/server/prisma/migrations/20240820234151_/migration.sql similarity index 97% rename from server/prisma/migrations/20240602204712_initial_data_model/migration.sql rename to server/prisma/migrations/20240820234151_/migration.sql index 9ee1ebda..12952da2 100644 --- a/server/prisma/migrations/20240602204712_initial_data_model/migration.sql +++ b/server/prisma/migrations/20240820234151_/migration.sql @@ -28,6 +28,8 @@ CREATE TABLE "User" ( "email" CITEXT NOT NULL, "emailVerificationToken" TEXT, "emailVerifiedAt" TIMESTAMP(3), + "passwordResetToken" TEXT, + "passwordResetExpires" TIMESTAMP(3), "role" "Role" NOT NULL, "hashedPassword" TEXT NOT NULL, "licenseNumber" TEXT, @@ -167,6 +169,9 @@ CREATE TABLE "_HospitalToPhysician" ( -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +-- CreateIndex +CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken"); + -- CreateIndex CREATE UNIQUE INDEX "User_licenseNumber_key" ON "User"("licenseNumber"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b540301c..627c390b 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -27,6 +27,8 @@ model User { email String @unique @db.Citext emailVerificationToken String? emailVerifiedAt DateTime? + passwordResetToken String? @unique + passwordResetExpires DateTime? role Role hashedPassword String licenseNumber String? @unique diff --git a/server/routes/api/v1/users/request-password-reset.js b/server/routes/api/v1/users/request-password-reset.js new file mode 100644 index 00000000..8048eb7f --- /dev/null +++ b/server/routes/api/v1/users/request-password-reset.js @@ -0,0 +1,58 @@ +import { StatusCodes } from 'http-status-codes'; +import User from '../../../../models/user.js'; + +export default async function (fastify, _opts) { + fastify.post( + '/request-password-reset', + { + schema: { + body: { + type: 'object', + required: ['email'], + properties: { + email: { type: 'string' }, + }, + }, + response: { + [StatusCodes.OK]: { + type: 'object', + properties: { + message: { type: 'string' }, + }, + }, + [StatusCodes.NOT_FOUND]: { + type: 'object', + properties: { + message: { type: 'string' }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { email } = request.body; + + const data = await fastify.prisma.user.findUnique({ + where: { email }, + }); + + if (!data) { + return reply.notFound('User not Found'); + } + + const user = new User(data); + + user.generatePasswordResetToken(); + await user.sendPasswordResetEmail(); + + await fastify.prisma.user.update({ + where: { email }, + data: { + passwordResetToken: user.passwordResetToken, + }, + }); + + reply.send('Yes'); + }, + ); +} diff --git a/server/test/routes/api/v1/users.test.js b/server/test/routes/api/v1/users.test.js index 6bdb7e39..8e68de6b 100644 --- a/server/test/routes/api/v1/users.test.js +++ b/server/test/routes/api/v1/users.test.js @@ -695,4 +695,48 @@ describe('/api/v1/users', () => { assert.deepStrictEqual(user.disabledAt, null); }); }); + + describe('POST /request-password-reset', () => { + it('should allow user to request a password reset', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + const res = await app + .inject() + .post('/api/v1/users/request-password-reset') + .payload({ + email: 'volunteer.user@test.com', + }); + + assert.deepStrictEqual(res.statusCode, StatusCodes.OK); + + const sentMails = nodemailerMock.mock.getSentMail(); + + assert.notDeepStrictEqual(sentMails.length, 0); + assert.deepStrictEqual( + sentMails[1].to, + 'Volunteer User ', + ); + assert.deepStrictEqual( + sentMails[1].subject, + 'Reset your password for your SF Lifeline account', + ); + }); + + it('should return not found if email does not exist', async (t) => { + const app = await build(t); + await t.loadFixtures(); + + const res = await app + .inject() + .post('/api/v1/users/request-password-reset') + .payload({ + email: 'no-exist@test.com', + }); + + assert.deepStrictEqual(res.statusCode, StatusCodes.NOT_FOUND); + const { message } = JSON.parse(res.body); + assert.deepStrictEqual(message, 'User not Found'); + }); + }); });