From 2545eaa3e11589e2387f376462b83c28d23dad1e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 1 Sep 2026 09:35:44 +1000 Subject: [PATCH] feat(accounts): add email change confirmation route --- .../accounts/src/accounts.routes.spec.tsx | 7 +- src/apps/accounts/src/accounts.routes.tsx | 6 ++ .../lib/services/email-change.service.spec.ts | 2 +- .../src/lib/services/email-change.service.ts | 6 +- .../ChangeEmailVerificationPage.spec.tsx | 88 +++++++++++++++++++ .../ChangeEmailVerificationPage.tsx | 17 ++-- 6 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.spec.tsx diff --git a/src/apps/accounts/src/accounts.routes.spec.tsx b/src/apps/accounts/src/accounts.routes.spec.tsx index 4f0c89412..2e5b2bebf 100644 --- a/src/apps/accounts/src/accounts.routes.spec.tsx +++ b/src/apps/accounts/src/accounts.routes.spec.tsx @@ -15,7 +15,10 @@ 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') + const validationRoute = root.children + ?.find(route => route.route === 'email-change/verify') + const legacyValidationRoute = root.children + ?.find(route => route.route === 'changeEmail') expect(root.authRequired) .toBeUndefined() @@ -23,5 +26,7 @@ describe('Account Settings routes', () => { .toBe(true) expect(validationRoute?.authRequired) .toBeUndefined() + expect(legacyValidationRoute?.authRequired) + .toBeUndefined() }) }) diff --git a/src/apps/accounts/src/accounts.routes.tsx b/src/apps/accounts/src/accounts.routes.tsx index b3e3f16c6..4ad2060dd 100644 --- a/src/apps/accounts/src/accounts.routes.tsx +++ b/src/apps/accounts/src/accounts.routes.tsx @@ -29,6 +29,12 @@ export const accountsRoutes: ReadonlyArray = [ children: [], element: , id: 'Change Email Verification', + route: 'email-change/verify', + }, + { + children: [], + element: , + id: 'Legacy Change Email Verification', route: 'changeEmail', }, ], 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 index 5b036483c..9a9d736f6 100644 --- a/src/apps/accounts/src/lib/services/email-change.service.spec.ts +++ b/src/apps/accounts/src/lib/services/email-change.service.spec.ts @@ -52,7 +52,7 @@ describe('email change API service', () => { ]) expect(mockedGet) .toHaveBeenCalledWith( - 'https://api.example.test/v6/users/email-change/verify?token=signed%2Ftoken', + 'https://api.example.test/v6/users/email-change/verify?code=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 index 06ffb9884..1ec6b26ac 100644 --- a/src/apps/accounts/src/lib/services/email-change.service.ts +++ b/src/apps/accounts/src/lib/services/email-change.service.ts @@ -80,15 +80,15 @@ export async function initiateEmailChangeAsync( /** * Completes the deferred email update from the validation link. * - * @param validationToken one-time token delivered to the proposed new email. + * @param validationCode one-time code delivered in the proposed-email link. * @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, + validationCode: string, ): Promise { return xhrGetAsync( - `${usersUrl}/email-change/verify?token=${encodeURIComponent(validationToken)}`, + `${usersUrl}/email-change/verify?code=${encodeURIComponent(validationCode)}`, ) } diff --git a/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.spec.tsx b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.spec.tsx new file mode 100644 index 000000000..c0c571d0b --- /dev/null +++ b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.spec.tsx @@ -0,0 +1,88 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, screen, waitFor } from '@testing-library/react' +import type { PropsWithChildren } from 'react' + +import { + completeEmailChangeAsync, + getEmailChangeErrorMessage, +} from '~/apps/accounts/src/lib/services' + +import ChangeEmailVerificationPage from './ChangeEmailVerificationPage' + +let mockSearchParams = new URLSearchParams() + +jest.mock('react-router-dom', () => ({ + useSearchParams: (): [URLSearchParams] => [mockSearchParams], +})) + +jest.mock('~/apps/accounts/src/lib/services', () => ({ + completeEmailChangeAsync: jest.fn(), + getEmailChangeErrorMessage: jest.fn(), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + ContentLayout: (props: PropsWithChildren): JSX.Element => ( +
{props.children}
+ ), + LinkButton: (props: { label: string, to: string }): JSX.Element => ( + {props.label} + ), + LoadingSpinner: (): JSX.Element => Loading, + PageTitle: (props: PropsWithChildren): JSX.Element => ( +

{props.children}

+ ), +}), { virtual: true }) + +const mockedCompleteEmailChange = completeEmailChangeAsync as jest.MockedFunction< + typeof completeEmailChangeAsync +> +const mockedGetErrorMessage = getEmailChangeErrorMessage as jest.MockedFunction< + typeof getEmailChangeErrorMessage +> + +describe('ChangeEmailVerificationPage', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSearchParams = new URLSearchParams() + mockedGetErrorMessage.mockReturnValue('Validation failed.') + }) + + it('forwards the validation code and reports the changed address', async () => { + mockSearchParams = new URLSearchParams('code=signed%2Fcode') + mockedCompleteEmailChange.mockResolvedValue({ + email: 'new@example.com', + }) + + render() + + await waitFor(() => expect(mockedCompleteEmailChange) + .toHaveBeenCalledWith('signed/code')) + expect(await screen.findByText('Email changed')) + .toBeInTheDocument() + expect(screen.getByText('new@example.com is now your primary email address.')) + .toBeInTheDocument() + }) + + it('continues to accept validation tokens from legacy links', async () => { + mockSearchParams = new URLSearchParams('token=legacy-token') + mockedCompleteEmailChange.mockResolvedValue({ + email: 'new@example.com', + }) + + render() + + await waitFor(() => expect(mockedCompleteEmailChange) + .toHaveBeenCalledWith('legacy-token')) + }) + + it('does not call identity API when the link has no code', () => { + render() + + expect(screen.getByText('This email validation link is incomplete.')) + .toBeInTheDocument() + expect(mockedCompleteEmailChange) + .not + .toHaveBeenCalled() + }) +}) diff --git a/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx index 00a7f7bef..4e4abd4a9 100644 --- a/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx +++ b/src/apps/accounts/src/settings/change-email-verification/ChangeEmailVerificationPage.tsx @@ -16,25 +16,26 @@ type VerificationStatus = 'error' | 'loading' | 'success' */ const ChangeEmailVerificationPage: FC = () => { const [searchParams] = useSearchParams() - const token: string | null = searchParams.get('token') + const validationCode: string | null = searchParams.get('code') + ?? searchParams.get('token') const [status, setStatus] = useState('loading') const [message, setMessage] = useState('Validating your new email address…') - const requestedToken = useRef() + const requestedCode = useRef() useEffect(() => { - if (!token) { - requestedToken.current = undefined + if (!validationCode) { + requestedCode.current = undefined setStatus('error') setMessage('This email validation link is incomplete.') return } - if (requestedToken.current === token) { + if (requestedCode.current === validationCode) { return } - requestedToken.current = token - completeEmailChangeAsync(token) + requestedCode.current = validationCode + completeEmailChangeAsync(validationCode) .then(response => { setStatus('success') setMessage(`${response.email} is now your primary email address.`) @@ -46,7 +47,7 @@ const ChangeEmailVerificationPage: FC = () => { 'This email validation link is invalid or has expired.', )) }) - }, [token]) + }, [validationCode]) return (