From 1b7eac5c1a8cdb756434f001bdf0d3d7faa7aa8c Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Tue, 21 Jul 2026 16:53:46 +1200 Subject: [PATCH 1/7] Adds AuthController test for email case sensitivity --- .../src/controllers/AuthController.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/application/backend/src/controllers/AuthController.test.ts b/application/backend/src/controllers/AuthController.test.ts index 71758ee8..44a0e1e2 100644 --- a/application/backend/src/controllers/AuthController.test.ts +++ b/application/backend/src/controllers/AuthController.test.ts @@ -256,6 +256,36 @@ describe('AuthController', () => { Number: { message: 'Password must contain at least one number' }, }) }) + + it('should save emails as lowercase in DB', async () => { + const upperCaseEmail = 'NewUser@Example.Com' + const lowerCaseEmail = 'newuser@example.com' + + const registerRequest: RegisterRequest = { + firstName: 'John', + lastName: 'Doe', + email: upperCaseEmail, + password: 'Password123', + role: Role.Participant, + } + + // Register user + const response = await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send(registerRequest) + expect(response.status).toEqual(201) + const body: RegisterResponse = response.body + expect(body.token).not.toBeNull() + + // Attempt to register someone with uppercase version of same email + const lowerResponse = await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ ...registerRequest, email: lowerCaseEmail }) + expect(lowerResponse.status).toEqual(500) // Not expected to work due to being a duplicate + expect(lowerResponse.body.message).toEqual('emailHash already in use') + }) }) describe('POST /auth/register/setup', () => { @@ -274,6 +304,7 @@ describe('AuthController', () => { .post('/auth/register/setup') .send({ email: 'testadmin@test.com', password: 'abDFS141@!' }) expect(res.ok).toBe(false) + expect(res.body.details).toEqual('CTRL is already set up') }) }) From adea15b0ecfe0dbecac700b4863ea536bd776ae9 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Jul 2026 15:56:07 +1200 Subject: [PATCH 2/7] Adds failing cypress tests for uppercase invite emails --- .../admin-client/cypress/e2e/invites.cy.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/application/admin-client/cypress/e2e/invites.cy.js b/application/admin-client/cypress/e2e/invites.cy.js index acf18b23..d8b78b2a 100644 --- a/application/admin-client/cypress/e2e/invites.cy.js +++ b/application/admin-client/cypress/e2e/invites.cy.js @@ -59,6 +59,34 @@ describe('', () => { .should('contain.text', 'email2@g.co') }) + it('Uppercase emails are not added when typing', () => { + cy.visit('/participants') + cy.get('[data-cy="invite-button"]').click() + cy.get('[data-cy="send-button"]').should('be.disabled') + cy.get('[data-cy="email-field"]').type('tom@example.com').type('{enter}') + cy.get('[data-cy="recipients-list"]').should('contain.text', 'tom@example.com') + cy.get('[data-cy="email-field"]').clear().type('ToM@ExAmPlE.cOm').type('{enter}') + cy.get('[data-cy="recipients-list"]').should('not.contain.text', 'ToM@ExAmPlE.cOm') + // try inverse + cy.get('[data-cy="remove-button"]').first().click() + cy.get('[data-cy="email-field"]').clear().type('ToM@ExAmPlE.cOm').type('{enter}') + cy.get('[data-cy="recipients-list"]').should('contain.text', 'tom@example.com') + cy.get('[data-cy="recipients-list"]').should('not.contain.text', 'ToM@ExAmPlE.cOm') + }) + + it('Uppercase emails are not pasted', () => { + cy.visit('/participants') + cy.get('[data-cy="invite-button"]').click() + cy.get('[data-cy="email-field"]').trigger('paste', { + bubbles: true, + cancelable: true, + clipboardData: { getData: (type) => 'email1@g.co\nEmAiL1@G.cO' }, + }) + cy.get('[data-cy="recipients-list"]') + .should('contain.text', 'email1@g.co') + .should('not.contain.text', 'EmAiL1@G.cO') + }) + it('Can paste invites with external ID', () => { cy.visit('/participants') cy.get('[data-cy="invite-button"]').click() From bf07500d222030a976a14ec2089b681cbf9658ed Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Jul 2026 17:14:14 +1200 Subject: [PATCH 3/7] Adds failing backend tests for uppercase invite emails --- .../ParticipantsController.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/application/backend/src/controllers/ParticipantsController.test.ts b/application/backend/src/controllers/ParticipantsController.test.ts index b386f211..98b5d060 100644 --- a/application/backend/src/controllers/ParticipantsController.test.ts +++ b/application/backend/src/controllers/ParticipantsController.test.ts @@ -585,6 +585,44 @@ describe('InvitesController', () => { // Reset mock for other tests mockNodeMailer.mock.reset() }) + + it('should not create new study invites for uppercase and lowercase versions of the same email', async () => { + const lowerCaseEmail = 'invite5@new.com' + const upperCaseEmail = 'iNvItE5@NeW.cOm' + + const recipients = [ + { email: lowerCaseEmail, prefill: {} }, + { email: upperCaseEmail, prefill: {} }, + ] + + const response = await request(app) + .post('/studies/1/invites') + .send({ recipients, subjectText: 'Subject Text', explanatoryText: 'Explanatory Text' }) + .set({ Authorization: `Bearer ${organisationAdminToken}` }) + + const body: InviteParticipantsResponse = response.body + expect(response.status).toBe(200) + + expect(body.newInvitesCount).toBe(1) // NOT TWO!! + + // Check emails were successfully sent + const sentEmails = mockNodeMailer.mock.getSentMail() + expect(sentEmails.length).toBe(1) // NOT TWO!! + + const sentEmail = sentEmails[0] + expect(sentEmail.from).toBe(`CTRL `) + expect(sentEmail.subject).toBe('Subject Text') + expect(sentEmail.html).toContain('Explanatory Text') + expect(sentEmail.to).toBe(lowerCaseEmail) + + const allCreatedInvites = await prisma.invite.findMany({ + where: { + studyId: 1, + email: lowerCaseEmail, + }, + }) + expect(allCreatedInvites.length).toBe(1) + }, 100000) }) describe('POST /studies/{studyId}/invites/resend', () => { From 10a11e2d1f4f38447300750b0fa8cc1cefb25a32 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Jul 2026 17:21:11 +1200 Subject: [PATCH 4/7] Treat all emails as lowercase in admin invite modal --- .../src/components/InviteModal.tsx | 94 +++++++++++++------ 1 file changed, 64 insertions(+), 30 deletions(-) diff --git a/application/admin-client/src/components/InviteModal.tsx b/application/admin-client/src/components/InviteModal.tsx index 7a0a755c..7d90e082 100644 --- a/application/admin-client/src/components/InviteModal.tsx +++ b/application/admin-client/src/components/InviteModal.tsx @@ -23,6 +23,49 @@ interface InviteModalProps { initialRecipients?: Recipient[] } +type RecipientInput = { email: string; externalId?: string } + +// Parse paste helper +const parsePastedText = (text: string): RecipientInput[] => { + return text + .split('\n') + .map((line) => { + // split by comma if it exists, otherwise fall back to tab + const split = line.includes(',') ? line.split(',') : line.split('\t') + return { + email: split[0], + externalId: split[1]?.trim(), + } + }) + .filter((entry) => entry.email !== '') // Drop empty lines +} + +// email dedupe and validate helper +const getUpdatedRecipients = ( + currentRecipients: Recipient[], + newEntries: RecipientInput[], + isValidEmail: (email: string) => boolean, +): { updatedRecipients: Recipient[]; hasInvalidEmails: boolean } => { + const updatedRecipients = [...currentRecipients] + let hasInvalidEmails = false + const seenEmails = new Set(updatedRecipients.map((r) => r.email.toLowerCase())) + + newEntries.forEach((entry) => { + const normalisedEmail = (entry.email || '').trim().toLowerCase() + + if (!isValidEmail(normalisedEmail)) { + hasInvalidEmails = true + } else if (!seenEmails.has(normalisedEmail)) { + seenEmails.add(normalisedEmail) + updatedRecipients.push({ + email: normalisedEmail, + prefill: { studyParticipant: { externalId: entry.externalId } }, + }) + } + }) + return { updatedRecipients, hasInvalidEmails } +} + export function InviteModal({ onSend, onCancel, initialRecipients = [] }: InviteModalProps) { const validateEmail = (email: string) => { const r = new RegExp(emailRegex) //eslint-disable-line @@ -41,19 +84,33 @@ export function InviteModal({ onSend, onCancel, initialRecipients = [] }: Invite const studyId = useCurrentStudyId() const handleAdd = () => { - if (!validateEmail(fieldValue)) { + const { updatedRecipients, hasInvalidEmails } = getUpdatedRecipients( + recipients, + [{ email: fieldValue, externalId: idFieldValue }], + validateEmail, + ) + + if (hasInvalidEmails) { setInvalid(true) - } else if (!emails.includes(fieldValue)) { - setRecipients((current) => { - const c = structuredClone(current) - c.push({ email: fieldValue, prefill: { studyParticipant: { externalId: idFieldValue } } }) - return c - }) + } else { + setRecipients(updatedRecipients) setFieldValue('') setIdFieldValue('') + setInvalid(false) } } + const handlePaste = (event: React.ClipboardEvent) => { + event.preventDefault() + const pastedText = event.clipboardData.getData('Text') + const parsedEntries = parsePastedText(pastedText) + + setRecipients((current) => { + const { updatedRecipients } = getUpdatedRecipients(current, parsedEntries, validateEmail) + return updatedRecipients + }) + } + useEffect(() => { axiosInstance.get(`/studies/${studyId}/invites/text`).then((res) => { const data: GetInviteTextResponse = res.data @@ -68,29 +125,6 @@ export function InviteModal({ onSend, onCancel, initialRecipients = [] }: Invite } }, [fieldValue]) - const handlePaste = (event: React.ClipboardEvent) => { - event.preventDefault() - const pasted = event.clipboardData.getData('Text').split('\n') - setRecipients((current) => { - const c = structuredClone(current) - for (const p of pasted) { - let split = p.split(',') - if (split.length == 1) { - split = split[0].split('\t') - } - const email = split[0] - let id - if (split.length > 1) { - id = split[1] - } - if (!emails.includes(email) && validateEmail(email)) { - c.push({ email, prefill: { studyParticipant: { externalId: id } } }) - } - } - return c - }) - } - const { html: emailPreview } = previewParticipantInviteEmail( 'http://exampleregisterurl', emailTitle, From 62c2a2535726a46c92e7c729be058420109bd388 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Jul 2026 17:24:49 +1200 Subject: [PATCH 5/7] Treat all emails as lowercase in backend endpoint --- .../src/controllers/ParticipantsController.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/application/backend/src/controllers/ParticipantsController.ts b/application/backend/src/controllers/ParticipantsController.ts index a3ef857f..dc2acbc6 100644 --- a/application/backend/src/controllers/ParticipantsController.ts +++ b/application/backend/src/controllers/ParticipantsController.ts @@ -689,8 +689,21 @@ export class InvitesController extends Controller { data: { inviteEmailSubject: subjectText, inviteEmailText: explanatoryText }, }) - const recipients = [...new Set(bodyRequest.recipients)] - const emails = recipients.map((val) => val.email) + const uniqueRecipientsMap = new Map() + + bodyRequest.recipients.forEach((recipient) => { + const normalisedEmail = (recipient.email || '').trim().toLowerCase() + + if (normalisedEmail && !uniqueRecipientsMap.has(normalisedEmail)) { + uniqueRecipientsMap.set(normalisedEmail, { + ...recipient, + email: normalisedEmail, // overwrite with normalised version + }) + } + }) + + const recipients = Array.from(uniqueRecipientsMap.values()) + const emails = Array.from(uniqueRecipientsMap.keys()) const expiresAt = inviteExpiresAt() From ffdd4fcade9509694639fea0e631125a916034a4 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Jul 2026 17:26:00 +1200 Subject: [PATCH 6/7] Normalise User and Invite emailHashs in prisma schema --- application/backend/prisma/schema.prisma | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/backend/prisma/schema.prisma b/application/backend/prisma/schema.prisma index 2118e797..b95a79c3 100644 --- a/application/backend/prisma/schema.prisma +++ b/application/backend/prisma/schema.prisma @@ -18,7 +18,7 @@ model User { middleName String? /// @encrypted lastName String /// @encrypted email String @unique /// @encrypted - emailHash String? @unique /// @encryption:hash(email) + emailHash String? @unique /// @encryption:hash(email)?normalize=lowercase password String role Role @default(Participant) adminOfStudies Study[] @@ -181,7 +181,7 @@ model SurveyVersionAnswers { model Invite { id String @id @default(uuid()) email String /// @encrypted - emailHash String? /// @encryption:hash(email) + emailHash String? /// @encryption:hash(email)?normalize=lowercase status InviteStatus @default(PENDING) studyId Int study Study @relation(fields: [studyId], references: [id], onDelete: Cascade) From ff7b83d5d32893e612ebf5458a9cb75bdac51ab7 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 23 Jul 2026 11:59:59 +1200 Subject: [PATCH 7/7] Add note to Auth Controller re email case --- application/backend/src/controllers/AuthController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/application/backend/src/controllers/AuthController.ts b/application/backend/src/controllers/AuthController.ts index 92c6a699..b5c4edf1 100644 --- a/application/backend/src/controllers/AuthController.ts +++ b/application/backend/src/controllers/AuthController.ts @@ -90,6 +90,7 @@ export class AuthController extends Controller { } const hashedPassword = await hashPassword(password) + // Note: email is stored as lowercase (see User model in `application/backend/prisma/schema.prisma`) const insertedUser: User = await this.userRepo.create({ data: { ...userDetails,