Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions application/admin-client/cypress/e2e/invites.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
94 changes: 64 additions & 30 deletions application/admin-client/src/components/InviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,49 @@
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
Expand All @@ -41,56 +84,47 @@
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
setEmailText(data.inviteEmailText)
setEmailTitle(data.inviteEmailSubject)
})
}, [])

Check warning on line 120 in application/admin-client/src/components/InviteModal.tsx

View workflow job for this annotation

GitHub Actions / build and check

React Hook useEffect has a missing dependency: 'studyId'. Either include it or remove the dependency array

useEffect(() => {
if (invalid) {
setInvalid(!validateEmail(fieldValue))
}
}, [fieldValue])

Check warning on line 126 in application/admin-client/src/components/InviteModal.tsx

View workflow job for this annotation

GitHub Actions / build and check

React Hook useEffect has a missing dependency: 'invalid'. Either include it or remove the dependency array

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,
Expand Down
4 changes: 2 additions & 2 deletions application/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions application/backend/src/controllers/AuthController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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')
})
})

Expand Down
1 change: 1 addition & 0 deletions application/backend/src/controllers/AuthController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions application/backend/src/controllers/ParticipantsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@${process.env.HOSTNAME}>`)
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', () => {
Expand Down
17 changes: 15 additions & 2 deletions application/backend/src/controllers/ParticipantsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading