Skip to content

Commit fcd9534

Browse files
authored
Add email spellchecker suggestions to login (#73)
### What are the relevant tickets? <!--- If it fixes an open issue, please link to the issue here. --> <!--- Closes # ---> <!--- Fixes # ---> <!--- N/A ---> - mitodl/hq#9336 ### Description (What does it do?) <!--- Describe your changes in detail --> The aim is to prevent profile registrations in Keycloak for MIT users that are not linked to the organization indentity provider. #70 added email spellchecker suggestions to the registration page. This PR adds it on the login page. On the registration page, blocks sign ups for MIT emails ([these](https://github.com/mitodl/ol-infrastructure/blob/a0d3000743e198c6a8c91d5a8c87d64de553e15e/src/ol_infrastructure/substructure/keycloak/olapps.py#L672-L688)) by disabling the submit button. The login page ensures the email is valid and provides suggestions for misspellings, so it should be unusual for MIT users to reach the registration screen, however this ensures that MIT users cannot create an account that is not linked to the org identity. Displays message: "Please return to login to be directed to MIT Touchstone." Updates to a more robust email validation regex on the login page. **Extra care should be taken when testing on RC as this impacts login and registration.** ### Screenshots (if appropriate): <!--- optional - delete if empty ---> <img width="647" height="557" alt="image" src="https://github.com/user-attachments/assets/135cb0aa-42f8-4d8c-9fca-51e16fbf54e0" /> <img width="744" height="1007" alt="image" src="https://github.com/user-attachments/assets/7c018bea-469a-486b-ab44-707333150fe2" /> ### How can this be tested? <!--- Please describe in detail how your changes have been tested. Include details of your testing environment, any set-up required (e.g. data entry required for validation) and the tests you ran to see how your change affects other areas of the code, etc. Please also include instructions for how your reviewer can validate your changes. ---> ### Additional Context <!--- optional - delete if empty ---> <!--- Please add any reviewer questions, details worth noting, etc. that will help in assessing this change. ---> Run `yarn start` to fire up Keycloak. Navigate to the dummy client at https://my-theme.keycloakify.dev/. On the login screen: - Suggestions should appear as you type misspellings (on blur will often only happen as the user submits the form). - Check suggestion is not provided if the email is not yet valid. - Check suggestions are not provided while correctly typing an MIT email. - Check suggestion is provided for close match typos for MIT emails. These are: - mit.edu - broad.mit.edu - cag.csail.mit.edu - csail.mit.edu - education.mit.edu - ll.mit.edu - math.mit.edu - med.mit.edu - media.mit.edu - mitimco.mit.edu - mtl.mit.edu - professional.mit.edu - sloan.mit.edu - smart.mit.edu - solve.mit.edu - wi.mit.edu - Check suggestions are not provided while correctly typing a common email domain. - Check suggestion is provided for close match typos for common email domains, e.g.: - gmail.com - yahoo.com - outlook.com - hotmail.com - live.com - The email validation should disallow: - `a@b.c` - TLD is only 1 letter - `user@abc..com` - consecutive dots - `user@sub..domain.com` - consecutive dots - `user@-abc.com` leading hyphen - `user@abc-.com` trailing hyphen - `user@-sub-domain.com` - leading hyphen - `a b@example.com` - space in local part - `user@exa mple.com` - space in domain - `user@@example.com` - double @ - `user@.example.com` - empty subdomain - `user@example.` - empty TLD - `user@example.c1` - TLD must be letters only On the registration screen: - If your local Keycloak config doesn't take you to the registration page, log `kcContext.url.registrationUrl` and open at that path. - Check that the registration page disables submit for any of the MIT emails above. - Message should read "Please return to login to be directed to MIT Touchstone." on blur. <!--- Uncomment and add steps to be completed before merging this PR if necessary ### Checklist: - [ ] e.g. Update secret values in Vault before merging --->
1 parent 50055e4 commit fcd9534

File tree

6 files changed

+183
-28
lines changed

6 files changed

+183
-28
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ol-keycloakify",
3-
"version": "0.0.28",
3+
"version": "0.0.29",
44
"description": "Keycloakify theme for Open Learning",
55
"repository": {
66
"type": "git",

src/login/UserProfileFormFields.tsx

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,24 @@ import type { KcContext } from "./KcContext"
1414
import type { I18n } from "./i18n"
1515
import { Label, ValidationMessage, RevealPasswordButton, HelperText, Suggestion } from "./components/Elements"
1616
import { StyledTextField } from "./components/Elements"
17+
import { ORG_EMAIL_DOMAINS } from "./constants"
18+
19+
const isOrgEmail = (email: string): boolean => {
20+
if (!email || !email.trim()) return false
21+
const emailParts = email.trim().split("@")
22+
if (emailParts.length !== 2) return false
23+
const domain = emailParts[1].toLowerCase()
24+
return ORG_EMAIL_DOMAINS.some(
25+
(orgEmailDomain: string) => domain === orgEmailDomain.toLowerCase() || domain.endsWith(`.${orgEmailDomain.toLowerCase()}`)
26+
)
27+
}
1728

18-
export default function UserProfileFormFields(props: Omit<UserProfileFormFieldsProps<KcContext, I18n>, "kcClsx">) {
19-
const { kcContext, i18n, onIsFormSubmittableValueChange, doMakeUserConfirmPassword, BeforeField, AfterField } = props
29+
export default function UserProfileFormFields(
30+
props: Omit<UserProfileFormFieldsProps<KcContext, I18n>, "kcClsx"> & {
31+
onEmailValueChange?: (email: string) => void
32+
}
33+
) {
34+
const { kcContext, i18n, onIsFormSubmittableValueChange, doMakeUserConfirmPassword, BeforeField, AfterField, onEmailValueChange } = props
2035

2136
const { advancedMsg } = i18n
2237

@@ -31,7 +46,15 @@ export default function UserProfileFormFields(props: Omit<UserProfileFormFieldsP
3146

3247
useEffect(() => {
3348
onIsFormSubmittableValueChange(isFormSubmittable)
34-
}, [isFormSubmittable])
49+
}, [isFormSubmittable, onIsFormSubmittableValueChange])
50+
51+
const emailValue = formFieldStates.find(state => state.attribute.name === "email")?.valueOrValues
52+
53+
useEffect(() => {
54+
if (typeof emailValue === "string") {
55+
onEmailValueChange?.(emailValue)
56+
}
57+
}, [emailValue, onEmailValueChange])
3558

3659
const groupNameRef = { current: "" }
3760

@@ -235,9 +258,22 @@ function BaseInputTag(
235258
transformValue?: (value: string) => string
236259
onBeforeChange?: () => void
237260
onBlur?: () => void
261+
onFocus?: () => void
238262
}
239263
) {
240-
const { attribute, fieldIndex, dispatchFormAction, valueOrValues, i18n, displayableErrors, label, transformValue, onBeforeChange, onBlur } = props
264+
const {
265+
attribute,
266+
fieldIndex,
267+
dispatchFormAction,
268+
valueOrValues,
269+
i18n,
270+
displayableErrors,
271+
label,
272+
transformValue,
273+
onBeforeChange,
274+
onBlur,
275+
onFocus
276+
} = props
241277

242278
const { advancedMsgStr } = i18n
243279

@@ -318,6 +354,11 @@ function BaseInputTag(
318354
"aria-invalid": displayableErrors.length !== 0,
319355
autoComplete: attribute.autocomplete
320356
}}
357+
inputProps={{
358+
onFocus: () => {
359+
onFocus?.()
360+
}
361+
}}
321362
fullWidth
322363
endAdornment={
323364
attribute.name === "password" || attribute.name === "password-confirm" ? (
@@ -353,31 +394,18 @@ function BaseInputTag(
353394

354395
const EMAIL_SUGGESTION_DOMAINS = [
355396
...emailSpellChecker.POPULAR_DOMAINS,
356-
// https://github.com/mitodl/ol-infrastructure/blob/a0d3000743e198c6a8c91d5a8c87d64de553e15e/src/ol_infrastructure/substructure/keycloak/olapps.py#L672-L688
357-
"mit.edu",
358-
"broad.mit.edu",
359-
"cag.csail.mit.edu",
360-
"csail.mit.edu",
361-
"education.mit.edu",
362-
"ll.mit.edu",
363-
"math.mit.edu",
364-
"med.mit.edu",
365-
"media.mit.edu",
366-
"mit.edu",
367-
"mitimco.mit.edu",
368-
"mtl.mit.edu",
369-
"professional.mit.edu",
370-
"sloan.mit.edu",
371-
"smart.mit.edu",
372-
"solve.mit.edu",
373-
"wi.mit.edu"
397+
// Users with MIT addresses should have been directed to Touchstone on login. We might prevent some mistyped registrations here, though correct organization addresses will be show a validation error prompting to return to login.
398+
...ORG_EMAIL_DOMAINS
374399
]
375400

376401
function EmailTag(props: InputFieldByTypeProps & { fieldIndex: number | undefined }) {
377-
const { attribute, fieldIndex, dispatchFormAction, valueOrValues } = props
402+
const { attribute, fieldIndex, dispatchFormAction, valueOrValues, i18n } = props
403+
404+
const { advancedMsgStr } = i18n
378405

379406
const [touched, setTouched] = useState(false)
380407
const [suggestion, setSuggestion] = useState<string | null>(null)
408+
const [hasBeenBlurred, setHasBeenBlurred] = useState(false)
381409

382410
const [initialValue, setInitialValue] = useState<string | null>(() => {
383411
if (fieldIndex === undefined) {
@@ -390,13 +418,16 @@ function EmailTag(props: InputFieldByTypeProps & { fieldIndex: number | undefine
390418
if (typeof valueOrValues !== "string" || !valueOrValues) {
391419
return
392420
}
421+
setHasBeenBlurred(true)
393422
const suggestion = emailSpellChecker.run({
394423
email: valueOrValues,
395424
domains: EMAIL_SUGGESTION_DOMAINS
396425
})
397426
setSuggestion(suggestion?.full || null)
398427
}
399428

429+
const orgEmailDetected = typeof valueOrValues === "string" && valueOrValues.trim() && isOrgEmail(valueOrValues)
430+
400431
useEffect(() => {
401432
if (initialValue && !touched) {
402433
dispatchFormAction({
@@ -428,7 +459,15 @@ function EmailTag(props: InputFieldByTypeProps & { fieldIndex: number | undefine
428459
}
429460
}}
430461
onBlur={checkEmailForSuggestion}
462+
onFocus={() => {
463+
setHasBeenBlurred(false)
464+
}}
431465
/>
466+
{orgEmailDetected && hasBeenBlurred ? (
467+
<ValidationMessage id="form-help-text-mit-email" aria-live="polite">
468+
{advancedMsgStr("orgEmailRegistrationMessage")}
469+
</ValidationMessage>
470+
) : null}
432471
{suggestion ? (
433472
<Suggestion
434473
onClick={() => {

src/login/constants.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import emailSpellChecker from "@zootools/email-spell-checker"
2+
3+
/* Users logging in with MIT email addresses should be directed to Touchstone.
4+
* On the login screen, emails that closely match are shown a suggestion for typo corrections.
5+
* On the registration screen, users attempting to register with MIT email addresses are shown a message to return to login.
6+
*/
7+
export const ORG_EMAIL_DOMAINS = [
8+
// https://github.com/mitodl/ol-infrastructure/blob/a0d3000743e198c6a8c91d5a8c87d64de553e15e/src/ol_infrastructure/substructure/keycloak/olapps.py#L672-L688
9+
"mit.edu",
10+
"broad.mit.edu",
11+
"cag.csail.mit.edu",
12+
"csail.mit.edu",
13+
"education.mit.edu",
14+
"ll.mit.edu",
15+
"math.mit.edu",
16+
"med.mit.edu",
17+
"media.mit.edu",
18+
"mitimco.mit.edu",
19+
"mtl.mit.edu",
20+
"professional.mit.edu",
21+
"sloan.mit.edu",
22+
"smart.mit.edu",
23+
"solve.mit.edu",
24+
"wi.mit.edu"
25+
]
26+
27+
export const EMAIL_SUGGESTION_DOMAINS = [
28+
...ORG_EMAIL_DOMAINS,
29+
...emailSpellChecker.POPULAR_DOMAINS,
30+
// Adding common email providers to the suggestion list. The email-spell-checker lists these as second-level domains,
31+
// but adding them here prevents them being suggested as they are being typed correctly.
32+
"yahoo.com",
33+
"outlook.com",
34+
"hotmail.com",
35+
"live.com",
36+
"hotmail.co.uk",
37+
"hotmail.fr",
38+
"msn.com",
39+
"icloud.com"
40+
]
41+
42+
export const EMAIL_SPELLCHECKER_CONFIG = {
43+
domains: EMAIL_SUGGESTION_DOMAINS,
44+
// Empty the TLD list to avoid false positives for domains we can't reliably suggest for.
45+
topLevelDomains: []
46+
}

src/login/i18n.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const { useI18n, ofTypeI18n } = i18nBuilder
6767
invalidUsernameOrEmailMessage:
6868
" We do not have an account for that email on record. Please try another email or sign up for free.",
6969
invalidEmailMessage: "Invalid email address.",
70+
orgEmailRegistrationMessage:
71+
"Please return to login to be directed to your identity provider.",
7072
invalidPasswordMessage:
7173
"The password you have entered is incorrect. Please try again or select Reset Password below.",
7274
expiredCodeMessage: "Sign in timeout. Please sign in again.",

src/login/pages/LoginUsername.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { clsx } from "keycloakify/tools/clsx"
33
import type { PageProps } from "keycloakify/login/pages/PageProps"
44
import type { KcContext } from "../KcContext"
55
import type { I18n } from "../i18n"
6-
import { Button, Form, SocialProviderButtonLink, OrBar, StyledTextField, ValidationMessage } from "../components/Elements"
6+
import { Button, Form, SocialProviderButtonLink, OrBar, StyledTextField, ValidationMessage, Suggestion } from "../components/Elements"
77
import mitLogo from "../components/mit-logo.svg"
8+
import emailSpellChecker from "@zootools/email-spell-checker"
9+
import { EMAIL_SPELLCHECKER_CONFIG, EMAIL_SUGGESTION_DOMAINS } from "../constants"
810

911
const isValidEmail = (email: string): boolean => {
1012
if (!email || !email.trim()) return false
11-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
13+
const emailRegex = /^[^\s@]+@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$/
1214
return emailRegex.test(email.trim())
1315
}
1416

@@ -25,6 +27,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
2527
const [emailInvalid, setEmailInvalid] = useState(false)
2628
const [isFocused, setIsFocused] = useState(false)
2729
const [isEmailValid, setIsEmailValid] = useState(true)
30+
const [suggestion, setSuggestion] = useState<string | null>(null)
2831
const inputRef = useRef<HTMLInputElement | null>(null)
2932
const isFocusedRef = useRef(isFocused)
3033
const usernameRef = useRef(username)
@@ -47,6 +50,28 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
4750
[shouldValidateEmail]
4851
)
4952

53+
const checkEmailForSuggestion = useCallback((value: string) => {
54+
if (!value.trim()) {
55+
return
56+
}
57+
58+
const parts = value.trim().split("@")
59+
const domain = parts[1]
60+
const startMatch = EMAIL_SUGGESTION_DOMAINS.some(d => d.startsWith(domain))
61+
// Don't show a suggestion while the user is typing towards a match
62+
if (startMatch) {
63+
setSuggestion(null)
64+
return
65+
}
66+
67+
const suggestionResult = emailSpellChecker.run({
68+
email: value.trim(),
69+
...EMAIL_SPELLCHECKER_CONFIG
70+
})
71+
72+
setSuggestion(suggestionResult?.full || null)
73+
}, [])
74+
5075
const isSubmitDisabled = isSubmitting || !username.trim() || (shouldValidateEmail && !isEmailValid)
5176

5277
useEffect(() => {
@@ -104,6 +129,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
104129
{realm.password && (
105130
<Form
106131
id="kc-form-login"
132+
noValidate
107133
onSubmit={() => {
108134
if (realm.registrationEmailAsUsername && username) {
109135
sessionStorage.setItem("email", username.trim())
@@ -134,13 +160,17 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
134160
onFocus: () => {
135161
setIsFocused(true)
136162
setEmailInvalid(false)
163+
setSuggestion(null)
137164
},
138165
onBlur: () => {
139166
setIsFocused(false)
140167
const value = inputRef.current?.value ?? ""
141168
const isValid = checkValidity(value)
142169
if (!isValid && value.trim()) {
143170
setEmailInvalid(true)
171+
setSuggestion(null)
172+
} else if (isValid && shouldValidateEmail && value.trim()) {
173+
checkEmailForSuggestion(value.trim())
144174
}
145175
}
146176
}}
@@ -152,6 +182,13 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
152182
const isValid = checkValidity(value)
153183
if (isValid) {
154184
setEmailInvalid(false)
185+
if (shouldValidateEmail && value.trim()) {
186+
checkEmailForSuggestion(value.trim())
187+
} else {
188+
setSuggestion(null)
189+
}
190+
} else {
191+
setSuggestion(null)
155192
}
156193
}}
157194
value={username}
@@ -161,6 +198,18 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
161198
{msgStr("invalidEmailMessage")}
162199
</ValidationMessage>
163200
)}
201+
{suggestion && (
202+
<Suggestion
203+
onClick={() => {
204+
setSuggestion(null)
205+
setUsername(suggestion)
206+
checkValidity(suggestion)
207+
setEmailInvalid(false)
208+
}}
209+
>
210+
Did you mean: {suggestion}?
211+
</Suggestion>
212+
)}
164213
</div>
165214
)}
166215
<div id="kc-form-buttons">

src/login/pages/Register.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,22 @@ import type { PageProps } from "keycloakify/login/pages/PageProps"
77
import type { KcContext } from "../KcContext"
88
import type { I18n } from "../i18n"
99
import { Form, ValidationMessage, Button, Link, Info, Subtitle } from "../components/Elements"
10+
import { ORG_EMAIL_DOMAINS } from "../constants"
11+
12+
const isOrgEmail = (email: string): boolean => {
13+
if (!email || !email.trim()) return false
14+
const emailParts = email.trim().split("@")
15+
if (emailParts.length !== 2) return false
16+
const domain = emailParts[1].toLowerCase()
17+
return ORG_EMAIL_DOMAINS.some(
18+
(orgEmailDomain: string) => domain === orgEmailDomain.toLowerCase() || domain.endsWith(`.${orgEmailDomain.toLowerCase()}`)
19+
)
20+
}
1021

1122
type RegisterProps = PageProps<Extract<KcContext, { pageId: "register.ftl" }>, I18n> & {
12-
UserProfileFormFields: LazyOrNot<(props: Omit<UserProfileFormFieldsProps, "kcClsx">) => JSX.Element>
23+
UserProfileFormFields: LazyOrNot<
24+
(props: Omit<UserProfileFormFieldsProps, "kcClsx"> & { onEmailValueChange?: (email: string) => void }) => JSX.Element
25+
>
1326
doMakeUserConfirmPassword: boolean
1427
}
1528

@@ -32,6 +45,11 @@ export default function Register(props: RegisterProps) {
3245

3346
const [isFormSubmittable, setIsFormSubmittable] = useState(false)
3447
const [areTermsAccepted, setAreTermsAccepted] = useState(false)
48+
const [emailValue, setEmailValue] = useState<string>("")
49+
50+
// Block form submission if org email is detected
51+
const hasOrgEmail = emailValue.trim() && isOrgEmail(emailValue)
52+
const isFormSubmittableWithOrgCheck = isFormSubmittable && !hasOrgEmail
3553

3654
return (
3755
<Template
@@ -54,6 +72,7 @@ export default function Register(props: RegisterProps) {
5472
kcContext={kcContext}
5573
i18n={i18n}
5674
onIsFormSubmittableValueChange={setIsFormSubmittable}
75+
onEmailValueChange={setEmailValue}
5776
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
5877
/>
5978
{termsAcceptanceRequired && (
@@ -87,7 +106,7 @@ export default function Register(props: RegisterProps) {
87106
</div>
88107
) : (
89108
<div id="kc-form-buttons">
90-
<Button disabled={!isFormSubmittable || (termsAcceptanceRequired && !areTermsAccepted)} type="submit" size="large">
109+
<Button disabled={!isFormSubmittableWithOrgCheck || (termsAcceptanceRequired && !areTermsAccepted)} type="submit" size="large">
91110
{msg("doRegister")}
92111
</Button>
93112
</div>

0 commit comments

Comments
 (0)