Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/apps/accounts/src/accounts.routes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ 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()
expect(settingsRoute?.authRequired)
.toBe(true)
expect(validationRoute?.authRequired)
.toBeUndefined()
expect(legacyValidationRoute?.authRequired)
.toBeUndefined()
})
})
6 changes: 6 additions & 0 deletions src/apps/accounts/src/accounts.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export const accountsRoutes: ReadonlyArray<PlatformRoute> = [
children: [],
element: <ChangeEmailVerificationPage />,
id: 'Change Email Verification',
route: 'email-change/verify',
},
{
children: [],
element: <ChangeEmailVerificationPage />,
id: 'Legacy Change Email Verification',
route: 'changeEmail',
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
})
})
6 changes: 3 additions & 3 deletions src/apps/accounts/src/lib/services/email-change.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailChangeResponse> {
return xhrGetAsync<EmailChangeResponse>(
`${usersUrl}/email-change/verify?token=${encodeURIComponent(validationToken)}`,
`${usersUrl}/email-change/verify?code=${encodeURIComponent(validationCode)}`,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 => (
<main>{props.children}</main>
),
LinkButton: (props: { label: string, to: string }): JSX.Element => (
<a href={props.to}>{props.label}</a>
),
LoadingSpinner: (): JSX.Element => <span>Loading</span>,
PageTitle: (props: PropsWithChildren): JSX.Element => (
<h1>{props.children}</h1>
),
}), { 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(<ChangeEmailVerificationPage />)

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(<ChangeEmailVerificationPage />)

await waitFor(() => expect(mockedCompleteEmailChange)
.toHaveBeenCalledWith('legacy-token'))
})

it('does not call identity API when the link has no code', () => {
render(<ChangeEmailVerificationPage />)

expect(screen.getByText('This email validation link is incomplete.'))
.toBeInTheDocument()
expect(mockedCompleteEmailChange)
.not
.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<VerificationStatus>('loading')
const [message, setMessage] = useState<string>('Validating your new email address…')
const requestedToken = useRef<string>()
const requestedCode = useRef<string>()

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.`)
Expand All @@ -46,7 +47,7 @@ const ChangeEmailVerificationPage: FC = () => {
'This email validation link is invalid or has expired.',
))
})
}, [token])
}, [validationCode])

return (
<ContentLayout outerClass={styles.layout}>
Expand Down
Loading