From d54b9a67fb434cc981f41b74bbc13af255a4a57f Mon Sep 17 00:00:00 2001 From: cade-exygy <131277283+cade-exygy@users.noreply.github.com> Date: Thu, 14 Mar 2024 10:05:56 -0500 Subject: [PATCH] feat: 3909/add redirect url prisma (#3938) * feat: get email url from getPublicEmailURL * fix: handle undefined url case and simplify parsing * fix: use only baseUrl in welcome and password emails * fix: fix test --- api/src/services/email.service.ts | 9 ++-- api/src/services/user.service.ts | 3 +- api/src/utilities/get-public-email-url.ts | 28 ++++++++++++ api/test/unit/services/email.service.spec.ts | 28 ++++++++++++ api/test/unit/services/user.service.spec.ts | 45 ++++++++++++++----- .../src/shared/utils/get-public-email-url.ts | 5 ++- 6 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 api/src/utilities/get-public-email-url.ts diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index 86e62194cb..040db59a3f 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -18,6 +18,7 @@ import { Listing } from '../dtos/listings/listing.dto'; import { SendGridService } from './sendgrid.service'; import { ApplicationCreate } from '../dtos/applications/application-create.dto'; import { User } from '../dtos/users/user.dto'; +import { getPublicEmailURL } from '../utilities/get-public-email-url'; dayjs.extend(utc); dayjs.extend(tz); dayjs.extend(advanced); @@ -194,6 +195,7 @@ export class EmailService { confirmationUrl: string, ) { const jurisdiction = await this.getJurisdiction(null, jurisdictionName); + const baseUrl = appUrl ? new URL(appUrl).origin : undefined; await this.loadTranslations(jurisdiction, user.language); await this.send( user.email, @@ -202,7 +204,7 @@ export class EmailService { this.template('register-email')({ user: user, confirmationUrl: confirmationUrl, - appOptions: { appUrl: appUrl }, + appOptions: { appUrl: baseUrl }, }), ); } @@ -287,7 +289,8 @@ export class EmailService { const jurisdiction = await this.getJurisdiction(jurisdictionIds); void (await this.loadTranslations(jurisdiction, user.language)); const compiledTemplate = this.template('forgot-password'); - const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + const resetUrl = getPublicEmailURL(appUrl, resetToken, '/reset-password'); + const baseUrl = appUrl ? new URL(appUrl).origin : undefined; const emailFromAddress = await this.getEmailToSendFrom( jurisdictionIds, jurisdiction, @@ -299,7 +302,7 @@ export class EmailService { this.polyglot.t('forgotPassword.subject'), compiledTemplate({ resetUrl: resetUrl, - resetOptions: { appUrl: appUrl }, + resetOptions: { appUrl: baseUrl }, user: user, }), ); diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index efa6757c9f..98ef7e61ca 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -35,6 +35,7 @@ import { EmailService } from './email.service'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { buildWhereClause } from '../utilities/build-user-where'; +import { getPublicEmailURL } from '../utilities/get-public-email-url'; import { UserRole } from '../dtos/users/user-role.dto'; /* @@ -815,7 +816,7 @@ export class UserService { constructs the url to confirm a public site user */ getPublicConfirmationUrl(appUrl: string, confirmationToken: string) { - return `${appUrl}?token=${confirmationToken}`; + return getPublicEmailURL(appUrl, confirmationToken); } /* diff --git a/api/src/utilities/get-public-email-url.ts b/api/src/utilities/get-public-email-url.ts new file mode 100644 index 0000000000..68fa63f1f0 --- /dev/null +++ b/api/src/utilities/get-public-email-url.ts @@ -0,0 +1,28 @@ +/** + * Creates a email URL object from passed url applies redirectUrl and listingId query params if they exist + * If they do not exist, the return value will be the email url with just the necessary token + */ + +export const getPublicEmailURL = ( + url: string, + token: string, + actionPath?: string, +): string => { + if (!url) { + return; + } + const urlObj = new URL(url); + const redirectUrl = urlObj.searchParams.get('redirectUrl'); + const listingId = urlObj.searchParams.get('listingId'); + + let emailUrl = `${urlObj.origin}${ + actionPath ? actionPath : '' + }?token=${token}`; + + if (!!redirectUrl && !!listingId) { + emailUrl = emailUrl.concat( + `&redirectUrl=${redirectUrl}&listingId=${listingId}`, + ); + } + return emailUrl; +}; diff --git a/api/test/unit/services/email.service.spec.ts b/api/test/unit/services/email.service.spec.ts index a8320e8d49..c264ac874f 100644 --- a/api/test/unit/services/email.service.spec.ts +++ b/api/test/unit/services/email.service.spec.ts @@ -160,6 +160,34 @@ describe('Testing email service', () => { ); }); + it('testing forgot password with query params', async () => { + await service.forgotPassword( + [ + { name: 'test', id: '1234' }, + { name: 'second', id: '1234' }, + { name: 'third', id: '1234' }, + ], + user, + 'http://localhost:3001?redirectUrl=redirect&listingId=123', + 'resetToken', + ); + expect(sendMock).toHaveBeenCalled(); + expect(sendMock.mock.calls[0][0].to).toEqual(user.email); + expect(sendMock.mock.calls[0][0].subject).toEqual('Forgot your password?'); + expect(sendMock.mock.calls[0][0].html).toContain( + 'A request to reset your Bloom Housing Portal website password for http://localhost:3001 has recently been made.', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'If you did make this request, please click on the link below to reset your password:', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Change my password', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Your password won't change until you access the link above and create a new one.', + ); + }); + it('should send csv data email', async () => { await service.sendCSV( [{ name: 'test', id: '1234' }], diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index a46f10715a..fb23419a0e 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -311,15 +311,36 @@ describe('Testing user service', () => { describe('getPublicConfirmationUrl', () => { it('should build public confirmation url', () => { - const res = service.getPublicConfirmationUrl('url', 'tokenExample'); - expect(res).toEqual('url?token=tokenExample'); + const res = service.getPublicConfirmationUrl( + 'https://www.example.com', + 'tokenExample', + ); + expect(res).toEqual('https://www.example.com?token=tokenExample'); + }); + it('should build public confirmation url with query params', () => { + const res = service.getPublicConfirmationUrl( + 'https://www.example.com?redirectUrl=redirect&listingId=123', + 'tokenExample', + ); + expect(res).toEqual( + 'https://www.example.com?token=tokenExample&redirectUrl=redirect&listingId=123', + ); + }); + it('should return undefined when url is undefined', () => { + const res = service.getPublicConfirmationUrl(undefined, 'tokenExample'); + expect(res).toEqual(undefined); }); }); describe('getPartnersConfirmationUrl', () => { it('should build partner confirmation url', () => { - const res = service.getPartnersConfirmationUrl('url', 'tokenExample'); - expect(res).toEqual('url/users/confirm?token=tokenExample'); + const res = service.getPartnersConfirmationUrl( + 'https://www.example.com', + 'tokenExample', + ); + expect(res).toEqual( + 'https://www.example.com/users/confirm?token=tokenExample', + ); }); }); @@ -910,7 +931,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -981,7 +1002,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1055,7 +1076,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError(`userID ${id}: request missing currentPassword`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1112,7 +1133,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError( `userID ${id}: incoming password doesn't match stored password`, @@ -1161,14 +1182,14 @@ describe('Testing user service', () => { lastName: 'last name', jurisdictions: [{ id: jurisId }], newEmail: 'new@email.com', - appUrl: 'www.example.com', + appUrl: 'https://www.example.com', agreedToTermsOfService: true, }, { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1242,7 +1263,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1334,7 +1355,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError(`user id: ${id} was requested but not found`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ diff --git a/backend/core/src/shared/utils/get-public-email-url.ts b/backend/core/src/shared/utils/get-public-email-url.ts index 2e6a2912a0..2406fcca08 100644 --- a/backend/core/src/shared/utils/get-public-email-url.ts +++ b/backend/core/src/shared/utils/get-public-email-url.ts @@ -4,12 +4,15 @@ */ export const getPublicEmailURL = (url: string, token: string, actionPath?: string): string => { + if (!url) { + return + } const urlObj = new URL(url) const redirectUrl = urlObj.searchParams.get("redirectUrl") const listingId = urlObj.searchParams.get("listingId") - let emailUrl = `${urlObj.origin}${urlObj.pathname}/${actionPath ? actionPath : ""}?token=${token}` + let emailUrl = `${urlObj.origin}/${actionPath ? actionPath : ""}?token=${token}` if (!!redirectUrl && !!listingId) { emailUrl = emailUrl.concat(`&redirectUrl=${redirectUrl}&listingId=${listingId}`)