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 logger = require('@overleaf/logger')
const SessionManager = require('../Authentication/SessionManager') const SessionManager = require('../Authentication/SessionManager')
const UserGetter = require('./UserGetter') const UserGetter = require('./UserGetter')
@ -32,7 +33,14 @@ async function add(req, res, next) {
if (!email) { if (!email) {
return res.sendStatus(422) 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 = { const affiliationOptions = {
university: req.body.university, university: req.body.university,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { Col } from 'react-bootstrap' import { Col } from 'react-bootstrap'
import Cell from './cell' import Cell from './cell'
import Layout from './add-email/layout' import Layout from './add-email/layout'
@ -15,6 +15,7 @@ import { postJSON } from '../../../../infrastructure/fetch-json'
import { University } from '../../../../../../types/university' import { University } from '../../../../../../types/university'
import { CountryCode } from '../../data/countries-list' import { CountryCode } from '../../data/countries-list'
import { isValidEmail } from '../../../../shared/utils/email' import { isValidEmail } from '../../../../shared/utils/email'
import getMeta from '../../../../utils/meta'
function AddEmail() { function AddEmail() {
const { t } = useTranslation() const { t } = useTranslation()
@ -38,6 +39,8 @@ function AddEmail() {
getEmails, getEmails,
} = useUserEmailsContext() } = useUserEmailsContext()
const emailAddressLimit = getMeta('ol-emailAddressLimit', 10)
useEffect(() => { useEffect(() => {
setUserEmailsContextLoading(isLoading) setUserEmailsContextLoading(isLoading)
}, [setUserEmailsContextLoading, isLoading]) }, [setUserEmailsContextLoading, isLoading])
@ -98,9 +101,21 @@ function AddEmail() {
if (!isFormVisible) { if (!isFormVisible) {
return ( return (
<Layout isError={isError} error={error}> <Layout isError={isError} error={error}>
<Col md={4}> <Col md={12}>
<Cell> <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> </Cell>
</Col> </Col>
</Layout> </Layout>

View file

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

View file

@ -6,6 +6,7 @@ import {
defaultSetupMocks, defaultSetupMocks,
reconfirmationSetupMocks, reconfirmationSetupMocks,
errorsMocks, errorsMocks,
emailLimitSetupMocks,
} from './helpers/emails' } from './helpers/emails'
export const EmailsList = args => { export const EmailsList = args => {
@ -15,6 +16,13 @@ export const EmailsList = args => {
return <EmailsSection {...args} /> return <EmailsSection {...args} />
} }
export const EmailLimitList = args => {
useFetchMock(emailLimitSetupMocks)
setDefaultMeta()
return <EmailsSection {...args} />
}
export const ReconfirmationEmailsList = args => { export const ReconfirmationEmailsList = args => {
useFetchMock(reconfirmationSetupMocks) useFetchMock(reconfirmationSetupMocks)
setReconfirmationMeta() 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) { export function errorsMocks(fetchMock) {
fetchMock fetchMock
.get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY }) .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_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_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_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_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": "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>.", "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 fetchMock from 'fetch-mock'
import { UserEmailData } from '../../../../../../types/user-email' import { UserEmailData } from '../../../../../../types/user-email'
import { Affiliation } from '../../../../../../types/affiliation' import { Affiliation } from '../../../../../../types/affiliation'
import withMarkup from '../../../../helpers/with-markup'
const userEmailData: UserEmailData & { affiliation: Affiliation } = { const userEmailData: UserEmailData & { affiliation: Affiliation } = {
affiliation: { affiliation: {
@ -149,6 +150,23 @@ describe('<EmailsSection />', function () {
screen.getByRole('button', { name: /add new email/i }) 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 () { it('adds new email address', async function () {
fetchMock.get('/user/emails?ensureAffiliation=true', []) fetchMock.get('/user/emails?ensureAffiliation=true', [])
render(<EmailsSection />) 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.req.sessionID = Math.random().toString()
this.res = new MockResponse() this.res = new MockResponse()
this.next = sinon.stub() 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 = { this.UserGetter = {
getUserFullEmails: sinon.stub(), getUserFullEmails: sinon.stub(),
@ -234,6 +238,28 @@ describe('UserEmailsController', function () {
}) })
this.UserEmailsController.add(this.req, this.res, this.next) 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 () { describe('remove', function () {