From 577f3367878f136b6de4a9719bb7261e9eca6a6c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 12:10:11 +1000 Subject: [PATCH] feat: add self-service email change flow --- .../accounts/src/accounts.routes.spec.tsx | 27 +++ src/apps/accounts/src/accounts.routes.tsx | 12 +- src/apps/accounts/src/lib/index.ts | 1 + .../lib/services/email-change.service.spec.ts | 58 +++++ .../src/lib/services/email-change.service.ts | 117 ++++++++++ src/apps/accounts/src/lib/services/index.ts | 1 + .../ChangeEmailVerificationPage.module.scss | 23 ++ .../ChangeEmailVerificationPage.tsx | 75 +++++++ .../change-email-verification/index.ts | 1 + .../user-and-pass/UserAndPassword.module.scss | 9 +- .../account/user-and-pass/UserAndPassword.tsx | 158 +++++++++++++- .../change-email/ChangeEmailModal.module.scss | 28 +++ .../change-email/ChangeEmailModal.tsx | 133 ++++++++++++ .../ChangeEmailOtpModal.module.scss | 56 +++++ .../change-email/ChangeEmailOtpModal.tsx | 137 ++++++++++++ .../user-and-pass/change-email/index.ts | 2 + .../user-and-pass.form.config.tsx | 199 ++++++++++-------- .../form-input/input-text/InputText.tsx | 1 + .../input-wrapper/InputWrapper.module.scss | 7 + .../form-input/input-wrapper/InputWrapper.tsx | 6 + .../lib/components/form/form-input.model.ts | 2 + 21 files changed, 963 insertions(+), 90 deletions(-) create mode 100644 src/apps/accounts/src/accounts.routes.spec.tsx create mode 100644 src/apps/accounts/src/lib/services/email-change.service.spec.ts create mode 100644 src/apps/accounts/src/lib/services/email-change.service.ts create mode 100644 src/apps/accounts/src/lib/services/index.ts create mode 100644 src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.module.scss create mode 100644 src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx create mode 100644 src/apps/accounts/src/settings/change-email-verification/index.ts create mode 100644 src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.module.scss create mode 100644 src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.tsx create mode 100644 src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.module.scss create mode 100644 src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.tsx create mode 100644 src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/index.ts diff --git a/src/apps/accounts/src/accounts.routes.spec.tsx b/src/apps/accounts/src/accounts.routes.spec.tsx new file mode 100644 index 000000000..4f0c89412 --- /dev/null +++ b/src/apps/accounts/src/accounts.routes.spec.tsx @@ -0,0 +1,27 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { accountsRoutes } from './accounts.routes' + +jest.mock('~/config', () => ({ + AppSubdomain: { accounts: 'account-settings' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, + ToolTitle: { accounts: 'Account Settings' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): JSX.Element =>
, +}), { virtual: true }) + +describe('Account Settings routes', () => { + it('protects settings while allowing validation links to work logged out', () => { + const [root] = accountsRoutes + const settingsRoute = root.children?.find(route => route.route === '') + const validationRoute = root.children?.find(route => route.route === 'changeEmail') + + expect(root.authRequired) + .toBeUndefined() + expect(settingsRoute?.authRequired) + .toBe(true) + expect(validationRoute?.authRequired) + .toBeUndefined() + }) +}) diff --git a/src/apps/accounts/src/accounts.routes.tsx b/src/apps/accounts/src/accounts.routes.tsx index ea60df349..b3e3f16c6 100644 --- a/src/apps/accounts/src/accounts.routes.tsx +++ b/src/apps/accounts/src/accounts.routes.tsx @@ -3,6 +3,10 @@ import { AppSubdomain, EnvironmentConfig, ToolTitle } from '~/config' const AccountsApp: LazyLoadedComponent = lazyLoad(() => import('./AccountsApp')) const AccountSettingsPage: LazyLoadedComponent = lazyLoad(() => import('./settings'), 'AccountSettingsPage') +const ChangeEmailVerificationPage: LazyLoadedComponent = lazyLoad( + () => import('./settings/change-email-verification'), + 'ChangeEmailVerificationPage', +) export const rootRoute: string = ( EnvironmentConfig.SUBDOMAIN === AppSubdomain.accounts ? '' : `/${AppSubdomain.accounts}` @@ -13,14 +17,20 @@ export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}` export const accountsRoutes: ReadonlyArray = [ { - authRequired: true, children: [ { + authRequired: true, children: [], element: , id: 'Account Settings', route: '', }, + { + children: [], + element: , + id: 'Change Email Verification', + route: 'changeEmail', + }, ], domain: AppSubdomain.accounts, element: , diff --git a/src/apps/accounts/src/lib/index.ts b/src/apps/accounts/src/lib/index.ts index a435df51a..fe7f884cd 100644 --- a/src/apps/accounts/src/lib/index.ts +++ b/src/apps/accounts/src/lib/index.ts @@ -1,3 +1,4 @@ export * from './accounts-swr' export * from './components' +export * from './services' export * from './assets' diff --git a/src/apps/accounts/src/lib/services/email-change.service.spec.ts b/src/apps/accounts/src/lib/services/email-change.service.spec.ts new file mode 100644 index 000000000..5b036483c --- /dev/null +++ b/src/apps/accounts/src/lib/services/email-change.service.spec.ts @@ -0,0 +1,58 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { xhrGetAsync, xhrPostAsync } from '~/libs/core' + +import { + completeEmailChangeAsync, + initiateEmailChangeAsync, + requestEmailChangeOtpAsync, + verifyEmailChangeOtpAsync, +} from './email-change.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { API: { V6: 'https://api.example.test/v6' } }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), + xhrPostAsync: jest.fn(), +}), { virtual: true }) + +const mockedGet = xhrGetAsync as jest.Mock +const mockedPost = xhrPostAsync as jest.Mock + +describe('email change API service', () => { + beforeEach(() => { + mockedGet.mockReset() + mockedPost.mockReset() + }) + + it('uses the ownership, proof, validation, and completion endpoints', async () => { + mockedPost + .mockResolvedValueOnce({ expiresIn: 600 }) + .mockResolvedValueOnce({ expiresIn: 600, verificationToken: 'proof' }) + .mockResolvedValueOnce({ email: 'new@example.com' }) + mockedGet.mockResolvedValueOnce({ email: 'new@example.com' }) + + await requestEmailChangeOtpAsync(123) + await verifyEmailChangeOtpAsync(123, '012345') + await initiateEmailChangeAsync(123, 'new@example.com', 'proof') + await completeEmailChangeAsync('signed/token') + + expect(mockedPost.mock.calls) + .toEqual([ + ['https://api.example.test/v6/users/123/email-change/otp', {}], + [ + 'https://api.example.test/v6/users/123/email-change/verify-otp', + { param: { otp: '012345' } }, + ], + [ + 'https://api.example.test/v6/users/123/email-change', + { param: { email: 'new@example.com', verificationToken: 'proof' } }, + ], + ]) + expect(mockedGet) + .toHaveBeenCalledWith( + 'https://api.example.test/v6/users/email-change/verify?token=signed%2Ftoken', + ) + }) +}) diff --git a/src/apps/accounts/src/lib/services/email-change.service.ts b/src/apps/accounts/src/lib/services/email-change.service.ts new file mode 100644 index 000000000..06ffb9884 --- /dev/null +++ b/src/apps/accounts/src/lib/services/email-change.service.ts @@ -0,0 +1,117 @@ +import { AxiosError } from 'axios' + +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync, xhrPostAsync } from '~/libs/core' + +export interface EmailChangeOtpResponse { + expiresIn: number +} + +export interface EmailChangeOtpVerificationResponse extends EmailChangeOtpResponse { + verificationToken: string +} + +export interface EmailChangeResponse { + email: string +} + +const usersUrl: string = `${EnvironmentConfig.API.V6}/users` + +/** + * Requests a six-digit ownership code at the member's current primary email. + * + * @param userId member ID whose email will be changed. + * @returns the code lifetime in seconds. + * @throws rejects when the identity API cannot send the code. + */ +export async function requestEmailChangeOtpAsync( + userId: number, +): Promise { + return xhrPostAsync, EmailChangeOtpResponse>( + `${usersUrl}/${userId}/email-change/otp`, + {}, + ) +} + +/** + * Verifies the ownership code sent to the member's current primary email. + * + * @param userId member ID that requested the code. + * @param otp six-digit ownership code. + * @returns a short-lived proof used to submit a new address. + * @throws rejects when the code is invalid, expired, or blocked. + */ +export async function verifyEmailChangeOtpAsync( + userId: number, + otp: string, +): Promise { + return xhrPostAsync< + { param: { otp: string } }, + EmailChangeOtpVerificationResponse + >( + `${usersUrl}/${userId}/email-change/verify-otp`, + { param: { otp } }, + ) +} + +/** + * Sends a validation link to the proposed new primary email. + * + * @param userId member ID whose email will be changed. + * @param email proposed new primary email. + * @param verificationToken proof that the current email OTP was verified. + * @returns the normalized address that received the validation link. + * @throws rejects when the address or proof is invalid. + */ +export async function initiateEmailChangeAsync( + userId: number, + email: string, + verificationToken: string, +): Promise { + return xhrPostAsync< + { param: { email: string, verificationToken: string } }, + EmailChangeResponse + >( + `${usersUrl}/${userId}/email-change`, + { param: { email, verificationToken } }, + ) +} + +/** + * Completes the deferred email update from the validation link. + * + * @param validationToken one-time token delivered to the proposed new email. + * @returns the email address that is now primary. + * @throws rejects when the validation link is invalid, expired, or already used. + */ +export async function completeEmailChangeAsync( + validationToken: string, +): Promise { + return xhrGetAsync( + `${usersUrl}/email-change/verify?token=${encodeURIComponent(validationToken)}`, + ) +} + +/** + * Extracts a user-facing message from an email-change API error. + * + * @param error unknown error caught from an API request. + * @param fallback message used when the response has no useful detail. + * @returns a concise user-facing error message. + */ +export function getEmailChangeErrorMessage(error: unknown, fallback: string): string { + if (!(error instanceof AxiosError)) { + return error instanceof Error && error.message ? error.message : fallback + } + + const responseMessage: unknown = error.response?.data?.message + ?? error.response?.data?.error?.message + + if (Array.isArray(responseMessage)) { + return responseMessage.join(' ') + } + + return typeof responseMessage === 'string' && responseMessage.trim() + ? responseMessage + : (error.message || fallback) +} diff --git a/src/apps/accounts/src/lib/services/index.ts b/src/apps/accounts/src/lib/services/index.ts new file mode 100644 index 000000000..410e86c47 --- /dev/null +++ b/src/apps/accounts/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './email-change.service' diff --git a/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.module.scss b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.module.scss new file mode 100644 index 000000000..d3965df1f --- /dev/null +++ b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.module.scss @@ -0,0 +1,23 @@ +@import '@libs/ui/styles/includes'; + +.layout { + margin: $sp-8 auto !important; +} + +.card { + align-items: center; + display: flex; + flex-direction: column; + gap: $sp-5; + margin: $sp-10 auto; + max-width: 620px; + text-align: center; + + p { + margin: 0; + } +} + +.error { + color: $red-100; +} diff --git a/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx new file mode 100644 index 000000000..00a7f7bef --- /dev/null +++ b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx @@ -0,0 +1,75 @@ +import { FC, useEffect, useRef, useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +import { + completeEmailChangeAsync, + getEmailChangeErrorMessage, +} from '~/apps/accounts/src/lib/services' +import { ContentLayout, LinkButton, LoadingSpinner, PageTitle } from '~/libs/ui' + +import styles from './ChangeEmailVerificationPage.module.scss' + +type VerificationStatus = 'error' | 'loading' | 'success' + +/** + * Completes a pending email change when the member follows the validation link. + */ +const ChangeEmailVerificationPage: FC = () => { + const [searchParams] = useSearchParams() + const token: string | null = searchParams.get('token') + const [status, setStatus] = useState('loading') + const [message, setMessage] = useState('Validating your new email address…') + const requestedToken = useRef() + + useEffect(() => { + if (!token) { + requestedToken.current = undefined + setStatus('error') + setMessage('This email validation link is incomplete.') + return + } + + if (requestedToken.current === token) { + return + } + + requestedToken.current = token + completeEmailChangeAsync(token) + .then(response => { + setStatus('success') + setMessage(`${response.email} is now your primary email address.`) + }) + .catch(error => { + setStatus('error') + setMessage(getEmailChangeErrorMessage( + error, + 'This email validation link is invalid or has expired.', + )) + }) + }, [token]) + + return ( + +
+ + {status === 'success' ? 'Email changed' : 'Validate email change'} + + {status === 'loading' && } +

+ {message} +

+ {status !== 'loading' && ( + + )} +
+
+ ) +} + +export default ChangeEmailVerificationPage diff --git a/src/apps/accounts/src/settings/change-email-verification/index.ts b/src/apps/accounts/src/settings/change-email-verification/index.ts new file mode 100644 index 000000000..af89295a3 --- /dev/null +++ b/src/apps/accounts/src/settings/change-email-verification/index.ts @@ -0,0 +1 @@ +export { default as ChangeEmailVerificationPage } from './ChangeEmailVerificationPage' diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.module.scss b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.module.scss index 09192f31e..ae1e34b16 100644 --- a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.module.scss +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.module.scss @@ -16,4 +16,11 @@ max-width: 380px; } } -} \ No newline at end of file +} + +.changeEmailLink { + font-size: 12px !important; + line-height: 14px !important; + min-height: 0 !important; + padding: 0 !important; +} diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx index fa3f9aab7..5d08c302b 100644 --- a/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/UserAndPassword.tsx @@ -19,8 +19,15 @@ import { UserTraits, } from '~/libs/core' import { SettingSection } from '~/apps/accounts/src/lib' +import { + getEmailChangeErrorMessage, + initiateEmailChangeAsync, + requestEmailChangeOtpAsync, + verifyEmailChangeOtpAsync, +} from '~/apps/accounts/src/lib/services' -import { UserAndPassFromConfig } from './user-and-pass.form.config' +import { ChangeEmailModal, ChangeEmailOtpModal } from './change-email' +import { createUserAndPassFormConfig } from './user-and-pass.form.config' import styles from './UserAndPassword.module.scss' interface UserAndPasswordProps { @@ -42,6 +49,41 @@ const UserAndPassword: FC = (props: UserAndPasswordProps) const { mutate: mutateTraits }: { mutate: KeyedMutator } = useMemberTraits(props.profile.handle) const [userConsent, setUserConsent]: [boolean, Dispatch] = useState(false) + const [isRequestingOtp, setIsRequestingOtp] = useState(false) + const [isResendingOtp, setIsResendingOtp] = useState(false) + const [isVerifyingOtp, setIsVerifyingOtp] = useState(false) + const [isSubmittingEmail, setIsSubmittingEmail] = useState(false) + const [isOtpModalOpen, setIsOtpModalOpen] = useState(false) + const [isChangeEmailModalOpen, setIsChangeEmailModalOpen] = useState(false) + const [otpError, setOtpError] = useState() + const [changeEmailError, setChangeEmailError] = useState() + const [emailChangeProof, setEmailChangeProof] = useState() + + /** + * Requests an ownership code at the current primary email and opens the OTP dialog. + * @returns a promise resolved after the code request finishes. + */ + const handleChangeEmailClick = useCallback(async (): Promise => { + setIsRequestingOtp(true) + setOtpError(undefined) + try { + await requestEmailChangeOtpAsync(props.profile.userId) + setIsOtpModalOpen(true) + toast.success(`Verification code sent to ${props.profile.email}.`) + } catch (error) { + toast.error(getEmailChangeErrorMessage( + error, + 'Unable to send a verification code. Please try again.', + )) + } finally { + setIsRequestingOtp(false) + } + }, [props.profile.email, props.profile.userId]) + + const userAndPassFormConfig = useMemo( + () => createUserAndPassFormConfig(handleChangeEmailClick, isRequestingOtp), + [handleChangeEmailClick, isRequestingOtp], + ) const requestGenerator: (inputs: ReadonlyArray) => any = useCallback((inputs: ReadonlyArray) => { @@ -90,6 +132,83 @@ const UserAndPassword: FC = (props: UserAndPasswordProps) }) } + /** + * Verifies a completed current-email OTP and opens the new-address dialog. + * @param otp six-digit code entered by the member. + * @returns a promise resolved after identity verification finishes. + */ + async function handleVerifyOtp(otp: string): Promise { + setIsVerifyingOtp(true) + setOtpError(undefined) + try { + const response = await verifyEmailChangeOtpAsync(props.profile.userId, otp) + setEmailChangeProof(response.verificationToken) + setIsOtpModalOpen(false) + setIsChangeEmailModalOpen(true) + } catch (error) { + setOtpError(getEmailChangeErrorMessage( + error, + 'The verification code could not be verified.', + )) + } finally { + setIsVerifyingOtp(false) + } + } + + /** + * Sends a replacement ownership code to the current primary email. + * @returns a promise resolved after the resend request finishes. + */ + async function handleResendOtp(): Promise { + setIsResendingOtp(true) + setOtpError(undefined) + try { + await requestEmailChangeOtpAsync(props.profile.userId) + toast.success(`A new verification code was sent to ${props.profile.email}.`) + } catch (error) { + setOtpError(getEmailChangeErrorMessage( + error, + 'Unable to resend the verification code.', + )) + } finally { + setIsResendingOtp(false) + } + } + + /** + * Sends the final validation link to the proposed new email address. + * @param email normalized address entered in the change-email dialog. + * @returns a promise resolved after the validation email request finishes. + */ + async function handleSubmitNewEmail(email: string): Promise { + if (!emailChangeProof) { + setChangeEmailError('Your verification expired. Start the email change again.') + return + } + + setIsSubmittingEmail(true) + setChangeEmailError(undefined) + try { + const response = await initiateEmailChangeAsync( + props.profile.userId, + email, + emailChangeProof, + ) + toast.success( + `Validation email sent to ${response.email}. Your primary email will change after validation.`, + ) + setIsChangeEmailModalOpen(false) + setEmailChangeProof(undefined) + } catch (error) { + setChangeEmailError(getEmailChangeErrorMessage( + error, + 'Unable to start the email change. Please try again.', + )) + } finally { + setIsSubmittingEmail(false) + } + } + function shouldDisableChangePasswordButton(): boolean { // pass reset form validation const specialChars: any = /[`!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?~]/ @@ -127,14 +246,14 @@ const UserAndPassword: FC = (props: UserAndPasswordProps) contentClass={styles.content} >

