mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #13164 from overleaf/msm-email-limit
[web] limit user email addresses to 10 GitOrigin-RevId: 038214cc921d86a407391e6c82fa9cd16a7f9646
This commit is contained in:
parent
d88e248178
commit
51223315e4
13 changed files with 133 additions and 5 deletions
|
@ -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,
|
||||||
|
|
|
@ -125,6 +125,7 @@ async function settingsPage(req, res) {
|
||||||
projectSyncSuccessMessage,
|
projectSyncSuccessMessage,
|
||||||
showPersonalAccessToken,
|
showPersonalAccessToken,
|
||||||
personalAccessTokens,
|
personalAccessTokens,
|
||||||
|
emailAddressLimit: Settings.emailAddressLimit,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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": "",
|
||||||
|
|
|
@ -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>
|
||||||
|
{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} />
|
<AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
|
||||||
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
</Col>
|
</Col>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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 don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
|
"email_does_not_belong_to_university": "We don’t 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>.",
|
||||||
|
|
|
@ -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 />)
|
||||||
|
|
30
services/web/test/frontend/helpers/with-markup.ts
Normal file
30
services/web/test/frontend/helpers/with-markup.ts
Normal 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
|
|
@ -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 () {
|
||||||
|
|
Loading…
Reference in a new issue