Merge pull request #13164 from overleaf/msm-email-limit

[web] limit user email addresses to 10

GitOrigin-RevId: 038214cc921d86a407391e6c82fa9cd16a7f9646
This commit is contained in:
Miguel Serrano 2023-05-26 12:56:15 +02:00 committed by Copybot
parent d88e248178
commit 51223315e4
13 changed files with 133 additions and 5 deletions

View file

@ -1,3 +1,4 @@
const Settings = require('@overleaf/settings')
const logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager')
const UserGetter = require('./UserGetter')
@ -32,7 +33,14 @@ async function add(req, res, next) {
if (!email) {
return res.sendStatus(422)
}
const user = await UserGetter.promises.getUser(userId, { email: 1 })
const user = await UserGetter.promises.getUser(userId, {
email: 1,
'emails.email': 1,
})
if (user.emails.length >= Settings.emailAddressLimit) {
return res.status(422).json({ message: 'secondary email limit exceeded' })
}
const affiliationOptions = {
university: req.body.university,

View file

@ -125,6 +125,7 @@ async function settingsPage(req, res) {
projectSyncSuccessMessage,
showPersonalAccessToken,
personalAccessTokens,
emailAddressLimit: Settings.emailAddressLimit,
})
}

View file

@ -24,6 +24,7 @@ block append meta
meta(name="ol-projectSyncSuccessMessage", content=projectSyncSuccessMessage)
meta(name="ol-showPersonalAccessToken", data-type="boolean" content=showPersonalAccessToken)
meta(name="ol-personalAccessTokens", data-type="json" content=personalAccessTokens)
meta(name="ol-emailAddressLimit", data-type="json", content=emailAddressLimit)
block content
main.content.content-alt#settings-page-root

View file

