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.
+
+
+
+
)
}
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 (
+
+
+
+ )
+}
+
+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: (
+ ,
+ ): void {
+ event.preventDefault()
+ event.stopPropagation()
+ onChangeEmail()
+ }}
+ />
+ ),
+ label: 'Primary Email',
+ name: 'email',
+ readonly: true,
+ type: 'text',
+ },
+ {
+ hideInlineErrors: true,
+ label: 'Current Password',
+ name: 'currentPassword',
+ placeholder: 'Type your current password',
+ type: 'password',
+ validators: [
+ {
+ validator: validatorRequired,
+ },
+ ],
},
- type: 'password',
- validators: [
- {
- validator: validatorRequired,
+ {
+ hideInlineErrors: true,
+ label: 'New Password',
+ name: 'newPassword',
+ placeholder: 'Type your new password',
+ tooltip: {
+ className: 'passTooltip',
+ content: ,
+ place: 'bottom',
},
- ],
- },
- {
- hideInlineErrors: true,
- label: 'Re-Type New Password',
- name: 'reTypeNewPassword',
- placeholder: 'Re-Type New password',
- tooltip: {
- className: 'passTooltip',
- content: ,
- place: 'bottom',
+ type: 'password',
+ validators: [
+ {
+ validator: validatorRequired,
+ },
+ ],
},
- type: 'password',
- validators: [
- {
- validator: validatorRequired,
+ {
+ hideInlineErrors: true,
+ label: 'Re-Type New Password',
+ name: 'reTypeNewPassword',
+ placeholder: 'Re-Type New password',
+ tooltip: {
+ className: 'passTooltip',
+ content: ,
+ place: 'bottom',
},
- ],
- },
- ],
- },
- ],
+ type: 'password',
+ validators: [
+ {
+ validator: validatorRequired,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
}
diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx
index b7315367f..2cfd8440e 100644
--- a/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-text/InputText.tsx
@@ -13,6 +13,7 @@ import styles from './InputText.module.scss'
export type InputTextTypes = 'checkbox' | 'password' | 'text' | 'number' | 'textarea'
export interface InputTextProps {
+ readonly actionElement?: JSX.Element
readonly autocomplete?: FormInputAutocompleteOption
readonly checked?: boolean
readonly className?: string
diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss
index 4e8d8fab0..7abaf856e 100644
--- a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss
@@ -167,3 +167,10 @@ $error-line-height: 14px;
margin-right: $sp-1;
}
}
+
+.action {
+ position: absolute;
+ right: $form-pad-top;
+ top: $sp-2;
+ z-index: 1;
+}
diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx
index 5b570fcee..cc24d4924 100644
--- a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx
+++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx
@@ -14,6 +14,7 @@ import styles from './InputWrapper.module.scss'
export const optional: string = '(optional)'
interface InputWrapperProps {
+ readonly actionElement?: JSX.Element
readonly children: ReactNode
readonly className?: string
readonly classNameWrapper?: string
@@ -77,6 +78,11 @@ const InputWrapper = forwardRef((props: Input
onBlur={clearFocusStyle}
onFocus={setStyleForFocus}
>
+ {props.actionElement && (
+
+ {props.actionElement}
+
+ )}
checked?: boolean