@@ -163,8 +163,49 @@ async function getUserRoles(group, username) {
163163 return existingRoles ;
164164}
165165
166+ /**
167+ * The cognito pools we use are case-sensitive for emails, but we want to avoid
168+ * provisioning multiple emails with different casings. Here we create a lookup
169+ * dictionary by uppercase email addresses, and for good measure spit out warnings
170+ * if there are multiple emails with different casings.
171+ */
172+ function summariseEmails ( usersByEmail ) {
173+ const db = { }
174+ for ( const email of Object . keys ( usersByEmail ) ) {
175+ const key = email . toUpperCase ( ) ;
176+ Object . hasOwn ( db , key ) ? db [ key ] . push ( email ) : ( db [ key ] = [ email ] ) ;
177+ }
178+ // eslint-disable-next-line no-unused-vars
179+ for ( const [ _key , emails ] of Object . entries ( db ) ) {
180+ if ( emails . length > 1 ) {
181+ console . log ( `WARNING: Multiple emails with different casings present in pool: ${ emails . join ( ', ' ) } ` )
182+ }
183+ }
184+ return db ;
185+ }
186+
187+ /**
188+ * Match a provided case-sensitive *email* against the *emailDb* which is
189+ * indexed by all-caps emails. This is necessary because cognito (at least the
190+ * way we have it) is case-sensitive, but emails are generally case-insensitive.
191+ * Ultimately we want to avoid situations where we provision multiple emails
192+ * which differ only in their casing.
193+ * @param {object } emailDb
194+ * @param {string } email
195+ * @returns {object } exact: boolean (whether the email is an exact match),
196+ * candidates: string[] cognito URLs which are identical to the provided email
197+ * except for potential different casing
198+ */
199+ function searchEmails ( emailDb , email ) {
200+ const candidates = emailDb [ email . toUpperCase ( ) ] || [ ] ;
201+ const exact = candidates . includes ( email ) ;
202+ if ( candidates . length === 0 ) return false
203+ return { candidates, exact}
204+ }
166205
167206async function queryUsernames ( group , members , usersByEmail , usersByUsername ) {
207+ const emailDb = summariseEmails ( usersByEmail ) ; // uppercase emails for searching purposes
208+
168209 for ( const member of members ) {
169210 console . log ( )
170211 console . log ( `Membership request for user "${ member . username } " (${ member . email } ), role: ${ member . role } ` )
@@ -175,7 +216,29 @@ async function queryUsernames(group, members, usersByEmail, usersByUsername) {
175216 process . exit ( 2 )
176217 }
177218
178- if ( Object . hasOwn ( usersByEmail , member . email ) ) {
219+ /* match emails because cognito is case-sensitive and emails generally aren't */
220+ const emailMatch = searchEmails ( emailDb , member . email ) ;
221+
222+ if ( emailMatch ) {
223+
224+ /* If the email's not an exact match, then print out a warning and go to the next member */
225+ if ( ! emailMatch . exact ) {
226+ if ( emailMatch . candidates . length === 1 ) {
227+ console . log ( `\tWARNING: The provided email ${ member . email } wasn't an exact match - you probably meant "${ emailMatch . candidates [ 0 ] } "` )
228+ console . log ( `\tACTION: try again with the case-corrected email` )
229+ } else {
230+ console . log ( `\tWARNING: The provided email ${ member . email } wasn't an exact match but there are` +
231+ `multiple different (case-sensitive) matches: ${ emailMatch . candidates . join ( ", " ) } ` )
232+ console . log ( `\tACTION: try again with a case-corrected email or (better) unify these various emails before proceeding` )
233+ }
234+ continue
235+ }
236+ /* If it's an exact match but there are also other (case-sensitive) alternatives, proceed with a loud warning */
237+ if ( emailMatch . candidates . length > 1 ) {
238+ console . log ( `\tWARNING: while this exact email already exists, other emails with different casing also exist (see earlier warnings). ` +
239+ `We recommend you unify these emails (and associated usernames) before proceeding` )
240+ }
241+
179242 // Pay the cost upfront to get all the roles currently set (in the context of the chosen
180243 // Nextstrain group) for all cognito users associated with this email address
181244 console . log ( `\tThis email address is currently associated with ${ usersByEmail [ member . email ] . length } user(s)` )
@@ -187,8 +250,6 @@ async function queryUsernames(group, members, usersByEmail, usersByUsername) {
187250 }
188251 const numAssociatedUsers = usersByEmail [ member . email ] . length ;
189252
190- // TODO XXX - check case of emails...
191-
192253 if ( member . username ) {
193254 // Simplest case: the requested username is associated with the requested email
194255 if ( Object . hasOwn ( userRoles , member . username ) ) {
0 commit comments