@ -684,6 +684,8 @@ module.exports = {
emailConfirmationDisabled:
process.env.EMAIL_CONFIRMATION_DISABLED === 'true' || false,
emailAddressLimit: intFromEnv('EMAIL_ADDRESS_LIMIT', 10),
enabledServices: (process.env.ENABLED_SERVICES || 'web,api')
.split(',')
.map(s => s.trim()),

View file

@ -257,6 +257,7 @@
"educational_discount_for_groups_of_x_or_more": "",
"educational_percent_discount_applied": "",
"email": "",
"email_limit_reached": "",
"email_or_password_wrong_try_again": "",
"emails_and_affiliations_explanation": "",
"emails_and_affiliations_title": "",

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation, Trans } from 'react-i18next'
import { Col } from 'react-bootstrap'
import Cell from './cell'
import Layout from './add-email/layout'
@ -15,6 +15,7 @@ import { postJSON } from '../../../../infrastructure/fetch-json'
import { University } from '../../../../../../types/university'
import { CountryCode } from '../../data/countries-list'
import { isValidEmail } from '../../../../shared/utils/email'
import getMeta from '../../../../utils/meta'
function AddEmail() {
const { t } = useTranslation()
@ -38,6 +39,8 @@ function AddEmail() {
getEmails,
} = useUserEmailsContext()
const emailAddressLimit = getMeta('ol-emailAddressLimit', 10)
useEffect(() => {
setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading])
@ -98,9 +101,21 @@ function AddEmail() {
if (!isFormVisible) {
return (
<Layout isError={isError} error={error}>
<Col md={4}>
<Col md={12}>
<Cell>
<AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
{state.data.emailCount >= emailAddressLimit ? (
<span className="small">
<Trans
i18nKey="email_limit_reached"
values={{
emailAddressLimit,
}}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</span>
) : (
<AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
)}
</Cell>
</Col>
</Layout>

View file

@ -65,6 +65,7 @@ export type State = {
isLoading: boolean
data: {
byId: NormalizedObject<UserEmailData>
emailCount: number
linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
}
@ -82,6 +83,7 @@ const setData = (state: State, action: ActionSetData) => {
const normalized = normalize<UserEmailData>(action.payload, {
idAttribute: 'email',
})
const emailCount = action.payload.length
const byId = normalized || {}
const linkedInstitutionIds = action.payload
.filter(email => Boolean(email.samlProviderId))
@ -94,6 +96,7 @@ const setData = (state: State, action: ActionSetData) => {
data: {
...initialState.data,
byId,
emailCount,
linkedInstitutionIds,
},
}
@ -132,6 +135,7 @@ const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
...state,
data: {
...state.data,
emailCount: state.data.emailCount - 1,
byId,
},
}
@ -191,6 +195,7 @@ const initialState: State = {
isLoading: false,
data: {
byId: {},
emailCount: 0,
linkedInstitutionIds: [],
emailAffiliationBeingEdited: null,
},

View file

@ -6,6 +6,7 @@ import {
defaultSetupMocks,
reconfirmationSetupMocks,
errorsMocks,
emailLimitSetupMocks,
} from './helpers/emails'
export const EmailsList = args => {
@ -15,6 +16,13 @@ export const EmailsList = args => {
return <EmailsSection {...args} />
}
export const EmailLimitList = args => {
useFetchMock(emailLimitSetupMocks)
setDefaultMeta()
return <EmailsSection {...args} />
}
export const ReconfirmationEmailsList = args => {
useFetchMock(reconfirmationSetupMocks)
setReconfirmationMeta()

View file

@ -176,6 +176,18 @@ export function reconfirmationSetupMocks(fetchMock) {
})
}
export function emailLimitSetupMocks(fetchMock) {
const userData = []
for (let i = 0; i < 10; i++) {
userData.push({ email: `example${i}@overleaf.com` })
}
defaultSetupMocks(fetchMock)
fetchMock.get(/\/user\/emails/, userData, {
delay: MOCK_DELAY,
overwriteRoutes: true,
})
}
export function errorsMocks(fetchMock) {
fetchMock
.get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY })

View file

@ -430,6 +430,7 @@
"email_already_registered_secondary": "This email is already registered as a secondary email",
"email_already_registered_sso": "This email is already registered. Please log in to your account another way and link your account to the new provider via your account settings.",
"email_does_not_belong_to_university": "We dont recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
"email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
"email_link_expired": "Email link expired, please request a new one.",
"email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
"email_or_password_wrong_try_again_or_reset": "Your email or password is incorrect. Please try again, or <0>set or reset your password</0>.",

View file

@ -10,6 +10,7 @@ import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { UserEmailData } from '../../../../../../types/user-email'
import { Affiliation } from '../../../../../../types/affiliation'
import withMarkup from '../../../../helpers/with-markup'
const userEmailData: UserEmailData & { affiliation: Affiliation } = {
affiliation: {
@ -149,6 +150,23 @@ describe('<EmailsSection />', function () {
screen.getByRole('button', { name: /add new email/i })
})
it('prevent users from adding new emails when the limit is reached', async function () {
const emails = []
for (let i = 0; i < 10; i++) {
emails.push({ email: `bar${i}@overleaf.com` })
}
fetchMock.get('/user/emails?ensureAffiliation=true', emails)
render(<EmailsSection />)
const findByTextWithMarkup = withMarkup(screen.findByText)
await findByTextWithMarkup(
'You can have a maximum of 10 email addresses on this account. To add another email address, please delete an existing one.'
)
expect(screen.queryByRole('button', { name: /add another email/i })).to.not
.exist
})
it('adds new email address', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />)

View file

@ -0,0 +1,30 @@
import { MatcherFunction } from '@testing-library/react'
type Query = (f: MatcherFunction) => Element | Promise<Element>
/*
Utility function to run testing-library queries over nodes that contain html tags, as in
`<p>this includes some <strong>bold</strong> text</p>`.
Usage:
const getByTextWithMarkup = withMarkup(screen.getByText)
getByTextWithMarkup('this includes some bold text')
const findByTextWithMarkup = withMarkup(screen.findByText)
await findByTextWithMarkup('this includes some bold text')
*/
const withMarkup =
(query: Query) =>
(text: string): Element | Promise<Element> =>
query((content: string, node: Element | null) => {
if (!node) {
return false
}
const hasText = (node: Element) => node.textContent === text
const childrenDontHaveText = Array.from(node.children).every(
child => !hasText(child as HTMLElement)
)
return hasText(node) && childrenDontHaveText
})
export default withMarkup

View file

@ -14,7 +14,11 @@ describe('UserEmailsController', function () {
this.req.sessionID = Math.random().toString()
this.res = new MockResponse()
this.next = sinon.stub()
this.user = { _id: 'mock-user-id', email: 'example@overleaf.com' }
this.user = {
_id: 'mock-user-id',
email: 'example@overleaf.com',
emails: {},
}
this.UserGetter = {
getUserFullEmails: sinon.stub(),
@ -234,6 +238,28 @@ describe('UserEmailsController', function () {
})
this.UserEmailsController.add(this.req, this.res, this.next)
})
it('should fail to add new emails when the limit has been reached', function (done) {
this.user.emails = []
for (let i = 0; i < 10; i++) {
this.user.emails.push({ email: `example${i}@overleaf.com` })
}
this.UserEmailsController.add(
this.req,
{
status: code => {
expect(code).to.equal(422)
return {
json: error => {
expect(error.message).to.equal('secondary email limit exceeded')
done()
},
}
},
},
this.next
)
})
})
describe('remove', function () {