add-email.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { useState, useEffect } from 'react'
  2. import { useTranslation, Trans } from 'react-i18next'
  3. import Cell from './cell'
  4. import Layout from './add-email/layout'
  5. import Input, { DomainInfo } from './add-email/input'
  6. import AddAnotherEmailBtn from './add-email/add-another-email-btn'
  7. import InstitutionFields from './add-email/institution-fields'
  8. import SsoLinkingInfo from './add-email/sso-linking-info'
  9. import AddNewEmailBtn from './add-email/add-new-email-btn'
  10. import useAsync from '../../../../shared/hooks/use-async'
  11. import { useUserEmailsContext } from '../../context/user-email-context'
  12. import { ssoAvailableForDomain } from '../../utils/sso'
  13. import { postJSON } from '../../../../infrastructure/fetch-json'
  14. import { University } from '../../../../../../types/university'
  15. import { CountryCode } from '../../data/countries-list'
  16. import { isValidEmail } from '../../../../shared/utils/email'
  17. import getMeta from '../../../../utils/meta'
  18. import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
  19. import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
  20. import OLCol from '@/shared/components/ol/ol-col'
  21. import { ConfirmEmailForm } from '@/features/settings/components/emails/confirm-email-form'
  22. import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
  23. import SsoLinkingInfoGroup from './add-email/sso-linking-info-group'
  24. import Notification from '@/shared/components/notification'
  25. import { useFeatureFlag } from '@/shared/context/split-test-context'
  26. function isDomainCapturedByGroup(
  27. domainInfo: DomainInfo,
  28. domainCapturedByGroupRolloutFlagEnabled: boolean
  29. ): boolean {
  30. return domainCapturedByGroupRolloutFlagEnabled
  31. ? (domainInfo.capturedByGroup && domainInfo.group?.domainCaptureEnabled) ||
  32. false
  33. : domainInfo.group?.domainCaptureEnabled || false
  34. }
  35. function AddEmail() {
  36. const { t } = useTranslation()
  37. const [isFormVisible, setIsFormVisible] = useState(
  38. () => window.location.hash === '#add-email'
  39. )
  40. const [newEmail, setNewEmail] = useState('')
  41. const [confirmationStep, setConfirmationStep] = useState(false)
  42. const [newEmailMatchedDomain, setNewEmailMatchedDomain] =
  43. useState<DomainInfo | null>(null)
  44. const [countryCode, setCountryCode] = useState<CountryCode | null>(null)
  45. const [universities, setUniversities] = useState<
  46. Partial<Record<CountryCode, University[]>>
  47. >({})
  48. const [universityName, setUniversityName] = useState('')
  49. const [role, setRole] = useState('')
  50. const [department, setDepartment] = useState('')
  51. const { isLoading, isError, error, runAsync } = useAsync()
  52. const {
  53. state,
  54. setLoading: setUserEmailsContextLoading,
  55. getEmails,
  56. } = useUserEmailsContext()
  57. const emailAddressLimit = getMeta('ol-emailAddressLimit') || 10
  58. const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha()
  59. const domainCapturedByGroupRolloutFlagEnabled = useFeatureFlag(
  60. 'domain-captured-by-group'
  61. )
  62. useEffect(() => {
  63. setUserEmailsContextLoading(isLoading)
  64. }, [setUserEmailsContextLoading, isLoading])
  65. const handleShowAddEmailForm = () => {
  66. setIsFormVisible(true)
  67. }
  68. const handleEmailChange = (value: string, domain?: DomainInfo) => {
  69. setNewEmail(value)
  70. setNewEmailMatchedDomain(domain || null)
  71. }
  72. const getSelectedKnownUniversityId = (): number | undefined => {
  73. if (countryCode) {
  74. return universities[countryCode]?.find(
  75. ({ name }) => name === universityName
  76. )?.id
  77. }
  78. return newEmailMatchedDomain?.university.id
  79. }
  80. const handleAddNewEmail = () => {
  81. const selectedKnownUniversityId = getSelectedKnownUniversityId()
  82. const knownUniversityData = selectedKnownUniversityId && {
  83. university: {
  84. id: selectedKnownUniversityId,
  85. },
  86. role,
  87. department,
  88. }
  89. const unknownUniversityData = universityName &&
  90. !selectedKnownUniversityId && {
  91. university: {
  92. name: universityName,
  93. country_code: countryCode,
  94. },
  95. role,
  96. department,
  97. }
  98. runAsync(
  99. (async () => {
  100. const token = await getReCaptchaToken()
  101. await postJSON('/user/emails/secondary', {
  102. body: {
  103. email: newEmail,
  104. ...knownUniversityData,
  105. ...unknownUniversityData,
  106. 'g-recaptcha-response': token,
  107. },
  108. })
  109. })()
  110. )
  111. .then(() => {
  112. setConfirmationStep(true)
  113. })
  114. .catch(() => {})
  115. }
  116. if (confirmationStep) {
  117. return (
  118. <ConfirmEmailForm
  119. confirmationEndpoint="/user/emails/confirm-secondary"
  120. resendEndpoint="/user/emails/resend-secondary-confirmation"
  121. flow="secondary"
  122. email={newEmail}
  123. onSuccessfulConfirmation={getEmails}
  124. interstitial={false}
  125. onCancel={() => {
  126. setConfirmationStep(false)
  127. setIsFormVisible(false)
  128. }}
  129. />
  130. )
  131. }
  132. if (!isFormVisible) {
  133. return (
  134. <Layout isError={isError} error={error}>
  135. <OLCol lg={12}>
  136. <Cell>
  137. {state.data.emailCount >= emailAddressLimit ? (
  138. <span className="small">
  139. <Trans
  140. i18nKey="email_limit_reached"
  141. values={{
  142. emailAddressLimit,
  143. }}
  144. shouldUnescape
  145. tOptions={{ interpolation: { escapeValue: true } }}
  146. components={[<strong />]} // eslint-disable-line react/jsx-key
  147. />
  148. </span>
  149. ) : (
  150. <AddAnotherEmailBtn onClick={handleShowAddEmailForm} />
  151. )}
  152. </Cell>
  153. </OLCol>
  154. </Layout>
  155. )
  156. }
  157. const InputComponent = (
  158. <>
  159. <label htmlFor="affiliations-email">{t('email')}</label>
  160. <Input
  161. onChange={handleEmailChange}
  162. handleAddNewEmail={handleAddNewEmail}
  163. />
  164. </>
  165. )
  166. const recaptchaConditions = (
  167. <OLCol>
  168. <Cell>
  169. <div className="affiliations-table-cell-tabbed">
  170. <RecaptchaConditions />
  171. </div>
  172. </Cell>
  173. </OLCol>
  174. )
  175. if (!isValidEmail(newEmail)) {
  176. return (
  177. <form>
  178. <Layout isError={isError} error={error}>
  179. <ReCaptcha2 page="addEmail" recaptchaRef={recaptchaRef} />
  180. <OLCol lg={8}>
  181. <Cell>
  182. {InputComponent}
  183. <div className="affiliations-table-cell-tabbed">
  184. <div>{t('start_by_adding_your_email')}</div>
  185. </div>
  186. </Cell>
  187. </OLCol>
  188. <OLCol lg={4}>
  189. <Cell className="text-lg-end">
  190. <AddNewEmailBtn email={newEmail} disabled />
  191. </Cell>
  192. </OLCol>
  193. {recaptchaConditions}
  194. </Layout>
  195. </form>
  196. )
  197. }
  198. const isDomainCaptured = newEmailMatchedDomain
  199. ? isDomainCapturedByGroup(
  200. newEmailMatchedDomain,
  201. domainCapturedByGroupRolloutFlagEnabled
  202. )
  203. : false
  204. const isSsoAvailableForDomain =
  205. newEmailMatchedDomain &&
  206. ssoAvailableForDomain(newEmailMatchedDomain, isDomainCaptured)
  207. return (
  208. <form>
  209. <Layout isError={isError} error={error}>
  210. <ReCaptcha2 page="addEmail" recaptchaRef={recaptchaRef} />
  211. <OLCol lg={8}>
  212. <Cell>
  213. {InputComponent}
  214. {!isSsoAvailableForDomain ? (
  215. <div className="affiliations-table-cell-tabbed">
  216. <InstitutionFields
  217. countryCode={countryCode}
  218. setCountryCode={setCountryCode}
  219. universities={universities}
  220. setUniversities={setUniversities}
  221. universityName={universityName}
  222. setUniversityName={setUniversityName}
  223. role={role}
  224. setRole={setRole}
  225. department={department}
  226. setDepartment={setDepartment}
  227. newEmailMatchedDomain={newEmailMatchedDomain}
  228. />
  229. </div>
  230. ) : null}
  231. </Cell>
  232. </OLCol>
  233. {!isSsoAvailableForDomain ? (
  234. <OLCol lg={4}>
  235. <Cell className="text-lg-end">
  236. <AddNewEmailBtn
  237. email={newEmail}
  238. disabled={state.isLoading}
  239. isLoading={isLoading}
  240. onClick={handleAddNewEmail}
  241. />
  242. </Cell>
  243. </OLCol>
  244. ) : (
  245. <OLCol lg={12}>
  246. <Cell>
  247. <div className="affiliations-table-cell-tabbed">
  248. <AddEmailViaSSO
  249. email={newEmail}
  250. domainInfo={newEmailMatchedDomain}
  251. isDomainCaptured={isDomainCaptured}
  252. userInstitutions={state.data.linkedInstitutionIds}
  253. />
  254. </div>
  255. </Cell>
  256. </OLCol>
  257. )}
  258. {recaptchaConditions}
  259. </Layout>
  260. </form>
  261. )
  262. }
  263. function AddEmailViaSSO({
  264. email,
  265. domainInfo,
  266. userInstitutions,
  267. isDomainCaptured,
  268. }: {
  269. email: string
  270. domainInfo: DomainInfo
  271. userInstitutions: string[]
  272. isDomainCaptured: boolean
  273. }) {
  274. if (isDomainCaptured && domainInfo.group?.managedUsersEnabled) {
  275. return (
  276. <Notification
  277. type="error"
  278. ariaLive="polite"
  279. content={
  280. <>
  281. Your company email address has been registered under a verified
  282. domain, and cannot be added as a secondary email. Please create a
  283. new <strong>Overleaf</strong> account linked to this email address.
  284. </>
  285. }
  286. />
  287. )
  288. } else if (domainInfo.university.ssoEnabled) {
  289. // Check if the user has already linked this institution
  290. if (userInstitutions.includes(domainInfo.university.id.toString())) {
  291. return (
  292. <Notification
  293. type="error"
  294. ariaLive="polite"
  295. content={
  296. <>
  297. This institution is already linked with your account via another
  298. email address.
  299. </>
  300. }
  301. />
  302. )
  303. }
  304. return <SsoLinkingInfo email={email} domainInfo={domainInfo} />
  305. } else if (isDomainCaptured && domainInfo.group?.ssoConfig?.enabled) {
  306. return <SsoLinkingInfoGroup domainInfo={domainInfo} />
  307. }
  308. }
  309. export default AddEmail