From 5b0c122f5dd565386a63d4f3b20adcaa04e1e54c Mon Sep 17 00:00:00 2001
From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com>
Date: Fri, 8 Apr 2022 14:00:46 +0300
Subject: [PATCH] Merge pull request #7290 from
overleaf/ii-7154-list-user-emails
List of user emails
GitOrigin-RevId: 28a8e405812932ba7ebd8043a4dc9d3c573a68b2
---
.../settings/components/emails-section.tsx | 49 +++++
.../settings/components/emails/cell.tsx | 9 +
.../settings/components/emails/email.tsx | 41 ++++
.../settings/components/emails/header.tsx | 33 ++++
.../resend-confirmation-email-button.tsx | 62 ++++++
.../settings/components/emails/row.tsx | 28 +++
.../settings/context/user-email-context.tsx | 105 +++++++++++
.../shared/components/{icon.js => icon.tsx} | 21 +--
.../web/frontend/js/shared/hooks/use-async.ts | 58 ++++++
.../js/shared/hooks/use-is-mounted.js | 13 --
.../js/shared/hooks/use-is-mounted.ts | 14 ++
.../js/shared/hooks/use-safe-dispatch.ts | 17 ++
services/web/frontend/js/utils/normalize.ts | 22 +++
.../web/frontend/stories/settings.stories.js | 75 ++++++++
.../frontend/stylesheets/_style_includes.less | 1 +
.../stylesheets/app/account-settings.less | 3 +
.../stylesheets/components/divider.less | 3 +
.../components/emails/emails-section.test.tsx | 159 ++++++++++++++++
.../frontend/shared/hooks/use-async.test.ts | 176 ++++++++++++++++++
services/web/types/affiliation.ts | 6 +
services/web/types/institution.ts | 1 +
services/web/types/user-email.ts | 9 +
services/web/types/utils.ts | 1 +
23 files changed, 882 insertions(+), 24 deletions(-)
create mode 100644 services/web/frontend/js/features/settings/components/emails-section.tsx
create mode 100644 services/web/frontend/js/features/settings/components/emails/cell.tsx
create mode 100644 services/web/frontend/js/features/settings/components/emails/email.tsx
create mode 100644 services/web/frontend/js/features/settings/components/emails/header.tsx
create mode 100644 services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx
create mode 100644 services/web/frontend/js/features/settings/components/emails/row.tsx
create mode 100644 services/web/frontend/js/features/settings/context/user-email-context.tsx
rename services/web/frontend/js/shared/components/{icon.js => icon.tsx} (67%)
create mode 100644 services/web/frontend/js/shared/hooks/use-async.ts
delete mode 100644 services/web/frontend/js/shared/hooks/use-is-mounted.js
create mode 100644 services/web/frontend/js/shared/hooks/use-is-mounted.ts
create mode 100644 services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
create mode 100644 services/web/frontend/js/utils/normalize.ts
create mode 100644 services/web/frontend/stories/settings.stories.js
create mode 100644 services/web/frontend/stylesheets/components/divider.less
create mode 100644 services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
create mode 100644 services/web/test/frontend/shared/hooks/use-async.test.ts
create mode 100644 services/web/types/affiliation.ts
create mode 100644 services/web/types/institution.ts
create mode 100644 services/web/types/user-email.ts
create mode 100644 services/web/types/utils.ts
diff --git a/services/web/frontend/js/features/settings/components/emails-section.tsx b/services/web/frontend/js/features/settings/components/emails-section.tsx
new file mode 100644
index 0000000000..5dde2bf56f
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails-section.tsx
@@ -0,0 +1,49 @@
+import { Fragment } from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import {
+ UserEmailsProvider,
+ useUserEmailsContext,
+} from '../context/user-email-context'
+import EmailsHeader from './emails/header'
+import EmailsRow from './emails/row'
+
+function EmailsSectionContent() {
+ const { t } = useTranslation()
+ const {
+ state: { data: userEmailsData },
+ } = useUserEmailsContext()
+ const userEmails = Object.values(userEmailsData.byId)
+
+ return (
+ <>
+
{t('emails_and_affiliations_title')}
+ {t('emails_and_affiliations_explanation')}
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/anchor-has-content */}
+
+
+
+
+ {userEmails?.map((userEmail, i) => (
+
+
+ {i + 1 !== userEmails.length && (
+
+ )}
+
+ ))}
+ >
+ )
+}
+
+function EmailsSection() {
+ return (
+
+
+
+ )
+}
+
+export default EmailsSection
diff --git a/services/web/frontend/js/features/settings/components/emails/cell.tsx b/services/web/frontend/js/features/settings/components/emails/cell.tsx
new file mode 100644
index 0000000000..de0f71ac58
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails/cell.tsx
@@ -0,0 +1,9 @@
+type CellProps = {
+ children: React.ReactNode
+}
+
+function Cell({ children }: CellProps) {
+ return {children}
+}
+
+export default Cell
diff --git a/services/web/frontend/js/features/settings/components/emails/email.tsx b/services/web/frontend/js/features/settings/components/emails/email.tsx
new file mode 100644
index 0000000000..e895e484f7
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails/email.tsx
@@ -0,0 +1,41 @@
+import { useTranslation } from 'react-i18next'
+import { UserEmailData } from '../../../../../../types/user-email'
+import ResendConfirmationEmailButton from './resend-confirmation-email-button'
+
+type EmailProps = {
+ userEmailData: UserEmailData
+}
+
+function Email({ userEmailData }: EmailProps) {
+ const { t } = useTranslation()
+
+ return (
+ <>
+ {userEmailData.email}
+ {userEmailData.default ? ' (primary)' : ''}
+ {!userEmailData.confirmedAt && (
+
+
+ {t('unconfirmed')}.
+ {!userEmailData.ssoAvailable && (
+ {t('please_check_your_inbox')}.
+ )}
+
+
+ {!userEmailData.ssoAvailable && (
+
+ )}
+
+ )}
+ {userEmailData.confirmedAt &&
+ userEmailData.affiliation?.institution.confirmed &&
+ userEmailData.affiliation?.licence !== 'free' && (
+
+ {t('professional')}
+
+ )}
+ >
+ )
+}
+
+export default Email
diff --git a/services/web/frontend/js/features/settings/components/emails/header.tsx b/services/web/frontend/js/features/settings/components/emails/header.tsx
new file mode 100644
index 0000000000..a3a3a56431
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails/header.tsx
@@ -0,0 +1,33 @@
+import { useTranslation } from 'react-i18next'
+import { Row, Col } from 'react-bootstrap'
+import EmailCell from './cell'
+
+function Header() {
+ const { t } = useTranslation()
+
+ return (
+ <>
+
+
+
+ {t('email')}
+
+
+
+
+ {t('institution_and_role')}
+
+
+
+
+ todo
+
+
+
+
+
+ >
+ )
+}
+
+export default Header
diff --git a/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx
new file mode 100644
index 0000000000..9cde7bb097
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../shared/components/icon'
+import useAsync from '../../../../shared/hooks/use-async'
+import { postJSON } from '../../../../infrastructure/fetch-json'
+import { UserEmailData } from '../../../../../../types/user-email'
+import { useUserEmailsContext } from '../../context/user-email-context'
+
+type ResendConfirmationEmailButtonProps = {
+ email: UserEmailData['email']
+}
+
+function ResendConfirmationEmailButton({
+ email,
+}: ResendConfirmationEmailButtonProps) {
+ const { t } = useTranslation()
+ const { isLoading, isError, runAsync } = useAsync()
+ const { setLoading } = useUserEmailsContext()
+
+ // Update global isLoading prop
+ useEffect(() => {
+ setLoading(isLoading)
+ }, [setLoading, isLoading])
+
+ const handleResendConfirmationEmail = () => {
+ runAsync(
+ postJSON('/user/emails/resend_confirmation', {
+ body: {
+ email,
+ },
+ })
+ )
+ }
+
+ if (isLoading) {
+ return (
+ <>
+ {t('sending')}...
+ >
+ )
+ }
+
+ return (
+ <>
+
+
+ {isError && (
+
+ {' '}
+ {t('error_performing_request')}
+
+ )}
+ >
+ )
+}
+
+export default ResendConfirmationEmailButton
diff --git a/services/web/frontend/js/features/settings/components/emails/row.tsx b/services/web/frontend/js/features/settings/components/emails/row.tsx
new file mode 100644
index 0000000000..791c9bc8f0
--- /dev/null
+++ b/services/web/frontend/js/features/settings/components/emails/row.tsx
@@ -0,0 +1,28 @@
+import { UserEmailData } from '../../../../../../types/user-email'
+import { Row, Col } from 'react-bootstrap'
+import Email from './email'
+import EmailCell from './cell'
+
+type EmailsRowProps = {
+ userEmailData: UserEmailData
+}
+
+function EmailsRow({ userEmailData }: EmailsRowProps) {
+ return (
+
+
+
+
+
+
+
+ todo
+
+
+ todo
+
+
+ )
+}
+
+export default EmailsRow
diff --git a/services/web/frontend/js/features/settings/context/user-email-context.tsx b/services/web/frontend/js/features/settings/context/user-email-context.tsx
new file mode 100644
index 0000000000..f2fdfac0de
--- /dev/null
+++ b/services/web/frontend/js/features/settings/context/user-email-context.tsx
@@ -0,0 +1,105 @@
+import { createContext, useContext, useReducer, useCallback } from 'react'
+import getMeta from '../../../utils/meta'
+import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
+import { UserEmailData } from '../../../../../types/user-email'
+import { normalize, NormalizedObject } from '../../../utils/normalize'
+
+// eslint-disable-next-line no-unused-vars
+enum Actions {
+ SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
+}
+
+type ActionSetLoading = {
+ type: Actions.SET_LOADING_STATE
+ payload: boolean
+}
+
+type State = {
+ isLoading: boolean
+ data: {
+ byId: NormalizedObject
+ }
+}
+
+type Action = ActionSetLoading
+
+const setLoadingAction = (state: State, action: ActionSetLoading) => ({
+ ...state,
+ isLoading: action.payload,
+})
+
+const initialState: State = {
+ isLoading: false,
+ data: {
+ byId: {},
+ },
+}
+
+const reducer = (state: State, action: Action) => {
+ switch (action.type) {
+ case Actions.SET_LOADING_STATE:
+ return setLoadingAction(state, action)
+ }
+}
+
+const initializer = (initialState: State) => {
+ const normalized = normalize(getMeta('ol-userEmails'), {
+ idAttribute: 'email',
+ })
+ const byId = normalized || {}
+
+ return {
+ ...initialState,
+ data: {
+ ...initialState.data,
+ byId,
+ },
+ }
+}
+
+function useUserEmails() {
+ const [state, dispatch] = useReducer(reducer, initialState, initializer)
+ const safeDispatch = useSafeDispatch(dispatch)
+
+ const setLoading = useCallback(
+ (flag: boolean) => {
+ safeDispatch({
+ type: Actions.SET_LOADING_STATE,
+ payload: flag,
+ })
+ },
+ [safeDispatch]
+ )
+
+ return {
+ state,
+ setLoading,
+ }
+}
+
+const UserEmailsContext = createContext<
+ ReturnType | undefined
+>(undefined)
+UserEmailsContext.displayName = 'UserEmailsContext'
+
+type UserEmailsProviderProps = {
+ children: React.ReactNode
+} & Record
+
+function UserEmailsProvider(props: UserEmailsProviderProps) {
+ const value = useUserEmails()
+
+ return
+}
+
+const useUserEmailsContext = () => {
+ const context = useContext(UserEmailsContext)
+
+ if (context === undefined) {
+ throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
+ }
+
+ return context
+}
+
+export { UserEmailsProvider, useUserEmailsContext }
diff --git a/services/web/frontend/js/shared/components/icon.js b/services/web/frontend/js/shared/components/icon.tsx
similarity index 67%
rename from services/web/frontend/js/shared/components/icon.js
rename to services/web/frontend/js/shared/components/icon.tsx
index 353e1fa3d2..fe4471fb96 100644
--- a/services/web/frontend/js/shared/components/icon.js
+++ b/services/web/frontend/js/shared/components/icon.tsx
@@ -1,6 +1,14 @@
-import PropTypes from 'prop-types'
import classNames from 'classnames'
+type IconProps = {
+ type: string
+ spin?: boolean
+ fw?: boolean
+ modifier?: string
+ className?: string
+ accessibilityLabel?: string
+}
+
function Icon({
type,
spin,
@@ -8,7 +16,7 @@ function Icon({
modifier,
className = '',
accessibilityLabel,
-}) {
+}: IconProps) {
const iconClassName = classNames(
'fa',
`fa-${type}`,
@@ -30,13 +38,4 @@ function Icon({
)
}
-Icon.propTypes = {
- type: PropTypes.string.isRequired,
- spin: PropTypes.bool,
- fw: PropTypes.bool,
- modifier: PropTypes.string,
- className: PropTypes.string,
- accessibilityLabel: PropTypes.string,
-}
-
export default Icon
diff --git a/services/web/frontend/js/shared/hooks/use-async.ts b/services/web/frontend/js/shared/hooks/use-async.ts
new file mode 100644
index 0000000000..707a4290f3
--- /dev/null
+++ b/services/web/frontend/js/shared/hooks/use-async.ts
@@ -0,0 +1,58 @@
+import * as React from 'react'
+import useSafeDispatch from './use-safe-dispatch'
+import { Nullable } from '../../../../types/utils'
+
+type State = {
+ status: 'idle' | 'pending' | 'resolved' | 'rejected'
+ data: Nullable
+ error: Nullable>
+}
+type Action = Partial
+
+const defaultInitialState: State = { status: 'idle', data: null, error: null }
+const initializer = (initialState: State) => ({ ...initialState })
+
+function useAsync(initialState?: Partial) {
+ const [{ status, data, error }, setState] = React.useReducer(
+ (state: State, action: Action) => ({ ...state, ...action }),
+ { ...defaultInitialState, ...initialState },
+ initializer
+ )
+
+ const safeSetState = useSafeDispatch(setState)
+
+ const setData = React.useCallback(
+ data => safeSetState({ data, status: 'resolved' }),
+ [safeSetState]
+ )
+
+ const setError = React.useCallback(
+ error => safeSetState({ error, status: 'rejected' }),
+ [safeSetState]
+ )
+
+ const runAsync = React.useCallback(
+ (promise: Promise>) => {
+ safeSetState({ status: 'pending' })
+
+ return promise.then(setData, setError)
+ },
+ [safeSetState, setData, setError]
+ )
+
+ return {
+ isIdle: status === 'idle',
+ isLoading: status === 'pending',
+ isError: status === 'rejected',
+ isSuccess: status === 'resolved',
+ setData,
+ setError,
+ error,
+ status,
+ data,
+ runAsync,
+ }
+}
+
+export default useAsync
+export type UseAsyncReturnType = ReturnType
diff --git a/services/web/frontend/js/shared/hooks/use-is-mounted.js b/services/web/frontend/js/shared/hooks/use-is-mounted.js
deleted file mode 100644
index 3fe667c3db..0000000000
--- a/services/web/frontend/js/shared/hooks/use-is-mounted.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useEffect, useRef } from 'react'
-
-export default function useIsMounted() {
- const isMounted = useRef(true)
-
- useEffect(() => {
- return () => {
- isMounted.current = false
- }
- }, [isMounted])
-
- return isMounted
-}
diff --git a/services/web/frontend/js/shared/hooks/use-is-mounted.ts b/services/web/frontend/js/shared/hooks/use-is-mounted.ts
new file mode 100644
index 0000000000..b207ee79de
--- /dev/null
+++ b/services/web/frontend/js/shared/hooks/use-is-mounted.ts
@@ -0,0 +1,14 @@
+import { useLayoutEffect, useRef } from 'react'
+
+export default function useIsMounted() {
+ const mounted = useRef(false)
+
+ useLayoutEffect(() => {
+ mounted.current = true
+ return () => {
+ mounted.current = false
+ }
+ }, [mounted])
+
+ return mounted
+}
diff --git a/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts b/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
new file mode 100644
index 0000000000..726da8ccd1
--- /dev/null
+++ b/services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
@@ -0,0 +1,17 @@
+import * as React from 'react'
+import useIsMounted from './use-is-mounted'
+
+function useSafeDispatch(dispatch: React.Dispatch) {
+ const mounted = useIsMounted()
+
+ return React.useCallback<(args: T) => void>(
+ action => {
+ if (mounted.current) {
+ dispatch(action)
+ }
+ },
+ [dispatch, mounted]
+ ) as React.Dispatch
+}
+
+export default useSafeDispatch
diff --git a/services/web/frontend/js/utils/normalize.ts b/services/web/frontend/js/utils/normalize.ts
new file mode 100644
index 0000000000..05ee6835c3
--- /dev/null
+++ b/services/web/frontend/js/utils/normalize.ts
@@ -0,0 +1,22 @@
+import mapKeys from 'lodash/mapKeys'
+
+export interface NormalizedObject {
+ [p: string]: T
+}
+
+type Data = T[]
+type Config = Partial<{
+ idAttribute: string
+}>
+
+export function normalize(
+ data: Data,
+ config: Config = {}
+): NormalizedObject | undefined {
+ const { idAttribute = 'id' } = config
+ const mapped = mapKeys(data, idAttribute)
+
+ return Object.prototype.hasOwnProperty.call(mapped, 'undefined')
+ ? undefined
+ : mapped
+}
diff --git a/services/web/frontend/stories/settings.stories.js b/services/web/frontend/stories/settings.stories.js
new file mode 100644
index 0000000000..23e6117a34
--- /dev/null
+++ b/services/web/frontend/stories/settings.stories.js
@@ -0,0 +1,75 @@
+import EmailsSection from '../js/features/settings/components/emails-section'
+import useFetchMock from './hooks/use-fetch-mock'
+
+const MOCK_DELAY = 1000
+window.metaAttributesCache = window.metaAttributesCache || new Map()
+
+function defaultSetupMocks(fetchMock) {
+ fetchMock.post(
+ /\/user\/emails\/resend_confirmation/,
+ (path, req) => {
+ return 200
+ },
+ {
+ delay: MOCK_DELAY,
+ }
+ )
+}
+
+const fakeUsersData = [
+ {
+ affiliation: {
+ institution: {
+ confirmed: true,
+ },
+ licence: 'pro_plus',
+ },
+ confirmedAt: '2022-03-09T10:59:44.139Z',
+ email: 'foo@overleaf.com',
+ default: true,
+ },
+ {
+ confirmedAt: '2022-03-10T10:59:44.139Z',
+ email: 'bar@overleaf.com',
+ default: false,
+ },
+ {
+ email: 'baz@overleaf.com',
+ default: false,
+ },
+ {
+ email: 'qux@overleaf.com',
+ default: false,
+ },
+]
+
+export const EmailsList = args => {
+ useFetchMock(defaultSetupMocks)
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+
+ return
+}
+
+export const NetworkErrors = args => {
+ useFetchMock(defaultSetupMocks)
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+
+ useFetchMock(fetchMock => {
+ fetchMock.post(
+ /\/user\/emails\/resend_confirmation/,
+ () => {
+ return 503
+ },
+ {
+ delay: MOCK_DELAY,
+ }
+ )
+ })
+
+ return
+}
+
+export default {
+ title: 'Emails and Affiliations',
+ component: EmailsSection,
+}
diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less
index 3e1241accb..4a25b163e9 100644
--- a/services/web/frontend/stylesheets/_style_includes.less
+++ b/services/web/frontend/stylesheets/_style_includes.less
@@ -58,6 +58,7 @@
@import 'components/infinite-scroll.less';
@import 'components/expand-collapse.less';
@import 'components/beta-badges.less';
+@import 'components/divider.less';
// Components w/ JavaScript
@import 'components/modals.less';
diff --git a/services/web/frontend/stylesheets/app/account-settings.less b/services/web/frontend/stylesheets/app/account-settings.less
index 436f0e8ee3..b8b53d4567 100644
--- a/services/web/frontend/stylesheets/app/account-settings.less
+++ b/services/web/frontend/stylesheets/app/account-settings.less
@@ -23,6 +23,9 @@
.affiliations-table {
table-layout: fixed;
}
+.affiliations-table-cell {
+ padding: 0.5rem;
+}
.affiliations-table-email {
width: 40%;
}
diff --git a/services/web/frontend/stylesheets/components/divider.less b/services/web/frontend/stylesheets/components/divider.less
new file mode 100644
index 0000000000..d005f06e4a
--- /dev/null
+++ b/services/web/frontend/stylesheets/components/divider.less
@@ -0,0 +1,3 @@
+.horizontal-divider {
+ border-top: 1px solid @table-border-color;
+}
diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
new file mode 100644
index 0000000000..c62455ddb5
--- /dev/null
+++ b/services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
@@ -0,0 +1,159 @@
+import {
+ render,
+ screen,
+ within,
+ fireEvent,
+ waitForElementToBeRemoved,
+} from '@testing-library/react'
+import EmailsSection from '../../../../../../frontend/js/features/settings/components/emails-section'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+
+const confirmedUserData = {
+ confirmedAt: '2022-03-10T10:59:44.139Z',
+ email: 'bar@overleaf.com',
+ default: false,
+}
+
+const unconfirmedUserData = {
+ email: 'baz@overleaf.com',
+ default: false,
+}
+
+const professionalUserData = {
+ affiliation: {
+ institution: {
+ confirmed: true,
+ },
+ licence: 'pro_plus',
+ },
+ confirmedAt: '2022-03-09T10:59:44.139Z',
+ email: 'foo@overleaf.com',
+ default: true,
+}
+
+const fakeUsersData = [
+ { ...confirmedUserData },
+ { ...unconfirmedUserData },
+ { ...professionalUserData },
+]
+
+describe('', function () {
+ afterEach(function () {
+ window.metaAttributesCache = new Map()
+ fetchMock.reset()
+ })
+
+ it('renders translated heading', function () {
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+ render()
+
+ screen.getByRole('heading', { name: /emails and affiliations/i })
+ })
+
+ it('renders translated description', function () {
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+ render()
+
+ screen.getByText(/add additional email addresses/i)
+ screen.getByText(/to change your primary email/i)
+ })
+
+ it('renders user emails', function () {
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+ render()
+
+ fakeUsersData.forEach(userData => {
+ screen.getByText(new RegExp(userData.email, 'i'))
+ })
+ })
+
+ it('renders primary status', function () {
+ window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+ render()
+
+ const primary = fakeUsersData.find(userData => userData.default)
+
+ screen.getByText(`${primary.email} (primary)`)
+ })
+
+ it('shows confirmation status for unconfirmed users', function () {
+ window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+ render()
+
+ screen.getByText(/please check your inbox/i)
+ })
+
+ it('hides confirmation status for confirmed users', function () {
+ window.metaAttributesCache.set('ol-userEmails', [confirmedUserData])
+ render()
+
+ expect(screen.queryByText(/please check your inbox/i)).to.be.null
+ })
+
+ it('renders resend link', function () {
+ window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+ render()
+
+ screen.getByRole('button', { name: /resend confirmation email/i })
+ })
+
+ it('renders professional label', function () {
+ window.metaAttributesCache.set('ol-userEmails', [professionalUserData])
+ render()
+
+ const node = screen.getByText(professionalUserData.email, {
+ exact: false,
+ })
+ expect(within(node).getByText(/professional/i)).to.exist
+ })
+
+ it('shows loader when resending email', async function () {
+ fetchMock.post('/user/emails/resend_confirmation', 200)
+ window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+ render()
+
+ const button = screen.getByRole('button', {
+ name: /resend confirmation email/i,
+ })
+ fireEvent.click(button)
+
+ expect(
+ screen.queryByRole('button', {
+ name: /resend confirmation email/i,
+ })
+ ).to.be.null
+
+ await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
+
+ expect(
+ screen.queryByText(/an error has occurred while performing your request/i)
+ ).to.be.null
+
+ await screen.findByRole('button', {
+ name: /resend confirmation email/i,
+ })
+ })
+
+ it('shows error when resending email fails', async function () {
+ fetchMock.post('/user/emails/resend_confirmation', 503)
+ window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+ render()
+
+ const button = screen.getByRole('button', {
+ name: /resend confirmation email/i,
+ })
+ fireEvent.click(button)
+
+ expect(
+ screen.queryByRole('button', {
+ name: /resend confirmation email/i,
+ })
+ ).to.be.null
+
+ await waitForElementToBeRemoved(() => screen.getByText(/sending/i))
+
+ screen.getByText(/an error has occurred while performing your request/i)
+ screen.getByRole('button', { name: /resend confirmation email/i })
+ })
+})
diff --git a/services/web/test/frontend/shared/hooks/use-async.test.ts b/services/web/test/frontend/shared/hooks/use-async.test.ts
new file mode 100644
index 0000000000..c812df26b1
--- /dev/null
+++ b/services/web/test/frontend/shared/hooks/use-async.test.ts
@@ -0,0 +1,176 @@
+import { renderHook, act } from '@testing-library/react-hooks'
+import { expect } from 'chai'
+import sinon from 'sinon'
+import useAsync from '../../../../frontend/js/shared/hooks/use-async'
+
+function deferred() {
+ let res!: (
+ value: Record | PromiseLike>
+ ) => void
+ let rej!: (reason?: any) => void
+
+ const promise = new Promise>((resolve, reject) => {
+ res = resolve
+ rej = reject
+ })
+
+ return { promise, resolve: res, reject: rej }
+}
+
+const defaultState = {
+ status: 'idle',
+ data: null,
+ error: null,
+
+ isIdle: true,
+ isLoading: false,
+ isError: false,
+ isSuccess: false,
+}
+
+const pendingState = {
+ ...defaultState,
+ status: 'pending',
+ isIdle: false,
+ isLoading: true,
+}
+
+const resolvedState = {
+ ...defaultState,
+ status: 'resolved',
+ isIdle: false,
+ isSuccess: true,
+}
+
+const rejectedState = {
+ ...defaultState,
+ status: 'rejected',
+ isIdle: false,
+ isError: true,
+}
+
+describe('useAsync', function () {
+ beforeEach(function () {
+ global.console.error = sinon.stub()
+ })
+
+ afterEach(function () {
+ // eslint-disable-next-line
+ // @ts-ignore
+ global.console.error.reset()
+ })
+
+ it('exposes the methods', function () {
+ const { result } = renderHook(() => useAsync())
+
+ expect(result.current.setData).to.be.a('function')
+ expect(result.current.setError).to.be.a('function')
+ expect(result.current.runAsync).to.be.a('function')
+ })
+
+ it('calling `runAsync` with a promise which resolves', async function () {
+ const { promise, resolve } = deferred()
+ const { result } = renderHook(() => useAsync())
+
+ expect(result.current).to.include(defaultState)
+
+ let p: Promise
+ act(() => {
+ p = result.current.runAsync(promise)
+ })
+
+ expect(result.current).to.include(pendingState)
+
+ const resolvedValue = {}
+ await act(async () => {
+ resolve(resolvedValue)
+ await p
+ })
+
+ expect(result.current).to.include({
+ ...resolvedState,
+ data: resolvedValue,
+ })
+ })
+
+ it('calling `runAsync` with a promise which rejects', async function () {
+ const { promise, reject } = deferred()
+ const { result } = renderHook(() => useAsync())
+
+ expect(result.current).to.include(defaultState)
+
+ let p: Promise
+ act(() => {
+ p = result.current.runAsync(promise)
+ })
+
+ expect(result.current).to.include(pendingState)
+
+ const rejectedValue = Symbol('rejected value')
+ await act(async () => {
+ reject(rejectedValue)
+ await p
+ })
+
+ expect(result.current).to.include({
+ ...rejectedState,
+ error: rejectedValue,
+ })
+ })
+
+ it('can specify an initial state', function () {
+ const mockData = Symbol('resolved value')
+ const customInitialState = { status: 'resolved' as const, data: mockData }
+ const { result } = renderHook(() => useAsync(customInitialState))
+
+ expect(result.current).to.include({
+ ...resolvedState,
+ ...customInitialState,
+ })
+ })
+
+ it('can set the data', function () {
+ const mockData = Symbol('resolved value')
+ const { result } = renderHook(() => useAsync())
+
+ act(() => {
+ result.current.setData(mockData)
+ })
+
+ expect(result.current).to.include({
+ ...resolvedState,
+ data: mockData,
+ })
+ })
+
+ it('can set the error', function () {
+ const mockError = Symbol('rejected value')
+ const { result } = renderHook(() => useAsync())
+
+ act(() => {
+ result.current.setError(mockError)
+ })
+
+ expect(result.current).to.include({
+ ...rejectedState,
+ error: mockError,
+ })
+ })
+
+ it('no state updates happen if the component is unmounted while pending', async function () {
+ const { promise, resolve } = deferred()
+ const { result, unmount } = renderHook(() => useAsync())
+
+ let p: Promise
+ act(() => {
+ p = result.current.runAsync(promise)
+ })
+ unmount()
+ await act(async () => {
+ resolve({})
+ await p
+ })
+
+ expect(global.console.error).not.to.have.been.called
+ })
+})
diff --git a/services/web/types/affiliation.ts b/services/web/types/affiliation.ts
new file mode 100644
index 0000000000..5b76d77494
--- /dev/null
+++ b/services/web/types/affiliation.ts
@@ -0,0 +1,6 @@
+import { Institution } from './institution'
+
+export type Affiliation = {
+ institution: Institution
+ licence?: 'free' | 'pro_plus'
+}
diff --git a/services/web/types/institution.ts b/services/web/types/institution.ts
new file mode 100644
index 0000000000..f857032b17
--- /dev/null
+++ b/services/web/types/institution.ts
@@ -0,0 +1 @@
+export type Institution = Record
diff --git a/services/web/types/user-email.ts b/services/web/types/user-email.ts
new file mode 100644
index 0000000000..b6ae8b8d42
--- /dev/null
+++ b/services/web/types/user-email.ts
@@ -0,0 +1,9 @@
+import { Affiliation } from './affiliation'
+
+export type UserEmailData = {
+ affiliation?: Affiliation
+ confirmedAt: string
+ email: string
+ default: boolean
+ ssoAvailable?: boolean
+}
diff --git a/services/web/types/utils.ts b/services/web/types/utils.ts
new file mode 100644
index 0000000000..36fa025789
--- /dev/null
+++ b/services/web/types/utils.ts
@@ -0,0 +1 @@
+export type Nullable = T | null