- While your Topcoder handle or username and your email cannot be changed, - we encourage to change your password frequently. + While your Topcoder handle or username cannot be changed, + we encourage you to change your password frequently.

= (props: UserAndPasswordProps) )} />
+ + + + ) } diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.module.scss b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.module.scss new file mode 100644 index 000000000..61a430d06 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.module.scss @@ -0,0 +1,28 @@ +@import '@libs/ui/styles/includes'; + +.container { + display: flex; + flex-direction: column; + gap: $sp-3; + min-width: 440px; + + > p { + margin: 0 0 $sp-2; + } + + @include ltesm { + min-width: 0; + } +} + +.actions { + align-items: center; + display: flex; + gap: $sp-2; + justify-content: flex-end; +} + +.spinner { + margin-right: auto; + width: 48px; +} diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.tsx b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.tsx new file mode 100644 index 000000000..95d928c3f --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailModal.tsx @@ -0,0 +1,133 @@ +import { FC, useEffect, useMemo } from 'react' +import { noop } from 'lodash' +import { useForm, UseFormReturn } from 'react-hook-form' +import { object, ObjectSchema, string } from 'yup' + +import { yupResolver } from '@hookform/resolvers/yup' +import { BaseModal, Button, InputText, LoadingSpinner } from '~/libs/ui' + +import styles from './ChangeEmailModal.module.scss' + +interface ChangeEmailForm { + email: string +} + +interface ChangeEmailModalProps { + currentEmail: string + error?: string + isOpen: boolean + isSubmitting: boolean + onClose: () => void + onSubmit: (email: string) => Promise +} + +/** + * Shows the member's current address and collects a different valid address. + */ +const ChangeEmailModal: FC = ( + props: ChangeEmailModalProps, +) => { + const schema: ObjectSchema = useMemo(() => object({ + email: string() + .trim() + .email('Enter a valid email address.') + .required('New email is required.') + .test( + 'different-email', + 'The new email must be different from your current email.', + value => value?.toLowerCase() !== props.currentEmail.toLowerCase(), + ), + }), [props.currentEmail]) + + const { + formState: { errors, isValid }, + handleSubmit, + register, + reset, + }: UseFormReturn = useForm({ + defaultValues: { email: '' }, + mode: 'all', + resolver: yupResolver(schema), + }) + + useEffect(() => { + if (props.isOpen) { + reset({ email: '' }) + } + }, [props.isOpen, reset]) + + /** + * Normalizes and forwards a valid proposed email. + * @param values validated modal form values. + * @returns a promise resolved after the validation email request completes. + */ + async function submit(values: ChangeEmailForm): Promise { + await props.onSubmit(values.email.trim() + .toLowerCase()) + } + + return ( + + +

+ We'll send a validation link to the new address. Your + primary email will stay unchanged until that link is used. +

+ + + + +
+ {props.isSubmitting && ( + + )} +
+ +
+ ) +} + +export default ChangeEmailModal diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.module.scss b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.module.scss new file mode 100644 index 000000000..5c232a5e1 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.module.scss @@ -0,0 +1,56 @@ +@import '@libs/ui/styles/includes'; + +.container { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + padding: $sp-5 $sp-5 0; + + p { + color: $black-80; + margin: $sp-2 0 $sp-4; + max-width: 480px; + text-align: center; + } + + .error { + color: $red-100; + margin-top: 0; + } +} + +.otpInput { + border: $border solid $black-40; + border-radius: $sp-1; + font-size: 20px; + height: 48px; + margin: $sp-1; + text-align: center; + width: 48px !important; + + &:focus { + border-color: $turq-160; + outline: none; + } + + &:disabled { + background: $black-10; + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } + + @include ltesm { + height: 40px; + margin: 2px; + width: 40px !important; + } +} diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.tsx b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.tsx new file mode 100644 index 000000000..5a453d1c8 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/ChangeEmailOtpModal.tsx @@ -0,0 +1,137 @@ +import { FC, useEffect, useState } from 'react' +import OTPInput, { InputProps } from 'react-otp-input' + +import { BaseModal, Button, LoadingCircles } from '~/libs/ui' + +import styles from './ChangeEmailOtpModal.module.scss' + +const RESEND_DELAY_MS: number = 60_000 + +interface ChangeEmailOtpModalProps { + email: string + error?: string + isOpen: boolean + isResending: boolean + isVerifying: boolean + onClose: () => void + onResend: () => Promise + onVerify: (otp: string) => Promise +} + +/** + * Collects the ownership code sent to the member's current primary email. + */ +const ChangeEmailOtpModal: FC = ( + props: ChangeEmailOtpModalProps, +) => { + const [otp, setOtp] = useState('') + const [canResend, setCanResend] = useState(false) + const [resendSequence, setResendSequence] = useState(0) + + useEffect(() => { + if (!props.isOpen) { + setOtp('') + setCanResend(false) + return undefined + } + + const timer: NodeJS.Timeout = setTimeout(() => { + setCanResend(true) + }, RESEND_DELAY_MS) + + return () => clearTimeout(timer) + }, [props.isOpen, resendSequence]) + + useEffect(() => { + if (props.error) { + setOtp('') + } + }, [props.error]) + + /** + * Updates the six OTP inputs and verifies a complete code. + * @param value digits currently entered by the member. + * @returns a promise resolved after any complete-code verification request. + */ + async function handleOtpChange(value: string): Promise { + const digits: string = value.replace(/\D/g, '') + .slice(0, 6) + setOtp(digits) + if (digits.length === 6 && !props.isVerifying) { + await props.onVerify(digits) + } + } + + /** + * Requests another code and restarts the resend cooldown. + * @returns a promise resolved after the resend request completes. + */ + async function handleResend(): Promise { + setCanResend(false) + setOtp('') + await props.onResend() + setResendSequence(value => value + 1) + } + + /** + * Renders one accessible OTP digit input. + * @param inputProps properties supplied by react-otp-input. + * @returns a styled input element for one code digit. + */ + function renderOtpInput(inputProps: InputProps): JSX.Element { + return ( + + ) + } + + return ( + +
+

+ For added security, we sent a 6-digit code to + {' '} + {props.email} + . Enter it below before changing your email address. +

+ + {props.error && ( +

{props.error}

+ )} + + + + {props.isVerifying && } + +

Can't find the code? Check your spam folder.

+
+
+ ) +} + +export default ChangeEmailOtpModal diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/index.ts b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/index.ts new file mode 100644 index 000000000..c58ef8d45 --- /dev/null +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/change-email/index.ts @@ -0,0 +1,2 @@ +export { default as ChangeEmailModal } from './ChangeEmailModal' +export { default as ChangeEmailOtpModal } from './ChangeEmailOtpModal' diff --git a/src/apps/accounts/src/settings/tabs/account/user-and-pass/user-and-pass.form.config.tsx b/src/apps/accounts/src/settings/tabs/account/user-and-pass/user-and-pass.form.config.tsx index 7b2ae8fe1..8265eab9a 100644 --- a/src/apps/accounts/src/settings/tabs/account/user-and-pass/user-and-pass.form.config.tsx +++ b/src/apps/accounts/src/settings/tabs/account/user-and-pass/user-and-pass.form.config.tsx @@ -1,96 +1,127 @@ +import { MouseEvent } from 'react' import { noop } from 'lodash' -import { FormDefinition, validatorRequired } from '~/libs/ui' +import { Button, FormDefinition, validatorRequired } from '~/libs/ui' import PasswordTips from './password-tips' +import styles from './UserAndPassword.module.scss' -export const UserAndPassFromConfig: FormDefinition = { - buttons: { - primaryGroup: [], - secondaryGroup: [ - { - buttonStyle: 'secondary', - label: 'Change Password', - onClick: noop, - type: 'submit', - }, - ], - }, - groups: [ - { - inputs: [ +/** + * Builds the username/password form and attaches the email-change action. + * + * @param onChangeEmail callback that starts current-email ownership verification. + * @param isChangeEmailLoading whether the ownership-code request is in progress. + * @returns the account form definition used by the shared Form component. + */ +export function createUserAndPassFormConfig( + onChangeEmail: () => void, + isChangeEmailLoading: boolean, +): FormDefinition { + return { + buttons: { + primaryGroup: [], + secondaryGroup: [ { - disabled: true, - label: 'Username', - name: 'handle', - type: 'text', + buttonStyle: 'secondary', + label: 'Change Password', + onClick: noop, + type: 'submit', }, - { - disabled: true, - label: 'Primary Email', - name: 'email', - type: 'text', - }, - { - hideInlineErrors: true, - label: 'Current Password', - name: 'currentPassword', - placeholder: 'Type your current password', - type: 'password', - validators: [ - { - validator: validatorRequired, - }, - ], - }, - { - hideInlineErrors: true, - label: 'New Password', - name: 'newPassword', - placeholder: 'Type your new password', - tooltip: { - className: 'passTooltip', - content: , - place: 'bottom', + ], + }, + groups: [ + { + inputs: [ + { + disabled: true, + label: 'Username', + name: 'handle', + type: 'text', + }, + { + actionElement: ( +