Răsfoiți Sursa

Merge pull request #7290 from overleaf/ii-7154-list-user-emails

List of user emails

GitOrigin-RevId: 28a8e405812932ba7ebd8043a4dc9d3c573a68b2
ilkin-overleaf 4 ani în urmă
părinte
comite
5b0c122f5d
23 a modificat fișierele cu 882 adăugiri și 24 ștergeri
  1. 49 0
      services/web/frontend/js/features/settings/components/emails-section.tsx
  2. 9 0
      services/web/frontend/js/features/settings/components/emails/cell.tsx
  3. 41 0
      services/web/frontend/js/features/settings/components/emails/email.tsx
  4. 33 0
      services/web/frontend/js/features/settings/components/emails/header.tsx
  5. 62 0
      services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx
  6. 28 0
      services/web/frontend/js/features/settings/components/emails/row.tsx
  7. 105 0
      services/web/frontend/js/features/settings/context/user-email-context.tsx
  8. 10 11
      services/web/frontend/js/shared/components/icon.tsx
  9. 58 0
      services/web/frontend/js/shared/hooks/use-async.ts
  10. 0 13
      services/web/frontend/js/shared/hooks/use-is-mounted.js
  11. 14 0
      services/web/frontend/js/shared/hooks/use-is-mounted.ts
  12. 17 0
      services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
  13. 22 0
      services/web/frontend/js/utils/normalize.ts
  14. 75 0
      services/web/frontend/stories/settings.stories.js
  15. 1 0
      services/web/frontend/stylesheets/_style_includes.less
  16. 3 0
      services/web/frontend/stylesheets/app/account-settings.less
  17. 3 0
      services/web/frontend/stylesheets/components/divider.less
  18. 159 0
      services/web/test/frontend/features/settings/components/emails/emails-section.test.tsx
  19. 176 0
      services/web/test/frontend/shared/hooks/use-async.test.ts
  20. 6 0
      services/web/types/affiliation.ts
  21. 1 0
      services/web/types/institution.ts
  22. 9 0
      services/web/types/user-email.ts
  23. 1 0
      services/web/types/utils.ts

+ 49 - 0
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 (
+    <>
+      <h3>{t('emails_and_affiliations_title')}</h3>
+      <p className="small">{t('emails_and_affiliations_explanation')}</p>
+      <p className="small">
+        <Trans i18nKey="change_primary_email_address_instructions">
+          <strong />
+          {/* eslint-disable-next-line jsx-a11y/anchor-has-content */}
+          <a href="/learn/how-to/Keeping_your_account_secure" />
+        </Trans>
+      </p>
+      <EmailsHeader />
+      {userEmails?.map((userEmail, i) => (
+        <Fragment key={userEmail.email}>
+          <EmailsRow userEmailData={userEmail} />
+          {i + 1 !== userEmails.length && (
+            <div className="horizontal-divider" />
+          )}
+        </Fragment>
+      ))}
+    </>
+  )
+}
+
+function EmailsSection() {
+  return (
+    <UserEmailsProvider>
+      <EmailsSectionContent />
+    </UserEmailsProvider>
+  )
+}
+
+export default EmailsSection

+ 9 - 0
services/web/frontend/js/features/settings/components/emails/cell.tsx

@@ -0,0 +1,9 @@
+type CellProps = {
+  children: React.ReactNode
+}
+
+function Cell({ children }: CellProps) {
+  return <div className="affiliations-table-cell">{children}</div>
+}
+
+export default Cell

+ 41 - 0
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 && (
+        <div className="small">
+          <strong>
+            {t('unconfirmed')}.
+            {!userEmailData.ssoAvailable && (
+              <span> {t('please_check_your_inbox')}.</span>
+            )}
+          </strong>
+          <br />
+          {!userEmailData.ssoAvailable && (
+            <ResendConfirmationEmailButton email={userEmailData.email} />
+          )}
+        </div>
+      )}
+      {userEmailData.confirmedAt &&
+        userEmailData.affiliation?.institution.confirmed &&
+        userEmailData.affiliation?.licence !== 'free' && (
+          <div className="small">
+            <span className="label label-primary">{t('professional')}</span>
+          </div>
+        )}
+    </>
+  )
+}
+
+export default Email

