From 818204668134cfeee2be2e896c84653823d1b01e Mon Sep 17 00:00:00 2001 From: Maxwell Austensen Date: Thu, 7 Mar 2024 08:09:01 -0500 Subject: [PATCH] Add validation/error handling to account settings update credentials (#854) This PR adds input validation and error handling to the forms for updating user email and password on the account settings page to ensure the new email is valid and not already in use, and that the new password satisfies rules. It swaps in the new , and adapts the existing validation/error handling logic from ` to show input- and page-level alerts. It prevents the default form reload and controls the toggling of edit mode after successful submission. Also adds an option to show an input-level error for password when rules aren't shown. Also fixes some spacing/styles that might have gotten messed up with the modal login changes. Also adds an input-level error state for password when the rule validation messages aren't shown (eg. for empty input when logging in). There is now a "callout" box under email when not editing if you haven't verified your email. And when you successfully change your email we mark it un-verified and send a verify email on the server side (JustFixNYC/auth-provider#17) and then the callout appears. --- client/src/components/EmailInput.tsx | 14 +- client/src/components/Icons.tsx | 25 ++- client/src/components/Login.tsx | 1 + client/src/components/PasswordInput.tsx | 33 +++- client/src/components/UserContext.tsx | 2 +- client/src/components/UserSettingField.tsx | 171 +++++++++++++++++---- client/src/styles/AccountSettingsPage.scss | 73 ++++++--- client/src/styles/Login.scss | 6 +- client/src/styles/Password.scss | 40 +++-- client/src/styles/UserTypeInput.scss | 2 +- client/src/styles/_callout.scss | 8 + client/src/styles/_typography.scss | 2 +- jfauthprovider/views.py | 5 +- 13 files changed, 287 insertions(+), 95 deletions(-) create mode 100644 client/src/styles/_callout.scss diff --git a/client/src/components/EmailInput.tsx b/client/src/components/EmailInput.tsx index a02cbcc4..2bffec46 100644 --- a/client/src/components/EmailInput.tsx +++ b/client/src/components/EmailInput.tsx @@ -16,17 +16,17 @@ interface EmailInputProps extends React.ComponentPropsWithoutRef<"input"> { showError: boolean; onChange: (e: ChangeEvent) => void; i18nHash?: string; + labelText?: string; } const EmailInputWithoutI18n = forwardRef( - ({ i18n, i18nHash, email, error, setError, showError, onChange, ...props }, ref) => { + ({ i18n, i18nHash, email, error, setError, showError, onChange, labelText, ...props }, ref) => { const isBadEmailFormat = (value: string) => { /* valid email regex rules alpha numeric characters are ok, upper/lower case agnostic username: leading \_ ok, chars \_\.\-\+ ok in all other positions domain name: chars \.\- ok as long as not leading. must end in a \. and at least two alphabet chars */ - const pattern = - "^([a-zA-Z0-9_]+[a-zA-Z0-9+_.-]+@[a-zA-Z0-9]+[a-zA-Z0-9.-]+[a-zA-Z0-9]+.[a-zA-Z]{2,})$"; + const pattern = /^([a-zA-Z0-9_]+[a-zA-Z0-9+_.-]+@[a-zA-Z0-9]+[a-zA-Z0-9.-]+[a-zA-Z0-9]+\.[a-zA-Z]{2,})$/; // HTML input element has loose email validation requirements, so we check the input against a custom regex const passStrictRegex = value.match(pattern); @@ -43,9 +43,11 @@ const EmailInputWithoutI18n = forwardRef( return (
-
- -
+ {!!labelText && ( +
+ +
+ )} {showError && error && (
diff --git a/client/src/components/Icons.tsx b/client/src/components/Icons.tsx index c2ae0863..c98b1c63 100644 --- a/client/src/components/Icons.tsx +++ b/client/src/components/Icons.tsx @@ -1,5 +1,18 @@ import React, { SVGProps } from "react"; +export const DotIcon = (props: SVGProps) => ( + + + +); + export const CloseIcon = (props: SVGProps) => ( ) => ( width="20" height="20" viewBox="0 0 20 20" - fill="none" + fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props} > ); diff --git a/client/src/components/Login.tsx b/client/src/components/Login.tsx index 4db9bd5e..720ef9b9 100644 --- a/client/src/components/Login.tsx +++ b/client/src/components/Login.tsx @@ -409,6 +409,7 @@ const LoginWithoutI18n = (props: LoginProps) => { setError={setEmailError} showError={showEmailError} autoFocus={showRegisterModal && !email} + labelText={i18n._(t`Email address`)} /> )} {(isLoginStep || isRegisterAccountStep) && ( diff --git a/client/src/components/PasswordInput.tsx b/client/src/components/PasswordInput.tsx index 7c9fa026..33a85f68 100644 --- a/client/src/components/PasswordInput.tsx +++ b/client/src/components/PasswordInput.tsx @@ -9,7 +9,7 @@ import { LocaleLink } from "i18n"; import { createWhoOwnsWhatRoutePaths } from "routes"; import { I18n } from "@lingui/core"; import { t } from "@lingui/macro"; -import { HideIcon, ShowIcon } from "./Icons"; +import { AlertIcon, CheckIcon, DotIcon, HideIcon, ShowIcon } from "./Icons"; import classNames from "classnames"; type PasswordRule = { @@ -82,25 +82,42 @@ const PasswordInputWithoutI18n = forwardRef - Forgot your password? + {i18n._(t`Forgot your password?`)} )}
- {showPasswordRules && ( + {showPasswordRules ? (
{passwordRules.map((rule, i) => { - const ruleClass = !!password - ? password.match(rule.regex) - ? "valid" - : "invalid" - : ""; + let ruleClass = ""; + let RuleIcon = ; + if (!!password || showError) { + if (password.match(rule.regex)) { + ruleClass = "valid"; + RuleIcon = ; + } else { + ruleClass = "invalid"; + RuleIcon = ; + } + } return ( + {RuleIcon} {rule.label} ); })}
+ ) : ( + showError && + error && ( +
+ + + {i18n._(t`Please enter password.`)} + +
+ ) )}
{ const response = await AuthClient.updateEmail(email); - setUser({ ...user, email: response.email }); + setUser({ ...user, email: response.email, verified: false }); }; asyncUpdateEmail(); } diff --git a/client/src/components/UserSettingField.tsx b/client/src/components/UserSettingField.tsx index bd29cd0a..8b1330d6 100644 --- a/client/src/components/UserSettingField.tsx +++ b/client/src/components/UserSettingField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; import { withI18n, withI18nProps } from "@lingui/react"; import { t, Trans } from "@lingui/macro"; @@ -6,6 +6,12 @@ import { t, Trans } from "@lingui/macro"; import "styles/EmailAlertSignup.css"; import PasswordInput from "./PasswordInput"; import { useInput } from "util/helpers"; +import { UserContext } from "./UserContext"; +import { JustfixUser } from "state-machine"; +import EmailInput from "./EmailInput"; +import AuthClient from "./AuthClient"; +import { Alert } from "./Alert"; +import { AlertIconOutline } from "./Icons"; type PasswordSettingFieldProps = withI18nProps & { onSubmit: (currentPassword: string, newPassword: string) => void; @@ -13,11 +19,14 @@ type PasswordSettingFieldProps = withI18nProps & { const PasswordSettingFieldWithoutI18n = (props: PasswordSettingFieldProps) => { const { i18n, onSubmit } = props; + const userContext = useContext(UserContext); + const { email } = userContext.user as JustfixUser; const { value: currentPassword, error: currentPasswordError, showError: showCurrentPasswordError, setError: setCurrentPasswordError, + setShowError: setShowCurrentPasswordError, onChange: onChangeCurrentPassword, } = useInput(""); const { @@ -25,16 +34,56 @@ const PasswordSettingFieldWithoutI18n = (props: PasswordSettingFieldProps) => { error: newPasswordError, showError: showNewPasswordError, setError: setNewPasswordError, + setShowError: setShowNewPasswordError, onChange: onChangeNewPassword, } = useInput(""); - // const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [invalidAuthError, setInvalidAuthError] = useState(false); + + const handleSubmit = async () => { + setInvalidAuthError(false); + setShowCurrentPasswordError(false); + setShowNewPasswordError(false); + + if (!currentPassword) { + setCurrentPasswordError(true); + setShowCurrentPasswordError(true); + throw new Error("Current password missing"); + } + + if (!newPassword || newPasswordError) { + setNewPasswordError(true); + setShowNewPasswordError(true); + throw new Error("New password format error"); + } + + // context doesn't update immediately so need to reurn user to check verified status + const resp = await userContext.login(email, currentPassword); + + if (!!resp?.error) { + setInvalidAuthError(true); + throw new Error("Incorrect current password"); + } - const handleSubmit = () => { onSubmit(currentPassword, newPassword); }; return ( + {invalidAuthError && ( + + + {i18n._(t`The old password you entered is incorrect`)} + + )} + + Password + { const { i18n, currentValue, onSubmit } = props; - const [value, setValue] = useState(currentValue); + const userContext = useContext(UserContext); + const { email: oldEmail, verified } = userContext.user as JustfixUser; + const [existingUserError, setExistingUserError] = useState(false); + const { + value: email, + error: emailError, + showError: showEmailError, + setError: setEmailError, + setShowError: setShowEmailError, + onChange: onChangeEmail, + } = useInput(oldEmail); - const handleValueChange = (e: React.ChangeEvent) => { - setValue(e.target.value); - }; + const handleSubmit = async () => { + setExistingUserError(false); + setShowEmailError(false); + + if (email === oldEmail) { + return; + } + + if (!email || emailError) { + setEmailError(true); + setShowEmailError(true); + throw new Error("Email format error"); + } - const handleSubmit = () => { - onSubmit(value); + if (!!email && !emailError) { + const existingUser = await AuthClient.isEmailAlreadyUsed(email); + if (existingUser) { + setExistingUserError(true); + throw new Error("Existing user error"); + } + } + + onSubmit(email); }; + const verifyCallout = !verified ? ( +
+ + Email address not verified. Click the link we sent to {email} start receiving emails. + +
+
+ +
+ ) : undefined; + return ( - Email address - This is used for logging in and for receiving weekly data updates. - + + {i18n._(t`That email is already used.`)} + + )} + + Email address + + We send data updates to this email. + ); @@ -101,39 +207,52 @@ export const EmailSettingField = withI18n()(EmailSettingFieldWithoutI18n); type UserSettingFieldProps = withI18nProps & { title: string; preview: string; - onSubmit: () => void; + onSubmit: () => Promise; children: React.ReactNode; + verifyCallout?: React.ReactNode; }; const UserSettingFieldWithoutI18n = (props: UserSettingFieldProps) => { - const { title, preview, onSubmit, children } = props; + const { title, preview, onSubmit, children, verifyCallout } = props; const [editing, setEditing] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit().then( + (response) => setEditing(false), + (error) => {} + ); + }; + return (
-
+ {editing ? ( <> {children}
-
) : ( <> - {title} + + {title} +
{preview} -
+ {!!verifyCallout && verifyCallout} )}
diff --git a/client/src/styles/AccountSettingsPage.scss b/client/src/styles/AccountSettingsPage.scss index 84631c7c..f3c54ae8 100644 --- a/client/src/styles/AccountSettingsPage.scss +++ b/client/src/styles/AccountSettingsPage.scss @@ -1,5 +1,6 @@ @import "_vars.scss"; @import "_typography.scss"; +@import "_callout.scss"; .AccountSettingsPage { h4 { @@ -42,16 +43,47 @@ form { display: flex; flex-direction: column; - margin: 3.2rem 1.6rem; - gap: 2.4rem; + margin: 1.625rem 1rem; + + .page-level-alert { + margin-bottom: 1.625rem; + svg { + margin-right: 0.5rem; + vertical-align: bottom; + } + } + + .user-setting-label { + @include desktop-eyebrow(); + // vars marked !important bc typography mixin is not available for inconsolata + font-weight: 600 !important; + font-family: $wow-font !important; + font-size: 0.875rem !important; + text-transform: none !important; + text-align: left; + margin-bottom: 0.5rem; + } + p { + margin-bottom: 0.375rem; + } + + .email-input-field, .password-input-field { display: flex; flex-direction: column; + &:has(#new-password-input) { + margin-top: 1.25rem; + } } - p { - margin: -0.63rem 0 0.37rem 0; + .jf-callout { + margin-top: 1rem; + display: flex; + flex-direction: column; + .button.is-text { + width: fit-content; + } } } @@ -61,38 +93,31 @@ span { flex: 1; } + span, + .edit-button { + font-size: 1rem; + } } .user-setting-actions { display: flex; - justify-content: right; + justify-content: left; + align-items: center; + margin-top: 1rem; @include for-phone-only() { justify-content: center; } input[type="submit"] { - margin: 0 1rem 0 0 !important; + margin: 0 3rem 0 0 !important; } - } - - label { - @include desktop-eyebrow(); - // vars marked !important bc typography mixin is not available for inconsolata - font-weight: 600 !important; - font-family: $wow-font !important; - font-size: 0.81rem !important; - - text-align: left; - margin-bottom: 1rem; - } - - .password-input-rules { - display: flex; - flex-direction: column; - span:last-child { - margin-bottom: 0.37rem !important; + .button.is-text { + @include body-standard; + font-size: 1.125rem; + font-weight: 500; + text-decoration: none; } } } diff --git a/client/src/styles/Login.scss b/client/src/styles/Login.scss index 58b8ba3a..bc3811cd 100644 --- a/client/src/styles/Login.scss +++ b/client/src/styles/Login.scss @@ -27,7 +27,7 @@ } .page-title { - margin-top: 0.59rem; + margin-top: 0.9375rem; } .building-page-footer { @@ -80,10 +80,6 @@ font-size: 0.63rem; } - .password-input { - margin-top: 0.3125rem; - } - .verify-email-container { color: $justfix-black; display: flex; diff --git a/client/src/styles/Password.scss b/client/src/styles/Password.scss index 8f68fa5b..fb806cf8 100644 --- a/client/src/styles/Password.scss +++ b/client/src/styles/Password.scss @@ -17,7 +17,7 @@ color: $justfix-black; text-align: left; - margin-bottom: 0.31rem; + margin-bottom: 0.3125rem; } a { @@ -60,6 +60,7 @@ .password-input-rules { display: flex; flex-direction: column; + margin-bottom: 0.375rem; } .password-input-rule { @@ -67,24 +68,11 @@ font-family: $wow-font !important; font-size: 0.8125rem !important; - // font-weight: 500 !important; line-height: 120%; text-transform: none !important; color: $justfix-black; text-align: left; - &::before { - content: "•"; - display: inline-block; - margin-right: 0.31rem; - vertical-align: top; - } - - // encoded SVG from https://yoksel.github.io/url-encoder/ - &.invalid:before { - content: url("data:image/svg+xml, %3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 14C3.14 14 0 10.8599 0 7C0 3.14007 3.14007 0 7 0C10.8599 0 14 3.14007 14 7C14 10.84 10.8599 14 7 14Z' fill='%23FF813A' /%3E%3Cpath d='M7 8.75C6.50422 8.75 6.125 8.37612 6.125 7.88729V3.48771C6.125 2.9989 6.50421 2.625 7 2.625C7.49579 2.625 7.875 2.99888 7.875 3.48771V7.88729C7.875 8.3763 7.49579 8.75 7 8.75Z' fill='%23FAF8F4' /%3E%3Cpath d='M6.97194 11.375C6.74608 11.375 6.52022 11.2879 6.37902 11.1136C6.20967 10.9394 6.125 10.736 6.125 10.5036C6.125 10.4455 6.125 10.3875 6.15316 10.3294C6.15316 10.2712 6.18132 10.2133 6.20967 10.1551C6.23783 10.097 6.26619 10.068 6.29435 10.0099C6.3225 9.95174 6.35086 9.92277 6.40718 9.86462C6.71771 9.54513 7.28225 9.54513 7.59282 9.86462C7.62098 9.89359 7.6775 9.95174 7.70565 10.0099C7.73381 10.068 7.76217 10.097 7.79033 10.1551C7.81849 10.2133 7.81849 10.2712 7.84684 10.3294C7.84684 10.3875 7.875 10.4455 7.875 10.5036C7.875 10.736 7.79033 10.9684 7.62098 11.1136C7.42367 11.2879 7.19781 11.375 6.97195 11.375H6.97194Z' fill='%23FAF8F4' /%3E%3C/svg%3E%0A"); - } - &.invalid { color: #ba4300; } @@ -93,8 +81,28 @@ color: $justfix-green; } - &.valid::before { - content: url("data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 17 17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1.16666 7.97664L5.83332 12.6433L15.8333 2.64331' stroke='%231AA551' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E "); + svg { + width: 0.875rem; + height: 0.875rem; + margin-right: 0.3125rem; + vertical-align: text-bottom; + } + } + + .password-input-errors { + display: flex; + flex-direction: column; + + span { + display: flex; + align-items: center; + font-size: 0.8125rem; + color: #ba4300; + margin-bottom: 0.3125rem; + + svg { + margin-right: 0.46875rem; + } } } } diff --git a/client/src/styles/UserTypeInput.scss b/client/src/styles/UserTypeInput.scss index 29f033b4..4a5da865 100644 --- a/client/src/styles/UserTypeInput.scss +++ b/client/src/styles/UserTypeInput.scss @@ -70,7 +70,7 @@ align-items: center; font-size: 0.8125rem; color: #ba4300; - margin-bottom: 0.31rem; + margin-bottom: 0.3125rem; svg { margin-right: 0.47rem; diff --git a/client/src/styles/_callout.scss b/client/src/styles/_callout.scss new file mode 100644 index 00000000..6cb75ce1 --- /dev/null +++ b/client/src/styles/_callout.scss @@ -0,0 +1,8 @@ +@import "_vars.scss"; +@import "_typography.scss"; + +.jf-callout { + @include desktop-text-small; + background-color: $justfix-white-200; + padding: 0.75rem; +} diff --git a/client/src/styles/_typography.scss b/client/src/styles/_typography.scss index 34020826..9103aa09 100644 --- a/client/src/styles/_typography.scss +++ b/client/src/styles/_typography.scss @@ -49,7 +49,7 @@ $eyebrow-font: "Suisse Int'l Mono", "Courier New", Courier, monospace; @mixin desktop-text-small { @include body-standard(); - font-size: 0.55rem; + font-size: 0.875rem; } @mixin desktop-text-small-bold { diff --git a/jfauthprovider/views.py b/jfauthprovider/views.py index ddf5f084..a9324701 100644 --- a/jfauthprovider/views.py +++ b/jfauthprovider/views.py @@ -60,7 +60,10 @@ def logout(request): @api def update(request): try: - post_data = {"new_email": request.POST.get("new_email")} + post_data = { + "new_email": request.POST.get("new_email"), + "origin": request.headers["Origin"], + } return authenticated_request( "user/",