diff --git a/server-ce/config/settings.js b/server-ce/config/settings.js
index 620c696695..ad15a8ffd8 100644
--- a/server-ce/config/settings.js
+++ b/server-ce/config/settings.js
@@ -193,6 +193,8 @@ const settings = {
maintenanceMessage: process.env.OVERLEAF_MAINTENANCE_MESSAGE,
maintenanceMessageHTML: process.env.OVERLEAF_MAINTENANCE_MESSAGE_HTML,
+ enableRegiserPage: process.env.OVERLEAF_ENABLE_REGISTER_PAGE === 'true',
+
// The name this is used to describe your Overleaf Community Edition Installation
appName: process.env.OVERLEAF_APP_NAME || 'Overleaf Community Edition',
diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
index 7543b42fb1..06997a5366 100644
--- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
+++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
@@ -11,6 +11,7 @@ import EmailHelper from '../Helpers/EmailHelper.mjs'
import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
+import Features from '../../infrastructure/Features.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
import { expressify } from '@overleaf/promise-utils'
@@ -386,7 +387,11 @@ async function viewInvite(req, res) {
user_first_name: owner.first_name,
}
AuthenticationController.setRedirectInSession(req)
- return res.redirect('/register')
+ if (Features.hasFeature('registration-page')) {
+ return res.redirect('/register')
+ } else {
+ return res.redirect('/login')
+ }
}
// cleanup if set for register page
@@ -442,6 +447,12 @@ async function viewSharingLink(req, res) {
if (!isPublicSharingLink) {
AuthenticationController.setRedirectInSession(req)
return res.redirect('/register')
+ AuthenticationController.setRedirectInSession(req)
+ if (Features.hasFeature('registration-page')) {
+ return res.redirect('/register')
+ } else {
+ return res.redirect('/login')
+ }
}
}
diff --git a/services/web/app/src/infrastructure/Features.mjs b/services/web/app/src/infrastructure/Features.mjs
index 2c1393d6bc..2a1a7a38e2 100644
--- a/services/web/app/src/infrastructure/Features.mjs
+++ b/services/web/app/src/infrastructure/Features.mjs
@@ -34,6 +34,7 @@ const Features = {
return (
(Boolean(Settings.ldap) && Boolean(Settings.ldap.enable)) ||
(Boolean(Settings.saml) && Boolean(Settings.saml.enable)) ||
+ (Boolean(Settings.oidc) && Boolean(Settings.oidc.enable)) ||
Boolean(Settings.overleaf)
)
},
@@ -51,10 +52,7 @@ const Features = {
case 'homepage':
return Boolean(Settings.enableHomepage)
case 'registration-page':
- return (
- !Features.externalAuthenticationSystemUsed() ||
- Boolean(Settings.overleaf) || Settings.oidc?.allowedOIDCEmailDomains
- )
+ return Boolean(Settings.enableRegistrationPage)
case 'registration':
return Boolean(Settings.overleaf)
case 'chat':
diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js
index edf623c288..eebff55984 100644
--- a/services/web/config/settings.defaults.js
+++ b/services/web/config/settings.defaults.js
@@ -1187,6 +1187,7 @@ module.exports = {
'authentication/saml',
'authentication/oidc',
'admin-tools', // import after authentication
+ 'registration-page', // import after authentication
'template-gallery',
'git-bridge',
'github-sync',
diff --git a/services/web/locales/de.json b/services/web/locales/de.json
index 7d067bc861..a472d910e7 100644
--- a/services/web/locales/de.json
+++ b/services/web/locales/de.json
@@ -75,6 +75,7 @@
"account_not_linked_to_dropbox": "Dein Konto ist nicht mit Dropbox verknüpft",
"account_settings": "Kontoeinstellungen",
"account_with_email_exists": "Anscheinend existiert bereits ein __appName__-Konto mit der E-Mail-Adresse __email__.",
+ "account_with_this_email_exists": "Anscheinend existiert bereits ein __appName__-Konto mit dieser E-Mail-Adresse.",
"acct_linked_to_institution_acct_2": "Du kannst dich jetzt mit dem Konto deiner Institution <0>__institutionName__0> bei Overleaf anmelden.",
"actions": "Aktionen",
"activate": "Aktivieren",
@@ -572,6 +573,7 @@
"export_project_to_github": "Projekt nach GitHub exportieren",
"failed_to_publish_as_a_template": "Veröffentlichung als Vorlage fehlgeschlagen.",
"failed_to_send_managed_user_invite_to_email": "Der Versand der Einladung für Verwaltete Benutzer an <0>__email__0> hat nicht funktioniert. Bitte versuche es später noch einmal.",
+ "failed_to_send_registration_email": "Die Registrierungs-E-Mail konnte nicht gesendet werden. Bitte überprüfe die E-Mail-Adresse.",
"faq_how_does_free_trial_works_answer": "Während deines __len__-tägigen Probe-Abonnements erhältst du vollen Zugriff auf die Funktionen des von dir gewählten __appName__-Abonnements. Es besteht keine Verpflichtung, über die Testperiode hinaus fortzufahren. Deine Karte wird am Ende des __len__-tägigen Testzeitraums belastet, sofern du nicht vorher gekündigt hast. Um zu kündigen, gehe zu deinen Abonnementeinstellungen in deinem Konto.",
"fast": "Schnell",
"fast_draft": "Schnell [Entwurf]",
@@ -875,6 +877,7 @@
"include_caption": "Beschriftung anzeigen",
"include_label": "Label anzeigen",
"included_as_part_of_your_writefull_subscription": "Als Teil deines Writefull-Abonnements enthalten",
+ "including_all_subdomains": "einschließlich aller Subdomains",
"increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen",
"info": "Info",
"inr_discount_modal_info": "Erhalte Dokumentenverlauf, Änderungsverfolgung, weitere Mitarbeiter und mehr zu Preisen nach Kaufkraftparität.",
@@ -1390,11 +1393,13 @@
"regards": "Viele Grüße",
"register": "Registrieren",
"register_error": "Registrierungsfehler",
+ "register_success": "Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail, um Ihr Konto zu aktivieren.",
"register_intercept_sso": "Du kannst dein __authProviderName__-Konto nach der Anmeldung auf der Seite Kontoeinstellungen verknüpfen.",
"register_to_edit_template": "Bitte registriere dich um die __templateName__ Vorlage zu bearbeiten",
"register_with_another_email": "Registriere Dich bei __appName__ mit einer anderen E-Mail-Adresse.",
"registered": "Registriert",
"registering": "Registrieren",
+ "registration_is_restricted_to_the_following_email_domains": "Die Registrierung ist auf die folgenden E-Mail-Domänen beschränkt:",
"reject": "Verwerfen",
"related_tags": "Ähnliche Stichwörter",
"relink_your_account": "Verknüpfe dein Konto neu",
@@ -1575,6 +1580,7 @@
"stops_compiling_after_the_first_error_so_you_can_fix_issues_one_at_a_time": "Stoppt das Kompilieren beim ersten Fehler, damit du Probleme einzeln beheben kannst",
"store_your_work": "Speichere deine Arbeit auf deiner eigenen Infrastruktur",
"student": "Student",
+ "subdomains_of_this_domain_only": "nur Subdomains dieser Domain",
"subject": "Betreff",
"submit": "Absenden",
"submit_title": "Einreichen",
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 0dde85c1f0..32b1de603d 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -86,6 +86,7 @@
"account_not_linked_to_dropbox": "Your account is not linked to Dropbox",
"account_settings": "Account settings",
"account_with_email_exists": "It looks like an __appName__ account with the email __email__ already exists.",
+ "account_with_this_email_exists": "It looks like an __appName__ account with this email address already exists.",
"acct_linked_to_institution_acct_2": "You can <0>log in0> to Overleaf through your <0>__institutionName__0> institutional login.",
"action_needed": "Action needed",
"actions": "Actions",
@@ -928,6 +929,7 @@
"failed_to_publish_as_a_template": "Failed to publish as a template.",
"failed_to_send_group_invite_to_email": "Failed to send group invite to <0>__email__0>. Please try again later.",
"failed_to_send_managed_user_invite_to_email": "Failed to send Managed User invite to <0>__email__0>. Please try again later.",
+ "failed_to_send_registration_email": "Failed to send the registration email. Please check the email address.",
"failed_to_send_sso_link_invite_to_email": "Failed to send SSO invite reminder to <0>__email__0>. Please try again later.",
"fair_usage_policy_applies": "Fair usage policy applies.",
"faq_how_does_free_trial_works_answer": "You get full access to your chosen __appName__ plan during your __len__-day free trial. There is no obligation to continue beyond the trial. Your card will be charged at the end of your __len__ day trial unless you cancel before then. You can cancel via your subscription settings.",
@@ -1380,6 +1382,7 @@
"include_the_error_message_and_ai_response": "Include the error message and AI response",
"included_as_part_of_your_writefull_subscription": "Included as part of your Writefull subscription",
"includes": "includes",
+ "including_all_subdomains": "including all subdomains",
"increase_indent": "Increase indentation",
"increased_ai_allowance": "Increased AI allowance",
"increased_ai_allowance_tooltip": "10 AI uses per day across all AI tools.",
@@ -2331,12 +2334,14 @@
"regards": "Regards",
"register": "Register",
"register_error": "Registration error",
+ "register_success": "Registration successful. Please check your email to activate your account.",
"register_intercept_sso": "You can link your __authProviderName__ account from the Account settings page after logging in.",
"register_to_accept_invitation": "Register to accept invitation",
"register_to_edit_template": "Please register to edit the __templateName__ template",
"register_with_another_email": "Register with __appName__ using another email.",
"registered": "Registered",
"registering": "Registering",
+ "registration_is_restricted_to_the_following_email_domains": "The registration is restricted to the following email domains:",
"reject": "Reject",
"reject_change": "Reject change",
"reject_selected_changes": "Reject selected changes",
@@ -2786,6 +2791,7 @@
"student_verification_rate_limited_info": "We’ve had too many verification requests recently. Please wait a few minutes and try again.",
"student_verification_required": "Student verification required",
"students": "Students",
+ "subdomains_of_this_domain_only": "subdomains of this domain only",
"subject": "Subject",
"subject_area": "Subject area",
"submit": "submit",
diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json
index 5d00f09a45..02e63f43f9 100644
--- a/services/web/locales/ru.json
+++ b/services/web/locales/ru.json
@@ -48,6 +48,7 @@
"account_information": "Информация об аккаунте",
"account_not_linked_to_dropbox": "Ваш аккаунт не синхронизирован с Dropbox",
"account_settings": "Настройки профиля",
+ "account_with_this_email_exists": "Похоже, что учётная запись __appName__ с этим адресом электронной почты уже существует.",
"actions": "Действия",
"activate": "Активировать",
"activate_account": "Активируйте Ваш аккаунт",
@@ -224,6 +225,7 @@
"explore_plans": "Посмотреть тарифы",
"export_project_to_github": "Экспорт проекта на GitHub",
"failed_to_publish_as_a_template": "Не удалось создать шаблон.",
+ "failed_to_send_registration_email": "Не удалось отправить письмо для подтверждения регистрации. Пожалуйста, проверьте адрес электронной почты.",
"fast": "быстрый",
"feature_controls": "Управление функциями",
"feature_settings_subtext": "Просмотр и управление функциями, доступными участникам Вашей группы",
@@ -377,6 +379,7 @@
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
"inactive_projects": "Неактивные проекты",
"included_as_part_of_your_writefull_subscription": "Входит в Вашу подписку Writefull",
+ "including_all_subdomains": "включая все поддомены",
"info": "Информация",
"institution": "Организация",
"integrations_like_github": "Интеграции, такие как синхронизация с GitHub",
@@ -599,9 +602,11 @@
"refresh_page_after_starting_free_trial": "Пожалуйста, обновите страницу после начала бесплатного пробного периода.",
"regards": "С уважением",
"register": "Регистрация",
+ "register_success": "Регистрация успешно завершена. Пожалуйста, проверьте свою электронную почту, чтобы активировать учётную запись.",
"register_to_edit_template": "Пожалуйста, зарегистрируйтесь, чтобы редактировать шаблон __templateName__",
"registered": "Зарегистрирован",
"registering": "Создание аккаунта",
+ "registration_is_restricted_to_the_following_email_domains": "Регистрация разрешена только для следующих доменов электронной почты:",
"remind_before_trial_ends": "Мы напомним Вам до окончания пробного периода",
"remove_from_group": "Удалить из группы",
"removed": "удалено",
@@ -684,6 +689,7 @@
"stop_compile": "Остановить компиляцию",
"stop_on_validation_error": "Проверить синтаксис перед компиляцией",
"student": "Студент",
+ "subdomains_of_this_domain_only": "только поддомены этого домена",
"subject": "Тема",
"subscribe": "Подписаться",
"subscribe_now": "Подписаться сейчас",
diff --git a/services/web/modules/registration-page/app/src/RegistrationPageController.mjs b/services/web/modules/registration-page/app/src/RegistrationPageController.mjs
new file mode 100644
index 0000000000..76337766f4
--- /dev/null
+++ b/services/web/modules/registration-page/app/src/RegistrationPageController.mjs
@@ -0,0 +1,101 @@
+import Path from 'path'
+import logger from '@overleaf/logger'
+import registerNewUserAndSendActivationEmail from './UserRegistrationHandler.mjs'
+import EmailHelper from '../../../../app/src/Features/Helpers/EmailHelper.mjs'
+import Settings from '@overleaf/settings'
+
+async function registrationPage(req, res, next) {
+ // Check if the user is already logged in
+ if (req.user != null) {
+ return res.redirect(`/`)
+ }
+
+ const sharedProjectData = req.session.sharedProjectData || {}
+
+ const newTemplateData = {}
+ if (req.session.templateData != null) {
+ newTemplateData.templateName = req.session.templateData.templateName
+ }
+
+ const allowedDomains = Settings.allowedRegistrationEmailDomains || []
+ const displayDomains = new Map()
+
+ for (const domain of allowedDomains) {
+ const subdomainsOnly = domain.startsWith('*.')
+ const base = subdomainsOnly ? domain.substring(2) : domain
+
+ const existing = displayDomains.get(base)
+
+ displayDomains.set(base, {
+ domain: base,
+ exact: existing?.exact || !subdomainsOnly,
+ subdomains: existing?.subdomains || subdomainsOnly,
+ })
+ }
+
+ const __dirname = Path.dirname(new URL(import.meta.url).pathname)
+ res.render(Path.resolve(__dirname, '../views/register'), {
+ title: 'register',
+ sharedProjectData,
+ newTemplateData,
+ displayDomains: [...displayDomains.values()],
+ csrfToken: req.csrfToken(),
+ })
+}
+
+async function registerNewUser(req, res, next) {
+ const { email, first_name, last_name } = req.body
+
+ if (
+ typeof first_name !== 'string' || first_name.length > 100 ||
+ typeof last_name !== 'string' || last_name.length > 100
+ ) {
+ return res.status(400).json({ message: 'Too long name.' })
+ }
+
+ const parsedEmail = EmailHelper.parseEmail(email)
+ if (!parsedEmail) {
+ return res.status(400).json({ message: 'Invalid email address.' })
+ }
+
+ // If registration is restricted to a specific email domains,
+ // check that the email domain is allowed
+ const domain = parsedEmail.split('@').pop()
+ const allowedDomains = Settings.allowedRegistrationEmailDomains
+
+ if (
+ allowedDomains &&
+ !allowedDomains.some(pattern => {
+ if (pattern.startsWith('*.')) {
+ const base = pattern.slice(2)
+ return domain.endsWith(`.${base}`)
+ }
+ return domain === pattern
+ })
+ ) {
+ return res.status(403).json({
+ message: 'Registration is not available for this email domain.',
+ })
+ }
+
+ const t = req.i18n.translate
+ try {
+ const userDetails = { email, first_name, last_name }
+ const success = await registerNewUserAndSendActivationEmail(userDetails)
+ if (success) {
+ return res.status(200).json({ message: t('register_success') })
+ } else {
+ return res.status(409).json({ message: { key: 'account_with_this_email_exists' } })
+ }
+ } catch (error) {
+ if (error.message === 'error sending message') {
+ return res.status(422).json({ message: t('failed_to_send_registration_email') })
+ }
+ return next(error)
+ }
+}
+
+export default {
+ registrationPage,
+ registerNewUser
+}
diff --git a/services/web/modules/registration-page/app/src/RegistrationPageRouter.mjs b/services/web/modules/registration-page/app/src/RegistrationPageRouter.mjs
new file mode 100644
index 0000000000..4ee2c86bc8
--- /dev/null
+++ b/services/web/modules/registration-page/app/src/RegistrationPageRouter.mjs
@@ -0,0 +1,33 @@
+import logger from "@overleaf/logger"
+import Settings from "@overleaf/settings"
+import RegistrationPageController from './RegistrationPageController.mjs'
+import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
+import RateLimiterMiddleware from "../../../../app/src/Features/Security/RateLimiterMiddleware.mjs"
+import { RateLimiter } from "../../../../app/src/infrastructure/RateLimiter.mjs"
+
+// Limit registration attempts to 3 per minute per IP
+const registrationRateLimiters = {
+ postRegister: new RateLimiter('postRegister', {
+ points: 5,
+ duration: 60,
+ }),
+}
+
+export default {
+ apply(webRouter) {
+ logger.debug({}, 'Init Registration Page module')
+
+ // remove default registration router if it exists
+ webRouter.stack = webRouter.stack.filter(layer => {
+ return !(layer.route && layer.route.path === '/register' && layer.route.methods.get)
+ })
+
+ webRouter.get('/register', RegistrationPageController.registrationPage)
+
+ webRouter.post(
+ '/register',
+ RateLimiterMiddleware.rateLimit(registrationRateLimiters.postRegister),
+ RegistrationPageController.registerNewUser
+ )
+ }
+}
diff --git a/services/web/modules/registration-page/app/src/UserRegistrationHandler.mjs b/services/web/modules/registration-page/app/src/UserRegistrationHandler.mjs
new file mode 100644
index 0000000000..5a067aad54
--- /dev/null
+++ b/services/web/modules/registration-page/app/src/UserRegistrationHandler.mjs
@@ -0,0 +1,51 @@
+import crypto from 'node:crypto'
+import settings from '@overleaf/settings'
+import logger from '@overleaf/logger'
+import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs'
+import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs'
+import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.mjs'
+import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.mjs'
+
+const ONE_WEEK = 7 * 24 * 60 * 60 // seconds
+
+export default async function registerNewUserAndSendActivationEmail(userData) {
+ let user
+ try {
+ const password = crypto.randomBytes(32).toString('hex')
+ user = await UserRegistrationHandler.promises.registerNewUser({ ...userData, password })
+ } catch (error) {
+ if (error.message === 'EmailAlreadyRegistered') {
+ logger.debug({ email: userData.email }, 'user already registered')
+ return false
+ }
+ throw error
+ }
+
+ try {
+ const token = await OneTimeTokenHandler.promises.getNewToken(
+ 'password',
+ { user_id: user._id.toString(), email: user.email },
+ { expiresIn: ONE_WEEK }
+ )
+
+ const setNewPasswordUrl = `${settings.siteUrl}/user/activate?token=${token}&user_id=${user._id}`
+
+ await EmailHandler.promises.sendEmail('registered', {
+ to: user.email,
+ setNewPasswordUrl,
+ })
+ } catch (error) {
+ try {
+ await UserDeleter.promises.deleteUser(user._id, {
+ ipAddress: '127.0.0.1',
+ skipEmail: true,
+ })
+ await UserDeleter.promises.expireDeletedUser(user._id)
+ } catch (err) {
+ logger.error({ err, userId: user._id }, 'failed delete user')
+ }
+ throw error
+ }
+
+ return true
+}
diff --git a/services/web/modules/registration-page/app/src/tsconfig.json b/services/web/modules/registration-page/app/src/tsconfig.json
new file mode 100644
index 0000000000..ddee721c50
--- /dev/null
+++ b/services/web/modules/registration-page/app/src/tsconfig.json
@@ -0,0 +1 @@
+{ "extends": "../../../../tsconfig.backend.json" }
diff --git a/services/web/modules/registration-page/app/views/register.pug b/services/web/modules/registration-page/app/views/register.pug
new file mode 100644
index 0000000000..99874ef8f2
--- /dev/null
+++ b/services/web/modules/registration-page/app/views/register.pug
@@ -0,0 +1,98 @@
+extends ../../../../app/views/layout-website-redesign
+
+block vars
+ - isWebsiteRedesign = true
+
+block content
+ main#main-content.content
+ .container
+ .row
+ .registration-message
+ if sharedProjectData.user_first_name !== undefined
+ h1 #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})}
+ em #{sharedProjectData.project_name}
+ div
+ | #{translate("join_sl_to_view_project")}.
+ div
+ | #{translate("already_have_sl_account")}
+ |
+ a(href='/login') #{translate("login_here")}
+ else if newTemplateData.templateName !== undefined
+ h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
+
+ div #{translate("already_have_sl_account")}
+ |
+ a(href='/login') #{translate("login_here")}
+ .row
+ .col-lg-8.offset-lg-2.col-xl-6.offset-xl-3
+ .page-header
+ h1.login-register-header-heading-focus.h3 #{translate("register")}
+ if displayDomains.length
+ .alert.alert-info
+ div.small.fst-italic.mb-2 #{translate("registration_is_restricted_to_the_following_email_domains")}
+ ul.mb-0.small
+ each item in displayDomains
+ li
+ span.small.fst-italic
+ code #{item.domain}
+ if item.subdomains && item.exact
+ | (#{translate("including_all_subdomains")})
+ else if item.subdomains
+ | (#{translate("subdomains_of_this_domain_only")})
+ form(
+ data-ol-async-form
+ name="registrationForm"
+ action="/register",
+ method="POST",
+ aria-label=translate('create_account')
+ )
+ input(name='_csrf' type='hidden' value=csrfToken)
+ +formMessagesNewStyle()
+ +customFormMessageNewStyle('account_with_this_email_exists', 'warn')
+ | !{translate("account_with_this_email_exists")}
+ a.d-block.mt-1(href='/user/password/reset') #{translate("forgot_your_password")}?
+ .form-group
+ label(for='firstNameField') #{translate("first_name")}
+ input.form-control(
+ aria-label="first_name"
+ id='firstNameField'
+ type='text',
+ name='first_name',
+ placeholder=translate("first_name"),
+ required,
+ autocomplete="given-name",
+ maxlength="100"
+ autofocus
+ )
+ .form-group
+ label(for='lastNameField') #{translate("last_name")}
+ input.form-control(
+ aria-label="last_name"
+ id='lastNameField'
+ type='text',
+ name='last_name',
+ placeholder=translate("last_name"),
+ required,
+ autocomplete="family-name",
+ maxlength="100"
+ )
+ .form-group
+ label(for='emailField') #{translate("email")}
+ input.form-control(
+ aria-label="email"
+ id='emailField'
+ type='email',
+ name='email',
+ placeholder=translate("email"),
+ required,
+ autocomplete="username",
+ maxlength="100"
+ )
+ div(style="margin-top: 3rem")
+ .actions
+ button.btn.btn-primary.w-100(
+ type='submit',
+ data-ol-disabled-inflight
+ )
+ span(data-ol-inflight="idle") #{translate("create_account")}
+ span(hidden data-ol-inflight="pending") #{translate("registering")}…
diff --git a/services/web/modules/registration-page/index.mjs b/services/web/modules/registration-page/index.mjs
new file mode 100644
index 0000000000..685cc49e8f
--- /dev/null
+++ b/services/web/modules/registration-page/index.mjs
@@ -0,0 +1,30 @@
+import Settings from '@overleaf/settings'
+import { boolFromEnv } from '../authentication/utils.mjs'
+
+let RegistrationPageModule = {}
+
+let enableRegistrationPage = boolFromEnv(process.env.OVERLEAF_ENABLE_REGISTRATION_PAGE)
+
+// if the env var is not set, enable registration page iff the external authentication is not enabled
+if (enableRegistrationPage === undefined) {
+ enableRegistrationPage = !(Settings.ldap?.enable || Settings.saml?.enable || Settings.oidc?.enable)
+}
+
+if (enableRegistrationPage) {
+ const { default: router } = await import('./app/src/RegistrationPageRouter.mjs')
+
+ Settings.enableRegistrationPage = true
+
+ if (process.env.OVERLEAF_ALLOWED_REGISTRATION_EMAIL_DOMAINS) {
+ Settings.allowedRegistrationEmailDomains = process.env.OVERLEAF_ALLOWED_REGISTRATION_EMAIL_DOMAINS
+ .split(/[,\s]+/)
+ .filter(Boolean)
+ }
+
+ RegistrationPageModule = {
+ name: 'registration-page',
+ router: router,
+ }
+}
+
+export default RegistrationPageModule