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
27 changes: 27 additions & 0 deletions src/apps/accounts/src/accounts.routes.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 => <div />,
}), { 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()
})
})
12 changes: 11 additions & 1 deletion src/apps/accounts/src/accounts.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand All @@ -13,14 +17,20 @@ export const absoluteRootRoute: string = `${window.location.origin}${rootRoute}`

export const accountsRoutes: ReadonlyArray<PlatformRoute> = [
{
authRequired: true,
children: [
{
authRequired: true,
children: [],
element: <AccountSettingsPage />,
id: 'Account Settings',
route: '',
},
{
children: [],
element: <ChangeEmailVerificationPage />,
id: 'Change Email Verification',
route: 'changeEmail',
},
],
domain: AppSubdomain.accounts,
element: <AccountsApp />,
Expand Down
1 change: 1 addition & 0 deletions src/apps/accounts/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './accounts-swr'
export * from './components'
export * from './services'
export * from './assets'
58 changes: 58 additions & 0 deletions src/apps/accounts/src/lib/services/email-change.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' })
mockedGet.mockResolvedValueOnce({ email: '[email protected]' })

await requestEmailChangeOtpAsync(123)
await verifyEmailChangeOtpAsync(123, '012345')
await initiateEmailChangeAsync(123, '[email protected]', '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: '[email protected]', verificationToken: 'proof' } },
],
])
expect(mockedGet)
.toHaveBeenCalledWith(
'https://api.example.test/v6/users/email-change/verify?token=signed%2Ftoken',
)
})
})
117 changes: 117 additions & 0 deletions src/apps/accounts/src/lib/services/email-change.service.ts
Original file line number Diff line number Diff line change
@@ -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<EmailChangeOtpResponse> {
return xhrPostAsync<Record<string, never>, 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<EmailChangeOtpVerificationResponse> {
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<EmailChangeResponse> {
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<EmailChangeResponse> {
return xhrGetAsync<EmailChangeResponse>(
`${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)
}
1 change: 1 addition & 0 deletions src/apps/accounts/src/lib/services/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './email-change.service'
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<VerificationStatus>('loading')
const [message, setMessage] = useState<string>('Validating your new email address…')
const requestedToken = useRef<string>()

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 (
<ContentLayout outerClass={styles.layout}>
<div className={styles.card}>
<PageTitle>
{status === 'success' ? 'Email changed' : 'Validate email change'}
</PageTitle>
{status === 'loading' && <LoadingSpinner />}
<p className={status === 'error' ? styles.error : undefined}>
{message}
</p>
{status !== 'loading' && (
<LinkButton
label='Return to Account Settings'
primary
reloadDocument
size='lg'
to='..'
/>
)}
</div>
</ContentLayout>
)
}

export default ChangeEmailVerificationPage
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ChangeEmailVerificationPage } from './ChangeEmailVerificationPage'
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,11 @@
max-width: 380px;
}
}
}
}

.changeEmailLink {
font-size: 12px !important;
line-height: 14px !important;
min-height: 0 !important;
padding: 0 !important;
}
Loading
Loading