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

List of user emails

GitOrigin-RevId: 28a8e405812932ba7ebd8043a4dc9d3c573a68b2
This commit is contained in:
ilkin-overleaf 2022-04-08 14:00:46 +03:00 committed by Copybot
parent d50271c1e9
commit 5b0c122f5d
23 changed files with 882 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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';

View file

@ -23,6 +23,9 @@
.affiliations-table {
table-layout: fixed;
}
.affiliations-table-cell {
padding: 0.5rem;
}
.affiliations-table-email {
width: 40%;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
import { Affiliation } from './affiliation'
export type UserEmailData = {
affiliation?: Affiliation
confirmedAt: string
email: string
default: boolean
ssoAvailable?: boolean
}

View file

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