diff --git a/application/admin-client/src/pages/participants/edit.tsx b/application/admin-client/src/pages/participants/edit.tsx index 741d35c6..853937dc 100644 --- a/application/admin-client/src/pages/participants/edit.tsx +++ b/application/admin-client/src/pages/participants/edit.tsx @@ -39,6 +39,12 @@ export const ParticipantEdit = () => { const { externalId, profile } = values // eslint-disable-next-line const { familyId, familyMembers, id, ...rest } = profile + if (rest.email) { + rest.email = rest.email.trim() + } + if (rest.nextOfKin?.email) { + rest.nextOfKin.email = rest.nextOfKin.email.trim() + } onFinish({ externalId: externalId || '', profile: rest, diff --git a/application/admin-client/src/pages/password/update.tsx b/application/admin-client/src/pages/password/update.tsx index a8351af6..9a439d5a 100644 --- a/application/admin-client/src/pages/password/update.tsx +++ b/application/admin-client/src/pages/password/update.tsx @@ -38,14 +38,16 @@ export const UpdatePassword = () => { } } - if (data.password !== data.confirmPassword) { + const password = data.password.trim() + + if (password !== data.confirmPassword?.trim()) { return { values: {}, errors: { password: { type: 'validate', message: "Passwords don't match" } }, } } - const { isValid, fields } = checkPasswordStrength(data.password) + const { isValid, fields } = checkPasswordStrength(password) if (!isValid) { return { values: {}, @@ -58,7 +60,7 @@ export const UpdatePassword = () => { } } return { - values: data, + values: { ...data, password }, errors: {}, } }, diff --git a/application/admin-client/src/pages/setup/index.tsx b/application/admin-client/src/pages/setup/index.tsx index cf7da645..46f76bf6 100644 --- a/application/admin-client/src/pages/setup/index.tsx +++ b/application/admin-client/src/pages/setup/index.tsx @@ -16,10 +16,13 @@ export const SetupPage = () => { const { mutate: login } = useLogin() const onSubmit = (data: any) => { + const password = data.password.trim() + const email = data.email.trim() + const payload = { ...data, email, password } fetch(import.meta.env.VITE_BACKEND_URL + '/auth/register/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), + body: JSON.stringify(payload), }) .then((res) => { if (res.ok) { @@ -27,8 +30,8 @@ export const SetupPage = () => { if (!rdata.token) throw new Error('No token provided') login({ loginType: 'Password', - email: data.email, - password: data.password, + email, + password, }) }) } else { @@ -87,7 +90,7 @@ export const SetupPage = () => { {...register('password', { required: true, validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val.trim()) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } diff --git a/application/admin-client/src/pages/users/create.tsx b/application/admin-client/src/pages/users/create.tsx index 66967e92..24e095c6 100644 --- a/application/admin-client/src/pages/users/create.tsx +++ b/application/admin-client/src/pages/users/create.tsx @@ -8,16 +8,24 @@ import { Controller } from 'react-hook-form' export const UserCreate = () => { const { saveButtonProps, - refineCore: { formLoading }, + refineCore: { formLoading, onFinish }, register, + handleSubmit, control, formState: { errors }, } = useForm({}) const { data: identity } = useGetIdentity<{ role: string; id: number }>() + const handleSubmitCustom = (values: any) => { + onFinish({ ...values, email: values.email?.trim() }) + } + return ( - + { type FieldValues = UpdateUserRequest const { saveButtonProps, - refineCore: { formLoading }, + refineCore: { formLoading, onFinish }, register, + handleSubmit, control, formState: { errors }, } = useForm({ refineCoreProps: { redirect: false } }) + const handleSubmitCustom = (values: any) => { + onFinish({ ...values, email: values.email?.trim() }) + } + const { query } = useShow() const { data } = query @@ -71,7 +76,7 @@ export const UserEdit = () => { return ( { - const req: GeneratePasswordResetLinkRequest = { email: email } + const req: GeneratePasswordResetLinkRequest = { email: email.trim() } try { await axiosInstance.post('/users/password/generate-reset-link', req, { headers: { 'Content-Type': 'application/json', 'x-client-type': clientType }, @@ -175,7 +177,8 @@ export const authProvider: AuthProvider = { }, } }, - updatePassword: async ({ password, token }) => { + updatePassword: async ({ password: rawPassword, token }) => { + const password = rawPassword.trim() const reqData: ResetPasswordRequest = { newPassword: password, token: token, diff --git a/application/backend/prisma/seed/seed.ts b/application/backend/prisma/seed/seed.ts index 297fa4f2..70c830b1 100644 --- a/application/backend/prisma/seed/seed.ts +++ b/application/backend/prisma/seed/seed.ts @@ -330,31 +330,35 @@ const main = async () => { }, }) + const exampleAdminEmail = (process.env.EXAMPLE_ORG_ADMIN_EMAIL ?? '').trim() + const exampleAdminPassword = (process.env.EXAMPLE_ORG_ADMIN_PASSWORD ?? '').trim() const exampleAdmin = await prisma.user.upsert({ - where: { email: String(process.env.EXAMPLE_ORG_ADMIN_EMAIL) }, + where: { email: exampleAdminEmail }, update: {}, create: { - email: String(process.env.EXAMPLE_ORG_ADMIN_EMAIL), + email: exampleAdminEmail, firstName: 'Example', lastName: 'Admin', role: 'OrganisationAdmin', - password: hashPassword(String(process.env.EXAMPLE_ORG_ADMIN_PASSWORD)), + password: hashPassword(exampleAdminPassword), }, }) console.log('Added the following users:', exampleAdmin) const exampleAnswers = createDefaultAnswers(SeedSurveyStepData) exampleAnswers[1].answers[0] = false //For DUO testing + const exampleParticipantEmail = (process.env.EXAMPLE_PARTICIPANT_EMAIL ?? '').trim() + const exampleParticipantPassword = (process.env.EXAMPLE_PARTICIPANT_PASSWORD ?? '').trim() const exampleUser = await prisma.user.upsert({ - where: { email: String(process.env.EXAMPLE_PARTICIPANT_EMAIL) }, + where: { email: exampleParticipantEmail }, update: {}, create: { - email: String(process.env.EXAMPLE_PARTICIPANT_EMAIL), + email: exampleParticipantEmail, firstName: 'Judith', middleName: 'Arundell', lastName: 'Wright', role: 'Participant', - password: hashPassword(String(process.env.EXAMPLE_PARTICIPANT_PASSWORD)), + password: hashPassword(exampleParticipantPassword), profiles: { create: [ { diff --git a/application/backend/src/controllers/AuthController.ts b/application/backend/src/controllers/AuthController.ts index 92c6a699..8025381a 100644 --- a/application/backend/src/controllers/AuthController.ts +++ b/application/backend/src/controllers/AuthController.ts @@ -81,7 +81,9 @@ export class AuthController extends Controller { @Security('jwt', ['OrganisationAdmin']) @SuccessResponse('201', 'User Created') public async registerUser(@Body() bodyRequest: RegisterRequest): Promise { - const { password, ...userDetails } = bodyRequest + const { password: rawPassword, email: rawEmail, ...userDetails } = bodyRequest + const password = rawPassword.trim() + const email = rawEmail.trim() const { isValid, fields } = await checkPasswordStrength(password) @@ -93,6 +95,7 @@ export class AuthController extends Controller { const insertedUser: User = await this.userRepo.create({ data: { ...userDetails, + email, password: hashedPassword, }, }) @@ -143,7 +146,9 @@ export class AuthController extends Controller { public async registerInitialUser( @Body() bodyRequest: RegisterSetupRequest, ): Promise { - const { password, email } = bodyRequest + const { password: rawPassword, email: rawEmail } = bodyRequest + const password = rawPassword.trim() + const email = rawEmail.trim() const { isValid, fields } = await checkPasswordStrength(password) @@ -205,7 +210,16 @@ export class AuthController extends Controller { @Body() bodyRequest: RegisterParticipantRequest, ): Promise { // Extract info for user creation - const { firstName, middleName, lastName, email, password, ...participantInfo } = bodyRequest + const { + firstName, + middleName, + lastName, + email: rawEmail, + password: rawPassword, + ...participantInfo + } = bodyRequest + const password = rawPassword.trim() + const email = rawEmail.trim() // Check that the Participant has an invitation const invite = await this.inviteRepo.findFirst({ where: { id: inviteId, email } }) @@ -319,7 +333,8 @@ export class AuthController extends Controller { method: 'POST', }) - const { email } = await userinfo_res.json() + const { email: rawEmail } = await userinfo_res.json() + const email = rawEmail?.trim() user = await this.userRepo.findFirst({ where: { email } }) } catch { throw new Error('Error authenticating with OIDC') @@ -353,8 +368,11 @@ export class AuthController extends Controller { @Body() bodyRequest: LoginRequest, @Header('x-client-type') clientType?: string, ): Promise { + const { email: rawEmail, password: rawPassword } = bodyRequest + const email = rawEmail.trim() + const password = rawPassword.trim() // Check if user exists and password matches - const user = await this.userRepo.findUnique({ where: { email: bodyRequest.email } }) + const user = await this.userRepo.findUnique({ where: { email } }) if (!user) { throw new InvalidCredentialsError('User not found') @@ -380,7 +398,7 @@ export class AuthController extends Controller { throw new IncorrectPermissionsError('User is not a participant') } - if (!(await verifyPassword(user.password, bodyRequest.password))) { + if (!(await verifyPassword(user.password, password))) { await this.userRepo.update({ where: { id: user.id }, data: { retriesRemaining: user.retriesRemaining - 1 }, @@ -538,6 +556,9 @@ export class AuthController extends Controller { // Extract user and profile data const { firstName, lastName, dob, externalId, ...profileData } = participantData const { nextOfKin, dependents, ...noNextOfKinProfileData } = profileData + if (nextOfKin?.email) { + nextOfKin.email = nextOfKin.email.trim() + } const nextOfKinCreateData = { nextOfKin: { create: { ...nextOfKin } } } // Check for existing dependents diff --git a/application/backend/src/controllers/IntegrationsController.ts b/application/backend/src/controllers/IntegrationsController.ts index 93f138c3..1d86dd13 100644 --- a/application/backend/src/controllers/IntegrationsController.ts +++ b/application/backend/src/controllers/IntegrationsController.ts @@ -337,7 +337,8 @@ export class IntegrationsController extends Controller { const existingUsers: string[] = [] for (const participant of data) { - const { email, ...participantData } = participant + const { email: rawEmail, ...participantData } = participant + const email = rawEmail.trim() // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (participantData as any).password diff --git a/application/backend/src/controllers/ProfilesController.ts b/application/backend/src/controllers/ProfilesController.ts index 8b56c6ae..913f2b25 100644 --- a/application/backend/src/controllers/ProfilesController.ts +++ b/application/backend/src/controllers/ProfilesController.ts @@ -155,6 +155,9 @@ export class ProfilesController extends Controller { where: { userId: request.user?.userId }, }) const { nextOfKin, ...updateData } = { ...bodyRequest } + if (nextOfKin?.email) { + nextOfKin.email = nextOfKin.email.trim() + } const hasNok = Boolean(nextOfKin) @@ -186,7 +189,11 @@ export class ProfilesController extends Controller { studies: { select: { studyId: true } }, }, }) - const { nextOfKin, email, ...updateData } = { ...bodyRequest } + const { nextOfKin, email: rawEmail, ...updateData } = { ...bodyRequest } + const email = rawEmail?.trim() + if (nextOfKin?.email) { + nextOfKin.email = nextOfKin.email.trim() + } const hasNok = Boolean(nextOfKin) diff --git a/application/backend/src/controllers/UsersController.ts b/application/backend/src/controllers/UsersController.ts index 733e5598..9f678566 100644 --- a/application/backend/src/controllers/UsersController.ts +++ b/application/backend/src/controllers/UsersController.ts @@ -176,6 +176,7 @@ export class UsersController extends Controller { @Request() request: RequestWithAuthentication, @Body() bodyRequest: CreateUserRequest, ): Promise { + const email = bodyRequest.email.trim() const callingUser = await this.userRepo.findUniqueOrThrow({ where: { id: request.user.userId }, select: { id: true, role: true, adminOfStudies: { select: { id: true } } }, @@ -184,14 +185,13 @@ export class UsersController extends Controller { throw new UnprocessableError('As a study admin, you can only create other study admins') } - const isDeleted = - (await this.userRepo.count({ where: { email: bodyRequest.email, deleted: true } })) > 0 + const isDeleted = (await this.userRepo.count({ where: { email, deleted: true } })) > 0 if (isDeleted) { throw new UnprocessableError( 'This email belongs to a deleted user, you must restore the user instead of creating a new one', ) } - const emailExists = (await this.userRepo.count({ where: { email: bodyRequest.email } })) > 0 + const emailExists = (await this.userRepo.count({ where: { email } })) > 0 if (emailExists) { throw new UnprocessableError('Email already exists') } @@ -205,6 +205,7 @@ export class UsersController extends Controller { const insertedUser = await this.userRepo.create({ data: { ...bodyRequest, + email, password, adminOfStudies: studyToAdd !== undefined ? { connect: { id: studyToAdd } } : undefined, }, @@ -213,7 +214,7 @@ export class UsersController extends Controller { const responseData = { id: insertedUser.id, } - await this.generatePasswordResetLink({ email: bodyRequest.email }, undefined, true) + await this.generatePasswordResetLink({ email }, undefined, true) logger.info({ ...responseData }) return responseData } @@ -233,6 +234,7 @@ export class UsersController extends Controller { @Path() userId: number, @Body() bodyRequest: UpdateUserRequest, ) { + const email = bodyRequest.email?.trim() const callingUser = await this.userRepo.findUniqueOrThrow({ where: { id: request.user.userId }, }) @@ -257,7 +259,7 @@ export class UsersController extends Controller { await this.userRepo.update({ where: { id: userId }, - data: bodyRequest, + data: { ...bodyRequest, email }, }) } @@ -371,7 +373,8 @@ export class UsersController extends Controller { clientType?: string, adminInvite = false, ): Promise { - const user = await prisma.user.findUnique({ where: { email: bodyRequest.email } }) + const email = bodyRequest.email.trim() + const user = await prisma.user.findUnique({ where: { email } }) if ( !user || @@ -441,7 +444,8 @@ export class UsersController extends Controller { @Response('404', 'Not Found') @Response('403', 'Forbidden') public async resetPassword(@Body() bodyRequest: ResetPasswordRequest): Promise { - const { token, newPassword } = bodyRequest + const { token, newPassword: rawNewPassword } = bodyRequest + const newPassword = rawNewPassword.trim() const passwordResetToken = await this.passwordResetTokenRepo.findUnique({ where: { token }, include: { user: true }, diff --git a/application/backend/src/utils/createAdmin.ts b/application/backend/src/utils/createAdmin.ts index d0d17091..e73d8b19 100644 --- a/application/backend/src/utils/createAdmin.ts +++ b/application/backend/src/utils/createAdmin.ts @@ -2,15 +2,17 @@ import { hashPassword } from '../authentication' import prisma from '../PrismaClient' const createAdmin = async () => { - const admin = await prisma.user.findFirst({ where: { email: process.env['ORG_ADMIN_EMAIL'] } }) + const email = (process.env['ORG_ADMIN_EMAIL'] ?? '').trim() + const password = (process.env['ORG_ADMIN_PASSWORD'] ?? '').trim() + const admin = await prisma.user.findFirst({ where: { email } }) - if (!admin && process.env['ORG_ADMIN_PASSWORD'] && process.env['ORG_ADMIN_EMAIL']) { + if (!admin && password && email) { await prisma.user.create({ data: { - email: process.env['ORG_ADMIN_EMAIL'] as string, + email, firstName: 'ORG_ADMIN', lastName: 'USER', - password: await hashPassword(process.env['ORG_ADMIN_PASSWORD']), + password: await hashPassword(password), role: 'OrganisationAdmin', }, }) diff --git a/application/backend/tests/integration/Auth.test.ts b/application/backend/tests/integration/Auth.test.ts index 73e7ab2a..4d5e1c0b 100644 --- a/application/backend/tests/integration/Auth.test.ts +++ b/application/backend/tests/integration/Auth.test.ts @@ -84,6 +84,238 @@ describe('Auth', () => { expect(protectedRouteResponse.body.message).toBe('No token provided') }) + it('trims leading whitespace on participant register so subsequent login without the space succeeds', async () => { + const email = 'ws-lead@example.com' + const rawPassword = ' Constellation-battery-24!' + + const invite = await prisma.invite.create({ + data: { + email, + status: 'PENDING', + studyId: TestStudies.TEST_STUDY.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }) + + const participantRequest: RegisterParticipantRequest = { + firstName: 'John', + lastName: 'Doe', + email, + password: rawPassword, + mobile: '+61477777777', + addressLine: '123 Some Street', + suburb: 'Sydney', + postcode: '2000', + state: StateTerritory.NSW, + preferredContact: ContactMethod.MOBILE, + dob: '1990-01-01', + participantType: ParticipantType.STANDARD, + nextOfKin: { + firstName: 'JOHN', + lastName: 'SMITH', + email: 'jonny@smith.com', + }, + dependents: [], + } + + await request(app) + .post(`/auth/register/participants/${invite.id}`) + .send(participantRequest) + .expect(201) + + const loginRes = await request(app) + .post('/auth/login') + .set('x-client-type', 'user-client') + .send({ email, password: rawPassword.trim() }) + expect(loginRes.status).toBe(200) + }) + + it('trims trailing whitespace on participant register so subsequent login without the space succeeds', async () => { + const email = 'ws-trail@example.com' + const rawPassword = 'Constellation-battery-24! ' + + const invite = await prisma.invite.create({ + data: { + email, + status: 'PENDING', + studyId: TestStudies.TEST_STUDY.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }) + + const participantRequest: RegisterParticipantRequest = { + firstName: 'Jane', + lastName: 'Doe', + email, + password: rawPassword, + mobile: '+61477777778', + addressLine: '124 Some Street', + suburb: 'Sydney', + postcode: '2000', + state: StateTerritory.NSW, + preferredContact: ContactMethod.MOBILE, + dob: '1990-01-02', + participantType: ParticipantType.STANDARD, + nextOfKin: { + firstName: 'JOHN', + lastName: 'SMITH', + email: 'jonny@smith.com', + }, + dependents: [], + } + + await request(app) + .post(`/auth/register/participants/${invite.id}`) + .send(participantRequest) + .expect(201) + + const loginRes = await request(app) + .post('/auth/login') + .set('x-client-type', 'user-client') + .send({ email, password: rawPassword.trim() }) + expect(loginRes.status).toBe(200) + }) + + it('rejects an all-whitespace password on participant register (fails length after trim)', async () => { + const email = 'ws-empty@example.com' + + const invite = await prisma.invite.create({ + data: { + email, + status: 'PENDING', + studyId: TestStudies.TEST_STUDY.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }) + + const participantRequest: RegisterParticipantRequest = { + firstName: 'Bob', + lastName: 'Doe', + email, + password: ' ', + mobile: '+61477777779', + addressLine: '125 Some Street', + suburb: 'Sydney', + postcode: '2000', + state: StateTerritory.NSW, + preferredContact: ContactMethod.MOBILE, + dob: '1990-01-03', + participantType: ParticipantType.STANDARD, + nextOfKin: { + firstName: 'JOHN', + lastName: 'SMITH', + email: 'jonny@smith.com', + }, + dependents: [], + } + + const res = await request(app) + .post(`/auth/register/participants/${invite.id}`) + .send(participantRequest) + expect(res.status).toBe(422) + }) + + it('trims whitespace on admin register so subsequent login without the space succeeds', async () => { + const orgAdminToken = await generateToken({ userId: TestUsers.ORG_ADMIN.id }) + const rawEmail = ' ws-admin-lead@example.com' + const password = 'Constellation-battery-24!' + + await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ + firstName: 'WS', + lastName: 'Admin', + email: rawEmail, + password, + role: Role.OrganisationAdmin, + }) + .expect(201) + + const loginRes = await request(app) + .post('/auth/login') + .send({ email: rawEmail.trim(), password }) + expect(loginRes.status).toBe(200) + }) + + it('rejects re-registering with a clean version of a padded email as duplicate', async () => { + const orgAdminToken = await generateToken({ userId: TestUsers.ORG_ADMIN.id }) + const paddedEmail = ' ws-admin-dup@example.com' + const password = 'Constellation-battery-24!' + + await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ + firstName: 'First', + lastName: 'Admin', + email: paddedEmail, + password, + role: Role.OrganisationAdmin, + }) + .expect(201) + + const dupRes = await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ + firstName: 'Second', + lastName: 'Admin', + email: paddedEmail.trim(), + password, + role: Role.OrganisationAdmin, + }) + expect(dupRes.status).not.toBe(201) + expect(dupRes.body.message).toBe('emailHash already in use') + }) + + it('trims whitespace on next-of-kin email during participant register', async () => { + const email = 'ws-nok@example.com' + const paddedNokEmail = ' nok-trim@example.com ' + const password = 'Constellation-battery-24!' + + const invite = await prisma.invite.create({ + data: { + email, + status: 'PENDING', + studyId: TestStudies.TEST_STUDY.id, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }) + + const participantRequest: RegisterParticipantRequest = { + firstName: 'Ned', + lastName: 'Kelly', + email, + password, + mobile: '+61477777780', + addressLine: '126 Some Street', + suburb: 'Sydney', + postcode: '2000', + state: StateTerritory.NSW, + preferredContact: ContactMethod.MOBILE, + dob: '1990-01-04', + participantType: ParticipantType.STANDARD, + nextOfKin: { + firstName: 'NOK', + lastName: 'PERSON', + email: paddedNokEmail, + }, + dependents: [], + } + + await request(app) + .post(`/auth/register/participants/${invite.id}`) + .send(participantRequest) + .expect(201) + + const profile = await prisma.participantProfile.findFirst({ + where: { user: { email } }, + include: { nextOfKin: true }, + }) + expect(profile?.nextOfKin?.email).toBe(paddedNokEmail.trim()) + }) + it('should return a 401 unauthorized error when accessing protected routes when using an expired token', async () => { // Set JWT expiry to 1 second process.env.JWT_EXPIRY = '0s' diff --git a/application/backend/tests/integration/ResetPassword.test.ts b/application/backend/tests/integration/ResetPassword.test.ts index 032a9181..9a58d819 100644 --- a/application/backend/tests/integration/ResetPassword.test.ts +++ b/application/backend/tests/integration/ResetPassword.test.ts @@ -156,4 +156,50 @@ describe('User Password Reset', () => { expect(response.status).toBe(403) expect(response.body.message).toBe('Reset token has already been used') }) + + it('trims whitespace on generate-reset-link so a padded email still finds the user and sends the reset email', async () => { + const paddedEmail = ` ${TestUsers.PARTICIPANT_COMPLETED.email} ` + const response = await request(app) + .post('/users/password/generate-reset-link') + .send({ email: paddedEmail }) + + expect(response.status).toBe(200) + + const sentMail = mockNodeMailer.mock.getSentMail() + expect(sentMail).toHaveLength(1) + expect(sentMail[0].to).toBe(TestUsers.PARTICIPANT_COMPLETED.email) + }) + + it('trims whitespace on password reset so subsequent login without the space succeeds', async () => { + const email = TestUsers.PARTICIPANT_UNANSWERED.email + const rawPassword = ' Constellation-battery-24! ' + + // Generate a fresh reset link for PARTICIPANT_UNANSWERED + const linkResponse = await request(app) + .post('/users/password/generate-reset-link') + .send({ email }) + expect(linkResponse.status).toBe(200) + + // Extract the reset token from the email + const sentMail = mockNodeMailer.mock.getSentMail() + const emailText = sentMail[0].text as string + const tokenMatch = emailText.match(/reset-password\?token=(\w+)/) + if (!tokenMatch || !tokenMatch[1]) { + throw new Error('Reset token not found in the email text') + } + const token = tokenMatch[1] + + // Reset the password with leading + trailing whitespace + await request(app) + .post('/users/password/reset') + .send({ token, newPassword: rawPassword }) + .expect(200) + + // Log in using the trimmed password + const loginRes = await request(app) + .post('/auth/login') + .set('x-client-type', 'user-client') + .send({ email, password: rawPassword.trim() }) + expect(loginRes.status).toBe(200) + }) }) diff --git a/application/user-client/cypress/e2e/registration.cy.js b/application/user-client/cypress/e2e/registration.cy.js index 579d03a0..d83da2e6 100644 --- a/application/user-client/cypress/e2e/registration.cy.js +++ b/application/user-client/cypress/e2e/registration.cy.js @@ -103,6 +103,62 @@ describe('registration', () => { cy.contains(`Invite for ${testEmail} not found`).should('exist') }) + it('trims whitespace on register password so subsequent login without the space succeeds', () => { + const rawPassword = ' Constellation-battery-24!' + const trimmedPassword = rawPassword.trim() + + cy.task('getInviteIdtask', { + email: TestInvites.INVITE_PENDING.email, + studyId: TestStudies.TEST_STUDY.id, + }) + .as('inviteId') + .then((inviteId) => { + cy.visit(`/register/${inviteId}`) + }) + fillValid() + // Override the password fields with a leading-whitespace value + cy.get('[data-cy="reg-password"]').clear().type(rawPassword) + cy.get('[data-cy="reg-confirm-password"]').clear().type(rawPassword) + cy.get('[data-cy="reg-button"]').click() + cy.contains('Welcome FIRST').should('exist') + + // Log out and log back in without the leading space + cy.clearAllLocalStorage() + cy.visit('/login') + cy.get('[data-cy="login-email"]').type(TestInvites.INVITE_PENDING.email) + cy.get('[data-cy="login-password"]').type(trimmedPassword) + cy.get('[data-cy="login"]').click() + cy.contains('Welcome FIRST').should('exist') + }) + + it('trims whitespace on register email so subsequent login without the space succeeds', () => { + const cleanEmail = TestInvites.INVITE_PENDING.email + const paddedEmail = ` ${cleanEmail}` + const password = 'Aadsfoswefw1515fd@!' + + cy.task('getInviteIdtask', { + email: cleanEmail, + studyId: TestStudies.TEST_STUDY.id, + }) + .as('inviteId') + .then((inviteId) => { + cy.visit(`/register/${inviteId}`) + }) + fillValid() + // Override the email field with a leading-whitespace value + cy.get('[data-cy="reg-email"]').clear().type(paddedEmail) + cy.get('[data-cy="reg-button"]').click() + cy.contains('Welcome FIRST').should('exist') + + // Log out and log back in with the clean email + cy.clearAllLocalStorage() + cy.visit('/login') + cy.get('[data-cy="login-email"]').type(cleanEmail) + cy.get('[data-cy="login-password"]').type(password) + cy.get('[data-cy="login"]').click() + cy.contains('Welcome FIRST').should('exist') + }) + it('Add dependents, check errors and valid submission', () => { cy.task('getInviteIdtask', { email: TestInvites.INVITE_PENDING.email, diff --git a/application/user-client/src/pages/ForgotPassword.tsx b/application/user-client/src/pages/ForgotPassword.tsx index 7f450016..0eb07587 100644 --- a/application/user-client/src/pages/ForgotPassword.tsx +++ b/application/user-client/src/pages/ForgotPassword.tsx @@ -35,8 +35,10 @@ export default function ForgotPassword() { // Set to pending before the request setStatus('pending') + const payload: GeneratePasswordResetLinkRequest = { ...data, email: data.email.trim() } + apiClient - .post('/users/password/generate-reset-link', data, { + .post('/users/password/generate-reset-link', payload, { headers: { 'Content-Type': 'application/json', 'x-client-type': 'user-client' }, }) .then((res) => { diff --git a/application/user-client/src/pages/Login.tsx b/application/user-client/src/pages/Login.tsx index 80859afa..225256ae 100644 --- a/application/user-client/src/pages/Login.tsx +++ b/application/user-client/src/pages/Login.tsx @@ -50,10 +50,12 @@ export default function Login() { const queryClient = useQueryClient() const onSubmit = (data: unknown) => { + const req = data as LoginRequest + const payload = { ...req, email: req.email.trim(), password: req.password.trim() } fetch(import.meta.env.VITE_BACKEND_URL + '/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-client-type': clientType }, - body: JSON.stringify(data as LoginRequest), + body: JSON.stringify(payload), }) .then((res) => { if (res.ok) { diff --git a/application/user-client/src/pages/ProfileEdit.tsx b/application/user-client/src/pages/ProfileEdit.tsx index 85546f3c..fb476abc 100644 --- a/application/user-client/src/pages/ProfileEdit.tsx +++ b/application/user-client/src/pages/ProfileEdit.tsx @@ -87,7 +87,7 @@ export default function ProfileEdit() { nextOfKin: { firstName: data['nok_first'], lastName: data['nok_surname'], - email: data['nok_email'], + email: data['nok_email'].trim(), mobile: data['nok_mobile'], }, } diff --git a/application/user-client/src/pages/Register.tsx b/application/user-client/src/pages/Register.tsx index eec9ca7e..36b6ce95 100644 --- a/application/user-client/src/pages/Register.tsx +++ b/application/user-client/src/pages/Register.tsx @@ -123,11 +123,13 @@ export default function Register() { }, [inviteId, reset]) const onSubmit = (data: FormValues) => { + const password = data.password.trim() + const email = data.email.trim() const reqData: RegisterParticipantRequest = { firstName: data.firstName, lastName: data.lastName, - email: data.email, - password: data.password, + email, + password, dob: data.dob, addressLine: data.addressLine, suburb: data.suburb, @@ -139,7 +141,7 @@ export default function Register() { nextOfKin: { firstName: data['nok_first'], lastName: data['nok_surname'], - email: data['nok_email'], + email: data['nok_email'].trim(), }, dependents: data['dependents'], } @@ -240,7 +242,7 @@ export default function Register() { {...register('password', { required: 'This field is required', validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val.trim()) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } @@ -258,7 +260,7 @@ export default function Register() { {...register('confirm_password', { required: 'This field is required', validate: (val: string) => { - if (watch('password') != val) { + if (watch('password')?.trim() !== val.trim()) { return 'Your passwords do not match' } }, diff --git a/application/user-client/src/pages/ResetPassword.tsx b/application/user-client/src/pages/ResetPassword.tsx index c3b11eaf..f70850b2 100644 --- a/application/user-client/src/pages/ResetPassword.tsx +++ b/application/user-client/src/pages/ResetPassword.tsx @@ -80,7 +80,7 @@ export default function ResetPassword() { setStatus('pending') const reqData: ResetPasswordRequest = { - newPassword: data.newPassword, + newPassword: data.newPassword.trim(), token: token, } @@ -171,7 +171,7 @@ export default function ResetPassword() { {...register('newPassword', { required: 'This field is required', validate: (val) => { - const { isValid, fields } = checkPasswordStrength(val) + const { isValid, fields } = checkPasswordStrength(val.trim()) if (!isValid) { return `Invalid password. ${Object.values(fields).map((f) => ' ' + f.message)}` } @@ -189,7 +189,7 @@ export default function ResetPassword() { {...register('confirmPassword', { required: 'This field is required', validate: (val: string) => { - if (watch('newPassword') != val) { + if (watch('newPassword')?.trim() !== val.trim()) { return 'Your passwords do not match' } },