+ 33 - 0
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 (
+    <>
+      <Row>
+        <Col sm={5} className="hidden-xs">
+          <EmailCell>
+            <strong>{t('email')}</strong>
+          </EmailCell>
+        </Col>
+        <Col sm={5} className="hidden-xs">
+          <EmailCell>
+            <strong>{t('institution_and_role')}</strong>
+          </EmailCell>
+        </Col>
+        <Col sm={2} className="hidden-xs">
+          <EmailCell>
+            <strong>todo</strong>
+          </EmailCell>
+        </Col>
+      </Row>
+      <div className="hidden-xs horizontal-divider" />
+      <div className="hidden-xs horizontal-divider" />
+    </>
+  )
+}
+
+export default Header

+ 62 - 0
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 (
+      <>
+        <Icon type="refresh" spin fw /> {t('sending')}...
+      </>
+    )
+  }
+
+  return (
+    <>
+      <button
+        className="btn btn-inline-link"
+        onClick={handleResendConfirmationEmail}
+      >
+        {t('resend_confirmation_email')}
+      </button>
+      <br />
+      {isError && (
+        <span className="text-danger">
+          <Icon type="exclamation-triangle" fw />{' '}
+          {t('error_performing_request')}
+        </span>
+      )}
+    </>
+  )
+}
+
+export default ResendConfirmationEmailButton

+ 28 - 0
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 (
+    <Row>
+      <Col sm={5}>
+        <EmailCell>
+          <Email userEmailData={userEmailData} />
+        </EmailCell>
+      </Col>
+      <Col sm={5}>
+        <EmailCell>todo</EmailCell>
+      </Col>
+      <Col sm={2}>
+        <EmailCell>todo</EmailCell>
+      </Col>
+    </Row>
+  )
+}
+
+export default EmailsRow

+ 105 - 0
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<UserEmailData>
+  }
+}
+
+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<UserEmailData>(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<typeof useUserEmails> | undefined
+>(undefined)
+UserEmailsContext.displayName = 'UserEmailsContext'
+
+type UserEmailsProviderProps = {
+  children: React.ReactNode
+} & Record<string, unknown>
+
+function UserEmailsProvider(props: UserEmailsProviderProps) {
+  const value = useUserEmails()
+
+  return <UserEmailsContext.Provider value={value} {...props} />
+}
+
+const useUserEmailsContext = () => {
+  const context = useContext(UserEmailsContext)
+
+  if (context === undefined) {
+    throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
+  }
+
+  return context
+}
+
+export { UserEmailsProvider, useUserEmailsContext }

+ 10 - 11
services/web/frontend/js/shared/components/icon.js → 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

+ 58 - 0
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<unknown>
+  error: Nullable<Record<string, unknown>>
+}
+type Action = Partial<State>
+
+const defaultInitialState: State = { status: 'idle', data: null, error: null }
+const initializer = (initialState: State) => ({ ...initialState })
+
+function useAsync(initialState?: Partial<State>) {
+  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<Record<string, unknown>>) => {
+      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<typeof useAsync>

+ 0 - 13
services/web/frontend/js/shared/hooks/use-is-mounted.js

@@ -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
-}

+ 14 - 0
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
+}

+ 17 - 0
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<T>(dispatch: React.Dispatch<T>) {
+  const mounted = useIsMounted()
+
+  return React.useCallback<(args: T) => void>(
+    action => {
+      if (mounted.current) {
+        dispatch(action)
+      }
+    },
+    [dispatch, mounted]
+  ) as React.Dispatch<T>
+}
+
+export default useSafeDispatch

+ 22 - 0
services/web/frontend/js/utils/normalize.ts

@@ -0,0 +1,22 @@
+import mapKeys from 'lodash/mapKeys'
+
+export interface NormalizedObject<T> {
+  [p: string]: T
+}
+
+type Data<T> = T[]
+type Config = Partial<{
+  idAttribute: string
+}>
+
+export function normalize<T>(
+  data: Data<T>,
+  config: Config = {}
+): NormalizedObject<T> | undefined {
+  const { idAttribute = 'id' } = config
+  const mapped = mapKeys(data, idAttribute)
+
+  return Object.prototype.hasOwnProperty.call(mapped, 'undefined')
+    ? undefined
+    : mapped
+}

+ 75 - 0
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 <EmailsSection {...args} />
+}
+
+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 <EmailsSection {...args} />
+}
+
+export default {
+  title: 'Emails and Affiliations',
+  component: EmailsSection,
+}

+ 1 - 0
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';

+ 3 - 0
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%;
 }

+ 3 - 0
services/web/frontend/stylesheets/components/divider.less

@@ -0,0 +1,3 @@
+.horizontal-divider {
+  border-top: 1px solid @table-border-color;
+}

+ 159 - 0
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('<EmailsSection />', function () {
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    fetchMock.reset()
+  })
+
+  it('renders translated heading', function () {
+    window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+    render(<EmailsSection />)
+
+    screen.getByRole('heading', { name: /emails and affiliations/i })
+  })
+
+  it('renders translated description', function () {
+    window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+    render(<EmailsSection />)
+
+    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(<EmailsSection />)
+
+    fakeUsersData.forEach(userData => {
+      screen.getByText(new RegExp(userData.email, 'i'))
+    })
+  })
+
+  it('renders primary status', function () {
+    window.metaAttributesCache.set('ol-userEmails', fakeUsersData)
+    render(<EmailsSection />)
+
+    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(<EmailsSection />)
+
+    screen.getByText(/please check your inbox/i)
+  })
+
+  it('hides confirmation status for confirmed users', function () {
+    window.metaAttributesCache.set('ol-userEmails', [confirmedUserData])
+    render(<EmailsSection />)
+
+    expect(screen.queryByText(/please check your inbox/i)).to.be.null
+  })
+
+  it('renders resend link', function () {
+    window.metaAttributesCache.set('ol-userEmails', [unconfirmedUserData])
+    render(<EmailsSection />)
+
+    screen.getByRole('button', { name: /resend confirmation email/i })
+  })
+
+  it('renders professional label', function () {
+    window.metaAttributesCache.set('ol-userEmails', [professionalUserData])
+    render(<EmailsSection />)
+
+    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(<EmailsSection />)
+
+    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(<EmailsSection />)
+
+    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 })
+  })
+})

+ 176 - 0
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<string, unknown> | PromiseLike<Record<string, unknown>>
+  ) => void
+  let rej!: (reason?: any) => void
+
+  const promise = new Promise<Record<string, unknown>>((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<unknown>
+    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<unknown>
+    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<unknown>
+    act(() => {
+      p = result.current.runAsync(promise)
+    })
+    unmount()
+    await act(async () => {
+      resolve({})
+      await p
+    })
+
+    expect(global.console.error).not.to.have.been.called
+  })
+})

+ 6 - 0
services/web/types/affiliation.ts

@@ -0,0 +1,6 @@
+import { Institution } from './institution'
+
+export type Affiliation = {
+  institution: Institution
+  licence?: 'free' | 'pro_plus'
+}

+ 1 - 0
services/web/types/institution.ts

@@ -0,0 +1 @@
+export type Institution = Record<string, unknown>

+ 9 - 0
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
+}

+ 1 - 0
services/web/types/utils.ts

@@ -0,0 +1 @@
+export type Nullable<T> = T | null