From fa6ea47952280ef1c7133a4e0ee3341b11940dbe Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 3 Dec 2024 01:18:19 +0100 Subject: [PATCH 001/274] Enable LDAP and SAML authentication support --- patches/@node-saml+node-saml+4.0.5.patch | 23 +++ patches/ldapauth-fork+4.3.3.patch | 64 +++++++ .../AuthenticationController.js | 5 +- .../PasswordReset/PasswordResetController.mjs | 4 + .../PasswordReset/PasswordResetHandler.mjs | 4 + .../app/src/Features/User/UserController.js | 3 +- services/web/app/views/user/login.pug | 14 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 2 + services/web/locales/en.json | 2 + .../launchpad/app/src/LaunchpadController.mjs | 3 +- .../modules/launchpad/app/views/launchpad.pug | 81 ++++++++- .../app/src/AuthenticationControllerLdap.mjs | 64 +++++++ .../app/src/AuthenticationManagerLdap.mjs | 80 +++++++++ .../app/src/InitLdapSettings.mjs | 17 ++ .../app/src/LdapContacts.mjs | 136 +++++++++++++++ .../app/src/LdapStrategy.mjs | 78 +++++++++ .../web/modules/ldap-authentication/index.mjs | 30 ++++ .../app/src/AuthenticationControllerSaml.mjs | 160 ++++++++++++++++++ .../app/src/AuthenticationManagerSaml.mjs | 60 +++++++ .../app/src/InitSamlSettings.mjs | 16 ++ .../app/src/SamlNonCsrfRouter.mjs | 12 ++ .../app/src/SamlRouter.mjs | 14 ++ .../app/src/SamlStrategy.mjs | 62 +++++++ .../web/modules/saml-authentication/index.mjs | 26 +++ 25 files changed, 953 insertions(+), 11 deletions(-) create mode 100644 patches/@node-saml+node-saml+4.0.5.patch create mode 100644 patches/ldapauth-fork+4.3.3.patch create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs create mode 100644 services/web/modules/ldap-authentication/index.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs create mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/patches/@node-saml+node-saml+4.0.5.patch b/patches/@node-saml+node-saml+4.0.5.patch new file mode 100644 index 0000000000..81fd700b31 --- /dev/null +++ b/patches/@node-saml+node-saml+4.0.5.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js +index fba15b9..a5778cb 100644 +--- a/node_modules/@node-saml/node-saml/lib/saml.js ++++ b/node_modules/@node-saml/node-saml/lib/saml.js +@@ -336,7 +336,8 @@ class SAML { + const requestOrResponse = request || response; + (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required"); + let buffer; +- if (this.options.skipRequestCompression) { ++ // logout requestOrResponse must be compressed anyway ++ if (this.options.skipRequestCompression && operation !== "logout") { + buffer = Buffer.from(requestOrResponse, "utf8"); + } + else { +@@ -495,7 +496,7 @@ class SAML { + try { + xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8"); + doc = await (0, xml_1.parseDomFromString)(xml); +- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo"); ++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo"); + if (inResponseToNodes) { + inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null; + await this.validateInResponseTo(inResponseTo); diff --git a/patches/ldapauth-fork+4.3.3.patch b/patches/ldapauth-fork+4.3.3.patch new file mode 100644 index 0000000000..4d31210c9d --- /dev/null +++ b/patches/ldapauth-fork+4.3.3.patch @@ -0,0 +1,64 @@ +diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js +index 85ecf36a8b..a7d07e0f78 100644 +--- a/node_modules/ldapauth-fork/lib/ldapauth.js ++++ b/node_modules/ldapauth-fork/lib/ldapauth.js +@@ -69,6 +69,7 @@ function LdapAuth(opts) { + this.opts.bindProperty || (this.opts.bindProperty = 'dn'); + this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub'); + this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn'); ++ this.opts.tlsStarted = false; + + EventEmitter.call(this); + +@@ -108,21 +109,7 @@ function LdapAuth(opts) { + this._userClient.on('error', this._handleError.bind(this)); + + var self = this; +- if (this.opts.starttls) { +- // When starttls is enabled, this callback supplants the 'connect' callback +- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } else { +- self._onConnectAdmin(); +- } +- }); +- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } +- }); +- } else if (opts.reconnect) { ++ if (opts.reconnect && !this.opts.starttls) { + this.once('_installReconnectListener', function() { + self.log && self.log.trace('install reconnect listener'); + self._adminClient.on('connect', function() { +@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) { + */ + LdapAuth.prototype.authenticate = function(username, password, callback) { + var self = this; ++ if (this.opts.starttls && !this.opts.tlsStarted) { ++ // When starttls is enabled, this callback supplants the 'connect' callback ++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } else { ++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);}); ++ } ++ }); ++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } ++ }); ++ } else { ++ self._handleAuthenticate(username, password, callback); ++ } ++}; ++ ++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) { ++ this.opts.tlsStarted = true; ++ var self = this; + + if (typeof password === 'undefined' || password === null || password === '') { + return callback(new Error('no password given')); diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 7a97d2ac9c..983526006e 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -102,9 +102,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - 'local', + Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], { keepSessionInfo: true }, - async function (err, user, info) { + async function (err, user, infoArray) { if (err) { return next(err) } @@ -126,6 +126,7 @@ const AuthenticationController = { return next(err) } } else { + let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 771782c302..85ed99fcb8 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -141,6 +141,10 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) + } else if (status === 'external') { + return res.status(403).json({ + message: req.i18n.translate('password_managed_externally'), + }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 094f18b95f..0ac203222c 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,6 +18,10 @@ async function generateAndEmailResetToken(email) { return null } + if (!user.hashedPassword) { + return 'external' + } + if (user.email !== email) { return 'secondary' } diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..772e77e3e4 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - req.externalAuthenticationSystemUsed() + (req.externalAuthenticationSystemUsed() && !user.hashedPassword) ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,6 +481,7 @@ async function doLogout(req) { } async function logout(req, res, next) { + if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 1ad77cb8b4..fc4973efb4 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -23,10 +23,10 @@ block content | !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}. .form-group input.form-control( - type='email', + type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email', name='email', required, - placeholder='email@example.com', + placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com', autofocus="true" ) .form-group @@ -47,4 +47,12 @@ block content if login_support_text hr p.text-center !{login_support_text} - + if settings.saml && settings.saml.enable + form(data-ol-async-form, name="samlLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/saml/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 4ac35bef71..0a4573d812 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -8,7 +8,7 @@ block vars block append meta meta(name="ol-hasPassword" data-type="boolean" content=hasPassword) - meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails) + meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails || hasPassword) meta(name="ol-oauthProviders", data-type="json", content=oauthProviders) meta(name="ol-institutionLinked", data-type="json", content=institutionLinked) meta(name="ol-samlError", data-type="json", content=samlError) @@ -20,7 +20,7 @@ block append meta meta(name="ol-ssoErrorMessage", content=ssoErrorMessage) meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {}) meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {}) - meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed()) + meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed() && !hasPassword) meta(name="ol-user" data-type="json" content=user) meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) meta(name="ol-dropbox" data-type="json" content=dropbox) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 07558a0420..f25f8760b1 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1010,6 +1010,8 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'ldap-authentication', + 'saml-authentication', ], viewIncludes: {}, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 5c6ab42fee..e8aafc842d 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -164,6 +164,7 @@ "already_have_sl_account": "Already have an __appName__ account?", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", + "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", @@ -1255,6 +1256,7 @@ "loading_prices": "loading prices", "loading_recent_github_commits": "Loading recent commits", "loading_writefull": "Loading Writefull", + "local_account": "Local account", "log_entry_description": "Log entry with level: __level__", "log_entry_maximum_entries": "Maximum log entries limit hit", "log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error” to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more", diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs index b626e0176e..49dd9ea9cb 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -154,7 +154,8 @@ function registerExternalAuthAdmin(authMethod) { await User.updateOne( { _id: user._id }, { - $set: { isAdmin: true, emails: [{ email, reversedHostname }] }, + $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] }, + $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword } ).exec() } catch (err) { diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index fdf0576c4a..80215cadf2 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -29,7 +29,7 @@ block vars block append meta meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists) - meta(name="ol-ideJsPath" content=buildJsPath('ide.js')) + meta(name="ol-ideJsPath" content=buildJsPath('ide-detached.js')) block content script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js') @@ -126,6 +126,45 @@ block content span(data-ol-inflight="idle") #{translate("register")} span(hidden data-ol-inflight="pending") #{translate("registering")}… + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action="/launchpad/register_admin" + method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email', + name='email', + placeholder="email@example.com" + autocomplete="username" + required, + autofocus="true" + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password', + name='password', + placeholder="********", + autocomplete="new-password" + required, + ) + .actions + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + // Saml Form if authMethod === 'saml' h3 #{translate('saml')} @@ -137,6 +176,35 @@ block content data-ol-register-admin action="/launchpad/register_saml_admin" method="POST" + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + name='email', + placeholder="email@example.com" + autocomplete="username" + required, + autofocus="true" + ) + .actions + button.btn-primary.btn( + type='submit' + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action="/launchpad/register_admin" + method="POST" ) input(name='_csrf', type='hidden', value=csrfToken) +formMessages() @@ -150,6 +218,15 @@ block content required, autofocus="true" ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password', + name='password', + placeholder="********", + autocomplete="new-password" + required, + ) .actions button.btn-primary.btn( type='submit' @@ -220,7 +297,7 @@ block content p a(href="/admin").btn.btn-info | Go To Admin Panel - |   + p a(href="/project").btn.btn-primary | Start Using #{settings.appName} br diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs new file mode 100644 index 0000000000..64fa4f5a96 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs @@ -0,0 +1,64 @@ +import logger from '@overleaf/logger' +import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' + +const AuthenticationControllerLdap = { + async doPassportLdapLogin(req, ldapUser, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( + req, + ldapUser + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLdapLogin(req, ldapUser) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + // async actions + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + status: 500, + }, + } + } + }, +} + +export const { + doPassportLdapLogin, +} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs new file mode 100644 index 0000000000..1371f76d52 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs @@ -0,0 +1,80 @@ +import Settings from '@overleaf/settings' +import { callbackify } from '@overleaf/promise-utils' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerLdap = { + splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; + }, + async findOrCreateLdapUser(profile, auditLog) { + //user is already authenticated in Ldap + const { + attEmail, + attFirstName, + attLastName, + attName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.ldap + + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = this.splitFullName(profile[attName] || "") + } + const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] + let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] + if (!firstName && !lastName) lastName = email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (profile._groups?.length > 0) || + (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of ldap user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), + promises: AuthenticationManagerLdap, +} +export const { + splitFullName, +} = AuthenticationManagerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs new file mode 100644 index 0000000000..e7f312fc11 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs @@ -0,0 +1,17 @@ +import Settings from '@overleaf/settings' + +function initLdapSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', + } +} + +export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs new file mode 100644 index 0000000000..c4093b8684 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs @@ -0,0 +1,136 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from './AuthenticationManagerLdap.mjs' + +async function fetchLdapContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials, + } = ldapOpts + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + const client = ldapjs.createClient(ldapConfig) + try { + if (starttls) { + await _upgradeToTLS(client, tlsOptions) + } + await _bindLdap(client, bindDN, bindCredentials) + + const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLdap(client, searchBase, searchOptions) + } catch (err) { + logger.warn({ err }, 'error in fetchLdapContacts') + return [] + } finally { + client.unbind() + } + + const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user', + }) + } + return acc + }, []) + + return newLdapContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(a.first_name) || + a.email.localeCompare(b.email) + ) +} + +function _upgradeToTLS(client, tlsOptions) { + return new Promise((resolve, reject) => { + client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) + client.on('connect', () => { + client.starttls(tlsOptions, null, error => { + if (error) { + reject(new Error(`StartTLS error: ${error}`)) + } else { + resolve() + } + }) + }) + }) +} + +function _bindLdap(client, bindDN, bindCredentials) { + return new Promise((resolve, reject) => { + client.bind(bindDN, bindCredentials, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function _searchLdap(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOpts.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})`, + } + const searchBase = ldapOpts.searchBase + const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) +} + +export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs new file mode 100644 index 0000000000..b07dc3f3bd --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs @@ -0,0 +1,78 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' +import { Strategy as LdapStrategy } from 'passport-ldapauth' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +// custom responses on authentication failure +class CustomFailLdapStrategy extends LdapStrategy { + constructor(options, validate) { + super(options, validate); + this.name = 'custom-fail-ldapauth' + } + authenticate(req, options) { + const defaultFail = this.fail.bind(this) + this.fail = function(info, status) { + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + info.status = 401 + return defaultFail(info, status) + }.bind(this) + super.authenticate(req, options) + } +} + +const ldapServerOpts = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', + timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, + connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, + starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', + tlsOptions: { + ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', + } +} + +function addLdapStrategy(passport) { + passport.use( + new CustomFailLdapStrategy( + { + server: ldapServerOpts, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + }, + doPassportLdapLogin + ) + ) +} + +export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs new file mode 100644 index 0000000000..f56d7ffee0 --- /dev/null +++ b/services/web/modules/ldap-authentication/index.mjs @@ -0,0 +1,30 @@ +import initLdapSettings from './app/src/InitLdapSettings.mjs' +import addLdapStrategy from './app/src/LdapStrategy.mjs' +import fetchLdapContacts from './app/src/LdapContacts.mjs' + +let ldapModule = {}; +if (process.env.EXTERNAL_AUTH === 'ldap') { + initLdapSettings() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addLdapStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + getContacts: async function (userId, contacts, callback) { + try { + const newLdapContacts = await fetchLdapContacts(userId, contacts) + callback(null, newLdapContacts) + } catch (error) { + callback(error) + } + }, + } + } +} +export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs new file mode 100644 index 0000000000..f5db3f738d --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs @@ -0,0 +1,160 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' +import UserController from '../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' + +const AuthenticationControllerSaml = { + passportSamlAuthWithIdP(req, res, next) { + if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { + const csp = res.getHeader('Content-Security-Policy') + if (csp) { + res.setHeader( + 'Content-Security-Policy', + csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '') + ) + } + } + passport.authenticate('saml')(req, res, next) + }, + passportSamlLogin(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate( + 'saml', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'SAML login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportSamlLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'SAML login', fromKnownDevice }, + } + + let user + try { + user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user) { + req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : profile.mail }, 'failed SAML log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async passportSamlSPLogout(req, res, next) { + passport._strategy('saml').logout(req, async (err, url) => { + if (err) logger.error({ err }, 'can not generate logout url') + await UserController.promises.doLogout(req) + res.redirect(url) + }) + }, + passportSamlIdPLogout(req, res, next) { + passport.authenticate('saml')(req, res, (err) => { + if (err) return next(err) + res.redirect('/login'); + }) + }, + async doPassportSamlLogout(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogout(req, profile) { + if (req?.session?.saml_extce?.nameID === profile.nameID && + req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { + profile = req.user + } + await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => { + logger.warn({ err, userId: req.user._id }, 'failed to untrack session') + }) + return { user: profile, info: undefined } + }, + passportSamlMetadata(req, res) { + const samlStratery = passport._strategy('saml') + res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) + xmlResponse(res, + samlStratery.generateServiceProviderMetadata( + samlStratery._saml.options.decryptionCert, + samlStratery._saml.options.signingCert + ) + ) + }, +} +export const { + passportSamlAuthWithIdP, + passportSamlLogin, + passportSamlSPLogout, + passportSamlIdPLogout, + doPassportSamlLogin, + doPassportSamlLogout, + passportSamlMetadata, +} = AuthenticationControllerSaml diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs new file mode 100644 index 0000000000..47d97f3019 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs @@ -0,0 +1,60 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerSaml = { + async findOrCreateSamlUser(profile, auditLog) { + const { + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of saml user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: AuthenticationManagerSaml, +} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs new file mode 100644 index 0000000000..441f9033af --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs @@ -0,0 +1,16 @@ +import Settings from '@overleaf/settings' + +function initSamlSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', + } +} + +export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs new file mode 100644 index 0000000000..65b42c92ae --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs @@ -0,0 +1,12 @@ +import logger from '@overleaf/logger' +import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.get('/saml/login/callback', passportSamlLogin) + webRouter.post('/saml/login/callback', passportSamlLogin) + webRouter.get('/saml/logout/callback', passportSamlIdPLogout) + webRouter.post('/saml/logout/callback', passportSamlIdPLogout) + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs new file mode 100644 index 0000000000..9ee3677901 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs @@ -0,0 +1,14 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', passportSamlAuthWithIdP) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) + webRouter.get('/saml/meta', passportSamlMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs new file mode 100644 index 0000000000..3a16459f98 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs @@ -0,0 +1,62 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' +import { Strategy as SamlStrategy } from '@node-saml/passport-saml' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), + privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', + disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, +// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, +} + +function addSamlStrategy(passport) { + passport.use( + new SamlStrategy( + samlOptions, + doPassportSamlLogin, + doPassportSamlLogout + ) + ) +} + +export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs new file mode 100644 index 0000000000..35ea70283f --- /dev/null +++ b/services/web/modules/saml-authentication/index.mjs @@ -0,0 +1,26 @@ +import initSamlSettings from './app/src/InitSamlSettings.mjs' +import addSamlStrategy from './app/src/SamlStrategy.mjs' +import SamlRouter from './app/src/SamlRouter.mjs' +import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' + +let samlModule = {}; + +if (process.env.EXTERNAL_AUTH === 'saml') { + initSamlSettings() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addSamlStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + }, + router: SamlRouter, + nonCsrfRouter: SamlNonCsrfRouter, + } +} +export default samlModule From 4003e6e7d33df89af5ef5b2c70770b978a4af7cc Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 17 Dec 2024 18:36:18 +0100 Subject: [PATCH 002/274] Refactor authentication code; add OIDC support --- .../AuthenticationController.js | 6 +- .../PasswordReset/PasswordResetController.mjs | 4 - .../PasswordReset/PasswordResetHandler.mjs | 5 +- .../app/src/Features/User/UserController.js | 3 +- .../src/Features/User/UserPagesController.mjs | 6 +- .../app/src/infrastructure/ExpressLocals.js | 4 +- services/web/app/src/router.mjs | 4 +- services/web/app/views/user/login.pug | 9 + services/web/app/views/user/passwordReset.pug | 2 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 19 +- .../web/frontend/extracted-translations.json | 1 + .../settings/components/linking-section.tsx | 3 +- .../components/linking/sso-widget.tsx | 4 +- .../settings/components/password-section.tsx | 6 +- .../frontend/js/shared/svgs/openid-logo.jsx | 27 +++ services/web/locales/en.json | 2 + .../app/src/LDAPAuthenticationController.mjs | 112 ++++++++++++ .../app/src/LDAPAuthenticationManager.mjs} | 36 ++-- .../ldap/app/src/LDAPContacts.mjs | 120 ++++++++++++ .../ldap/app/src/LDAPModuleManager.mjs | 112 ++++++++++++ .../ldap/app/src/LDAPRouter.mjs | 19 ++ .../web/modules/authentication/ldap/index.mjs | 17 ++ .../web/modules/authentication/logout.mjs | 18 ++ .../app/src/OIDCAuthenticationController.mjs | 171 ++++++++++++++++++ .../app/src/OIDCAuthenticationManager.mjs | 94 ++++++++++ .../oidc/app/src/OIDCModuleManager.mjs | 82 +++++++++ .../oidc/app/src/OIDCRouter.mjs | 15 ++ .../web/modules/authentication/oidc/index.mjs | 16 ++ .../app/src/SAMLAuthenticationController.mjs} | 66 +++---- .../app/src/SAMLAuthenticationManager.mjs | 85 +++++++++ .../saml/app/src/SAMLModuleManager.mjs | 100 ++++++++++ .../saml/app/src/SAMLNonCsrfRouter.mjs | 11 ++ .../saml/app/src/SAMLRouter.mjs | 16 ++ .../web/modules/authentication/saml/index.mjs | 18 ++ services/web/modules/authentication/utils.mjs | 42 +++++ .../modules/launchpad/app/views/launchpad.pug | 2 +- .../app/src/AuthenticationControllerLdap.mjs | 64 ------- .../app/src/InitLdapSettings.mjs | 17 -- .../app/src/LdapContacts.mjs | 136 -------------- .../app/src/LdapStrategy.mjs | 78 -------- .../web/modules/ldap-authentication/index.mjs | 30 --- .../app/src/AuthenticationManagerSaml.mjs | 60 ------ .../app/src/InitSamlSettings.mjs | 16 -- .../app/src/SamlNonCsrfRouter.mjs | 12 -- .../app/src/SamlRouter.mjs | 14 -- .../app/src/SamlStrategy.mjs | 62 ------- .../web/modules/saml-authentication/index.mjs | 26 --- services/web/package.json | 1 + 49 files changed, 1170 insertions(+), 607 deletions(-) create mode 100644 services/web/frontend/js/shared/svgs/openid-logo.jsx create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs rename services/web/modules/{ldap-authentication/app/src/AuthenticationManagerLdap.mjs => authentication/ldap/app/src/LDAPAuthenticationManager.mjs} (67%) create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs create mode 100644 services/web/modules/authentication/ldap/index.mjs create mode 100644 services/web/modules/authentication/logout.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs create mode 100644 services/web/modules/authentication/oidc/index.mjs rename services/web/modules/{saml-authentication/app/src/AuthenticationControllerSaml.mjs => authentication/saml/app/src/SAMLAuthenticationController.mjs} (65%) create mode 100644 services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLRouter.mjs create mode 100644 services/web/modules/authentication/saml/index.mjs create mode 100644 services/web/modules/authentication/utils.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs delete mode 100644 services/web/modules/ldap-authentication/index.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs delete mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 983526006e..baba8aacee 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -82,6 +82,7 @@ const AuthenticationController = { analyticsId: user.analyticsId || user._id, alphaProgram: user.alphaProgram || undefined, // only store if set betaProgram: user.betaProgram || undefined, // only store if set + externalAuth: user.externalAuth || false, } if (user.isAdmin) { lightUser.isAdmin = true @@ -102,9 +103,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], + 'local', { keepSessionInfo: true }, - async function (err, user, infoArray) { + async function (err, user, info) { if (err) { return next(err) } @@ -126,7 +127,6 @@ const AuthenticationController = { return next(err) } } else { - let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 85ed99fcb8..771782c302 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -141,10 +141,6 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) - } else if (status === 'external') { - return res.status(403).json({ - message: req.i18n.translate('password_managed_externally'), - }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 0ac203222c..2c1aefe6a6 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,10 +18,6 @@ async function generateAndEmailResetToken(email) { return null } - if (!user.hashedPassword) { - return 'external' - } - if (user.email !== email) { return 'secondary' } @@ -76,6 +72,7 @@ async function getUserForPasswordResetToken(token) { 'overleaf.id': 1, email: 1, must_reconfirm: 1, + hashedPassword: 1, }) await assertUserPermissions(user, ['change-password']) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 772e77e3e4..b767dcd4a1 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - (req.externalAuthenticationSystemUsed() && !user.hashedPassword) + req.externalAuthenticationSystemUsed() ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,7 +481,6 @@ async function doLogout(req) { } async function logout(req, res, next) { - if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index d353ca88e3..2f5d46d0d3 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -53,10 +53,8 @@ async function settingsPage(req, res) { const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) delete req.session.saml let shouldAllowEditingDetails = true - if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { + const externalAuth = req.user.externalAuth + if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) { shouldAllowEditingDetails = false } const oauthProviders = Settings.oauthProviders || {} diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 34eda0ba2d..836670233c 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -107,9 +107,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { webRouter.use(function (req, res, next) { req.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth res.locals.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth req.hasFeature = res.locals.hasFeature = Features.hasFeature next() }) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 7851a4a66f..484c4e7960 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -217,6 +217,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CaptchaMiddleware.canSkipCaptcha ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') @@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { TokenAccessRouter.apply(webRouter) HistoryRouter.apply(webRouter, privateApiRouter) - await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if (Settings.enableSubscriptions) { webRouter.get( '/user/bonus', diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index fc4973efb4..1ed2fc4950 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -56,3 +56,12 @@ block content ) span(data-ol-inflight="idle") #{settings.saml.identityServiceName} span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + if settings.oidc && settings.oidc.enable + form(data-ol-async-form, name="oidcLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/oidc/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug index ed806c32cd..4eb1adbca5 100644 --- a/services/web/app/views/user/passwordReset.pug +++ b/services/web/app/views/user/passwordReset.pug @@ -53,7 +53,7 @@ block content .notification-content-and-cta .notification-content p - | !{translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])} + | !{translate("you_cant_reset_password_due_to_ldap_or_sso")} input(type="hidden", name="_csrf", value=csrfToken) .form-group.mb-3 diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 0a4573d812..4ac35bef71 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -8,7 +8,7 @@ block vars block append meta meta(name="ol-hasPassword" data-type="boolean" content=hasPassword) - meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails || hasPassword) + meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails) meta(name="ol-oauthProviders", data-type="json", content=oauthProviders) meta(name="ol-institutionLinked", data-type="json", content=institutionLinked) meta(name="ol-samlError", data-type="json", content=samlError) @@ -20,7 +20,7 @@ block append meta meta(name="ol-ssoErrorMessage", content=ssoErrorMessage) meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {}) meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {}) - meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed() && !hasPassword) + meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed()) meta(name="ol-user" data-type="json" content=user) meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) meta(name="ol-dropbox" data-type="json" content=dropbox) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index f25f8760b1..3b2e17593a 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1010,8 +1010,9 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', - 'ldap-authentication', - 'saml-authentication', + 'authentication/ldap', + 'authentication/saml', + 'authentication/oidc', ], viewIncludes: {}, @@ -1038,6 +1039,20 @@ module.exports = { managedUsers: { enabled: false, }, + + oauthProviders: { + ...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && { + [process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: { + name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider', + descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION, + descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK }, + hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ? + process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined, + linkPath: '/oidc/login', + }, + }), + }, + } module.exports.mergeWith = function (overrides) { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 156ec9df3f..e01bb9aa94 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2137,6 +2137,7 @@ "you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator_plural": "", "you_can_still_use_your_premium_features": "", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", "you_dont_have_any_add_ons_on_your_account": "", diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index a198cb1328..411d38e650 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -204,7 +204,8 @@ function SSOLinkingWidgetContainer({ const { t } = useTranslation() const { unlink } = useSSOContext() - let description = '' + let description = subscription.provider.descriptionKey || + `${t('login_with_service', { service: subscription.provider.name, })}.` switch (subscription.providerId) { case 'collabratec': description = t('linked_collabratec_description') diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx index 800a7540ae..bb767d984c 100644 --- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx @@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json' import IEEELogo from '../../../../shared/svgs/ieee-logo' import GoogleLogo from '../../../../shared/svgs/google-logo' import OrcidLogo from '../../../../shared/svgs/orcid-logo' +import OpenIDLogo from '../../../../shared/svgs/openid-logo' import LinkingStatus from './status' import OLButton from '@/features/ui/components/ol/ol-button' import OLModal, { @@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = { collabratec: , google: , orcid: , + oidc: , } type SSOLinkingWidgetProps = { @@ -66,7 +68,7 @@ export function SSOLinkingWidget({ return (
-
{providerLogos[providerId]}
+
{providerLogos[providerId] || providerLogos['oidc']}

{title}

diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index 739636e998..c09ad50562 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() { return (

, - ]} + i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso" />

) diff --git a/services/web/frontend/js/shared/svgs/openid-logo.jsx b/services/web/frontend/js/shared/svgs/openid-logo.jsx new file mode 100644 index 0000000000..3de933820b --- /dev/null +++ b/services/web/frontend/js/shared/svgs/openid-logo.jsx @@ -0,0 +1,27 @@ +function OpenIDLogo() { + return ( + + + + + + + ) +} + +export default OpenIDLogo + diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e8aafc842d..f1ff9799ff 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2700,8 +2700,10 @@ "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "You can’t add or change your password because your group or organization uses LDAP or SSO.", "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", + "you_cant_reset_password_due_to_ldap_or_sso": "You can’t reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", "you_dont_have_any_repositories": "You don’t have any repositories", diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs new file mode 100644 index 0000000000..1a3ed01d3c --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs' + +const LDAPAuthenticationController = { + passportLogin(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate( + 'ldapauth', + { keepSessionInfo: true }, + async function (err, user, info, status) { + if (err) { //we cannot be here as long as errors are treated as fails + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'LDAP password login', + }) + + try { + await AuthenticationController.promises.finishLogin(user, req, res) + res.status(200) + return + } catch (err) { + return next(err) + } + } else { + if (status != 401) { + logger.warn(status, 'LDAP: ' + info.message) + } + if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(status || info.status || 401) + delete info.status + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await LDAPAuthenticationController._doPassportLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + user.externalAuth = 'ldap' + return { user, info: undefined } + } else { //we cannot be here, something is terribly wrong + logger.debug({ email : profile.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, +} + +export default LDAPAuthenticationController diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs similarity index 67% rename from services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs rename to services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs index 1371f76d52..66943e82a3 100644 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -1,18 +1,13 @@ import Settings from '@overleaf/settings' import { callbackify } from '@overleaf/promise-utils' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' +import { splitFullName } from '../../../utils.mjs' -const AuthenticationManagerLdap = { - splitFullName(fullName) { - fullName = fullName.trim(); - let lastSpaceIndex = fullName.lastIndexOf(' '); - let firstNames = fullName.substring(0, lastSpaceIndex).trim(); - let lastName = fullName.substring(lastSpaceIndex + 1).trim(); - return [firstNames, lastName]; - }, - async findOrCreateLdapUser(profile, auditLog) { - //user is already authenticated in Ldap +const LDAPAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + //user is already authenticated in LDAP const { attEmail, attFirstName, @@ -28,7 +23,7 @@ const AuthenticationManagerLdap = { : profile[attEmail].toLowerCase() let nameParts = ["",""] if ((!attFirstName || !attLastName) && attName) { - nameParts = this.splitFullName(profile[attName] || "") + nameParts = splitFullName(profile[attName] || "") } const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] @@ -40,6 +35,7 @@ const AuthenticationManagerLdap = { profile[attAdmin] === valAdmin) } let user = await User.findOne({ 'email': email }).exec() + if( !user ) { user = await UserCreator.promises.createNewUser( { @@ -61,8 +57,12 @@ const AuthenticationManagerLdap = { userDetails.isAdmin = isAdmin } const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + } ).exec() if (result.modifiedCount !== 1) { throw new ParallelLoginError() @@ -72,9 +72,5 @@ const AuthenticationManagerLdap = { } export default { - findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), - promises: AuthenticationManagerLdap, + promises: LDAPAuthenticationManager, } -export const { - splitFullName, -} = AuthenticationManagerLdap diff --git a/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs new file mode 100644 index 0000000000..4557b4a4e4 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs @@ -0,0 +1,120 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { promisify } from 'util' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from '../../../utils.mjs' + +function _searchLDAP(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function fetchLDAPContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOptions = passport._strategy('ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials + } = ldapOptions + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + let client + + try { + await new Promise((resolve, reject) => { + client = ldapjs.createClient(ldapConfig) + client.on('error', (error) => { reject(error) }) + client.on('connectTimeout', (error) => { reject(error) }) + client.on('connect', () => { resolve() }) + }) + + if (starttls) { + const starttlsAsync = promisify(client.starttls).bind(client) + await starttlsAsync(tlsOptions, null) + } + const bindAsync = promisify(client.bind).bind(client) + await bindAsync(bindDN, bindCredentials) + + async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOptions.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})` + } + const searchBase = ldapOptions.searchBase + const ldapUser = (await _searchLDAP(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) + } + + const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLDAP(client, searchBase, searchOptions) + } catch (error) { + logger.warn({ error }, 'Error in fetchLDAPContacts') + return [] + } finally { + client?.unbind() + } + + const newLDAPContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["", ""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user' + }) + } + return acc + }, []) + + return newLDAPContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) || + a.email.localeCompare(b.email) + ) +} + +export default fetchLDAPContacts diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs new file mode 100644 index 0000000000..846ca9b158 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import { Strategy as LDAPStrategy } from 'passport-ldapauth' +import Settings from '@overleaf/settings' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' +import fetchLDAPContacts from './LDAPContacts.mjs' + +const LDAPModuleManager = { + initSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const ldapOptions = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), + timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), + connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT), + starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS), + tlsOptions: { + ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH), + } + } + try { + passport.use( + new LDAPStrategy( + { + server: ldapOptions, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + handleErrorsAsFailures: true, + }, + LDAPAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + + async getContacts(userId, contacts, callback) { + try { + const newContacts = await fetchLDAPContacts(userId, contacts) + callback(null, newContacts) + } catch (error) { + callback(error) + } + }, + + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const ldapPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'ldapPolicy', + { 'change-password' : false }, + { validator: ldapPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default LDAPModuleManager diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs new file mode 100644 index 0000000000..44d9d373d2 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -0,0 +1,19 @@ +import logger from '@overleaf/logger' +import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.js' +import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.js' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init LDAP router') + webRouter.post('/login', + RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) + RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + CaptchaMiddleware.validateCaptcha('login'), + LDAPAuthenticationController.passportLogin, + AuthenticationController.passportLogin, + ) + }, +} diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs new file mode 100644 index 0000000000..244a8db8e7 --- /dev/null +++ b/services/web/modules/authentication/ldap/index.mjs @@ -0,0 +1,17 @@ +let ldapModule = {} +if (process.env.EXTERNAL_AUTH.includes('ldap')) { + const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') + const { default: router } = await import('./app/src/LDAPRouter.mjs') + LDAPModuleManager.initSettings() + LDAPModuleManager.initPolicy() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: LDAPModuleManager.passportSetup, + getContacts: LDAPModuleManager.getContacts, + getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default ldapModule diff --git a/services/web/modules/authentication/logout.mjs b/services/web/modules/authentication/logout.mjs new file mode 100644 index 0000000000..4163cf536d --- /dev/null +++ b/services/web/modules/authentication/logout.mjs @@ -0,0 +1,18 @@ +let SAMLAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('saml')) { + SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs') +} +let OIDCAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs') +} +export default async function logout(req, res, next) { + switch(req.user.externalAuth) { + case 'saml': + return SAMLAuthenticationController.default.passportLogout(req, res, next) + case 'oidc': + return OIDCAuthenticationController.default.passportLogout(req, res, next) + default: + next() + } +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs new file mode 100644 index 0000000000..42c01e712f --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -0,0 +1,171 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs' +import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js' + +const OIDCAuthenticationController = { + passportLogin(req, res, next) { + req.session.intent = req.query.intent + passport.authenticate('openidconnect')(req, res, next) + }, + passportLoginCallback(req, res, next) { + passport.authenticate( + 'openidconnect', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if(req.session.intent === 'link') { + delete req.session.intent +// After linking, log out from the OIDC provider and redirect back to '/user/settings'. +// Keycloak supports this; Authentik does not (yet). + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings` + return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + } + if (user) { + req.session.idToken = info.idToken + user.externalAuth = 'oidc' + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'OIDC login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 401) + delete info.status + const body = { message: info } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) { + let user, info + try { + if(req.session.intent === 'link') { + ;({ user, info } = await OIDCAuthenticationController._doLink( + req, + profile + )) + } else { + ;({ user, info } = await OIDCAuthenticationController._doLogin( + req, + profile + )) + } + } catch (error) { + return done(error) + } + if (user) { + info = { + ...(info || {}), + idToken: idToken + } + } + return done(null, user, info) + }, + async _doLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'OIDC login', fromKnownDevice }, + } + + let user + try { + user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`) + return { + user: false, + info: { + type: 'error', + text: error.message, + status: 401, + }, + } + } + if (user) { + return { user, info: undefined } + } else { // we cannot be here, something is terribly wrong + logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async _doLink(req, profile) { + const { user: { _id: userId }, ip } = req + try { + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog) + } catch (error) { + logger.error(error.info, error.message) + return { + user: true, + info: { + type: 'error', + text: error.message, + status: 200, + }, + } + } + return { user: true, info: undefined } + }, + async unlinkAccount(req, res, next) { + try { + const { user: { _id: userId }, body: { providerId }, ip } = req + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog) + return res.status(200).end() + } catch (error) { + logger.error(error.info, error.message) + return { + user: false, + info: { + type: 'error', + text: 'Can not unlink account', + status: 200, + } + } + } + }, + async passportLogout(req, res, next) { +// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? + const idTokenHint = req.session.idToken + await UserController.promises.doLogout(req) + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = Settings.siteUrl + res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + }, + passportLogoutCallback(req, res, next) { + const redirectUri = Settings.siteUrl + res.redirect(redirectUri) + }, +} +export default OIDCAuthenticationController diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs new file mode 100644 index 0000000000..56ec2e5455 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -0,0 +1,94 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' + +const OIDCAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const email = profile.emails[0].value + const firstName = profile.name?.givenName || "" + const lastName = profile.name?.familyName || "" + let isAdmin = false + if (attAdmin && valAdmin) { + if (attAdmin === 'email') { + isAdmin = (email === valAdmin) + } else { + isAdmin = (profile[attAdmin] === valAdmin) + } + } + const oidcUserData = null // Possibly it can be used later + let user + try { + user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) + } catch { +// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. +// If no user exists with this email, create a new user and link the OIDC account to it. +// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. +// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. +// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + } +// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId) +// if (!alreadyLinked) { + auditLog.initiatorId = user._id + await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog) + await User.updateOne( + { _id: user._id }, + { $set : { + 'emails.0.confirmedAt': Date.now(), //email of external user is confirmed + }, + } + ).exec() +// } else { +// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`) +// } + } + + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, + async linkAccount(userId, profile, auditLog) { + const { + attUserId, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const oidcUserData = null // Possibly it can be used later + await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) + }, +} + +export default { + promises: OIDCAuthenticationManager, +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs new file mode 100644 index 0000000000..3a2e6e2780 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -0,0 +1,82 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import { Strategy as OIDCStrategy } from 'passport-openidconnect' + +const OIDCModuleManager = { + initSettings() { + let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc' + Settings.oidc = { + enable: true, + providerId: providerId, + identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`, + attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', + attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const oidcOptions = { + issuer: process.env.OVERLEAF_OIDC_ISSUER, + authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL, + tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL, + userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL, + clientID: process.env.OVERLEAF_OIDC_CLIENT_ID, + clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET, + callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`, + scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email', + passReqToCallback: true, + } + try { + passport.use( + new OIDCStrategy( + oidcOptions, + OIDCAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const oidcPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'oidcPolicy', + { 'change-password' : false }, + { validator: oidcPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default OIDCModuleManager diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs new file mode 100644 index 0000000000..519fa5043a --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -0,0 +1,15 @@ +import logger from '@overleaf/logger' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init OIDC router') + webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) + webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs new file mode 100644 index 0000000000..51d9e0d483 --- /dev/null +++ b/services/web/modules/authentication/oidc/index.mjs @@ -0,0 +1,16 @@ +let oidcModule = {} +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') + const { default: router } = await import('./app/src/OIDCRouter.mjs') + OIDCModuleManager.initSettings() + OIDCModuleManager.initPolicy() + oidcModule = { + name: 'oidc-authentication', + hooks: { + passportSetup: OIDCModuleManager.passportSetup, + getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default oidcModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs similarity index 65% rename from services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs rename to services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index f5db3f738d..ac0e5398b2 100644 --- a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -1,15 +1,16 @@ import Settings from '@overleaf/settings' import logger from '@overleaf/logger' import passport from 'passport' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' -import UserController from '../../../../app/src/Features/User/UserController.js' -import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import SAMLAuthenticationManager from './SAMLAuthenticationManager.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../../app/src/infrastructure/Response.js' +import { readFilesContentFromEnv } from '../../../utils.mjs' -const AuthenticationControllerSaml = { - passportSamlAuthWithIdP(req, res, next) { +const SAMLAuthenticationController = { + passportLogin(req, res, next) { if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { const csp = res.getHeader('Content-Security-Policy') if (csp) { @@ -21,7 +22,7 @@ const AuthenticationControllerSaml = { } passport.authenticate('saml')(req, res, next) }, - passportSamlLogin(req, res, next) { + passportLoginCallback(req, res, next) { // This function is middleware which wraps the passport.authenticate middleware, // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success @@ -46,24 +47,19 @@ const AuthenticationControllerSaml = { if (info.redir != null) { return res.json({ redir: info.redir }) } else { - res.status(info.status || 200) + res.status(info.status || 401) delete info.status const body = { message: info } - const { errorReason } = info - if (errorReason) { - body.errorReason = errorReason - delete info.errorReason - } return res.json(body) } } } )(req, res, next) }, - async doPassportSamlLogin(req, profile, done) { + async doPassportLogin(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogin( req, profile )) @@ -72,7 +68,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogin(req, profile) { + async _doPassportLogin(req, profile) { const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) const auditLog = { ipAddress: req.ip, @@ -81,7 +77,7 @@ const AuthenticationControllerSaml = { let user try { - user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog) } catch (error) { return { user: false, @@ -89,9 +85,10 @@ const AuthenticationControllerSaml = { } } if (user) { + user.externalAuth = 'saml' req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} return { user, info: undefined } - } else { //something wrong + } else { // we cannot be here, something is terribly wrong logger.debug({ email : profile.mail }, 'failed SAML log in') return { user: false, @@ -103,23 +100,24 @@ const AuthenticationControllerSaml = { } } }, - async passportSamlSPLogout(req, res, next) { + async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - if (err) logger.error({ err }, 'can not generate logout url') await UserController.promises.doLogout(req) + if (err) return next(err) res.redirect(url) }) }, - passportSamlIdPLogout(req, res, next) { + passportLogoutCallback(req, res, next) { +//TODO: is it possible to close the editor? passport.authenticate('saml')(req, res, (err) => { if (err) return next(err) res.redirect('/login'); }) }, - async doPassportSamlLogout(req, profile, done) { + async doPassportLogout(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogout( req, profile )) @@ -128,7 +126,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogout(req, profile) { + async _doPassportLogout(req, profile) { if (req?.session?.saml_extce?.nameID === profile.nameID && req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { profile = req.user @@ -138,23 +136,15 @@ const AuthenticationControllerSaml = { }) return { user: profile, info: undefined } }, - passportSamlMetadata(req, res) { + getSPMetadata(req, res) { const samlStratery = passport._strategy('saml') res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) xmlResponse(res, samlStratery.generateServiceProviderMetadata( - samlStratery._saml.options.decryptionCert, - samlStratery._saml.options.signingCert + readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT) ) ) }, } -export const { - passportSamlAuthWithIdP, - passportSamlLogin, - passportSamlSPLogout, - passportSamlIdPLogout, - doPassportSamlLogin, - doPassportSamlLogout, - passportSamlMetadata, -} = AuthenticationControllerSaml +export default SAMLAuthenticationController diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs new file mode 100644 index 0000000000..80c4e30ea7 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs @@ -0,0 +1,85 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.js' +import { User } from '../../../../../app/src/models/User.js' + +const SAMLAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const externalUserId = profile[attUserId] + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if (attAdmin && valAdmin) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + const providerId = '1' // for now, only one fixed IdP is supported +// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found, +// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of +// multiple SAML IdPs, one would have to do something similar, or possibly report an error like +// 'the email is associated with the wrong IdP' + let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId) + if (!user) { + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + samlIdentifiers: [{ providerId: providerId }], + } + ) + } + // cannot use SAMLIdentityManager.linkAccounts because affilations service is not there + await User.updateOne( + { _id: user._id }, + { + $set : { + 'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed + 'emails.0.samlProviderId': providerId, + 'samlIdentifiers.0.providerId': providerId, + 'samlIdentifiers.0.externalUserId': externalUserId, + 'samlIdentifiers.0.userIdAttribute': attUserId, + }, + } + ).exec() + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + }, + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: SAMLAuthenticationManager, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs new file mode 100644 index 0000000000..c7efdef214 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -0,0 +1,100 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import { Strategy as SAMLStrategy } from '@node-saml/passport-saml' + +const SAMLModuleManager = { + initSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP', + attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN), + } +}, + passportSetup(passport, callback) { + const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS), + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN), + disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT), + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS), + // cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, + } + try { + passport.use( + new SAMLStrategy( + samlOptions, + SAMLAuthenticationController.doPassportLogin, + SAMLAuthenticationController.doPassportLogout + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const samlPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'samlPolicy', + { 'change-password' : false }, + { validator: samlPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default SAMLModuleManager diff --git a/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs new file mode 100644 index 0000000000..c0d617b299 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs @@ -0,0 +1,11 @@ +import logger from '@overleaf/logger' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback) + webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + }, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs new file mode 100644 index 0000000000..3cd6e56e2d --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs @@ -0,0 +1,16 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs new file mode 100644 index 0000000000..2d6ee5706c --- /dev/null +++ b/services/web/modules/authentication/saml/index.mjs @@ -0,0 +1,18 @@ +let samlModule = {} +if (process.env.EXTERNAL_AUTH.includes('saml')) { + const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') + const { default: router } = await import('./app/src/SAMLRouter.mjs') + const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') + SAMLModuleManager.initSettings() + SAMLModuleManager.initPolicy() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: SAMLModuleManager.passportSetup, + getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser, + }, + router: router, + nonCsrfRouter: nonCsrfRouter, + } +} +export default samlModule diff --git a/services/web/modules/authentication/utils.mjs b/services/web/modules/authentication/utils.mjs new file mode 100644 index 0000000000..468dae32b1 --- /dev/null +++ b/services/web/modules/authentication/utils.mjs @@ -0,0 +1,42 @@ +import fs from 'fs' +function readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} +function numFromEnv(env) { + return env ? Number(env) : undefined +} +function boolFromEnv(env) { + if (env === undefined || env === null) return undefined + if (typeof env === "string") { + const envLower = env.toLowerCase() + if (envLower === 'true') return true + if (envLower === 'false') return false + } + throw new Error("Invalid value for boolean envirionment variable") +} + +function splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; +} + +export { + readFilesContentFromEnv, + numFromEnv, + boolFromEnv, + splitFullName, +} diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index 80215cadf2..32dcd7abb7 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -29,7 +29,7 @@ block vars block append meta meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists) - meta(name="ol-ideJsPath" content=buildJsPath('ide-detached.js')) + meta(name="ol-ideJsPath" content=buildJsPath('ide.js')) block content script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js') diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs deleted file mode 100644 index 64fa4f5a96..0000000000 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import logger from '@overleaf/logger' -import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' - -const AuthenticationControllerLdap = { - async doPassportLdapLogin(req, ldapUser, done) { - let user, info - try { - ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( - req, - ldapUser - )) - } catch (error) { - return done(error) - } - return done(undefined, user, info) - }, - async _doPassportLdapLogin(req, ldapUser) { - const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) - const auditLog = { - ipAddress: req.ip, - info: { method: 'LDAP password login', fromKnownDevice }, - } - - let user, isPasswordReused - try { - user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) - } catch (error) { - return { - user: false, - info: handleAuthenticateErrors(error, req), - } - } - if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { - return { - user: false, - info: { - text: req.i18n.translate('cannot_verify_user_not_robot'), - type: 'error', - errorReason: 'cannot_verify_user_not_robot', - status: 400, - }, - } - } else if (user) { - // async actions - return { user, info: undefined } - } else { //something wrong - logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') - return { - user: false, - info: { - type: 'error', - status: 500, - }, - } - } - }, -} - -export const { - doPassportLdapLogin, -} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs deleted file mode 100644 index e7f312fc11..0000000000 --- a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import Settings from '@overleaf/settings' - -function initLdapSettings() { - Settings.ldap = { - enable: true, - placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', - attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', - attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, - attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, - attName: process.env.OVERLEAF_LDAP_NAME_ATT, - attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, - valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', - } -} - -export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs deleted file mode 100644 index c4093b8684..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import Settings from '@overleaf/settings' -import logger from '@overleaf/logger' -import passport from 'passport' -import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' -import UserGetter from '../../../../app/src/Features/User/UserGetter.js' -import { splitFullName } from './AuthenticationManagerLdap.mjs' - -async function fetchLdapContacts(userId, contacts) { - if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { - return [] - } - - const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server - const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap - const { - url, - timeout, - connectTimeout, - tlsOptions, - starttls, - bindDN, - bindCredentials, - } = ldapOpts - const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase - const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' - const ldapConfig = { url, timeout, connectTimeout, tlsOptions } - - let ldapUsers - const client = ldapjs.createClient(ldapConfig) - try { - if (starttls) { - await _upgradeToTLS(client, tlsOptions) - } - await _bindLdap(client, bindDN, bindCredentials) - - const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) - const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } - - ldapUsers = await _searchLdap(client, searchBase, searchOptions) - } catch (err) { - logger.warn({ err }, 'error in fetchLdapContacts') - return [] - } finally { - client.unbind() - } - - const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { - const email = Array.isArray(ldapUser[attEmail]) - ? ldapUser[attEmail][0]?.toLowerCase() - : ldapUser[attEmail]?.toLowerCase() - if (!email) return acc - if (!contacts.some(contact => contact.email === email)) { - let nameParts = ["",""] - if ((!attFirstName || !attLastName) && attName) { - nameParts = splitFullName(ldapUser[attName] || "") - } - const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] - const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] - acc.push({ - first_name: firstName, - last_name: lastName, - email: email, - type: 'user', - }) - } - return acc - }, []) - - return newLdapContacts.sort((a, b) => - a.last_name.localeCompare(b.last_name) || - a.first_name.localeCompare(a.first_name) || - a.email.localeCompare(b.email) - ) -} - -function _upgradeToTLS(client, tlsOptions) { - return new Promise((resolve, reject) => { - client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) - client.on('connect', () => { - client.starttls(tlsOptions, null, error => { - if (error) { - reject(new Error(`StartTLS error: ${error}`)) - } else { - resolve() - } - }) - }) - }) -} - -function _bindLdap(client, bindDN, bindCredentials) { - return new Promise((resolve, reject) => { - client.bind(bindDN, bindCredentials, error => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function _searchLdap(client, baseDN, options) { - return new Promise((resolve, reject) => { - const searchEntries = [] - client.search(baseDN, options, (error, res) => { - if (error) { - reject(error) - } else { - res.on('searchEntry', entry => searchEntries.push(entry.object)) - res.on('error', reject) - res.on('end', () => resolve(searchEntries)) - } - }) - }) -} - -async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { - const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY - if (!searchProperty) { - return contactsFilter - } - const email = await UserGetter.promises.getUserEmail(userId) - const searchOptions = { - scope: ldapOpts.searchScope, - attributes: [searchProperty], - filter: `(${Settings.ldap.attEmail}=${email})`, - } - const searchBase = ldapOpts.searchBase - const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] - const searchPropertyValue = ldapUser ? ldapUser[searchProperty] - : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' - return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) -} - -export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs deleted file mode 100644 index b07dc3f3bd..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' -import { Strategy as LdapStrategy } from 'passport-ldapauth' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -// custom responses on authentication failure -class CustomFailLdapStrategy extends LdapStrategy { - constructor(options, validate) { - super(options, validate); - this.name = 'custom-fail-ldapauth' - } - authenticate(req, options) { - const defaultFail = this.fail.bind(this) - this.fail = function(info, status) { - info.type = 'error' - info.key = 'invalid-password-retry-or-reset' - info.status = 401 - return defaultFail(info, status) - }.bind(this) - super.authenticate(req, options) - } -} - -const ldapServerOpts = { - url: process.env.OVERLEAF_LDAP_URL, - bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", - bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", - bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, - searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, - searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, - searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', - searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), - groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, - groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, - groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', - groupSearchAttributes: ["dn"], - groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, - cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', - timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, - connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, - starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', - tlsOptions: { - ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), - rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', - } -} - -function addLdapStrategy(passport) { - passport.use( - new CustomFailLdapStrategy( - { - server: ldapServerOpts, - passReqToCallback: true, - usernameField: 'email', - passwordField: 'password', - }, - doPassportLdapLogin - ) - ) -} - -export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs deleted file mode 100644 index f56d7ffee0..0000000000 --- a/services/web/modules/ldap-authentication/index.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import initLdapSettings from './app/src/InitLdapSettings.mjs' -import addLdapStrategy from './app/src/LdapStrategy.mjs' -import fetchLdapContacts from './app/src/LdapContacts.mjs' - -let ldapModule = {}; -if (process.env.EXTERNAL_AUTH === 'ldap') { - initLdapSettings() - ldapModule = { - name: 'ldap-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addLdapStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - getContacts: async function (userId, contacts, callback) { - try { - const newLdapContacts = await fetchLdapContacts(userId, contacts) - callback(null, newLdapContacts) - } catch (error) { - callback(error) - } - }, - } - } -} -export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs deleted file mode 100644 index 47d97f3019..0000000000 --- a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import Settings from '@overleaf/settings' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' - -const AuthenticationManagerSaml = { - async findOrCreateSamlUser(profile, auditLog) { - const { - attEmail, - attFirstName, - attLastName, - attAdmin, - valAdmin, - updateUserDetailsOnLogin, - } = Settings.saml - const email = Array.isArray(profile[attEmail]) - ? profile[attEmail][0].toLowerCase() - : profile[attEmail].toLowerCase() - const firstName = attFirstName ? profile[attFirstName] : "" - const lastName = attLastName ? profile[attLastName] : email - let isAdmin = false - if( attAdmin && valAdmin ) { - isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : - profile[attAdmin] === valAdmin) - } - let user = await User.findOne({ 'email': email }).exec() - if( !user ) { - user = await UserCreator.promises.createNewUser( - { - email: email, - first_name: firstName, - last_name: lastName, - isAdmin: isAdmin, - holdingAccount: false, - } - ) - await User.updateOne( - { _id: user._id }, - { $set : { 'emails.0.confirmedAt' : Date.now() } } - ).exec() //email of saml user is confirmed - } - let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} - if( attAdmin && valAdmin ) { - user.isAdmin = isAdmin - userDetails.isAdmin = isAdmin - } - const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} - ).exec() - - if (result.modifiedCount !== 1) { - throw new ParallelLoginError() - } - return user - }, -} - -export default { - promises: AuthenticationManagerSaml, -} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs deleted file mode 100644 index 441f9033af..0000000000 --- a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import Settings from '@overleaf/settings' - -function initSamlSettings() { - Settings.saml = { - enable: true, - identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', - attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', - attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', - attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', - attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, - valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', - } -} - -export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs deleted file mode 100644 index 65b42c92ae..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import logger from '@overleaf/logger' -import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML NonCsrfRouter') - webRouter.get('/saml/login/callback', passportSamlLogin) - webRouter.post('/saml/login/callback', passportSamlLogin) - webRouter.get('/saml/logout/callback', passportSamlIdPLogout) - webRouter.post('/saml/logout/callback', passportSamlIdPLogout) - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs deleted file mode 100644 index 9ee3677901..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import logger from '@overleaf/logger' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML router') - webRouter.get('/saml/login', passportSamlAuthWithIdP) - AuthenticationController.addEndpointToLoginWhitelist('/saml/login') - webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) - webRouter.get('/saml/meta', passportSamlMetadata) - AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs deleted file mode 100644 index 3a16459f98..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' -import { Strategy as SamlStrategy } from '@node-saml/passport-saml' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -const samlOptions = { - entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, - callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, - issuer: process.env.OVERLEAF_SAML_ISSUER, - audience: process.env.OVERLEAF_SAML_AUDIENCE, - cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), - signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), - privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), - decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), - decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), - signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, - additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), - additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), - identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, - acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, - attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, - authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, - forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', - disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', - skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST - authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, - validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, - requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, -// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, - logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, - logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, - additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), - passReqToCallback: true, -} - -function addSamlStrategy(passport) { - passport.use( - new SamlStrategy( - samlOptions, - doPassportSamlLogin, - doPassportSamlLogout - ) - ) -} - -export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs deleted file mode 100644 index 35ea70283f..0000000000 --- a/services/web/modules/saml-authentication/index.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import initSamlSettings from './app/src/InitSamlSettings.mjs' -import addSamlStrategy from './app/src/SamlStrategy.mjs' -import SamlRouter from './app/src/SamlRouter.mjs' -import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' - -let samlModule = {}; - -if (process.env.EXTERNAL_AUTH === 'saml') { - initSamlSettings() - samlModule = { - name: 'saml-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addSamlStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - }, - router: SamlRouter, - nonCsrfRouter: SamlNonCsrfRouter, - } -} -export default samlModule diff --git a/services/web/package.json b/services/web/package.json index 59825e0e68..ff837e87ac 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -159,6 +159,7 @@ "passport-ldapauth": "^2.1.4", "passport-local": "^1.0.0", "passport-oauth2": "^1.5.0", + "passport-openidconnect": "^0.1.2", "passport-orcid": "0.0.4", "pug": "^3.0.3", "pug-runtime": "^3.0.1", From 855baba4240fdf0e2dc1ce5d38c7974d75bf0fe6 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 27 Jan 2025 04:58:23 +0100 Subject: [PATCH 003/274] Re-export `doLogout` (was removed from exports in commit b9fb636). --- services/web/app/src/Features/User/UserController.js | 1 + .../oidc/app/src/OIDCAuthenticationController.mjs | 2 +- .../saml/app/src/SAMLAuthenticationController.mjs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..cabab8c891 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -518,4 +518,5 @@ module.exports = { expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), ensureAffiliation, + doLogout, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 42c01e712f..0b8dc501e0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -158,7 +158,7 @@ const OIDCAuthenticationController = { async passportLogout(req, res, next) { // TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? const idTokenHint = req.session.idToken - await UserController.promises.doLogout(req) + await UserController.doLogout(req) const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL const redirectUri = Settings.siteUrl res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index ac0e5398b2..3ed834608f 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -102,7 +102,7 @@ const SAMLAuthenticationController = { }, async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - await UserController.promises.doLogout(req) + await UserController.doLogout(req) if (err) return next(err) res.redirect(url) }) From 9b23e0d8d772d271b89299dcf648c4256b263cb2 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Thu, 6 Feb 2025 12:12:03 +0100 Subject: [PATCH 004/274] Add ENV variables to control SAML signature validation --- .../modules/authentication/saml/app/src/SAMLModuleManager.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index c7efdef214..29e9ae52cd 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -46,6 +46,8 @@ const SAMLModuleManager = { logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + wantAssertionsSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED), + wantAuthnResponseSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED), passReqToCallback: true, } try { From 8c3b7567746b4e88939ae88ed83483df51a30003 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sat, 22 Feb 2025 03:26:25 +0100 Subject: [PATCH 005/274] Whitelist /oidc/login endpoint, fixes #21 --- .../web/modules/authentication/oidc/app/src/OIDCRouter.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs index 519fa5043a..0857e41889 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -1,5 +1,6 @@ import logger from '@overleaf/logger' import UserController from '../../../../../app/src/Features/User/UserController.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' import logout from '../../../logout.mjs' @@ -7,7 +8,9 @@ export default { apply(webRouter) { logger.debug({}, 'Init OIDC router') webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login') webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login/callback') webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) webRouter.post('/logout', logout, UserController.logout) From acb0a75e6ed380bbb4cc11d4c10f13899e082b29 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 05:55:01 +0100 Subject: [PATCH 006/274] See upstream commit 42ee56e --- services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs index 44d9d373d2..d2bbb35236 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -10,7 +10,7 @@ export default { logger.debug({}, 'Init LDAP router') webRouter.post('/login', RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) - RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + RateLimiterMiddleware.loginRateLimitEmail(), // rate limit email (10 / 120s) CaptchaMiddleware.validateCaptcha('login'), LDAPAuthenticationController.passportLogin, AuthenticationController.passportLogin, From 7770eddc17a1e2076518a9d39579b8911691fd20 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 06:37:50 +0100 Subject: [PATCH 007/274] Make OVERLEAF_OIDC_USER_ID_FIELD support 'email' as a value --- .../authentication/oidc/app/src/OIDCAuthenticationManager.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 56ec2e5455..5295ce63d0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -13,8 +13,8 @@ const OIDCAuthenticationManager = { updateUserDetailsOnLogin, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] const email = profile.emails[0].value + const oidcUserId = (attUserId === 'email') ? email : profile[attUserId] const firstName = profile.name?.givenName || "" const lastName = profile.name?.familyName || "" let isAdmin = false @@ -83,7 +83,7 @@ const OIDCAuthenticationManager = { attUserId, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] + const oidcUserId = (attUserId === 'email') ? profile.emails[0].value : profile[attUserId] const oidcUserData = null // Possibly it can be used later await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) }, From ca16cc8fa620701d116d0dd2253236ef9011452d Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 4 Apr 2025 15:14:14 +0200 Subject: [PATCH 008/274] Allow EXTERNAL_AUTH to be undefined, fixes #26 --- services/web/modules/authentication/ldap/index.mjs | 2 +- services/web/modules/authentication/oidc/index.mjs | 2 +- services/web/modules/authentication/saml/index.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs index 244a8db8e7..94743a6611 100644 --- a/services/web/modules/authentication/ldap/index.mjs +++ b/services/web/modules/authentication/ldap/index.mjs @@ -1,5 +1,5 @@ let ldapModule = {} -if (process.env.EXTERNAL_AUTH.includes('ldap')) { +if (process.env.EXTERNAL_AUTH?.includes('ldap')) { const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') const { default: router } = await import('./app/src/LDAPRouter.mjs') LDAPModuleManager.initSettings() diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs index 51d9e0d483..f10ff64c82 100644 --- a/services/web/modules/authentication/oidc/index.mjs +++ b/services/web/modules/authentication/oidc/index.mjs @@ -1,5 +1,5 @@ let oidcModule = {} -if (process.env.EXTERNAL_AUTH.includes('oidc')) { +if (process.env.EXTERNAL_AUTH?.includes('oidc')) { const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') const { default: router } = await import('./app/src/OIDCRouter.mjs') OIDCModuleManager.initSettings() diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs index 2d6ee5706c..36f0281637 100644 --- a/services/web/modules/authentication/saml/index.mjs +++ b/services/web/modules/authentication/saml/index.mjs @@ -1,5 +1,5 @@ let samlModule = {} -if (process.env.EXTERNAL_AUTH.includes('saml')) { +if (process.env.EXTERNAL_AUTH?.includes('saml')) { const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') const { default: router } = await import('./app/src/SAMLRouter.mjs') const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') From 17c07707db3f8764ad3f2f9357374499a5ffd30b Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 23 May 2025 16:00:40 +0200 Subject: [PATCH 009/274] Fix login page --- services/web/app/views/user/login.pug | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 1ed2fc4950..ffeb3eca89 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -48,20 +48,20 @@ block content hr p.text-center !{login_support_text} if settings.saml && settings.saml.enable - form(data-ol-async-form, name="samlLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/saml/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.saml.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/saml/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… if settings.oidc && settings.oidc.enable - form(data-ol-async-form, name="oidcLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/oidc/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/oidc/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… From 339968e8d8cd0dc9340771c26f989f8301fa4f43 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 16 Jun 2025 13:39:16 +0200 Subject: [PATCH 010/274] Avoid DEP0174 by removing async from callback-based getGroupPolicyForUser --- .../ldap/app/src/LDAPModuleManager.mjs | 20 +++++++++---------- .../oidc/app/src/OIDCModuleManager.mjs | 20 +++++++++---------- .../saml/app/src/SAMLModuleManager.mjs | 19 +++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 846ca9b158..64afd02b0c 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -94,18 +94,18 @@ const LDAPModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'ldapPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index 3a2e6e2780..ec734ced19 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -64,18 +64,18 @@ const OIDCModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'oidcPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index 29e9ae52cd..67545f7fc9 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -84,18 +84,17 @@ const SAMLModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'samlPolicy' : true }, - subscription : null - }) + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } From 04e04e63189733ccff89f80908d6001d96c7a5de Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 24 Jun 2025 00:51:45 +0200 Subject: [PATCH 011/274] Introduce an environment variable to control user creation in OIDC authentication, closes #47 --- services/web/app/src/infrastructure/Features.js | 2 +- .../oidc/app/src/OIDCAuthenticationController.mjs | 14 +++++++------- .../oidc/app/src/OIDCAuthenticationManager.mjs | 3 +++ .../oidc/app/src/OIDCModuleManager.mjs | 1 + 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index aaf51103b9..3264c323cd 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -56,7 +56,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) + Boolean(Settings.overleaf) || Settings.oidc?.disableJITAccountCreation ) case 'registration': return Boolean(Settings.overleaf) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 0b8dc501e0..f8bbd32c29 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -42,7 +42,8 @@ const OIDCAuthenticationController = { } } else { if (info.redir != null) { - return res.json({ redir: info.redir }) + await UserController.doLogout(req) + return res.redirect(info.redir) } else { res.status(info.status || 401) delete info.status @@ -95,20 +96,19 @@ const OIDCAuthenticationController = { info: { type: 'error', text: error.message, - status: 401, + status: 500, }, } } if (user) { return { user, info: undefined } - } else { // we cannot be here, something is terribly wrong - logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + } else { // user account is not created + logger.debug({ email : profile.emails[0].value }, 'OIDC users JIT account creation is off') return { user: false, info: { - type: 'error', - text: 'Unknown error', - status: 500, + redir: '/register', + status: 401, }, } } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 5295ce63d0..3082558e39 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -37,6 +37,9 @@ const OIDCAuthenticationManager = { // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { + if (Settings.oidc.disableJITAccountCreation) { + return null + } user = await UserCreator.promises.createNewUser( { email: email, diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index ec734ced19..debb5c8c5d 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -17,6 +17,7 @@ const OIDCModuleManager = { attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), } }, passportSetup(passport, callback) { From c40ab3234dd413b045157c9739d97ff91c25c6cb Mon Sep 17 00:00:00 2001 From: Rebeka Dekany <50901361+rebekadekany@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:08:08 +0200 Subject: [PATCH 012/274] Prettier for PUG templates (#26170) * Setup prettier * Ignore these pug templates by prettier * Fix typo * Fix prettier error * Add prettier-ignore for quoting of event-segmentation attribute * Manual tab indentation * Interpolate * Remove unbuffered if conditional * Inline event-segmentation objects and remove prettier-ignore rule * Fix spacing before interpolation * Source format * Source format GitOrigin-RevId: c30e037f5caf8f91efc1bd9e75f81ae533b5a506 --- package-lock.json | 28 ++ services/web/.prettierignore | 25 + services/web/.prettierrc | 16 +- services/web/Makefile | 7 + services/web/app/views/_cookie_banner.pug | 18 +- services/web/app/views/_metadata.pug | 157 ++++--- .../web/app/views/_mixins/back_to_btns.pug | 4 +- .../web/app/views/_mixins/begin_now_card.pug | 10 +- .../app/views/_mixins/bookmarkable_tabset.pug | 8 +- .../web/app/views/_mixins/bootstrap_js.pug | 4 +- services/web/app/views/_mixins/eyebrow.pug | 8 +- .../views/_mixins/faq_search-marketing.pug | 29 +- .../web/app/views/_mixins/foot_scripts.pug | 15 +- .../web/app/views/_mixins/formMessages.pug | 110 ++--- services/web/app/views/_mixins/links.pug | 75 ++- .../web/app/views/_mixins/material_symbol.pug | 5 +- services/web/app/views/_mixins/navbar.pug | 14 +- .../web/app/views/_mixins/notification.pug | 16 +- services/web/app/views/_mixins/pagination.pug | 45 +- .../app/views/_mixins/previous_page_link.pug | 2 +- services/web/app/views/_mixins/quote.pug | 28 +- services/web/app/views/_mixins/recaptcha.pug | 2 +- .../reconfirm_affiliation-marketing.pug | 36 +- .../app/views/_mixins/terms_of_service.pug | 2 +- services/web/app/views/admin/index.pug | 85 ++-- .../web/app/views/beta_program/opt_in.pug | 49 +- services/web/app/views/general/400.pug | 14 +- services/web/app/views/general/404.pug | 4 +- services/web/app/views/general/500.pug | 10 +- services/web/app/views/general/closed.pug | 2 +- .../web/app/views/general/post-gateway.pug | 14 +- .../app/views/general/unsupported-browser.pug | 10 +- services/web/app/views/layout-base.pug | 133 ++++-- services/web/app/views/layout-marketing.pug | 10 +- services/web/app/views/layout-react.pug | 70 +-- .../web/app/views/layout-website-redesign.pug | 12 +- .../web/app/views/layout/fat-footer-base.pug | 67 ++- .../layout/fat-footer-website-redesign.pug | 79 ++-- services/web/app/views/layout/fat-footer.pug | 79 ++-- .../layout/language-picker-bootstrap-5.pug | 30 +- .../web/app/views/layout/language-picker.pug | 27 +- .../web/app/views/layout/layout-no-js.pug | 14 +- .../layout/navbar-marketing-bootstrap-5.pug | 204 ++++---- .../web/app/views/layout/navbar-marketing.pug | 191 ++++---- .../views/layout/navbar-website-redesign.pug | 182 ++++---- .../views/layout/thin-footer-bootstrap-5.pug | 4 +- services/web/app/views/layout/thin-footer.pug | 4 +- .../project/editor/new_from_template.pug | 58 +-- .../app/views/project/ide-react-detached.pug | 2 +- services/web/app/views/project/ide-react.pug | 9 +- .../app/views/project/invite/not-valid.pug | 6 +- .../web/app/views/project/invite/show.pug | 16 +- services/web/app/views/project/list-react.pug | 106 +++-- .../app/views/project/token/access-react.pug | 10 +- .../views/project/token/sharing-updates.pug | 8 +- .../web/app/views/subscriptions/add-seats.pug | 18 +- .../canceled-subscription-react.pug | 4 +- .../views/subscriptions/dashboard-react.pug | 109 +++-- .../manually-collected-subscription.pug | 6 +- .../missing-billing-information.pug | 6 +- .../views/subscriptions/plans/_faq_new.pug | 70 ++- .../subscriptions/plans/_plans_faq_tabs.pug | 438 +++++++++--------- .../views/subscriptions/preview-change.pug | 12 +- .../subscriptions/subtotal-limit-exceeded.pug | 6 +- .../successful-subscription-react.pug | 8 +- .../subscriptions/team/group-invites.pug | 6 +- .../subscriptions/team/invite-managed.pug | 26 +- .../app/views/subscriptions/team/invite.pug | 26 +- .../subscriptions/team/invite_logged_out.pug | 14 +- .../upgrade-group-subscription-react.pug | 14 +- .../web/app/views/user/accountSuspended.pug | 2 +- .../app/views/user/compromised_password.pug | 2 +- .../app/views/user/confirmSecondaryEmail.pug | 2 +- services/web/app/views/user/confirm_email.pug | 54 +-- .../web/app/views/user/email-preferences.pug | 34 +- services/web/app/views/user/login.pug | 40 +- .../web/app/views/user/one_time_login.pug | 4 +- .../web/app/views/user/passwordReset-bs5.pug | 56 +-- services/web/app/views/user/passwordReset.pug | 70 +-- .../app/views/user/primaryEmailCheck-bs5.pug | 38 +- services/web/app/views/user/reconfirm-bs5.pug | 55 +-- services/web/app/views/user/reconfirm.pug | 57 +-- services/web/app/views/user/register.pug | 6 +- services/web/app/views/user/sessions.pug | 32 +- .../web/app/views/user/setPassword-bs5.pug | 50 +- services/web/app/views/user/setPassword.pug | 66 +-- services/web/app/views/user/settings.pug | 81 ++-- .../user_membership/group-managers-react.pug | 10 +- .../user_membership/group-members-react.pug | 40 +- .../institution-managers-react.pug | 10 +- .../web/app/views/user_membership/new.pug | 19 +- .../publisher-managers-react.pug | 10 +- .../modules/launchpad/app/views/launchpad.pug | 159 +++---- .../user-activate/app/views/user/activate.pug | 58 ++- .../user-activate/app/views/user/register.pug | 4 +- services/web/package.json | 3 + 96 files changed, 2140 insertions(+), 1758 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a3bb7696d..53388a5732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8911,6 +8911,33 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@prettier/plugin-pug": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@prettier/plugin-pug/-/plugin-pug-3.4.0.tgz", + "integrity": "sha512-Jzd5rE/ellJz3vqfxyVewPsCHXw1dmIzJ3AXhAnqVBKQOj2u73ZS2oUacji8CbQSsYyCy7GXFjXWDlDTMG1x2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/Shinigami92" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=L7GY729FBKTZY" + } + ], + "license": "MIT", + "dependencies": { + "pug-lexer": "^5.0.1" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -45272,6 +45299,7 @@ "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", + "@prettier/plugin-pug": "^3.4.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", diff --git a/services/web/.prettierignore b/services/web/.prettierignore index 94ab5579c2..2e8db8b35b 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -13,3 +13,28 @@ frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs + +# complex pages +app/views/project/editor.pug +app/views/project/editor/** +modules/open-in-overleaf/app/views/documentation.pug +modules/references-search/app/views/project/editor/** +modules/rich-text/app/views/toolbar.pug + +# loops +app/views/referal/bonus.pug +modules/templates/app/views/tag.pug + +# expressions that could not be formatted correctly +app/views/_mixins/faq_search.pug +app/views/external/home/v2.pug +app/views/project/token/access.pug +app/views/user/primaryEmailCheck.pug +app/views/user/restricted.pug +modules/admin-panel/app/views/project/show.pug +modules/templates/app/views/project/editor/_left-menu.pug +modules/two-factor-authentication/app/views/_mixins.pug + +# minified files +app/views/_google_analytics.pug +app/views/_customer_io.pug diff --git a/services/web/.prettierrc b/services/web/.prettierrc index 13e31862ff..b99212a874 100644 --- a/services/web/.prettierrc +++ b/services/web/.prettierrc @@ -1,9 +1,23 @@ { "arrowParens": "avoid", "jsxSingleQuote": false, + "pugAttributeSeparator": "as-needed", + "pugBracketSpacing": false, + "pugClassNotation": "as-is", + "pugIdNotation": "as-is", + "pugSortAttributesBeginning": ["name", "data-type"], + "plugins": ["@prettier/plugin-pug"], "semi": false, "singleQuote": true, "trailingComma": "es5", "tabWidth": 2, - "useTabs": false + "useTabs": false, + "overrides": [ + { + "files": "*.pug", + "options": { + "useTabs": true + } + } + ] } diff --git a/services/web/Makefile b/services/web/Makefile index 6ebbc357c6..f5a7542691 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -455,12 +455,19 @@ format: format_styles format_styles: npm run --silent format:styles +format: format_pug +format_pug: + npm run --silent format:pug + format_fix: npm run --silent format:fix format_styles_fix: npm run --silent format:styles:fix +format_pug_fix: + npm run --silent format:pug:fix + format_in_docker: $(RUN_LINT_FORMAT) make format -j2 --output-sync diff --git a/services/web/app/views/_cookie_banner.pug b/services/web/app/views/_cookie_banner.pug index 2d5631f9c8..56974326cd 100644 --- a/services/web/app/views/_cookie_banner.pug +++ b/services/web/app/views/_cookie_banner.pug @@ -1,5 +1,13 @@ -section.cookie-banner.hidden-print.hidden(aria-label="Cookie banner") - .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. - .cookie-banner-actions - button(type="button" class="btn btn-link btn-sm" data-ol-cookie-banner-set-consent="essential") Essential cookies only - button(type="button" class="btn btn-primary btn-sm" data-ol-cookie-banner-set-consent="all") Accept all cookies +section.cookie-banner.hidden-print.hidden(aria-label='Cookie banner') + .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. + .cookie-banner-actions + button( + type='button' + class='btn btn-link btn-sm' + data-ol-cookie-banner-set-consent='essential' + ) Essential cookies only + button( + type='button' + class='btn btn-primary btn-sm' + data-ol-cookie-banner-set-consent='all' + ) Accept all cookies diff --git a/services/web/app/views/_metadata.pug b/services/web/app/views/_metadata.pug index a784860095..6d7c599546 100644 --- a/services/web/app/views/_metadata.pug +++ b/services/web/app/views/_metadata.pug @@ -1,123 +1,140 @@ - //- Title -if (metadata && metadata.title) - title= metadata.title + ' - ' + settings.appName + ', ' + translate("online_latex_editor") - meta(name="twitter:title", content=metadata.title) - meta(name="og:title", content=metadata.title) -else if (typeof(title) == "undefined") - title= settings.appName + ', '+ translate("online_latex_editor") - meta(name="twitter:title", content=settings.appName + ', '+ translate("online_latex_editor")) - meta(name="og:title", content=settings.appName + ', '+ translate("online_latex_editor")) +if metadata && metadata.title + title= metadata.title + ' - ' + settings.appName + ', ' + translate('online_latex_editor') + meta(name='twitter:title' content=metadata.title) + meta(name='og:title' content=metadata.title) +else if typeof title == 'undefined' + title= settings.appName + ', ' + translate('online_latex_editor') + meta( + name='twitter:title' + content=settings.appName + ', ' + translate('online_latex_editor') + ) + meta( + name='og:title' + content=settings.appName + ', ' + translate('online_latex_editor') + ) else - title= translate(title) + ' - ' + settings.appName + ', ' + translate("online_latex_editor") + title= translate(title) + ' - ' + settings.appName + ', ' + translate('online_latex_editor') //- to do - not translate? - meta(name="twitter:title", content=translate(title)) - meta(name="og:title", content=translate(title)) + meta(name='twitter:title' content=translate(title)) + meta(name='og:title' content=translate(title)) //- Description -if (metadata && metadata.description) - meta(name="description" , content=metadata.description) - meta(itemprop="description" , content=metadata.description) +if metadata && metadata.description + meta(name='description' content=metadata.description) + meta(itemprop='description' content=metadata.description) //-twitter and og descriptions handeled in their sections below else - meta(name="description", content=translate("site_description")) - meta(itemprop="description", content=translate("site_description")) + meta(name='description' content=translate('site_description')) + meta(itemprop='description' content=translate('site_description')) //- Image -if (metadata && metadata.image && metadata.image.fields) +if metadata && metadata.image && metadata.image.fields //- from the CMS - meta(itemprop="image", content=metadata.image.fields.file.url) - meta(name="image", content=metadata.image.fields.file.url) -else if (metadata && metadata.image_src) + meta(itemprop='image' content=metadata.image.fields.file.url) + meta(name='image' content=metadata.image.fields.file.url) +else if metadata && metadata.image_src //- pages with custom metadata images, metadata.image_src is the full image URL - meta(itemprop="image", content=metadata.image_src) - meta(name="image", content=metadata.image_src) -else if (settings.overleaf) + meta(itemprop='image' content=metadata.image_src) + meta(name='image' content=metadata.image_src) +else if settings.overleaf //- the default image for Overleaf - meta(itemprop="image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) - meta(name="image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) + meta(itemprop='image' content=buildImgPath('ol-brand/overleaf_og_logo.png')) + meta(name='image' content=buildImgPath('ol-brand/overleaf_og_logo.png')) else //- the default image for Overleaf Community Edition/Server Pro - meta(itemprop="image", content='/apple-touch-icon.png') - meta(name="image", content='/apple-touch-icon.png') + meta(itemprop='image' content='/apple-touch-icon.png') + meta(name='image' content='/apple-touch-icon.png') //- Keywords -if (metadata && metadata.keywords) - meta(name="keywords" content=metadata.keywords) +if metadata && metadata.keywords + meta(name='keywords' content=metadata.keywords) //- Misc -meta(itemprop="name", content=settings.appName + ", the Online LaTeX Editor") +meta(itemprop='name' content=settings.appName + ', the Online LaTeX Editor') -if (metadata && metadata.robotsNoindexNofollow) - meta(name="robots" content="noindex, nofollow") +if metadata && metadata.robotsNoindexNofollow + meta(name='robots' content='noindex, nofollow') //- Twitter -meta(name="twitter:card", content=metadata && metadata.twitterCardType ? metadata.twitterCardType : 'summary') -if (settings.social && settings.social.twitter && settings.social.twitter.handle) - meta(name="twitter:site", content="@" + settings.social.twitter.handle) -if (metadata && metadata.twitterDescription) - meta(name="twitter:description", content=metadata.twitterDescription) +meta( + name='twitter:card' + content=metadata && metadata.twitterCardType ? metadata.twitterCardType : 'summary' +) +if settings.social && settings.social.twitter && settings.social.twitter.handle + meta(name='twitter:site' content='@' + settings.social.twitter.handle) +if metadata && metadata.twitterDescription + meta(name='twitter:description' content=metadata.twitterDescription) else - meta(name="twitter:description", content=translate("site_description")) -if (metadata && metadata.twitterImage && metadata.twitterImage.fields) + meta(name='twitter:description' content=translate('site_description')) +if metadata && metadata.twitterImage && metadata.twitterImage.fields //- from the CMS - meta(name="twitter:image", content=metadata.twitterImage.fields.file.url) - meta(name="twitter:image:alt", content=metadata.twitterImage.fields.title) -else if (settings.overleaf) + meta(name='twitter:image' content=metadata.twitterImage.fields.file.url) + meta(name='twitter:image:alt' content=metadata.twitterImage.fields.title) +else if settings.overleaf //- the default image for Overleaf - meta(name="twitter:image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) + meta( + name='twitter:image' + content=buildImgPath('ol-brand/overleaf_og_logo.png') + ) else //- the default image for Overleaf Community Edition/Server Pro - meta(name="twitter:image", content='/apple-touch-icon.png') + meta(name='twitter:image' content='/apple-touch-icon.png') //- Open Graph //- to do - add og:url -if (settings.social && settings.social.facebook && settings.social.facebook.appId) - meta(property="fb:app_id", content=settings.social.facebook.appId) +if settings.social && settings.social.facebook && settings.social.facebook.appId + meta(property='fb:app_id' content=settings.social.facebook.appId) -if (metadata && metadata.openGraphDescription) - meta(property="og:description", content=metadata.openGraphDescription) +if metadata && metadata.openGraphDescription + meta(property='og:description' content=metadata.openGraphDescription) else - meta(property="og:description", content=translate("site_description")) + meta(property='og:description' content=translate('site_description')) -if (metadata && metadata.openGraphImage && metadata.openGraphImage.fields) +if metadata && metadata.openGraphImage && metadata.openGraphImage.fields //- from the CMS - meta(property="og:image", content=metadata.openGraphImage.fields.file.url) -else if (settings.overleaf) + meta(property='og:image' content=metadata.openGraphImage.fields.file.url) +else if settings.overleaf //- the default image for Overleaf - meta(property="og:image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) + meta( + property='og:image' + content=buildImgPath('ol-brand/overleaf_og_logo.png') + ) else //- the default image for Overleaf Community Edition/Server Pro - meta(property="og:image", content='/apple-touch-icon.png') + meta(property='og:image' content='/apple-touch-icon.png') -if (metadata && metadata.openGraphType) - meta(property="og:type", metadata.openGraphType) +if metadata && metadata.openGraphType + meta(property='og:type' metadata.openGraphType) else - meta(property="og:type", content="website") + meta(property='og:type' content='website') -if (metadata && metadata.openGraphVideo) +if metadata && metadata.openGraphVideo //- from the CMS - meta(property="og:video", content=metadata.openGraphVideo) + meta(property='og:video' content=metadata.openGraphVideo) //- Viewport if !metadata || metadata.viewport !== false - meta(name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes") + meta( + name='viewport' + content='width=device-width, initial-scale=1.0, user-scalable=yes' + ) //- Noindex if settings.robotsNoindex - meta(name="robots" content="noindex") + meta(name='robots' content='noindex') //- Icons -link(rel="icon", sizes="32x32", href="/favicon-32x32.png") -link(rel="icon", sizes="16x16", href="/favicon-16x16.png") -link(rel="icon", href="/favicon.svg" type="image/svg+xml") -link(rel="apple-touch-icon", href="/apple-touch-icon.png") -link(rel="mask-icon", href="/mask-favicon.svg", color="#046530") +link(rel='icon' sizes='32x32' href='/favicon-32x32.png') +link(rel='icon' sizes='16x16' href='/favicon-16x16.png') +link(rel='icon' href='/favicon.svg' type='image/svg+xml') +link(rel='apple-touch-icon' href='/apple-touch-icon.png') +link(rel='mask-icon' href='/mask-favicon.svg' color='#046530') //- Canonical Tag for SEO -if (metadata && metadata.canonicalURL) - link(rel="canonical" href=metadata.canonicalURL) +if metadata && metadata.canonicalURL + link(rel='canonical' href=metadata.canonicalURL) //- Manifest //- Does not currently contain a start_url to prevent browser installation prompts -link(rel="manifest" href="/web.sitemanifest") +link(rel='manifest' href='/web.sitemanifest') diff --git a/services/web/app/views/_mixins/back_to_btns.pug b/services/web/app/views/_mixins/back_to_btns.pug index dacd9ea7a2..c3a1f4f76e 100644 --- a/services/web/app/views/_mixins/back_to_btns.pug +++ b/services/web/app/views/_mixins/back_to_btns.pug @@ -1,4 +1,6 @@ mixin back-to-btns(settingsAnchor) .d-flex.flex-column.flex-sm-row.gap-3 - a.btn.btn-secondary(href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }`) #{translate('back_to_account_settings')} + a.btn.btn-secondary( + href=`/user/settings${settingsAnchor ? '#' + settingsAnchor : '' }` + ) #{translate('back_to_account_settings')} a.btn.btn-secondary(href='/project') #{translate('back_to_your_projects')} diff --git a/services/web/app/views/_mixins/begin_now_card.pug b/services/web/app/views/_mixins/begin_now_card.pug index 8f9919553d..641703d606 100644 --- a/services/web/app/views/_mixins/begin_now_card.pug +++ b/services/web/app/views/_mixins/begin_now_card.pug @@ -1,10 +1,10 @@ -mixin begin_now_card() +mixin begin_now_card - var registerURL = '/register' - var plansURL = '/user/subscription/plans' - var isUserLoggedIn = !!getSessionUser() .begin-now-card - div.card.card-pattern + .card.card-pattern .card-body p.dm-mono span.font-size-display-xs @@ -16,10 +16,8 @@ mixin begin_now_card() p #{translate("discover_why_over_people_worldwide_trust_overleaf", {count: settings.userCountInMillions})} p.card-links if !isUserLoggedIn - a.btn.btn-primary.card-link( - href=registerURL - ) #{translate("sign_up_for_free")} + a.btn.btn-primary.card-link(href=registerURL) #{translate("sign_up_for_free")} a.btn.card-link( - class = isUserLoggedIn ? 'btn-primary' : 'btn-secondary' + class=isUserLoggedIn ? 'btn-primary' : 'btn-secondary' href=plansURL ) #{translate("explore_all_plans")} diff --git a/services/web/app/views/_mixins/bookmarkable_tabset.pug b/services/web/app/views/_mixins/bookmarkable_tabset.pug index 27ac74ef66..72579c7725 100644 --- a/services/web/app/views/_mixins/bookmarkable_tabset.pug +++ b/services/web/app/views/_mixins/bookmarkable_tabset.pug @@ -1,10 +1,10 @@ mixin bookmarkable-tabset-header(id, title, active) - li(role="presentation") + li(role='presentation') a.nav-link( href='#' + id - class=(active ? 'active' : '') + class=active ? 'active' : '' aria-controls=id - role="tab" - data-toggle="tab" + role='tab' + data-toggle='tab' data-ol-bookmarkable-tab ) #{title} diff --git a/services/web/app/views/_mixins/bootstrap_js.pug b/services/web/app/views/_mixins/bootstrap_js.pug index 866b0b4218..746e811bf7 100644 --- a/services/web/app/views/_mixins/bootstrap_js.pug +++ b/services/web/app/views/_mixins/bootstrap_js.pug @@ -1,3 +1,3 @@ mixin bootstrap-js(bootstrapVersion) - each file in (entrypointScripts(bootstrapVersion === 5 ? 'bootstrap-5' : 'bootstrap-3')) - script(type="text/javascript", nonce=scriptNonce, src=file) + each file in entrypointScripts(bootstrapVersion === 5 ? 'bootstrap-5' : 'bootstrap-3') + script(type='text/javascript' nonce=scriptNonce src=file) diff --git a/services/web/app/views/_mixins/eyebrow.pug b/services/web/app/views/_mixins/eyebrow.pug index c5f01a10db..3eae0b8308 100644 --- a/services/web/app/views/_mixins/eyebrow.pug +++ b/services/web/app/views/_mixins/eyebrow.pug @@ -1,5 +1,5 @@ mixin eyebrow(text) - span.eyebrow-text - span(aria-hidden="true") { - span #{text} - span(aria-hidden="true") } \ No newline at end of file + span.eyebrow-text + span(aria-hidden='true') { + span #{text} + span(aria-hidden='true') } diff --git a/services/web/app/views/_mixins/faq_search-marketing.pug b/services/web/app/views/_mixins/faq_search-marketing.pug index aa41d00f9b..1b161dbdf6 100644 --- a/services/web/app/views/_mixins/faq_search-marketing.pug +++ b/services/web/app/views/_mixins/faq_search-marketing.pug @@ -1,30 +1,37 @@ mixin faq_search-marketing(headerText, headerClass) - if (typeof(settings.algolia) != "undefined" && typeof(settings.algolia.indexes) != "undefined" && typeof(settings.algolia.indexes.wiki) != "undefined") + if typeof settings.algolia != 'undefined' && typeof settings.algolia.indexes != 'undefined' && typeof settings.algolia.indexes.wiki != 'undefined' if headerText div(class=headerClass) #{headerText} .wiki - form.project-search.form-horizontal(role="search" data-ol-faq-search) + form.project-search.form-horizontal(role='search' data-ol-faq-search) .form-group.has-feedback.has-feedback-left .col-sm-12 - input.form-control(type='search', placeholder="Search help library…" aria-label="Search help library…") - i.fa.fa-search.form-control-feedback-left(aria-hidden="true") + input.form-control( + type='search' + placeholder='Search help library…' + aria-label='Search help library…' + ) + i.fa.fa-search.form-control-feedback-left(aria-hidden='true') i.fa.fa-times.form-control-feedback( - style="cursor: pointer;", + style='cursor: pointer' hidden data-ol-clear-search - aria-hidden="true" + aria-hidden='true' ) button.sr-only( - type="button" + type='button' hidden data-ol-clear-search aria-label=translate('clear_search') ) - .row(role="region" aria-label="search results") - .col-md-12() + .row(role='region' aria-label='search results') + .col-md-12 div(data-ol-search-results-wrapper) - span.sr-only(aria-live="polite" data-ol-search-sr-help-message) + span.sr-only(aria-live='polite' data-ol-search-sr-help-message) div(data-ol-search-results) - .row-spaced-small.search-result.card.card-thin(hidden data-ol-search-no-results) + .row-spaced-small.search-result.card.card-thin( + hidden + data-ol-search-no-results + ) p #{translate("no_search_results")} diff --git a/services/web/app/views/_mixins/foot_scripts.pug b/services/web/app/views/_mixins/foot_scripts.pug index c6b65e81c7..717c46cdd9 100644 --- a/services/web/app/views/_mixins/foot_scripts.pug +++ b/services/web/app/views/_mixins/foot_scripts.pug @@ -1,6 +1,11 @@ -mixin foot-scripts() +mixin foot-scripts each file in entrypointScripts(entrypoint) - script(type="text/javascript", nonce=scriptNonce, src=file, defer=deferScripts) - if (settings.devToolbar.enabled) - each file in entrypointScripts("devToolbar") - script(type="text/javascript", nonce=scriptNonce, src=file, defer=deferScripts) + script(type='text/javascript' nonce=scriptNonce src=file defer=deferScripts) + if settings.devToolbar.enabled + each file in entrypointScripts('devToolbar') + script( + type='text/javascript' + nonce=scriptNonce + src=file + defer=deferScripts + ) diff --git a/services/web/app/views/_mixins/formMessages.pug b/services/web/app/views/_mixins/formMessages.pug index 2fab7e40d8..a14bb2196a 100644 --- a/services/web/app/views/_mixins/formMessages.pug +++ b/services/web/app/views/_mixins/formMessages.pug @@ -1,41 +1,35 @@ include ./material_symbol -mixin formMessages() - div( - data-ol-form-messages='', - role="alert" - ) +mixin formMessages + div(data-ol-form-messages='' role='alert') mixin formMessagesNewStyle(extraClass = 'form-messages-bottom-margin') - - const attrs = extraClass ? { 'class': extraClass } : {} - div( - data-ol-form-messages-new-style='', - role="alert" - )&attributes(attrs) + - const attrs = extraClass ? {class: extraClass} : {} + div(data-ol-form-messages-new-style='' role='alert')&attributes(attrs) mixin customFormMessage(key, kind) if kind === 'success' - div.alert.alert-success( - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" + .alert.alert-success( + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='polite' ) block else if kind === 'danger' - div.alert.alert-danger( - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="assertive" + .alert.alert-danger( + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='assertive' ) block else - div.alert.alert-warning( - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" + .alert.alert-warning( + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='polite' ) block @@ -43,56 +37,50 @@ mixin customFormMessageNewStyle(key, kind, extraClass = 'mb-3') - extraClass = extraClass ? ' ' + extraClass : '' if kind === 'success' div( - class="notification notification-type-success" + extraClass, - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" + class='notification notification-type-success' + extraClass + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='polite' ) - div.notification-icon - +material-symbol("check_circle") - div.notification-content.text-left + .notification-icon + +material-symbol('check_circle') + .notification-content.text-left block else if kind === 'danger' div( - class="notification notification-type-error" + extraClass, - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" + class='notification notification-type-error' + extraClass + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='polite' ) - div.notification-icon - +material-symbol("error") - div.notification-content.text-left + .notification-icon + +material-symbol('error') + .notification-content.text-left block else div( - class="notification notification-type-warning" + extraClass, - hidden, - data-ol-custom-form-message=key, - role="alert" - aria-live="polite" + class='notification notification-type-warning' + extraClass + hidden + data-ol-custom-form-message=key + role='alert' + aria-live='polite' ) - div.notification-icon - +material-symbol("warning") - div.notification-content.text-left + .notification-icon + +material-symbol('warning') + .notification-content.text-left block mixin customValidationMessage(key) - div.invalid-feedback.mt-2( - hidden, - data-ol-custom-form-message=key - ) - i.fa.fa-fw.fa-warning.me-1(aria-hidden="true") + .invalid-feedback.mt-2(hidden data-ol-custom-form-message=key) + i.fa.fa-fw.fa-warning.me-1(aria-hidden='true') div block mixin customValidationMessageNewStyle(key) - div.notification.notification-type-error( - hidden, - data-ol-custom-form-message=key - ) - div.notification-icon - +material-symbol("error") - div.notification-content.text-left.small + .notification.notification-type-error(hidden data-ol-custom-form-message=key) + .notification-icon + +material-symbol('error') + .notification-content.text-left.small block diff --git a/services/web/app/views/_mixins/links.pug b/services/web/app/views/_mixins/links.pug index 566c90ee50..a919cdd74e 100644 --- a/services/web/app/views/_mixins/links.pug +++ b/services/web/app/views/_mixins/links.pug @@ -8,7 +8,8 @@ mixin linkAdvisors(linkText, linkClass, track) - var mb = track && track.mb ? 'true' : null - var mbSegmentation = track && track.segmentation ? track.segmentation : null - var trigger = track && track.trigger ? track.trigger : null - a(href="/advisors" + a( + href='/advisors' class=linkClass ? linkClass : '' event-tracking-ga=gaCategory event-tracking=gaAction @@ -20,24 +21,36 @@ mixin linkAdvisors(linkText, linkClass, track) span #{linkText ? linkText : 'advisor programme'} mixin linkBenefits(linkText, linkClass) - a(href=(settings.siteUrl ? settings.siteUrl : '') + "/for/authors" class=linkClass ? linkClass : '') + a( + href=(settings.siteUrl ? settings.siteUrl : '') + '/for/authors' + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'benefits'} - + mixin linkBlog(linkText, linkClass, slug) if slug - a(href=(settings.siteUrl ? settings.siteUrl : '') + "/blog/" + slug class=linkClass ? linkClass : '') + a( + href=(settings.siteUrl ? settings.siteUrl : '') + '/blog/' + slug + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'blog'} mixin linkContact(linkText, linkClass) - a(href=(settings.siteUrl ? settings.siteUrl : '') + "/contact" class=linkClass ? linkClass : '') + a( + href=(settings.siteUrl ? settings.siteUrl : '') + '/contact' + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'contact'} mixin linkDash(linkText, linkClass) - a(href="/project" class=linkClass ? linkClass : '') + a(href='/project' class=linkClass ? linkClass : '') | #{linkText ? linkText : 'project dashboard'} mixin linkEducation(linkText, linkClass) - a(href=(settings.siteUrl ? settings.siteUrl : '') + "/for/edu" class=linkClass ? linkClass : '') + a( + href=(settings.siteUrl ? settings.siteUrl : '') + '/for/edu' + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'teaching toolkit'} mixin linkInvite(linkText, linkClass, track) @@ -48,7 +61,8 @@ mixin linkInvite(linkText, linkClass, track) - var mbSegmentation = track && track.segmentation ? track.segmentation : null - var trigger = track && track.trigger ? track.trigger : null - a(href="/user/bonus" + a( + href='/user/bonus' class=linkClass ? linkClass : '' event-tracking-ga=gaCategory event-tracking=gaAction @@ -60,7 +74,7 @@ mixin linkInvite(linkText, linkClass, track) span #{linkText ? linkText : 'invite your friends'} mixin linkPlansAndPricing(linkText, linkClass) - a(href="/user/subscription/plans" class=linkClass ? linkClass : '') + a(href='/user/subscription/plans' class=linkClass ? linkClass : '') | #{linkText ? linkText : 'plans and pricing'} mixin linkPrintNewTab(linkText, linkClass, icon, track) @@ -71,7 +85,8 @@ mixin linkPrintNewTab(linkText, linkClass, icon, track) - var mbSegmentation = track && track.segmentation ? track.segmentation : null - var trigger = track && track.trigger ? track.trigger : null - a(href='?media=print' + a( + href='?media=print' class=linkClass ? linkClass : '' event-tracking-ga=gaCategory event-tracking=gaAction @@ -79,20 +94,26 @@ mixin linkPrintNewTab(linkText, linkClass, icon, track) event-tracking-trigger=trigger event-tracking-mb=mb event-segmentation=mbSegmentation - target="_BLANK", - rel="noopener noreferrer" + target='_BLANK' + rel='noopener noreferrer' ) if icon - i(class="fa fa-print") + i(class='fa fa-print') |   span #{linkText ? linkText : 'print'} mixin linkSignIn(linkText, linkClass, redirect) - a(href=`/login${redirect ? '?redir=' + redirect : ''}` class=linkClass ? linkClass : '') + a( + href=`/login${redirect ? '?redir=' + redirect : ''}` + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'sign in'} mixin linkSignUp(linkText, linkClass, redirect) - a(href=`/register${redirect ? '?redir=' + redirect : ''}` class=linkClass ? linkClass : '') + a( + href=`/register${redirect ? '?redir=' + redirect : ''}` + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'sign up'} mixin linkTweet(linkText, linkClass, tweetText, track) @@ -103,23 +124,33 @@ mixin linkTweet(linkText, linkClass, tweetText, track) - var mb = track && track.mb ? 'true' : null - var mbSegmentation = track && track.segmentation ? track.segmentation : null - var trigger = track && track.trigger ? track.trigger : null - a(class="twitter-share-button " + linkClass + a( + class='twitter-share-button ' + linkClass event-tracking-ga=gaCategory event-tracking=gaAction event-tracking-label=gaLabel event-tracking-trigger=trigger event-tracking-mb=mb event-segmentation=mbSegmentation - href="https://twitter.com/intent/tweet?text=" + tweetText - target="_BLANK", - rel="noopener noreferrer" + href='https://twitter.com/intent/tweet?text=' + tweetText + target='_BLANK' + rel='noopener noreferrer' ) #{linkText ? linkText : 'tweet'} mixin linkUniversities(linkText, linkClass) - a(href=(settings.siteUrl ? settings.siteUrl : '') + "/for/universities" class=linkClass ? linkClass : '') + a( + href=(settings.siteUrl ? settings.siteUrl : '') + '/for/universities' + class=linkClass ? linkClass : '' + ) | #{linkText ? linkText : 'universities'} mixin linkWithArrow({text, href, eventTracking, eventSegmentation, eventTrackingTrigger}) - a.link-with-arrow(href=href event-tracking=eventTracking event-segmentation=eventSegmentation, event-tracking-trigger=eventTrackingTrigger event-tracking-mb) + a.link-with-arrow( + href=href + event-tracking=eventTracking + event-segmentation=eventSegmentation + event-tracking-trigger=eventTrackingTrigger + event-tracking-mb + ) | #{text} - +material-symbol("arrow_right_alt") + +material-symbol('arrow_right_alt') diff --git a/services/web/app/views/_mixins/material_symbol.pug b/services/web/app/views/_mixins/material_symbol.pug index 1e97425faf..e4e8925f93 100644 --- a/services/web/app/views/_mixins/material_symbol.pug +++ b/services/web/app/views/_mixins/material_symbol.pug @@ -1,7 +1,8 @@ mixin material-symbol(icon, extraClass = null) - extraClass = extraClass ? ' ' + extraClass : '' - span(aria-hidden="true", translate="no", class="material-symbols" + extraClass)&attributes(attributes) #{icon} - + span(aria-hidden='true' translate='no' class='material-symbols' + extraClass)&attributes(attributes) + | #{icon} + mixin material-symbol-outlined(icon, extraClass = null) - extraClass = extraClass ? ' ' + extraClass : '' +material-symbol(icon, 'material-symbols-outlined' + extraClass)&attributes(attributes) diff --git a/services/web/app/views/_mixins/navbar.pug b/services/web/app/views/_mixins/navbar.pug index f3482d3b54..0ea2e4e3a0 100644 --- a/services/web/app/views/_mixins/navbar.pug +++ b/services/web/app/views/_mixins/navbar.pug @@ -1,23 +1,23 @@ mixin nav-item - li(role="none")&attributes(attributes) + li(role='none')&attributes(attributes) block mixin nav-link - a(role="menuitem").nav-link&attributes(attributes) + a.nav-link(role='menuitem')&attributes(attributes) block mixin dropdown-menu - ul(role="menu").dropdown-menu&attributes(attributes) + ul.dropdown-menu(role='menu')&attributes(attributes) block mixin dropdown-menu-item - li(role="none") + li(role='none') block mixin dropdown-menu-link-item +dropdown-menu-item - a(role="menuitem").dropdown-item&attributes(attributes) + a.dropdown-item(role='menuitem')&attributes(attributes) block - + mixin dropdown-menu-divider - li(role="separator").dropdown-divider.d-none.d-lg-block + li.dropdown-divider.d-none.d-lg-block(role='separator') diff --git a/services/web/app/views/_mixins/notification.pug b/services/web/app/views/_mixins/notification.pug index fb0db79630..482dd540c5 100644 --- a/services/web/app/views/_mixins/notification.pug +++ b/services/web/app/views/_mixins/notification.pug @@ -3,25 +3,19 @@ include ./material_symbol mixin notificationIcon(type) if type === 'info' - +material-symbol("info") + +material-symbol('info') else if type === 'success' - +material-symbol("check_circle") + +material-symbol('check_circle') else if type === 'error' - +material-symbol("error") + +material-symbol('error') else if type === 'warning' - +material-symbol("warning") - + +material-symbol('warning') mixin notification(options) - var {ariaLive, id, type, title, content, disclaimer, className} = options - var classNames = `notification notification-type-${type} ${className ? className : ''} ${isActionBelowContent ? 'notification-cta-below-content' : ''}` - div( - aria-live=ariaLive, - role="alert", - id=id, - class=classNames - ) + div(aria-live=ariaLive role='alert' id=id class=classNames) .notification-icon +notificationIcon(type) .notification-content-and-cta diff --git a/services/web/app/views/_mixins/pagination.pug b/services/web/app/views/_mixins/pagination.pug index ef5f62bd0e..a2422e8c5d 100644 --- a/services/web/app/views/_mixins/pagination.pug +++ b/services/web/app/views/_mixins/pagination.pug @@ -10,77 +10,74 @@ mixin pagination(pages, page_path, max_btns) - var max_btns = max_btns || 4 - var prev_page = Math.max(parseInt(pages.current_page, 10) - max_btns, 1) - var next_page = parseInt(pages.current_page, 10) + 1 - - var next_index = 0; - - var full_page_path = page_path + "/page/" + - var next_index = 0 + - var full_page_path = page_path + '/page/' - nav(role="navigation" aria-label=(translate("pagination_navigation"))) + nav(role='navigation' aria-label=translate('pagination_navigation')) ul.pagination if pages.current_page > 1 li - a( - aria-label=translate("go_to_first_page") - href=page_path - ) - span(aria-hidden="true") << + a(aria-label=translate('go_to_first_page') href=page_path) + span(aria-hidden='true') << | | First li a( - aria-label=translate("go_to_previous_page") + aria-label=translate('go_to_previous_page') href=full_page_path + (parseInt(pages.current_page, 10) - 1) - rel="prev" + rel='prev' ) - span(aria-hidden="true") < + span(aria-hidden='true') < | | Prev if pages.current_page - max_btns > 1 - li(aria-hidden="true") + li(aria-hidden='true') span … while prev_page < pages.current_page li a( - aria-label=translate("go_to_page_x", {page: prev_page}) + aria-label=translate('go_to_page_x', {page: prev_page}) href=full_page_path + prev_page ) #{prev_page} - prev_page++ - li(class="active") + li(class='active') span( - aria-label=translate("current_page_page", {page: pages.current_page}) - aria-current="true" + aria-label=translate('current_page_page', {page: pages.current_page}) + aria-current='true' ) #{pages.current_page} if pages.current_page < pages.total_pages while next_page <= pages.total_pages && next_index < max_btns li a( - aria-label=translate("go_to_page_x", {page: next_page}) + aria-label=translate('go_to_page_x', {page: next_page}) href=full_page_path + next_page ) #{next_page} - next_page++ - next_index++ - if next_page <= pages.total_pages - li.ellipses(aria-hidden="true") + if next_page <= pages.total_pages + li.ellipses(aria-hidden='true') span … li a( - aria-label=translate("go_to_next_page") + aria-label=translate('go_to_next_page') href=full_page_path + (parseInt(pages.current_page, 10) + 1) - rel="next" + rel='next' ) | Next | - span(aria-hidden="true") > + span(aria-hidden='true') > li a( - aria-label=translate("go_to_last_page") + aria-label=translate('go_to_last_page') href=full_page_path + pages.total_pages ) | Last | - span(aria-hidden="true") >> + span(aria-hidden='true') >> diff --git a/services/web/app/views/_mixins/previous_page_link.pug b/services/web/app/views/_mixins/previous_page_link.pug index 1b646a04d2..9809409d4e 100644 --- a/services/web/app/views/_mixins/previous_page_link.pug +++ b/services/web/app/views/_mixins/previous_page_link.pug @@ -2,5 +2,5 @@ include ./material_symbol mixin previous-page-link(href, text) a.previous-page-link(href=href) - +material-symbol-rounded("arrow_left_alt") + +material-symbol-rounded('arrow_left_alt') | #{text} diff --git a/services/web/app/views/_mixins/quote.pug b/services/web/app/views/_mixins/quote.pug index 573e0b6b0c..a414c4dcec 100644 --- a/services/web/app/views/_mixins/quote.pug +++ b/services/web/app/views/_mixins/quote.pug @@ -3,10 +3,10 @@ mixin quoteLargeTextCentered(quote, person, position, affiliation, link, picture .quote !{quote} if pictureUrl .quote-img - -var pictureAlt=`Photo of ${person}` + - var pictureAlt = `Photo of ${person}` img(src=pictureUrl alt=pictureAlt) footer - div.quote-person + .quote-person strong #{person} if person && position div #{position} @@ -29,27 +29,27 @@ mixin quoteLeftGreenBorder({quote, person, position, affiliation, link}) mixin collinsQuote1 .card.card-dark-green-bg - -var quote = 'Overleaf is indispensable for us. We use it in our research, thesis writing, project proposals, and manuscripts for publication. When it comes to writing, it’s our main tool.' - -var quotePerson = 'Christopher Collins' - -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' - -var quotePersonImg = buildImgPath("advocates/collins.jpg") + - var quote = 'Overleaf is indispensable for us. We use it in our research, thesis writing, project proposals, and manuscripts for publication. When it comes to writing, it’s our main tool.' + - var quotePerson = 'Christopher Collins' + - var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' + - var quotePersonImg = buildImgPath('advocates/collins.jpg') .card-body +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) mixin collinsQuote2 .card.card-dark-green-bg - -var quote = 'We are writing collaboratively right up until the last minute. We are faced with deadlines all the time, and Overleaf gives us the ability to polish right up until the last possible second.' - -var quotePerson = 'Christopher Collins' - -var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' - -var quotePersonImg = buildImgPath("advocates/collins.jpg") + - var quote = 'We are writing collaboratively right up until the last minute. We are faced with deadlines all the time, and Overleaf gives us the ability to polish right up until the last possible second.' + - var quotePerson = 'Christopher Collins' + - var quotePersonPosition = 'Associate Professor and Lab Director, Ontario Tech University' + - var quotePersonImg = buildImgPath('advocates/collins.jpg') .card-body +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) mixin bennettQuote1 .card.card-dark-green-bg - -var quote = 'With Overleaf, we now have a process for developing technical documentation which has virtually eliminated the time required to properly format and layout documents.' - -var quotePerson = 'Andrew Bennett' - -var quotePersonPosition = 'Software Architect, Symplectic' - -var quotePersonImg = buildImgPath("advocates/bennett.jpg") + - var quote = 'With Overleaf, we now have a process for developing technical documentation which has virtually eliminated the time required to properly format and layout documents.' + - var quotePerson = 'Andrew Bennett' + - var quotePersonPosition = 'Software Architect, Symplectic' + - var quotePersonImg = buildImgPath('advocates/bennett.jpg') .card-body +quoteLargeTextCentered(quote, quotePerson, quotePersonPosition, null, null, quotePersonImg) diff --git a/services/web/app/views/_mixins/recaptcha.pug b/services/web/app/views/_mixins/recaptcha.pug index 24e0c501ea..ec5604c825 100644 --- a/services/web/app/views/_mixins/recaptcha.pug +++ b/services/web/app/views/_mixins/recaptcha.pug @@ -1,2 +1,2 @@ -mixin recaptchaConditions() +mixin recaptchaConditions .recaptcha-branding !{translate("recaptcha_conditions", {}, [{}, {name: 'a', attrs: {href: 'https://policies.google.com/privacy', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: 'https://policies.google.com/terms', rel: 'noopener noreferrer', target: '_blank'}}])} diff --git a/services/web/app/views/_mixins/reconfirm_affiliation-marketing.pug b/services/web/app/views/_mixins/reconfirm_affiliation-marketing.pug index c42a3b439a..f54dd5d4ba 100644 --- a/services/web/app/views/_mixins/reconfirm_affiliation-marketing.pug +++ b/services/web/app/views/_mixins/reconfirm_affiliation-marketing.pug @@ -1,14 +1,11 @@ mixin reconfirmAffiliationNotification-marketing(userEmail, location) - form( - data-ol-async-form - action='/user/emails/send-reconfirmation' - ) - input(name="_csrf" type="hidden" value=csrfToken) - input(name="email" type="hidden" value=userEmail.email) - +formMessages() + form(data-ol-async-form action='/user/emails/send-reconfirmation') + input(name='_csrf' type='hidden' value=csrfToken) + input(name='email' type='hidden' value=userEmail.email) + +formMessages .reconfirm-notification - div(data-ol-not-sent style="width:100%;") + div(data-ol-not-sent style='width: 100%') i.fa.fa-warning - var ssoEnabled = userEmail.affiliation && userEmail.affiliation.institution && userEmail.affiliation.institution.ssoEnabled @@ -18,16 +15,16 @@ mixin reconfirmAffiliationNotification-marketing(userEmail, location) data-ol-slow-link href=`${settings.saml.ukamf.initPath}?university_id=${institutionId}&reconfirm=${location}` ) - span(data-ol-inflight="idle") #{translate("confirm_affiliation")} - span(hidden data-ol-inflight="pending") #{translate("pending")}… + span(data-ol-inflight='idle') #{translate("confirm_affiliation")} + span(hidden data-ol-inflight='pending') #{translate("pending")}… else button.btn-reconfirm.btn.btn-sm.btn-info( - type="submit" + type='submit' data-ol-disabled-inflight ) - span(data-ol-inflight="idle") #{translate("confirm_affiliation")} - span(hidden data-ol-inflight="pending") #{translate("pending")}… + span(data-ol-inflight='idle') #{translate("confirm_affiliation")} + span(hidden data-ol-inflight='pending') #{translate("pending")}… | !{translate("are_you_still_at", {institutionName: userEmail.affiliation.institution.name}, ['strong'])}  @@ -39,22 +36,19 @@ mixin reconfirmAffiliationNotification-marketing(userEmail, location) | !{translate("please_reconfirm_institutional_email", {}, [{name: 'a', attrs: {href: '/user/settings?remove=' + userEmail.email}}])} |   - a(href="/learn/how-to/Institutional_Email_Reconfirmation" target="_blank") #{translate("learn_more")} + a(href='/learn/how-to/Institutional_Email_Reconfirmation' target='_blank') #{translate("learn_more")} div(hidden data-ol-sent) | !{translate("please_check_your_inbox_to_confirm", {institutionName: userEmail.affiliation.institution.name}, ['strong'])} |   - button.btn-inline-link( - type="submit" - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate('resend_confirmation_email')} - span(hidden data-ol-inflight="pending") #{translate("pending")}… + button.btn-inline-link(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate('resend_confirmation_email')} + span(hidden data-ol-inflight='pending') #{translate("pending")}… mixin reconfirmedAffiliationNotification-marketing(userEmail) .alert.alert-info .reconfirm-notification - div(style="width:100%;") + div(style='width: 100%') //- extra div for flex styling | !{translate("your_affiliation_is_confirmed", {institutionName: userEmail.affiliation.institution.name}, ['strong'])} | diff --git a/services/web/app/views/_mixins/terms_of_service.pug b/services/web/app/views/_mixins/terms_of_service.pug index 0fc3887b42..b0b5aaf81d 100644 --- a/services/web/app/views/_mixins/terms_of_service.pug +++ b/services/web/app/views/_mixins/terms_of_service.pug @@ -1,3 +1,3 @@ mixin termsOfServiceAgreement - div.tos-agreement-notice + .tos-agreement-notice | !{translate("by_registering_you_agree_to_our_terms_of_service", {}, [{name: 'a', attrs: {href: '/legal#Terms', target: '_blank'}}, {name: 'a', attrs: {href: '/legal#Privacy', target: '_blank'}}])} diff --git a/services/web/app/views/admin/index.pug b/services/web/app/views/admin/index.pug index aaf2228cbc..7a284d1f41 100644 --- a/services/web/app/views/admin/index.pug +++ b/services/web/app/views/admin/index.pug @@ -2,7 +2,7 @@ extends ../layout-marketing include ../_mixins/bookmarkable_tabset block content - .content.content-alt#main-content + #main-content.content.content-alt .container .row .col-sm-12 @@ -12,7 +12,7 @@ block content h1 Admin Panel .ol-tabs(data-ol-bookmarkable-tabset) .nav-tabs-container - ul.nav.nav-tabs.align-left(role="tablist") + ul.nav.nav-tabs.align-left(role='tablist') +bookmarkable-tabset-header('system-messages', 'System Messages', true) +bookmarkable-tabset-header('open-sockets', 'Open Sockets') +bookmarkable-tabset-header('open-close-editor', 'Open/Close Editor') @@ -20,29 +20,28 @@ block content +bookmarkable-tabset-header('tpds', 'TPDS/Dropbox Management') .tab-content - .tab-pane.active( - role="tabpanel" - id='system-messages' - ) + .tab-pane.active(role='tabpanel' id='system-messages') each message in systemMessages ul.system-messages li.system-message.row-spaced #{message.content} hr - form(method='post', action='/admin/messages') - input(name="_csrf", type="hidden", value=csrfToken) + form(method='post' action='/admin/messages') + input(name='_csrf' type='hidden' value=csrfToken) .form-group - label.form-label(for="content") - input.form-control(name="content", type="text", placeholder="Message…", required) - button.btn.btn-primary(type="submit") Post Message + label.form-label(for='content') + input.form-control( + name='content' + type='text' + placeholder='Message…' + required + ) + button.btn.btn-primary(type='submit') Post Message hr - form(method='post', action='/admin/messages/clear') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Clear all messages + form(method='post' action='/admin/messages/clear') + input(name='_csrf' type='hidden' value=csrfToken) + button.btn.btn-danger(type='submit') Clear all messages - .tab-pane( - role="tabpanel" - id='open-sockets' - ) + .tab-pane(role='tabpanel' id='open-sockets') .row-spaced ul each agents, url in openSockets @@ -51,52 +50,56 @@ block content each agent in agents li #{agent} - .tab-pane( - role="tabpanel" - id='open-close-editor' - ) + .tab-pane(role='tabpanel' id='open-close-editor') if hasFeature('saas') | The "Open/Close Editor" feature is not available in SAAS. else .row-spaced - form(method='post',action='/admin/closeEditor') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Close Editor + form(method='post' action='/admin/closeEditor') + input(name='_csrf' type='hidden' value=csrfToken) + button.btn.btn-danger(type='submit') Close Editor p.small Will stop anyone opening the editor. Will NOT disconnect already connected users. .row-spaced - form(method='post',action='/admin/disconnectAllUsers') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Disconnect all users + form(method='post' action='/admin/disconnectAllUsers') + input(name='_csrf' type='hidden' value=csrfToken) + button.btn.btn-danger(type='submit') Disconnect all users p.small Will force disconnect all users with the editor open. Make sure to close the editor first to avoid them reconnecting. .row-spaced - form(method='post',action='/admin/openEditor') - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-danger(type="submit") Reopen Editor + form(method='post' action='/admin/openEditor') + input(name='_csrf' type='hidden' value=csrfToken) + button.btn.btn-danger(type='submit') Reopen Editor p.small Will reopen the editor after closing. if hasFeature('saas') - .tab-pane( - role="tabpanel" - id='tpds' - ) + .tab-pane(role='tabpanel' id='tpds') h3 Flush project to TPDS .row - form.col-xs-6(method='post',action='/admin/flushProjectToTpds') - input(name="_csrf", type="hidden", value=csrfToken) + form.col-xs-6(method='post' action='/admin/flushProjectToTpds') + input(name='_csrf' type='hidden' value=csrfToken) .form-group label.form-label(for='project_id') project_id - input.form-control(type='text', name='project_id', placeholder='project_id', required) + input.form-control( + name='project_id' + type='text' + placeholder='project_id' + required + ) .form-group button.btn-primary.btn(type='submit') Flush hr h3 Poll Dropbox for user .row - form.col-xs-6(method='post',action='/admin/pollDropboxForUser') - input(name="_csrf", type="hidden", value=csrfToken) + form.col-xs-6(method='post' action='/admin/pollDropboxForUser') + input(name='_csrf' type='hidden' value=csrfToken) .form-group label.form-label(for='user_id') user_id - input.form-control(type='text', name='user_id', placeholder='user_id', required) + input.form-control( + name='user_id' + type='text' + placeholder='user_id' + required + ) .form-group button.btn-primary.btn(type='submit') Poll diff --git a/services/web/app/views/beta_program/opt_in.pug b/services/web/app/views/beta_program/opt_in.pug index 3122dc127f..c30cb78d9a 100644 --- a/services/web/app/views/beta_program/opt_in.pug +++ b/services/web/app/views/beta_program/opt_in.pug @@ -2,14 +2,14 @@ extends ../layout-marketing include ../_mixins/back_to_btns block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container.beta-opt-in-wrapper .row .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card .card-body .page-header - h1 + h1 | #{translate("sharelatex_beta_program")} .beta-opt-in .container-fluid @@ -28,7 +28,9 @@ block content ul li | #{translate("beta_program_badge_description")}  - span.badge.bg-warning-light-bg.text-warning(aria-label=translate("beta_feature_badge")) + span.badge.bg-warning-light-bg.text-warning( + aria-label=translate('beta_feature_badge') + ) span.badge-content β li !{translate("you_will_be_able_to_contact_us_any_time_to_share_your_feedback", {}, ['strong'])}. li !{translate("we_may_also_contact_you_from_time_to_time_by_email_with_a_survey", {}, ['strong'])}. @@ -40,37 +42,30 @@ block content if user.betaProgram form( data-ol-regular-form - method="post" - action="/beta/opt-out" + method='post' + action='/beta/opt-out' novalidate ) - input(type="hidden", name="_csrf", value=csrfToken) + input(name='_csrf' type='hidden' value=csrfToken) .form-group - a( - href="https://forms.gle/CFEsmvZQTAwHCd3X9" - target="_blank" - rel="noopener noreferrer" - ).btn.btn-primary.btn-lg #{translate("give_feedback")} + a.btn.btn-primary.btn-lg( + href='https://forms.gle/CFEsmvZQTAwHCd3X9' + target='_blank' + rel='noopener noreferrer' + ) #{translate("give_feedback")} .form-group button.btn.btn-secondary-info.btn-secondary.btn-sm( - type="submit" + type='submit' data-ol-disabled-inflight ) - span(data-ol-inflight="idle") #{translate("beta_program_opt_out_action")} - span(hidden data-ol-inflight="pending") #{translate("processing")}… + span(data-ol-inflight='idle') #{translate("beta_program_opt_out_action")} + span(hidden data-ol-inflight='pending') #{translate("processing")}… else - form( - data-ol-regular-form - method="post", - action="/beta/opt-in" - ) - input(type="hidden", name="_csrf", value=csrfToken) + form(data-ol-regular-form method='post' action='/beta/opt-in') + input(name='_csrf' type='hidden' value=csrfToken) .form-group - button.btn.btn-primary( - type="submit" - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("beta_program_opt_in_action")} - span(hidden data-ol-inflight="pending") #{translate("joining")}… + button.btn.btn-primary(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("beta_program_opt_in_action")} + span(hidden data-ol-inflight='pending') #{translate("joining")}… .page-separator - +back-to-btns() + +back-to-btns diff --git a/services/web/app/views/general/400.pug b/services/web/app/views/general/400.pug index fcc3007e6f..5dad09910f 100644 --- a/services/web/app/views/general/400.pug +++ b/services/web/app/views/general/400.pug @@ -1,29 +1,29 @@ extends ../layout/layout-no-js block vars - - metadata = { title: 'Something went wrong' } + - metadata = {title: 'Something went wrong'} block body body.full-height - main.content.content-alt.full-height#main-content + main#main-content.content.content-alt.full-height .container.full-height .error-container.full-height .error-details p.error-status Something went wrong, sorry. p.error-description | There was a problem with your request. - if(message) + if message | | The error is: - if(message) + if message p.error-box | #{message} p.error-description | Please go back and try again. | If the problem persists, please contact us at | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + a(href='mailto:' + settings.adminEmail) #{settings.adminEmail} | . p.error-actions - a.error-btn(href="javascript:history.back()") Back - a.btn.btn-secondary(href="/") Home + a.error-btn(href='javascript:history.back()') Back + a.btn.btn-secondary(href='/') Home diff --git a/services/web/app/views/general/404.pug b/services/web/app/views/general/404.pug index f76eac6997..ce92d6d56e 100644 --- a/services/web/app/views/general/404.pug +++ b/services/web/app/views/general/404.pug @@ -1,11 +1,11 @@ extends ../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .error-container .error-details p.error-status Not found p.error-description #{translate("cant_find_page")} p.error-actions - a.error-btn(href="/") Home + a.error-btn(href='/') Home diff --git a/services/web/app/views/general/500.pug b/services/web/app/views/general/500.pug index 41e7440e0d..22d6ceb35c 100644 --- a/services/web/app/views/general/500.pug +++ b/services/web/app/views/general/500.pug @@ -1,11 +1,11 @@ extends ../layout/layout-no-js block vars - - metadata = { title: 'Something went wrong' } + - metadata = {title: 'Something went wrong'} block body body.full-height - main.content.content-alt.full-height#main-content + main#main-content.content.content-alt.full-height .container.full-height .error-container.full-height .error-details @@ -13,11 +13,11 @@ block body p.error-description Our staff are probably looking into this, but if it continues, please check our status page at | | - a(href="http://" + settings.statusPageUrl) #{settings.statusPageUrl} + a(href='http://' + settings.statusPageUrl) #{settings.statusPageUrl} | | or contact us at | - a(href="mailto:" + settings.adminEmail) #{settings.adminEmail} + a(href='mailto:' + settings.adminEmail) #{settings.adminEmail} | . p.error-actions - a.error-btn(href="/") Home + a.error-btn(href='/') Home diff --git a/services/web/app/views/general/closed.pug b/services/web/app/views/general/closed.pug index b3f8ea2c04..3c1196a4cc 100644 --- a/services/web/app/views/general/closed.pug +++ b/services/web/app/views/general/closed.pug @@ -1,7 +1,7 @@ extends ../layout-marketing block content - main.content#main-content + main#main-content.content .container .row .col-lg-8.offset-lg-2.text-center diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index b17e61cb41..c6bbc92d01 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -15,12 +15,8 @@ block content .card-body p.text-center #{translate('processing_your_request')} - form( - data-ol-regular-form - data-ol-auto-submit - method="POST" - ) - input(name="_csrf" type="hidden" value=csrfToken) - input(hidden name="viaGateway" type="submit" value="true") - for name in Object.keys(form_data) - input(name=name type="hidden" value=form_data[name]) + form(data-ol-regular-form data-ol-auto-submit method='POST') + input(name='_csrf' type='hidden' value=csrfToken) + input(name='viaGateway' hidden type='submit' value='true') + each name in Object.keys(form_data) + input(name=name type='hidden' value=form_data[name]) diff --git a/services/web/app/views/general/unsupported-browser.pug b/services/web/app/views/general/unsupported-browser.pug index a2c2216315..1fd2c42bdb 100644 --- a/services/web/app/views/general/unsupported-browser.pug +++ b/services/web/app/views/general/unsupported-browser.pug @@ -1,11 +1,11 @@ extends ../layout/layout-no-js block vars - - metadata = { title: 'Unsupported browser' } + - metadata = {title: 'Unsupported browser'} block body body.full-height - main.content.content-alt.full-height#main-content + main#main-content.content.content-alt.full-height .container.full-height .error-container.full-height .error-details @@ -15,7 +15,7 @@ block body br | If you think you're seeing this message in error, | - a(href="mailto:" + settings.adminEmail) please let us know + a(href='mailto:' + settings.adminEmail) please let us know | . if fromURL @@ -33,7 +33,7 @@ block body p | Support for beta or developer-preview browser versions cannot be guaranteed. Please | - a(href="mailto:" + settings.adminEmail) get in touch + a(href='mailto:' + settings.adminEmail) get in touch | | if you encounter any issues while using the service with beta or developer-preview releases of supported browsers. p @@ -41,5 +41,5 @@ block body p | If you cannot upgrade to one of the supported browsers, | - a(href="mailto:" + settings.adminEmail) please let us know + a(href='mailto:' + settings.adminEmail) please let us know | . diff --git a/services/web/app/views/layout-base.pug b/services/web/app/views/layout-base.pug index 0493281353..b590618387 100644 --- a/services/web/app/views/layout-base.pug +++ b/services/web/app/views/layout-base.pug @@ -2,8 +2,8 @@ include ./_mixins/foot_scripts doctype html html( - lang=(currentLngCode || 'en') - class=(fixedSizeDocument ? 'fixed-size-document' : undefined) + lang=currentLngCode || 'en' + class=fixedSizeDocument ? 'fixed-size-document' : undefined ) - metadata = metadata || {} - let bootstrap5PageStatus = 'enabled' // One of 'disabled' and 'enabled' @@ -22,91 +22,128 @@ html( include ./_metadata.pug - const bootstrapVersion = bootstrap5PageStatus !== 'disabled' && (bootstrap5Override || bootstrap5PageSplitTest === '' || splitTestVariants[bootstrap5PageSplitTest] === 'enabled') ? 5 : 3 - + //- Stylesheet - link(rel='stylesheet', href=buildCssPath(getCssThemeModifier(userSettings, brandVariation, enableIeeeBranding), bootstrapVersion), id="main-stylesheet") + link( + rel='stylesheet' + href=buildCssPath(getCssThemeModifier(userSettings, brandVariation, enableIeeeBranding), bootstrapVersion) + id='main-stylesheet' + ) block css each file in entrypointStyles(entrypoint) - link(rel='stylesheet', href=file) + link(rel='stylesheet' href=file) block _headLinks - if (typeof suppressRelAlternateLinks == "undefined") + if typeof suppressRelAlternateLinks == 'undefined' if settings.i18n.subdomainLang each subdomainDetails in settings.i18n.subdomainLang if !subdomainDetails.hide - link(rel="alternate", href=subdomainDetails.url + currentUrl, hreflang=subdomainDetails.lngCode) + link( + rel='alternate' + href=subdomainDetails.url + currentUrl + hreflang=subdomainDetails.lngCode + ) - if (entrypoint !== 'marketing') - link(rel="preload", href=buildJsPath(currentLngCode + "-json.js"), as="script", nonce=scriptNonce) + if entrypoint !== 'marketing' + link( + rel='preload' + href=buildJsPath(currentLngCode + '-json.js') + as='script' + nonce=scriptNonce + ) //- Scripts - if (typeof suppressGoogleAnalytics == "undefined") + if typeof suppressGoogleAnalytics == 'undefined' include _google_analytics block meta - meta(name="ol-csrfToken" content=csrfToken) + meta(name='ol-csrfToken' content=csrfToken) //- Configure dynamically loaded assets (via webpack) to be downloaded from CDN //- See: https://webpack.js.org/guides/public-path/#on-the-fly - meta(name="ol-baseAssetPath" content=buildBaseAssetPath()) - meta(name="ol-mathJaxPath" content=mathJaxPath) - meta(name="ol-dictionariesRoot" content=dictionariesRoot) + meta(name='ol-baseAssetPath' content=buildBaseAssetPath()) + meta(name='ol-mathJaxPath' content=mathJaxPath) + meta(name='ol-dictionariesRoot' content=dictionariesRoot) - meta(name="ol-usersEmail" content=getUserEmail()) - meta(name="ol-ab" data-type="json" content={}) - meta(name="ol-user_id" content=getLoggedInUserId()) + meta(name='ol-usersEmail' content=getUserEmail()) + meta(name='ol-ab' data-type='json' content={}) + meta(name='ol-user_id' content=getLoggedInUserId()) //- Internationalisation settings - meta(name="ol-i18n" data-type="json" content={ - currentLangCode: currentLngCode - }) + meta( + name='ol-i18n' + data-type='json' + content={ + currentLangCode: currentLngCode, + } + ) //- Expose some settings globally to the frontend - meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings) - meta(name="ol-splitTestVariants" data-type="json" content=splitTestVariants || {}) - meta(name="ol-splitTestInfo" data-type="json" content=splitTestInfo || {}) + meta(name='ol-ExposedSettings' data-type='json' content=ExposedSettings) + meta( + name='ol-splitTestVariants' + data-type='json' + content=splitTestVariants || {} + ) + meta(name='ol-splitTestInfo' data-type='json' content=splitTestInfo || {}) - if (typeof settings.algolia != "undefined") - meta(name="ol-algolia" data-type="json" content={ - appId: settings.algolia.app_id, - apiKey: settings.algolia.read_only_api_key, - indexes: settings.algolia.indexes - }) + if typeof settings.algolia != 'undefined' + meta( + name='ol-algolia' + data-type='json' + content={ + appId: settings.algolia.app_id, + apiKey: settings.algolia.read_only_api_key, + indexes: settings.algolia.indexes, + } + ) - meta(name="ol-isManagedAccount" data-type="boolean" content=isManagedAccount) + meta( + name='ol-isManagedAccount' + data-type='boolean' + content=isManagedAccount + ) each restriction in userRestrictions || [] - meta(name='ol-cannot-' + restriction data-type="boolean" content=true) - meta(name="ol-bootstrapVersion" data-type="json" content=bootstrapVersion) + meta(name='ol-cannot-' + restriction data-type='boolean' content) + meta(name='ol-bootstrapVersion' data-type='json' content=bootstrapVersion) block head-scripts - body(class={ - 'thin-footer': showThinFooter, - 'website-redesign': isWebsiteRedesign === true || websiteRedesignOverride, - 'application-page': isApplicationPage - }, data-theme="default") - if(settings.recaptcha && settings.recaptcha.siteKeyV3) - script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=" + settings.recaptcha.siteKeyV3, defer=deferScripts) + body( + class={ + 'thin-footer': showThinFooter, + 'website-redesign': isWebsiteRedesign === true || websiteRedesignOverride, + 'application-page': isApplicationPage, + } + data-theme='default' + ) + if settings.recaptcha && settings.recaptcha.siteKeyV3 + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=' + settings.recaptcha.siteKeyV3 + defer=deferScripts + ) - if (typeof suppressSkipToContent == "undefined") - a(class="skip-to-content" href="#main-content") #{translate('skip_to_content')} + if typeof suppressSkipToContent == 'undefined' + a(class='skip-to-content' href='#main-content') #{translate('skip_to_content')} block body - if (settings.devToolbar.enabled) - div#dev-toolbar + if settings.devToolbar.enabled + #dev-toolbar block foot-scripts +foot-scripts include _customer_io - script(type="text/javascript", nonce=scriptNonce). - window.addEventListener('DOMContentLoaded', function() { + script(type='text/javascript' nonce=scriptNonce). + window.addEventListener('DOMContentLoaded', function () { //- Look for bundle var cdnBlocked = typeof Frontend === 'undefined' //- Prevent loops - var noCdnAlreadyInUrl = window.location.href.indexOf("nocdn=true") != -1 - if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf("Googlebot") == -1) { + var noCdnAlreadyInUrl = window.location.href.indexOf('nocdn=true') != -1 + if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf('Googlebot') == -1) { //- Set query param, server will not set CDN url - window.location.search += "&nocdn=true"; + window.location.search += '&nocdn=true' } }) diff --git a/services/web/app/views/layout-marketing.pug b/services/web/app/views/layout-marketing.pug index 20126beda3..b54c30f033 100644 --- a/services/web/app/views/layout-marketing.pug +++ b/services/web/app/views/layout-marketing.pug @@ -7,7 +7,7 @@ block entrypointVar - entrypoint = 'marketing' block body - if (typeof suppressNavbar === "undefined") + if typeof suppressNavbar === 'undefined' if bootstrapVersion === 5 include layout/navbar-marketing-bootstrap-5 else @@ -15,7 +15,7 @@ block body block content - if (typeof suppressFooter === "undefined") + if typeof suppressFooter === 'undefined' if showThinFooter if bootstrapVersion === 5 include layout/thin-footer-bootstrap-5 @@ -24,13 +24,13 @@ block body else include layout/fat-footer - if (typeof(suppressCookieBanner) == 'undefined') + if typeof suppressCookieBanner == 'undefined' include _cookie_banner if bootstrapVersion === 5 - != moduleIncludes("contactModal-marketing-bootstrap-5", locals) + != moduleIncludes('contactModal-marketing-bootstrap-5', locals) else - != moduleIncludes("contactModal-marketing", locals) + != moduleIncludes('contactModal-marketing', locals) block prepend foot-scripts +bootstrap-js(bootstrapVersion) diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index be875b29f8..94ff3ba247 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -7,10 +7,10 @@ include ./_mixins/bootstrap_js block entrypointVar - entrypoint = 'marketing' - + block isApplicationPageVar - isApplicationPage = true - + block append meta - const canDisplayAdminMenu = hasAdminAccess() - const canDisplayAdminRedirect = canRedirectToAdminDomain() @@ -22,44 +22,52 @@ block append meta - const enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - const showSignUpLink = hasFeature('registration-page') - meta(name="ol-navbar" data-type="json" content={ - customLogo: settings.nav.custom_logo, - title: nav.title, - canDisplayAdminMenu, - canDisplayAdminRedirect, - canDisplaySplitTestMenu, - canDisplaySurveyMenu, - canDisplayScriptLogMenu, - enableUpgradeButton, - suppressNavbarRight: !!suppressNavbarRight, - suppressNavContentLinks: !!suppressNavContentLinks, - showSubscriptionLink: nav.showSubscriptionLink, - showSignUpLink: showSignUpLink, - currentUrl: currentUrl, - sessionUser: sessionUser ? { email: sessionUser.email} : undefined, - adminUrl: settings.adminUrl, - items: cloneAndTranslateText(nav.header_extras) - }) - meta(name="ol-footer" data-type="json" content={ - showThinFooter: showThinFooter, - showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by, - subdomainLang: settings.i18n.subdomainLang, - translatedLanguages: settings.translatedLanguages, - leftItems: cloneAndTranslateText(settings.nav.left_footer), - rightItems: settings.nav.right_footer - }) + meta( + name='ol-navbar' + data-type='json' + content={ + customLogo: settings.nav.custom_logo, + title: nav.title, + canDisplayAdminMenu, + canDisplayAdminRedirect, + canDisplaySplitTestMenu, + canDisplaySurveyMenu, + canDisplayScriptLogMenu, + enableUpgradeButton, + suppressNavbarRight: !!suppressNavbarRight, + suppressNavContentLinks: !!suppressNavContentLinks, + showSubscriptionLink: nav.showSubscriptionLink, + showSignUpLink: showSignUpLink, + currentUrl: currentUrl, + sessionUser: sessionUser ? {email: sessionUser.email} : undefined, + adminUrl: settings.adminUrl, + items: cloneAndTranslateText(nav.header_extras), + } + ) + meta( + name='ol-footer' + data-type='json' + content={ + showThinFooter: showThinFooter, + showPoweredBy: !hasFeature('saas') && !settings.nav.hide_powered_by, + subdomainLang: settings.i18n.subdomainLang, + translatedLanguages: settings.translatedLanguages, + leftItems: cloneAndTranslateText(settings.nav.left_footer), + rightItems: settings.nav.right_footer, + } + ) block body - if (typeof suppressNavbar === "undefined") + if typeof suppressNavbar === 'undefined' include layout/navbar-marketing-react-bootstrap-5 block content - if (typeof suppressFooter === "undefined") + if typeof suppressFooter === 'undefined' if showThinFooter include layout/thin-footer-bootstrap-5 else include layout/fat-footer-react-bootstrap-5 - if (typeof suppressCookieBanner === "undefined") + if typeof suppressCookieBanner === 'undefined' include _cookie_banner diff --git a/services/web/app/views/layout-website-redesign.pug b/services/web/app/views/layout-website-redesign.pug index d04d4b1202..5d7bce6c4d 100644 --- a/services/web/app/views/layout-website-redesign.pug +++ b/services/web/app/views/layout-website-redesign.pug @@ -7,7 +7,7 @@ block entrypointVar - entrypoint = 'marketing' block body - if (typeof(suppressNavbar) == "undefined") + if typeof suppressNavbar == 'undefined' if bootstrapVersion === 5 include layout/navbar-marketing-bootstrap-5 else @@ -17,23 +17,23 @@ block body //- bootstrapVersion needed here, because plans.pug uses both BS version //- If the `plans-page-bs5` split test has been completed, remove bootstrapVersion logic - if (typeof(suppressFooter) == "undefined") + if typeof suppressFooter == 'undefined' if showThinFooter if bootstrapVersion === 5 include layout/thin-footer-bootstrap-5 - else + else include layout/thin-footer else include layout/fat-footer-website-redesign - if (typeof(suppressCookieBanner) == 'undefined') + if typeof suppressCookieBanner == 'undefined' include _cookie_banner block contactModal if bootstrapVersion === 5 - != moduleIncludes("contactModal-marketing-bootstrap-5", locals) + != moduleIncludes('contactModal-marketing-bootstrap-5', locals) else - != moduleIncludes("contactModal-marketing", locals) + != moduleIncludes('contactModal-marketing', locals) block prepend foot-scripts +bootstrap-js(bootstrapVersion) diff --git a/services/web/app/views/layout/fat-footer-base.pug b/services/web/app/views/layout/fat-footer-base.pug index 2e3dd2074f..e8380939f6 100644 --- a/services/web/app/views/layout/fat-footer-base.pug +++ b/services/web/app/views/layout/fat-footer-base.pug @@ -1,9 +1,9 @@ .fat-footer-base .fat-footer-base-section.fat-footer-base-meta - .fat-footer-base-item + .fat-footer-base-item .fat-footer-base-copyright © #{new Date().getFullYear()} Overleaf - a(href="/legal") #{translate('privacy_and_terms')} - a(href="https://www.digital-science.com/security-certifications/") #{translate('compliance')} + a(href='/legal') #{translate('privacy_and_terms')} + a(href='https://www.digital-science.com/security-certifications/') #{translate('compliance')} ul.fat-footer-base-item.list-unstyled.fat-footer-base-language if bootstrapVersion === 5 include language-picker-bootstrap-5 @@ -11,22 +11,53 @@ include language-picker .fat-footer-base-section.fat-footer-base-social .fat-footer-base-item - a.fat-footer-social.x-logo(href="https://x.com/overleaf") - svg(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1227" height="25") - path(d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z") + a.fat-footer-social.x-logo(href='https://x.com/overleaf') + svg( + xmlns='http://www.w3.org/2000/svg' + viewBox='0 0 1200 1227' + height='25' + ) + path( + d='M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z' + ) span.visually-hidden #{translate("app_on_x", {social: "X"})} - a.fat-footer-social.facebook-logo(href="https://www.facebook.com/overleaf.editor") - svg(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 666.66668 666.66717" height="25") + a.fat-footer-social.facebook-logo( + href='https://www.facebook.com/overleaf.editor' + ) + svg( + xmlns='http://www.w3.org/2000/svg' + viewBox='0 0 666.66668 666.66717' + height='25' + ) defs - clipPath(id="a" clipPathUnits="userSpaceOnUse") - path(d="M0 700h700V0H0Z") - g(clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 -133.333 800)") - path.background(d="M0 0c0 138.071-111.929 250-250 250S-500 138.071-500 0c0-117.245 80.715-215.622 189.606-242.638v166.242h-51.552V0h51.552v32.919c0 85.092 38.508 124.532 122.048 124.532 15.838 0 43.167-3.105 54.347-6.211V81.986c-5.901.621-16.149.932-28.882.932-40.993 0-56.832-15.528-56.832-55.9V0h81.659l-14.028-76.396h-67.631v-171.773C-95.927-233.218 0-127.818 0 0" fill="#0866ff" transform="translate(600 350)") - path.text(d="m0 0 14.029 76.396H-67.63v27.019c0 40.372 15.838 55.899 56.831 55.899 12.733 0 22.981-.31 28.882-.931v69.253c-11.18 3.106-38.509 6.212-54.347 6.212-83.539 0-122.048-39.441-122.048-124.533V76.396h-51.552V0h51.552v-166.242a250.559 250.559 0 0 1 60.394-7.362c10.254 0 20.358.632 30.288 1.831V0Z" fill="#fff" transform="translate(447.918 273.604)") + clipPath(id='a' clipPathUnits='userSpaceOnUse') + path(d='M0 700h700V0H0Z') + g( + clip-path='url(#a)' + transform='matrix(1.33333 0 0 -1.33333 -133.333 800)' + ) + path.background( + d='M0 0c0 138.071-111.929 250-250 250S-500 138.071-500 0c0-117.245 80.715-215.622 189.606-242.638v166.242h-51.552V0h51.552v32.919c0 85.092 38.508 124.532 122.048 124.532 15.838 0 43.167-3.105 54.347-6.211V81.986c-5.901.621-16.149.932-28.882.932-40.993 0-56.832-15.528-56.832-55.9V0h81.659l-14.028-76.396h-67.631v-171.773C-95.927-233.218 0-127.818 0 0' + fill='#0866ff' + transform='translate(600 350)' + ) + path.text( + d='m0 0 14.029 76.396H-67.63v27.019c0 40.372 15.838 55.899 56.831 55.899 12.733 0 22.981-.31 28.882-.931v69.253c-11.18 3.106-38.509 6.212-54.347 6.212-83.539 0-122.048-39.441-122.048-124.533V76.396h-51.552V0h51.552v-166.242a250.559 250.559 0 0 1 60.394-7.362c10.254 0 20.358.632 30.288 1.831V0Z' + fill='#fff' + transform='translate(447.918 273.604)' + ) span.visually-hidden #{translate("app_on_x", {social: "Facebook"})} - a.fat-footer-social.linkedin-logo(href="https://www.linkedin.com/company/writelatex-limited") - svg(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" height="25") - g(fill="none" fill-rule="evenodd") - path.background(fill="#0B66C3" d="M8 72h56a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H8a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8") - path.text(fill="#FFF" d="M62 62H51.316V43.802c0-4.99-1.896-7.777-5.845-7.777-4.296 0-6.54 2.901-6.54 7.777V62H28.632V27.333H38.93v4.67s3.096-5.729 10.453-5.729c7.353 0 12.617 4.49 12.617 13.777zM16.35 22.794c-3.508 0-6.35-2.864-6.35-6.397C10 12.864 12.842 10 16.35 10c3.507 0 6.347 2.864 6.347 6.397 0 3.533-2.84 6.397-6.348 6.397ZM11.032 62h10.736V27.333H11.033V62") + a.fat-footer-social.linkedin-logo( + href='https://www.linkedin.com/company/writelatex-limited' + ) + svg(xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 72' height='25') + g(fill='none' fill-rule='evenodd') + path.background( + fill='#0B66C3' + d='M8 72h56a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H8a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8' + ) + path.text( + fill='#FFF' + d='M62 62H51.316V43.802c0-4.99-1.896-7.777-5.845-7.777-4.296 0-6.54 2.901-6.54 7.777V62H28.632V27.333H38.93v4.67s3.096-5.729 10.453-5.729c7.353 0 12.617 4.49 12.617 13.777zM16.35 22.794c-3.508 0-6.35-2.864-6.35-6.397C10 12.864 12.842 10 16.35 10c3.507 0 6.347 2.864 6.347 6.397 0 3.533-2.84 6.397-6.348 6.397ZM11.032 62h10.736V27.333H11.033V62' + ) span.visually-hidden #{translate("app_on_x", {social: "LinkedIn"})} diff --git a/services/web/app/views/layout/fat-footer-website-redesign.pug b/services/web/app/views/layout/fat-footer-website-redesign.pug index cd68e1daf6..ba6286a296 100644 --- a/services/web/app/views/layout/fat-footer-website-redesign.pug +++ b/services/web/app/views/layout/fat-footer-website-redesign.pug @@ -1,84 +1,87 @@ footer.fat-footer.hidden-print.website-redesign-fat-footer - .fat-footer-container(role="navigation" aria-label=translate('footer_navigation')) + .fat-footer-container( + role='navigation' + aria-label=translate('footer_navigation') + ) .fat-footer-sections(class=hideFatFooter ? 'hidden' : undefined) - .footer-section#footer-brand - a(href='/', aria-label=settings.appName).footer-brand - + #footer-brand.footer-section + a.footer-brand(href='/' aria-label=settings.appName) + .footer-section h2.footer-section-heading #{translate('About')} - + ul.list-unstyled li - a(href="/about") #{translate('footer_about_us')} + a(href='/about') #{translate('footer_about_us')} li - a(href="/about/values") #{translate('our_values')} + a(href='/about/values') #{translate('our_values')} li - a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')} + a(href='https://digitalscience.pinpointhq.com/') #{translate('careers')} li - a(href="/for/press") !{translate('press_and_awards')} + a(href='/for/press') !{translate('press_and_awards')} li - a(href="/blog") #{translate('blog')} - + a(href='/blog') #{translate('blog')} + .footer-section h2.footer-section-heading #{translate('learn')} - + ul.list-unstyled li - a(href="/learn/latex/Learn_LaTeX_in_30_minutes") #{translate('latex_in_thirty_minutes')} + a(href='/learn/latex/Learn_LaTeX_in_30_minutes') #{translate('latex_in_thirty_minutes')} li - a(href="/latex/templates") #{translate('templates')} + a(href='/latex/templates') #{translate('templates')} li - a(href="/events/webinars") #{translate('webinars')} + a(href='/events/webinars') #{translate('webinars')} li - a(href="/learn/latex/Tutorials") #{translate('tutorials')} + a(href='/learn/latex/Tutorials') #{translate('tutorials')} li - a(href="/learn/latex/Inserting_Images") #{translate('how_to_insert_images')} + a(href='/learn/latex/Inserting_Images') #{translate('how_to_insert_images')} li - a(href="/learn/latex/Tables") #{translate('how_to_create_tables')} - + a(href='/learn/latex/Tables') #{translate('how_to_create_tables')} + .footer-section h2.footer-section-heading !{translate('footer_plans_and_pricing')} - + ul.list-unstyled li - a(href="/learn/how-to/Overleaf_premium_features") #{translate('premium_features')} + a(href='/learn/how-to/Overleaf_premium_features') #{translate('premium_features')} li - a(href="/user/subscription/plans?itm_referrer=footer-for-indv-groups") !{translate('for_individuals_and_groups')} + a(href='/user/subscription/plans?itm_referrer=footer-for-indv-groups') !{translate('for_individuals_and_groups')} li - a(href="/for/enterprises") #{translate('for_business')} + a(href='/for/enterprises') #{translate('for_business')} li - a(href="/for/universities") #{translate('for_universities')} + a(href='/for/universities') #{translate('for_universities')} li a( data-ol-for-students-link - href="/user/subscription/plans?itm_referrer=footer-for-students#student-annual" + href='/user/subscription/plans?itm_referrer=footer-for-students#student-annual' ) #{translate('for_students')} li - a(href="/for/government") #{translate('for_government')} - + a(href='/for/government') #{translate('for_government')} + .footer-section h2.footer-section-heading #{translate('get_involved')} - + ul.list-unstyled li - a(href="/for/community/advisors") #{translate('become_an_advisor')} + a(href='/for/community/advisors') #{translate('become_an_advisor')} li - a(href="https://forms.gle/67PSpN1bLnjGCmPQ9") #{translate('let_us_know_what_you_think')} + a(href='https://forms.gle/67PSpN1bLnjGCmPQ9') #{translate('let_us_know_what_you_think')} if user li - a(href="/beta/participate") #{translate('join_beta_program')} - + a(href='/beta/participate') #{translate('join_beta_program')} + .footer-section h2.footer-section-heading #{translate('help')} - + ul.list-unstyled li - a(href="/about/why-latex") #{translate('why_latex')} + a(href='/about/why-latex') #{translate('why_latex')} li - a(href="/learn") #{translate('Documentation')} + a(href='/learn') #{translate('Documentation')} li - a(href="/contact") #{translate('footer_contact_us')} + a(href='/contact') #{translate('footer_contact_us')} li - a(href="https://status.overleaf.com/") #{translate('website_status')} - + a(href='https://status.overleaf.com/') #{translate('website_status')} + include fat-footer-base diff --git a/services/web/app/views/layout/fat-footer.pug b/services/web/app/views/layout/fat-footer.pug index d319a217cb..c7c19bd4a3 100644 --- a/services/web/app/views/layout/fat-footer.pug +++ b/services/web/app/views/layout/fat-footer.pug @@ -1,84 +1,87 @@ footer.fat-footer.hidden-print - .fat-footer-container(role="navigation" aria-label=translate('footer_navigation')) + .fat-footer-container( + role='navigation' + aria-label=translate('footer_navigation') + ) .fat-footer-sections(class=hideFatFooter ? 'hidden' : undefined) - .footer-section#footer-brand - a(href='/', aria-label=settings.appName).footer-brand - + #footer-brand.footer-section + a.footer-brand(href='/' aria-label=settings.appName) + .footer-section h2.footer-section-heading #{translate('About')} - + ul.list-unstyled li - a(href="/about") #{translate('footer_about_us')} + a(href='/about') #{translate('footer_about_us')} li - a(href="/about/values") #{translate('our_values')} + a(href='/about/values') #{translate('our_values')} li - a(href="https://digitalscience.pinpointhq.com/") #{translate('careers')} + a(href='https://digitalscience.pinpointhq.com/') #{translate('careers')} li - a(href="/for/press") !{translate('press_and_awards')} + a(href='/for/press') !{translate('press_and_awards')} li - a(href="/blog") #{translate('blog')} - + a(href='/blog') #{translate('blog')} + .footer-section h2.footer-section-heading #{translate('learn')} - + ul.list-unstyled li - a(href="/learn/latex/Learn_LaTeX_in_30_minutes") #{translate('latex_in_thirty_minutes')} + a(href='/learn/latex/Learn_LaTeX_in_30_minutes') #{translate('latex_in_thirty_minutes')} li - a(href="/latex/templates") #{translate('templates')} + a(href='/latex/templates') #{translate('templates')} li - a(href="/events/webinars") #{translate('webinars')} + a(href='/events/webinars') #{translate('webinars')} li - a(href="/learn/latex/Tutorials") #{translate('tutorials')} + a(href='/learn/latex/Tutorials') #{translate('tutorials')} li - a(href="/learn/latex/Inserting_Images") #{translate('how_to_insert_images')} + a(href='/learn/latex/Inserting_Images') #{translate('how_to_insert_images')} li - a(href="/learn/latex/Tables") #{translate('how_to_create_tables')} - + a(href='/learn/latex/Tables') #{translate('how_to_create_tables')} + .footer-section h2.footer-section-heading !{translate('footer_plans_and_pricing')} - + ul.list-unstyled li - a(href="/learn/how-to/Overleaf_premium_features") #{translate('premium_features')} + a(href='/learn/how-to/Overleaf_premium_features') #{translate('premium_features')} li - a(href="/user/subscription/plans?itm_referrer=footer-for-indv-groups") !{translate('for_individuals_and_groups')} + a(href='/user/subscription/plans?itm_referrer=footer-for-indv-groups') !{translate('for_individuals_and_groups')} li - a(href="/for/enterprises") #{translate('for_enterprise')} + a(href='/for/enterprises') #{translate('for_enterprise')} li - a(href="/for/universities") #{translate('for_universities')} + a(href='/for/universities') #{translate('for_universities')} li a( data-ol-for-students-link - href="/user/subscription/plans?itm_referrer=footer-for-students#student-annual" + href='/user/subscription/plans?itm_referrer=footer-for-students#student-annual' ) #{translate('for_students')} li - a(href="/for/government") #{translate('for_government')} - + a(href='/for/government') #{translate('for_government')} + .footer-section h2.footer-section-heading #{translate('get_involved')} - + ul.list-unstyled li - a(href="/for/community/advisors") #{translate('become_an_advisor')} + a(href='/for/community/advisors') #{translate('become_an_advisor')} li - a(href="https://forms.gle/67PSpN1bLnjGCmPQ9") #{translate('let_us_know_what_you_think')} + a(href='https://forms.gle/67PSpN1bLnjGCmPQ9') #{translate('let_us_know_what_you_think')} if user li - a(href="/beta/participate") #{translate('join_beta_program')} - + a(href='/beta/participate') #{translate('join_beta_program')} + .footer-section h2.footer-section-heading #{translate('help')} - + ul.list-unstyled li - a(href="/about/why-latex") #{translate('why_latex')} + a(href='/about/why-latex') #{translate('why_latex')} li - a(href="/learn") #{translate('Documentation')} + a(href='/learn') #{translate('Documentation')} li - a(href="/contact") #{translate('footer_contact_us')} + a(href='/contact') #{translate('footer_contact_us')} li - a(href="https://status.overleaf.com/") #{translate('website_status')} - + a(href='https://status.overleaf.com/') #{translate('website_status')} + include fat-footer-base diff --git a/services/web/app/views/layout/language-picker-bootstrap-5.pug b/services/web/app/views/layout/language-picker-bootstrap-5.pug index 44997a8ca4..09f8f58c7d 100644 --- a/services/web/app/views/layout/language-picker-bootstrap-5.pug +++ b/services/web/app/views/layout/language-picker-bootstrap-5.pug @@ -1,27 +1,35 @@ include ../_mixins/material_symbol -li.dropdown.dropup.subdued(dropdown).language-picker +li.dropdown.dropup.subdued.language-picker(dropdown) button#language-picker-toggle.btn.btn-link.btn-inline-link( - dropdown-toggle, - data-ol-lang-selector-tooltip, - data-bs-toggle="dropdown", - aria-haspopup="true", - aria-expanded="false", - aria-label="Select " + translate('language'), + dropdown-toggle + data-ol-lang-selector-tooltip + data-bs-toggle='dropdown' + aria-haspopup='true' + aria-expanded='false' + aria-label='Select ' + translate('language') tooltip=translate('language') title=translate('language') ) - +material-symbol("translate") + +material-symbol('translate') |   span.language-picker-text #{settings.translatedLanguages[currentLngCode]} - ul.dropdown-menu.dropdown-menu-sm-width(role="menu" aria-labelledby="language-picker-toggle") + ul.dropdown-menu.dropdown-menu-sm-width( + role='menu' + aria-labelledby='language-picker-toggle' + ) li.dropdown-header #{translate("language")} each subdomainDetails, subdomain in settings.i18n.subdomainLang if !subdomainDetails.hide - let isActive = subdomainDetails.lngCode === currentLngCode li.lng-option - a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams, role="menuitem", class=isActive ? 'dropdown-item active' : 'dropdown-item', aria-selected=isActive ? 'true' : 'false') + a.menu-indent( + href=subdomainDetails.url + currentUrlWithQueryParams + role='menuitem' + class=isActive ? 'dropdown-item active' : 'dropdown-item' + aria-selected=isActive ? 'true' : 'false' + ) | #{settings.translatedLanguages[subdomainDetails.lngCode]} if subdomainDetails.lngCode === currentLngCode - +material-symbol("check", "dropdown-item-trailing-icon") + +material-symbol('check', 'dropdown-item-trailing-icon') diff --git a/services/web/app/views/layout/language-picker.pug b/services/web/app/views/layout/language-picker.pug index d26d8a8bf7..e88ff716ec 100644 --- a/services/web/app/views/layout/language-picker.pug +++ b/services/web/app/views/layout/language-picker.pug @@ -1,13 +1,13 @@ -li.dropdown.dropup.subdued(dropdown).language-picker - a.dropdown-toggle#language-picker-toggle( - href="#", - dropdown-toggle, - data-ol-lang-selector-tooltip, - data-toggle="dropdown", - role="button" - aria-haspopup="true", - aria-expanded="false", - aria-label="Select " + translate('language'), +li.dropdown.dropup.subdued.language-picker(dropdown) + a#language-picker-toggle.dropdown-toggle( + href='#' + dropdown-toggle + data-ol-lang-selector-tooltip + data-toggle='dropdown' + role='button' + aria-haspopup='true' + aria-expanded='false' + aria-label='Select ' + translate('language') tooltip=translate('language') title=translate('language') ) @@ -15,10 +15,13 @@ li.dropdown.dropup.subdued(dropdown).language-picker | | #{settings.translatedLanguages[currentLngCode]} - ul.dropdown-menu(role="menu" aria-labelledby="language-picker-toggle") + ul.dropdown-menu(role='menu' aria-labelledby='language-picker-toggle') li.dropdown-header #{translate("language")} each subdomainDetails, subdomain in settings.i18n.subdomainLang if !subdomainDetails.hide li.lng-option - a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams role="menuitem") + a.menu-indent( + href=subdomainDetails.url + currentUrlWithQueryParams + role='menuitem' + ) | #{settings.translatedLanguages[subdomainDetails.lngCode]} diff --git a/services/web/app/views/layout/layout-no-js.pug b/services/web/app/views/layout/layout-no-js.pug index b5bf3cc434..76a31b72a5 100644 --- a/services/web/app/views/layout/layout-no-js.pug +++ b/services/web/app/views/layout/layout-no-js.pug @@ -1,18 +1,20 @@ doctype html -html(lang="en") - +html(lang='en') - metadata = metadata || {} block vars head - if (metadata && metadata.title) + if metadata && metadata.title title= metadata.title if metadata && metadata.viewport - meta(name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes") + meta( + name='viewport' + content='width=device-width, initial-scale=1.0, user-scalable=yes' + ) - link(rel="icon", href="/favicon.ico") + link(rel='icon' href='/favicon.ico') if buildCssPath - link(rel="stylesheet", href=buildCssPath('', 5)) + link(rel='stylesheet' href=buildCssPath('', 5)) block body diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index c581ab29ce..29b12a056d 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -1,26 +1,32 @@ include ../_mixins/navbar -nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ - 'website-redesign-navbar': isWebsiteRedesign -}) +nav.navbar.navbar-default.navbar-main.navbar-expand-lg( + class={ + 'website-redesign-navbar': isWebsiteRedesign, + } +) .container-fluid.navbar-container .navbar-header if settings.nav.custom_logo - a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand - else if (nav.title) - a(href='/', aria-label=settings.appName).navbar-title #{nav.title} + a.navbar-brand( + href='/' + aria-label=settings.appName + style='background-image:url("' + settings.nav.custom_logo + '")' + ) + else if nav.title + a.navbar-title(href='/' aria-label=settings.appName) #{nav.title} else - a(href='/', aria-label=settings.appName).navbar-brand + a.navbar-brand(href='/' aria-label=settings.appName) - var enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - if (enableUpgradeButton) + if enableUpgradeButton a.btn.btn-primary.me-2.d-md-none( - href="/user/subscription/plans" - event-tracking="upgrade-button-click" - event-tracking-mb="true" - event-tracking-label="upgrade" - event-tracking-trigger="click" - event-segmentation='{"source": "dashboard-top", "project-dashboard-react": "enabled", "is-dashboard-sidebar-hidden": "true", "is-screen-width-less-than-768px": "true"}' + href='/user/subscription/plans' + event-tracking='upgrade-button-click' + event-tracking-mb='true' + event-tracking-label='upgrade' + event-tracking-trigger='click' + event-segmentation={source: 'dashboard-top', projectDashboardReact: 'enabled', isDashboardSidebarHidden: 'true', isScreenWidthLessThan768px: 'true'} ) #{translate("upgrade")} - var canDisplayAdminMenu = hasAdminAccess() @@ -29,45 +35,45 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu - if (typeof suppressNavbarRight === "undefined") + if typeof suppressNavbarRight === 'undefined' button.navbar-toggler.collapsed( - type="button", - data-bs-toggle="collapse", - data-bs-target="#navbar-main-collapse" - aria-controls="navbar-main-collapse" - aria-expanded="false" - aria-label="Toggle " + translate('navigation') + type='button' + data-bs-toggle='collapse' + data-bs-target='#navbar-main-collapse' + aria-controls='navbar-main-collapse' + aria-expanded='false' + aria-label='Toggle ' + translate('navigation') ) - i.fa.fa-bars(aria-hidden="true") + i.fa.fa-bars(aria-hidden='true') - .navbar-collapse.collapse#navbar-main-collapse - ul.nav.navbar-nav.navbar-right.ms-auto(role="menubar") - if (canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu) + #navbar-main-collapse.navbar-collapse.collapse + ul.nav.navbar-nav.navbar-right.ms-auto(role='menubar') + if canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu +nav-item.dropdown.subdued button.dropdown-toggle( - aria-haspopup="true", - aria-expanded="false", - data-bs-toggle="dropdown" - role="menuitem" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "admin", "location": "top-menu"} + aria-haspopup='true' + aria-expanded='false' + data-bs-toggle='dropdown' + role='menuitem' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'admin', location: 'top-menu'} ) | Admin +dropdown-menu.dropdown-menu-end if canDisplayAdminMenu - +dropdown-menu-link-item()(href="/admin") Manage Site - +dropdown-menu-link-item()(href="/admin/user") Manage Users - +dropdown-menu-link-item()(href="/admin/project") Project URL Lookup + +dropdown-menu-link-item(href='/admin') Manage Site + +dropdown-menu-link-item(href='/admin/user') Manage Users + +dropdown-menu-link-item(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect - +dropdown-menu-link-item()(href=settings.adminUrl) Switch to Admin + +dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin if canDisplaySplitTestMenu - +dropdown-menu-link-item()(href="/admin/split-test") Manage Feature Flags + +dropdown-menu-link-item(href='/admin/split-test') Manage Feature Flags if canDisplaySurveyMenu - +dropdown-menu-link-item()(href="/admin/survey") Manage Surveys + +dropdown-menu-link-item(href='/admin/survey') Manage Surveys if canDisplayScriptLogMenu - +dropdown-menu-link-item()(href="/admin/script-logs") View Script Logs + +dropdown-menu-link-item(href='/admin/script-logs') View Script Logs // loop over header_extras each item in nav.header_extras @@ -86,14 +92,14 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ if item.dropdown +nav-item.dropdown(class=item.class) button.dropdown-toggle( - aria-haspopup="true", - aria-expanded="false", - data-bs-toggle="dropdown" - role="menuitem" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": item.trackingKey, "location": "top-menu"} + aria-haspopup='true' + aria-expanded='false' + data-bs-toggle='dropdown' + role='menuitem' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) | !{translate(item.text)} +dropdown-menu.dropdown-menu-end @@ -101,31 +107,41 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ if child.divider +dropdown-menu-divider else if child.isContactUs - +dropdown-menu-link-item()(data-ol-open-contact-form-modal="contact-us" data-bs-target="#contactUsModal" href data-bs-toggle="modal" event-tracking="menu-click" event-tracking-mb="true" event-tracking-trigger="click" event-segmentation={"item": "contact", "location": "top-menu"}) + +dropdown-menu-link-item( + data-ol-open-contact-form-modal='contact-us' + data-bs-target='#contactUsModal' + href + data-bs-toggle='modal' + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'contact', location: 'top-menu'} + ) span | #{translate("contact_us")} else if child.url - +dropdown-menu-link-item()( - href=child.url, - class=child.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ item: child.trackingKey, location: 'top-menu' } + +dropdown-menu-link-item( + href=child.url + class=child.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: child.trackingKey, location: 'top-menu'} ) !{translate(child.text)} else - +dropdown-menu-item !{translate(child.text)} + +dropdown-menu-item + | !{translate(child.text)} else +nav-item(class=item.class) if item.url +nav-link( - href=item.url, - class=item.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ item: item.trackingKey, location: 'top-menu' } + href=item.url + class=item.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) !{translate(item.text)} else | !{translate(item.text)} @@ -136,48 +152,48 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ if hasFeature('registration-page') +nav-item.primary +nav-link( - href="/register" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'register', location: 'top-menu' } + href='/register' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'register', location: 'top-menu'} ) #{translate('sign_up')} // login link +nav-item +nav-link( - href="/login" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'login', location: 'top-menu' } + href='/login' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'login', location: 'top-menu'} ) #{translate('log_in')} // projects link and account menu if getSessionUser() +nav-item - +nav-link(href="/project") #{translate('Projects')} + +nav-link(href='/project') #{translate('Projects')} +nav-item.dropdown button.dropdown-toggle( - aria-haspopup="true", - aria-expanded="false", - data-bs-toggle="dropdown" - role="menuitem" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "account", "location": "top-menu"} + aria-haspopup='true' + aria-expanded='false' + data-bs-toggle='dropdown' + role='menuitem' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'account', location: 'top-menu'} ) | #{translate('Account')} +dropdown-menu.dropdown-menu-end +dropdown-menu-item - div.disabled.dropdown-item #{getSessionUser().email} + .disabled.dropdown-item #{getSessionUser().email} +dropdown-menu-divider - +dropdown-menu-link-item()(href="/user/settings") #{translate('account_settings')} + +dropdown-menu-link-item(href='/user/settings') #{translate('account_settings')} if nav.showSubscriptionLink - +dropdown-menu-link-item()(href="/user/subscription") #{translate('subscription')} + +dropdown-menu-link-item(href='/user/subscription') #{translate('subscription')} +dropdown-menu-divider +dropdown-menu-item //- @@ -185,14 +201,10 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={ this is that if the button is inside the form, screen readers will not count it in the total number of menu items. button.btn-link.text-left.dropdown-menu-button.dropdown-item( - role="menuitem", - tabindex="-1" - form="logOutForm" + role='menuitem' + tabindex='-1' + form='logOutForm' ) | #{translate('log_out')} - form( - method="POST", - action="/logout", - id="logOutForm" - ) - input(name='_csrf', type='hidden', value=csrfToken) + form(method='POST' action='/logout' id='logOutForm') + input(name='_csrf' type='hidden' value=csrfToken) diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index c5e9f2e0bf..4d374bd8ad 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -1,32 +1,44 @@ -nav.navbar.navbar-default.navbar-main(class={ - 'website-redesign-navbar': isWebsiteRedesign -}) +nav.navbar.navbar-default.navbar-main( + class={ + 'website-redesign-navbar': isWebsiteRedesign, + } +) .container-fluid .navbar-header - if (typeof(suppressNavbarRight) == "undefined") + if typeof suppressNavbarRight == 'undefined' button.navbar-toggle.collapsed( - type="button", - data-toggle="collapse", - data-target="#navbar-main-collapse" - aria-label="Toggle " + translate('navigation') + type='button' + data-toggle='collapse' + data-target='#navbar-main-collapse' + aria-label='Toggle ' + translate('navigation') ) - i.fa.fa-bars(aria-hidden="true") + i.fa.fa-bars(aria-hidden='true') - var enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - if (enableUpgradeButton) + if enableUpgradeButton + //- prettier-ignore a.btn.btn-primary.pull-right.me-2.visible-xs( - href="/user/subscription/plans" - event-tracking="upgrade-button-click" - event-tracking-mb="true" - event-tracking-label="upgrade" - event-tracking-trigger="click" - event-segmentation='{"source": "dashboard-top", "project-dashboard-react": "enabled", "is-dashboard-sidebar-hidden": "true", "is-screen-width-less-than-768px": "true"}' + href='/user/subscription/plans' + event-tracking='upgrade-button-click' + event-tracking-mb='true' + event-tracking-label='upgrade' + event-tracking-trigger='click' + event-segmentation={ + source: "dashboard-top", + "project-dashboard-react": "enabled", + "is-dashboard-sidebar-hidden": "true", + "is-screen-width-less-than-768px": "true" + } ) #{translate("upgrade")} if settings.nav.custom_logo - a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand - else if (nav.title) - a(href='/', aria-label=settings.appName).navbar-title #{nav.title} + a.navbar-brand( + href='/' + aria-label=settings.appName + style='background-image:url("' + settings.nav.custom_logo + '")' + ) + else if nav.title + a.navbar-title(href='/' aria-label=settings.appName) #{nav.title} else - a(href='/', aria-label=settings.appName).navbar-brand + a.navbar-brand(href='/' aria-label=settings.appName) - var canDisplayAdminMenu = hasAdminAccess() - var canDisplayAdminRedirect = canRedirectToAdminDomain() @@ -34,44 +46,44 @@ nav.navbar.navbar-default.navbar-main(class={ - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu - if (typeof(suppressNavbarRight) == "undefined") - .navbar-collapse.collapse#navbar-main-collapse + if typeof suppressNavbarRight == 'undefined' + #navbar-main-collapse.navbar-collapse.collapse ul.nav.navbar-nav.navbar-right - if (canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu) + if canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu li.dropdown.subdued a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "admin", "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'admin', location: 'top-menu'} ) | Admin span.caret ul.dropdown-menu if canDisplayAdminMenu li - a(href="/admin") Manage Site + a(href='/admin') Manage Site li - a(href="/admin/user") Manage Users + a(href='/admin/user') Manage Users li - a(href="/admin/project") Project URL Lookup + a(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect li a(href=settings.adminUrl) Switch to Admin if canDisplaySplitTestMenu li - a(href="/admin/split-test") Manage Feature Flags + a(href='/admin/split-test') Manage Feature Flags if canDisplaySurveyMenu li - a(href="/admin/survey") Manage Surveys + a(href='/admin/survey') Manage Surveys if canDisplayScriptLogMenu li - a(href="/admin/script-logs") View Script Logs + a(href='/admin/script-logs') View Script Logs // loop over header_extras each item in nav.header_extras @@ -90,15 +102,15 @@ nav.navbar.navbar-default.navbar-main(class={ if item.dropdown li.dropdown(class=item.class) a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": item.trackingKey, "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) | !{translate(item.text)} span.caret @@ -108,18 +120,25 @@ nav.navbar.navbar-default.navbar-main(class={ li.divider else if child.isContactUs li - a(data-ol-open-contact-form-modal="contact-us" href event-tracking="menu-click" event-tracking-mb="true" event-tracking-trigger="click" event-segmentation={"item": "contact", "location": "top-menu"}) + a( + data-ol-open-contact-form-modal='contact-us' + href + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'contact', location: 'top-menu'} + ) span | #{translate("contact_us")} else li if child.url a( - href=child.url, - class=child.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" + href=child.url + class=child.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' event-segmentation={item: item.trackingKey, location: 'top-menu'} ) !{translate(child.text)} else @@ -128,12 +147,12 @@ nav.navbar.navbar-default.navbar-main(class={ li(class=item.class) if item.url a( - href=item.url, - class=item.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ item: item.trackingKey, location: 'top-menu' } + href=item.url + class=item.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) !{translate(item.text)} else | !{translate(item.text)} @@ -144,54 +163,54 @@ nav.navbar.navbar-default.navbar-main(class={ if hasFeature('registration-page') li.primary a( - href="/register" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'register', location: 'top-menu' } + href='/register' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'register', location: 'top-menu'} ) #{translate('sign_up')} // login link li a( - href="/login" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'login', location: 'top-menu' } + href='/login' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'login', location: 'top-menu'} ) #{translate('log_in')} // projects link and account menu if getSessionUser() li - a(href="/project") #{translate('Projects')} + a(href='/project') #{translate('Projects')} li.dropdown a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "account", "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'account', location: 'top-menu'} ) | #{translate('Account')} span.caret ul.dropdown-menu li - div.subdued #{getSessionUser().email} + .subdued #{getSessionUser().email} li.divider.hidden-xs.hidden-sm li - a(href="/user/settings") #{translate('account_settings')} + a(href='/user/settings') #{translate('account_settings')} if nav.showSubscriptionLink li - a(href="/user/subscription") #{translate('subscription')} + a(href='/user/subscription') #{translate('subscription')} li.divider.hidden-xs.hidden-sm li - form(method="POST" action="/logout") - input(name='_csrf', type='hidden', value=csrfToken) + form(method='POST' action='/logout') + input(name='_csrf' type='hidden' value=csrfToken) button.btn-link.text-left.dropdown-menu-button #{translate('log_out')} diff --git a/services/web/app/views/layout/navbar-website-redesign.pug b/services/web/app/views/layout/navbar-website-redesign.pug index 8ea71861c0..0a8337c2f5 100644 --- a/services/web/app/views/layout/navbar-website-redesign.pug +++ b/services/web/app/views/layout/navbar-website-redesign.pug @@ -1,30 +1,39 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar .container-fluid .navbar-header - if (typeof(suppressNavbarRight) == "undefined") + if typeof suppressNavbarRight == 'undefined' button.navbar-toggle.collapsed( - type="button", - data-toggle="collapse", - data-target="#navbar-main-collapse" - aria-label="Toggle " + translate('navigation') + type='button' + data-toggle='collapse' + data-target='#navbar-main-collapse' + aria-label='Toggle ' + translate('navigation') ) - i.fa.fa-bars(aria-hidden="true") + i.fa.fa-bars(aria-hidden='true') - var enableUpgradeButton = projectDashboardReact && usersBestSubscription && (usersBestSubscription.type === 'free' || usersBestSubscription.type === 'standalone-ai-add-on') - if (enableUpgradeButton) + if enableUpgradeButton a.btn.btn-primary.pull-right.me-2.visible-xs( - href="/user/subscription/plans" - event-tracking="upgrade-button-click" - event-tracking-mb="true" - event-tracking-label="upgrade" - event-tracking-trigger="click" - event-segmentation='{"source": "dashboard-top", "project-dashboard-react": "enabled", "is-dashboard-sidebar-hidden": "true", "is-screen-width-less-than-768px": "true"}' + href='/user/subscription/plans' + event-tracking='upgrade-button-click' + event-tracking-mb='true' + event-tracking-label='upgrade' + event-tracking-trigger='click' + event-segmentation={ + source: 'dashboard-top', + projectDashboardReact: 'enabled', + isDashboardSidebarHidden: 'true', + isScreenWidthLessThan768px: 'true', + } ) #{translate("upgrade")} if settings.nav.custom_logo - a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand - else if (nav.title) - a(href='/', aria-label=settings.appName).navbar-title #{nav.title} + a.navbar-brand( + href='/' + aria-label=settings.appName + style='background-image:url("' + settings.nav.custom_logo + '")' + ) + else if nav.title + a.navbar-title(href='/' aria-label=settings.appName) #{nav.title} else - a(href='/', aria-label=settings.appName).navbar-brand + a.navbar-brand(href='/' aria-label=settings.appName) - var canDisplayAdminMenu = hasAdminAccess() - var canDisplayAdminRedirect = canRedirectToAdminDomain() @@ -32,44 +41,44 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu - var canDisplayScriptLogMenu = hasFeature('saas') && canDisplayAdminMenu - if (typeof(suppressNavbarRight) == "undefined") - .navbar-collapse.collapse#navbar-main-collapse + if typeof suppressNavbarRight == 'undefined' + #navbar-main-collapse.navbar-collapse.collapse ul.nav.navbar-nav.navbar-right - if (canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu) + if canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu li.dropdown.subdued a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "admin", "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'admin', location: 'top-menu'} ) | Admin span.caret ul.dropdown-menu if canDisplayAdminMenu li - a(href="/admin") Manage Site + a(href='/admin') Manage Site li - a(href="/admin/user") Manage Users + a(href='/admin/user') Manage Users li - a(href="/admin/project") Project URL Lookup + a(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect li a(href=settings.adminUrl) Switch to Admin if canDisplaySplitTestMenu li - a(href="/admin/split-test") Manage Feature Flags + a(href='/admin/split-test') Manage Feature Flags if canDisplaySurveyMenu li - a(href="/admin/survey") Manage Surveys + a(href='/admin/survey') Manage Surveys if canDisplayScriptLogMenu li - a(href="/admin/script-logs") View Script Logs + a(href='/admin/script-logs') View Script Logs // loop over header_extras each item in nav.header_extras @@ -88,15 +97,15 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar if item.dropdown li.dropdown(class=item.class) a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": item.trackingKey, "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) | !{translate(item.text)} span.caret @@ -106,18 +115,25 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar li.divider else if child.isContactUs li - a(data-ol-open-contact-form-modal="contact-us" href event-tracking="menu-click" event-tracking-mb="true" event-tracking-trigger="click" event-segmentation={"item": "contact", "location": "top-menu"}) + a( + data-ol-open-contact-form-modal='contact-us' + href + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'contact', location: 'top-menu'} + ) span | #{translate("contact_us")} else li if child.url a( - href=child.url, - class=child.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" + href=child.url + class=child.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' event-segmentation={item: child.trackingKey, location: 'top-menu'} ) !{translate(child.text)} else @@ -126,12 +142,12 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar li(class=item.class) if item.url a( - href=item.url, - class=item.class, - event-tracking="menu-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ item: item.trackingKey, location: 'top-menu' } + href=item.url + class=item.class + event-tracking='menu-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: item.trackingKey, location: 'top-menu'} ) !{translate(item.text)} else | !{translate(item.text)} @@ -142,54 +158,54 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar if hasFeature('registration-page') li.primary a( - href="/register" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'register', location: 'top-menu' } + href='/register' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'register', location: 'top-menu'} ) #{translate('sign_up')} // login link li.secondary a( - href="/login" - event-tracking="menu-click" - event-tracking-action="clicked" - event-tracking-trigger="click" - event-tracking-mb="true" - event-segmentation={ page: currentUrl, item: 'login', location: 'top-menu' } + href='/login' + event-tracking='menu-click' + event-tracking-action='clicked' + event-tracking-trigger='click' + event-tracking-mb='true' + event-segmentation={page: currentUrl, item: 'login', location: 'top-menu'} ) #{translate('log_in')} // projects link and account menu if getSessionUser() li.secondary - a(href="/project") #{translate('Projects')} + a(href='/project') #{translate('Projects')} li.secondary.dropdown a.dropdown-toggle( - href="#", - role="button", - aria-haspopup="true", - aria-expanded="false", - data-toggle="dropdown" - event-tracking="menu-expand" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={"item": "account", "location": "top-menu"} + href='#' + role='button' + aria-haspopup='true' + aria-expanded='false' + data-toggle='dropdown' + event-tracking='menu-expand' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={item: 'account', location: 'top-menu'} ) | #{translate('Account')} span.caret ul.dropdown-menu li - div.subdued #{getSessionUser().email} + .subdued #{getSessionUser().email} li.divider.hidden-xs.hidden-sm li - a(href="/user/settings") #{translate('account_settings')} + a(href='/user/settings') #{translate('account_settings')} if nav.showSubscriptionLink li - a(href="/user/subscription") #{translate('subscription')} + a(href='/user/subscription') #{translate('subscription')} li.divider.hidden-xs.hidden-sm li - form(method="POST" action="/logout") - input(name='_csrf', type='hidden', value=csrfToken) + form(method='POST' action='/logout') + input(name='_csrf' type='hidden' value=csrfToken) button.btn-link.text-left.dropdown-menu-button #{translate('log_out')} diff --git a/services/web/app/views/layout/thin-footer-bootstrap-5.pug b/services/web/app/views/layout/thin-footer-bootstrap-5.pug index 1f06a054fc..4c29933ab1 100644 --- a/services/web/app/views/layout/thin-footer-bootstrap-5.pug +++ b/services/web/app/views/layout/thin-footer-bootstrap-5.pug @@ -25,7 +25,7 @@ footer.site-footer each item in nav.left_footer li if item.url - a(href=item.url, class=item.class) !{translate(item.text)} + a(href=item.url class=item.class) !{translate(item.text)} else | !{item.text} @@ -33,6 +33,6 @@ footer.site-footer each item in nav.right_footer li if item.url - a(href=item.url, class=item.class, aria-label=item.label) !{item.text} + a(href=item.url class=item.class aria-label=item.label) !{item.text} else | !{item.text} diff --git a/services/web/app/views/layout/thin-footer.pug b/services/web/app/views/layout/thin-footer.pug index 6eeecf628a..879e337983 100644 --- a/services/web/app/views/layout/thin-footer.pug +++ b/services/web/app/views/layout/thin-footer.pug @@ -27,7 +27,7 @@ footer.site-footer each item in nav.left_footer li if item.url - a(href=item.url, class=item.class) !{translate(item.text)} + a(href=item.url class=item.class) !{translate(item.text)} else | !{item.text} @@ -35,6 +35,6 @@ footer.site-footer each item in nav.right_footer li if item.url - a(href=item.url, class=item.class, aria-label=item.label) !{item.text} + a(href=item.url class=item.class aria-label=item.label) !{item.text} else | !{item.text} diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index b1b5ae1e25..c84288a21a 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -1,36 +1,36 @@ extends ../../layout-marketing block vars - - var suppressFooter = true - - var suppressCookieBanner = true - - var suppressSkipToContent = true + - var suppressFooter = true + - var suppressCookieBanner = true + - var suppressSkipToContent = true block content - .editor.full-size - .loading-screen() - .loading-screen-brand-container - .loading-screen-brand( - style="height: 20%;" - ) + .editor.full-size + .loading-screen() + .loading-screen-brand-container + .loading-screen-brand( + style="height: 20%;" + ) - h3.loading-screen-label() #{translate("Opening template")} - span.loading-screen-ellip . - span.loading-screen-ellip . - span.loading-screen-ellip . + h3.loading-screen-label() #{translate("Opening template")} + span.loading-screen-ellip . + span.loading-screen-ellip . + span.loading-screen-ellip . - form( - data-ol-regular-form - data-ol-auto-submit - method='POST' - action='/project/new/template/' - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden" name="templateId" value=templateId) - input(type="hidden" name="templateVersionId" value=templateVersionId) - input(type="hidden" name="templateName" value=name) - input(type="hidden" name="compiler" value=compiler) - input(type="hidden" name="imageName" value=imageName) - input(type="hidden" name="mainFile" value=mainFile) - if brandVariationId - input(type="hidden" name="brandVariationId" value=brandVariationId) - input(hidden type="submit") + form( + data-ol-regular-form + data-ol-auto-submit + method='POST' + action='/project/new/template/' + ) + input(type="hidden", name="_csrf", value=csrfToken) + input(type="hidden" name="templateId" value=templateId) + input(type="hidden" name="templateVersionId" value=templateVersionId) + input(type="hidden" name="templateName" value=name) + input(type="hidden" name="compiler" value=compiler) + input(type="hidden" name="imageName" value=imageName) + input(type="hidden" name="mainFile" value=mainFile) + if brandVariationId + input(type="hidden" name="brandVariationId" value=brandVariationId) + input(hidden type="submit") diff --git a/services/web/app/views/project/ide-react-detached.pug b/services/web/app/views/project/ide-react-detached.pug index 8109da7f74..ca1a178bbf 100644 --- a/services/web/app/views/project/ide-react-detached.pug +++ b/services/web/app/views/project/ide-react-detached.pug @@ -11,7 +11,7 @@ block vars - metadata.robotsNoindexNofollow = true block content - #pdf-preview-detached-root() + #pdf-preview-detached-root block append meta include editor/_meta diff --git a/services/web/app/views/project/ide-react.pug b/services/web/app/views/project/ide-react.pug index bc30f69202..8af9de8296 100644 --- a/services/web/app/views/project/ide-react.pug +++ b/services/web/app/views/project/ide-react.pug @@ -15,7 +15,7 @@ block content main#ide-root .loading-screen .loading-screen-brand-container - .loading-screen-brand(style="height: 20%;") + .loading-screen-brand(style='height: 20%') h3.loading-screen-label #{translate("loading")} span.loading-screen-ellip . span.loading-screen-ellip . @@ -25,4 +25,9 @@ block append meta include editor/_meta block prepend foot-scripts - script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js', defer=deferScripts) + script( + type='text/javascript' + nonce=scriptNonce + src=(wsUrl || '/socket.io') + '/socket.io.js' + defer=deferScripts + ) diff --git a/services/web/app/views/project/invite/not-valid.pug b/services/web/app/views/project/invite/not-valid.pug index b4cbc1be1b..3722ab7919 100644 --- a/services/web/app/views/project/invite/not-valid.pug +++ b/services/web/app/views/project/invite/not-valid.pug @@ -1,18 +1,18 @@ extends ../../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-md-8.col-md-offset-2.offset-md-2 .card.project-invite-invalid .card-body .page-header.text-center - h1 #{translate("invite_not_valid")} + h1 #{translate("invite_not_valid")} .row.text-center .col-12.col-md-12 p | #{translate("invite_not_valid_description")}. .row.text-center.actions .col-12.col-md-12 - a.btn.btn-secondary-info.btn-secondary(href="/project") #{translate("back_to_your_projects")} + a.btn.btn-secondary-info.btn-secondary(href='/project') #{translate("back_to_your_projects")} diff --git a/services/web/app/views/project/invite/show.pug b/services/web/app/views/project/invite/show.pug index a18518c716..503ec78796 100644 --- a/services/web/app/views/project/invite/show.pug +++ b/services/web/app/views/project/invite/show.pug @@ -1,7 +1,7 @@ extends ../../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-12.col-md-8.col-md-offset-2.offset-md-2 @@ -20,16 +20,16 @@ block content .col-12.col-md-12 form.form( data-ol-regular-form - method="POST", - action="/project/"+invite.projectId+"/invite/token/"+token+"/accept" + method='POST' + action='/project/' + invite.projectId + '/invite/token/' + token + '/accept' ) - input(name='_csrf', type='hidden', value=csrfToken) - input(name='token', type='hidden', value=token) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='token' type='hidden' value=token) .form-group.text-center button.btn.btn-lg.btn-primary( - type="submit" + type='submit' data-ol-disabled-inflight ) - span(data-ol-inflight="idle") #{translate("join_project")} - span(hidden data-ol-inflight="pending") #{translate("joining")}… + span(data-ol-inflight='idle') #{translate("join_project")} + span(hidden data-ol-inflight='pending') #{translate("joining")}… .form-group.text-center diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index 60e7d0c0fc..fa7bc24c09 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -9,35 +9,83 @@ block vars - const suppressFooter = true block append meta - meta(name="ol-usersBestSubscription" data-type="json" content=usersBestSubscription) - meta(name="ol-notifications" data-type="json" content=notifications) - meta(name="ol-notificationsInstitution" data-type="json" content=notificationsInstitution) - meta(name="ol-userEmails" data-type="json" content=userEmails) - meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods) - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-userAffiliations" data-type="json" content=userAffiliations) - meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML) - meta(name="ol-survey" data-type="json" content=survey) - meta(name="ol-tags" data-type="json" content=tags) - meta(name="ol-portalTemplates" data-type="json" content=portalTemplates) - meta(name="ol-prefetchedProjectsBlob" data-type="json" content=prefetchedProjectsBlob) - if (suggestedLanguageSubdomainConfig) - meta(name="ol-suggestedLanguage" data-type="json" content=Object.assign(suggestedLanguageSubdomainConfig, { - lngName: translate(suggestedLanguageSubdomainConfig.lngCode), - imgUrl: buildImgPath("flags/24/" + suggestedLanguageSubdomainConfig.lngCode + ".png") - })) - meta(name="ol-currentUrl" data-type="string" content=currentUrl) - meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner) - meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant) - meta(name="ol-showInrGeoBanner" data-type="boolean" content=showInrGeoBanner) - meta(name="ol-showBrlGeoBanner" data-type="boolean" content=showBrlGeoBanner) - meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency) - meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner) - meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) - meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) - meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess) - meta(name="ol-showUSGovBanner" data-type="boolean" content=showUSGovBanner) - meta(name="ol-usGovBannerVariant" data-type="string" content=usGovBannerVariant) + meta( + name='ol-usersBestSubscription' + data-type='json' + content=usersBestSubscription + ) + meta(name='ol-notifications' data-type='json' content=notifications) + meta( + name='ol-notificationsInstitution' + data-type='json' + content=notificationsInstitution + ) + meta(name='ol-userEmails' data-type='json' content=userEmails) + meta( + name='ol-allInReconfirmNotificationPeriods' + data-type='json' + content=allInReconfirmNotificationPeriods + ) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-userAffiliations' data-type='json' content=userAffiliations) + meta(name='ol-reconfirmedViaSAML' content=reconfirmedViaSAML) + meta(name='ol-survey' data-type='json' content=survey) + meta(name='ol-tags' data-type='json' content=tags) + meta(name='ol-portalTemplates' data-type='json' content=portalTemplates) + meta( + name='ol-prefetchedProjectsBlob' + data-type='json' + content=prefetchedProjectsBlob + ) + if suggestedLanguageSubdomainConfig + meta( + name='ol-suggestedLanguage' + data-type='json' + content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'), + }) + ) + meta(name='ol-currentUrl' data-type='string' content=currentUrl) + meta( + name='ol-showGroupsAndEnterpriseBanner' + data-type='boolean' + content=showGroupsAndEnterpriseBanner + ) + meta( + name='ol-groupsAndEnterpriseBannerVariant' + data-type='string' + content=groupsAndEnterpriseBannerVariant + ) + meta(name='ol-showInrGeoBanner' data-type='boolean' content=showInrGeoBanner) + meta(name='ol-showBrlGeoBanner' data-type='boolean' content=showBrlGeoBanner) + meta( + name='ol-recommendedCurrency' + data-type='string' + content=recommendedCurrency + ) + meta(name='ol-showLATAMBanner' data-type='boolean' content=showLATAMBanner) + meta( + name='ol-groupSubscriptionsPendingEnrollment' + data-type='json' + content=groupSubscriptionsPendingEnrollment + ) + meta( + name='ol-hasIndividualPaidSubscription' + data-type='boolean' + content=hasIndividualPaidSubscription + ) + meta( + name='ol-groupSsoSetupSuccess' + data-type='boolean' + content=groupSsoSetupSuccess + ) + meta(name='ol-showUSGovBanner' data-type='boolean' content=showUSGovBanner) + meta( + name='ol-usGovBannerVariant' + data-type='string' + content=usGovBannerVariant + ) block content #project-list-root diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index eabfd18eb6..80b91f1a99 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -7,11 +7,11 @@ block vars - var suppressFooter = true - var suppressCookieBanner = true - var suppressSkipToContent = true - + block append meta - meta(name="ol-postUrl" data-type="string" content=postUrl) - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-postUrl' data-type='string' content=postUrl) + meta(name='ol-user' data-type='json' content=user) block content - .content.content-alt#main-content - div#token-access-page + #main-content.content.content-alt + #token-access-page diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug index 66d8ac9077..d1818be0af 100644 --- a/services/web/app/views/project/token/sharing-updates.pug +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -9,9 +9,9 @@ block vars - var suppressSkipToContent = true block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-project_id" data-type="string" content=projectId) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-project_id' data-type='string' content=projectId) block content - .content.content-alt#main-content - div#sharing-updates-page + #main-content.content.content-alt + #sharing-updates-page diff --git a/services/web/app/views/subscriptions/add-seats.pug b/services/web/app/views/subscriptions/add-seats.pug index bcbf5be666..6fa644ee46 100644 --- a/services/web/app/views/subscriptions/add-seats.pug +++ b/services/web/app/views/subscriptions/add-seats.pug @@ -4,13 +4,17 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/add-seats' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-groupName", data-type="string", content=groupName) - meta(name="ol-subscriptionId", data-type="string", content=subscriptionId) - meta(name="ol-totalLicenses", data-type="number", content=totalLicenses) - meta(name="ol-isProfessional", data-type="boolean", content=isProfessional) - meta(name="ol-isCollectionMethodManual", data-type="boolean", content=isCollectionMethodManual) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-groupName' data-type='string' content=groupName) + meta(name='ol-subscriptionId' data-type='string' content=subscriptionId) + meta(name='ol-totalLicenses' data-type='number' content=totalLicenses) + meta(name='ol-isProfessional' data-type='boolean' content=isProfessional) + meta( + name='ol-isCollectionMethodManual' + data-type='boolean' + content=isCollectionMethodManual + ) block content - main.content.content-alt#main-content + main#main-content.content.content-alt #add-seats-root diff --git a/services/web/app/views/subscriptions/canceled-subscription-react.pug b/services/web/app/views/subscriptions/canceled-subscription-react.pug index 3a89234fd9..a33732e1f6 100644 --- a/services/web/app/views/subscriptions/canceled-subscription-react.pug +++ b/services/web/app/views/subscriptions/canceled-subscription-react.pug @@ -4,7 +4,7 @@ block entrypointVar - entrypoint = 'pages/user/subscription/canceled-subscription' block append meta - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-user' data-type='json' content=user) block content - main.content.content-alt#subscription-canceled-root + main#subscription-canceled-root.content.content-alt diff --git a/services/web/app/views/subscriptions/dashboard-react.pug b/services/web/app/views/subscriptions/dashboard-react.pug index 2b6251f2a3..b253097d25 100644 --- a/services/web/app/views/subscriptions/dashboard-react.pug +++ b/services/web/app/views/subscriptions/dashboard-react.pug @@ -1,36 +1,91 @@ extends ../layout-react block entrypointVar - - entrypoint = 'pages/user/subscription/dashboard' + - entrypoint = 'pages/user/subscription/dashboard' block head-scripts - script(type="text/javascript", nonce=scriptNonce, src="https://js.recurly.com/v4/recurly.js") + script( + type='text/javascript' + nonce=scriptNonce + src='https://js.recurly.com/v4/recurly.js' + ) block append meta - meta(name="ol-subscription" data-type="json" content=personalSubscription) - meta(name="ol-userCanExtendTrial" data-type="boolean" content=userCanExtendTrial) - meta(name="ol-managedGroupSubscriptions" data-type="json" content=managedGroupSubscriptions) - meta(name="ol-memberGroupSubscriptions" data-type="json" content=memberGroupSubscriptions) - meta(name="ol-managedInstitutions" data-type="json" content=managedInstitutions) - meta(name="ol-managedPublishers" data-type="json" content=managedPublishers) - meta(name="ol-planCodesChangingAtTermEnd" data-type="json", content=planCodesChangingAtTermEnd) - meta(name="ol-currentInstitutionsWithLicence" data-type="json" content=currentInstitutionsWithLicence) - meta(name="ol-hasSubscription" data-type="boolean" content=hasSubscription) - meta(name="ol-fromPlansPage" data-type="boolean" content=fromPlansPage) - meta(name="ol-plans" data-type="json" content=plans) - meta(name="ol-groupSettingsAdvertisedFor" data-type="json" content=groupSettingsAdvertisedFor) - meta(name="ol-canUseFlexibleLicensing" data-type="boolean", content=canUseFlexibleLicensing) - meta(name="ol-showGroupDiscount" data-type="boolean", content=showGroupDiscount) - meta(name="ol-groupSettingsEnabledFor" data-type="json" content=groupSettingsEnabledFor) - meta(name="ol-hasAiAssistViaWritefull" data-type="boolean", content=hasAiAssistViaWritefull) - meta(name="ol-aiAssistViaWritefullSource" data-type="string", content=aiAssistViaWritefullSource) - meta(name="ol-user" data-type="json" content=user) - if (personalSubscription && personalSubscription.payment) - meta(name="ol-recurlyApiKey" content=settings.apis.recurly.publicKey) - meta(name="ol-stripeUKApiKey" content=settings.apis.stripeUK.publishableKey) - meta(name="ol-recommendedCurrency" content=personalSubscription.payment.currency) - meta(name="ol-groupPlans" data-type="json" content=groupPlans) + meta(name='ol-subscription' data-type='json' content=personalSubscription) + meta( + name='ol-userCanExtendTrial' + data-type='boolean' + content=userCanExtendTrial + ) + meta( + name='ol-managedGroupSubscriptions' + data-type='json' + content=managedGroupSubscriptions + ) + meta( + name='ol-memberGroupSubscriptions' + data-type='json' + content=memberGroupSubscriptions + ) + meta( + name='ol-managedInstitutions' + data-type='json' + content=managedInstitutions + ) + meta(name='ol-managedPublishers' data-type='json' content=managedPublishers) + meta( + name='ol-planCodesChangingAtTermEnd' + data-type='json' + content=planCodesChangingAtTermEnd + ) + meta( + name='ol-currentInstitutionsWithLicence' + data-type='json' + content=currentInstitutionsWithLicence + ) + meta(name='ol-hasSubscription' data-type='boolean' content=hasSubscription) + meta(name='ol-fromPlansPage' data-type='boolean' content=fromPlansPage) + meta(name='ol-plans' data-type='json' content=plans) + meta( + name='ol-groupSettingsAdvertisedFor' + data-type='json' + content=groupSettingsAdvertisedFor + ) + meta( + name='ol-canUseFlexibleLicensing' + data-type='boolean' + content=canUseFlexibleLicensing + ) + meta( + name='ol-showGroupDiscount' + data-type='boolean' + content=showGroupDiscount + ) + meta( + name='ol-groupSettingsEnabledFor' + data-type='json' + content=groupSettingsEnabledFor + ) + meta( + name='ol-hasAiAssistViaWritefull' + data-type='boolean' + content=hasAiAssistViaWritefull + ) + meta( + name='ol-aiAssistViaWritefullSource' + data-type='string' + content=aiAssistViaWritefullSource + ) + meta(name='ol-user' data-type='json' content=user) + if personalSubscription && personalSubscription.payment + meta(name='ol-recurlyApiKey' content=settings.apis.recurly.publicKey) + meta(name='ol-stripeUKApiKey' content=settings.apis.stripeUK.publishableKey) + meta( + name='ol-recommendedCurrency' + content=personalSubscription.payment.currency + ) + meta(name='ol-groupPlans' data-type='json' content=groupPlans) block content - main.content.content-alt#main-content - #subscription-dashboard-root + main#main-content.content.content-alt + #subscription-dashboard-root diff --git a/services/web/app/views/subscriptions/manually-collected-subscription.pug b/services/web/app/views/subscriptions/manually-collected-subscription.pug index ba6bf73473..a693ac749f 100644 --- a/services/web/app/views/subscriptions/manually-collected-subscription.pug +++ b/services/web/app/views/subscriptions/manually-collected-subscription.pug @@ -4,8 +4,8 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/manually-collected-subscription' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-groupName", data-type="string", content=groupName) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-groupName' data-type='string' content=groupName) block content - main.content.content-alt#manually-collected-subscription-root + main#manually-collected-subscription-root.content.content-alt diff --git a/services/web/app/views/subscriptions/missing-billing-information.pug b/services/web/app/views/subscriptions/missing-billing-information.pug index 416bac65f5..d0a0e05ae8 100644 --- a/services/web/app/views/subscriptions/missing-billing-information.pug +++ b/services/web/app/views/subscriptions/missing-billing-information.pug @@ -4,8 +4,8 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/missing-billing-information' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-groupName", data-type="string", content=groupName) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-groupName' data-type='string' content=groupName) block content - main.content.content-alt#missing-billing-information-root + main#missing-billing-information-root.content.content-alt diff --git a/services/web/app/views/subscriptions/plans/_faq_new.pug b/services/web/app/views/subscriptions/plans/_faq_new.pug index 3c926fb22d..760f27b1a4 100644 --- a/services/web/app/views/subscriptions/plans/_faq_new.pug +++ b/services/web/app/views/subscriptions/plans/_faq_new.pug @@ -9,7 +9,7 @@ include ../../_mixins/material_symbol .row.row-spaced-extra-large .col-md-12.faq-heading-container h2 - +eyebrow(translate("frequently_asked_questions")) + +eyebrow(translate('frequently_asked_questions')) | #{translate("your_questions_answered")} .row @@ -18,74 +18,62 @@ include ../../_mixins/material_symbol class={ 'plans-faq-tabs': bootstrapVersion === 5, 'ol-tabs': bootstrapVersion === 5, - 'ol-tabs-scrollable': bootstrapVersion === 3 + 'ol-tabs-scrollable': bootstrapVersion === 3, } ) .nav-tabs-container - ul.nav.nav-tabs(role="tablist") + ul.nav.nav-tabs(role='tablist') //- In the bs5 version of plans page, the `active` class need to be added to the `a` tag instead of the parent `li` tag //- If the `plans-page-bs5` split test has been completed, remove the `active` class from the `li` tag since we're not using it anymore //- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` because it is not needed anymore (bs5 uses `data-bs-toggle`) - li( - role="presentation" - class={ - active: bootstrapVersion === 3 - } - ) + li(role='presentation' class={ + active: bootstrapVersion === 3, + }) a( - role="tab" - data-toggle="tab" - data-bs-toggle="tab" + role='tab' + data-toggle='tab' + data-bs-toggle='tab' href='#' + managingYourSubscription aria-controls=managingYourSubscription class={ - active: bootstrapVersion === 5 + active: bootstrapVersion === 5, } ) | #{translate('managing_your_subscription')} - li(role="presentation") + li(role='presentation') a( - role="tab" - data-toggle="tab" - data-bs-toggle="tab" + role='tab' + data-toggle='tab' + data-bs-toggle='tab' href='#' + overleafIndividualPlans aria-controls=overleafIndividualPlans ) | #{translate('overleaf_individual_plans')} - li(role="presentation") + li(role='presentation') a( - role="tab" - data-toggle="tab" - data-bs-toggle="tab" + role='tab' + data-toggle='tab' + data-bs-toggle='tab' href='#' + overleafGroupPlans aria-controls=overleafGroupPlans ) | #{translate('overleaf_group_plans')} .tab-content - .tab-pane.active( - role="tabpanel" - id=managingYourSubscription - ) - +managingYourSubscription() - .tab-pane( - role="tabpanel" - id=overleafIndividualPlans - ) - +overleafIndividualPlans() - .tab-pane( - role="tabpanel" - id=overleafGroupPlans - ) - +overleafGroupPlans() + .tab-pane.active(role='tabpanel' id=managingYourSubscription) + +managingYourSubscription + .tab-pane(role='tabpanel' id=overleafIndividualPlans) + +overleafIndividualPlans + .tab-pane(role='tabpanel' id=overleafGroupPlans) + +overleafGroupPlans .row .col-xs-12.plans-faq-support span #{translate('still_have_questions')} button( - data-ol-open-contact-form-modal="general" - data-bs-toggle=bootstrapVersion === 5 ? "modal" : undefined - data-bs-target=bootstrapVersion === 5 ? "#contactUsModal" : undefined + data-ol-open-contact-form-modal='general' + data-bs-toggle=bootstrapVersion === 5 ? 'modal' : undefined + data-bs-target=bootstrapVersion === 5 ? '#contactUsModal' : undefined ) - span(style="margin-right: 4px") #{translate('contact_support')} - +material-symbol-rounded("arrow_right_alt", "icon-md") + span(style='margin-right: 4px') #{translate('contact_support')} + +material-symbol-rounded('arrow_right_alt', 'icon-md') diff --git a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug index a598f4774c..59eac8efac 100644 --- a/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug +++ b/services/web/app/views/subscriptions/plans/_plans_faq_tabs.pug @@ -1,357 +1,353 @@ //- If the `plans-page-bs5` split test has been completed, remove the `data-toggle` and `data-target` because it is not needed anymore (bs5 uses `data-bs-toggle` and `data-bs-target`) include ../../_mixins/material_symbol - -mixin managingYourSubscription() + +mixin managingYourSubscription .ol-accordions-container .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#managingYourSubscriptionQ1" - data-bs-toggle="collapse" - data-bs-target="#managingYourSubscriptionQ1" - aria-expanded="false" - aria-controls="managingYourSubscriptionQ1" + type='button' + data-toggle='collapse' + data-target='#managingYourSubscriptionQ1' + data-bs-toggle='collapse' + data-bs-target='#managingYourSubscriptionQ1' + aria-expanded='false' + aria-controls='managingYourSubscriptionQ1' ) | Can I change plans or cancel later? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="managingYourSubscriptionQ1") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='managingYourSubscriptionQ1') .custom-accordion-body - span Yes, you can do this at any time by going to - strong Account > Subscription + span Yes, you can do this at any time by going to + strong Account > Subscription span when logged in to Overleaf. You can change plans, switch between monthly and annual billing options, or cancel to downgrade to the free plan. When canceling, your subscription will continue until the end of the billing period. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#managingYourSubscriptionQ2" - data-bs-toggle="collapse" - data-bs-target="#managingYourSubscriptionQ2" - aria-expanded="false" - aria-controls="managingYourSubscriptionQ2" + type='button' + data-toggle='collapse' + data-target='#managingYourSubscriptionQ2' + data-bs-toggle='collapse' + data-bs-target='#managingYourSubscriptionQ2' + aria-expanded='false' + aria-controls='managingYourSubscriptionQ2' ) | If I change or cancel my Overleaf plan, will I lose my projects? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="managingYourSubscriptionQ2") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='managingYourSubscriptionQ2') .custom-accordion-body | No. Changing or canceling your plan won’t affect your projects, the only change will be to the features available to you. You can see which features are available only on paid plans in the comparison table. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#managingYourSubscriptionQ3" - data-bs-toggle="collapse" - data-bs-target="#managingYourSubscriptionQ3" - aria-expanded="false" - aria-controls="managingYourSubscriptionQ3" + type='button' + data-toggle='collapse' + data-target='#managingYourSubscriptionQ3' + data-bs-toggle='collapse' + data-bs-target='#managingYourSubscriptionQ3' + aria-expanded='false' + aria-controls='managingYourSubscriptionQ3' ) | Can I pay by invoice or purchase order? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="managingYourSubscriptionQ3") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='managingYourSubscriptionQ3') .custom-accordion-body | This is possible when you’re purchasing a group subscription for five or more people, or a site license. For individual subscriptions, we can only accept payment online via credit card, debit card, or PayPal. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#managingYourSubscriptionQ4" - data-bs-toggle="collapse" - data-bs-target="#managingYourSubscriptionQ4" - aria-expanded="false" - aria-controls="managingYourSubscriptionQ4" + type='button' + data-toggle='collapse' + data-target='#managingYourSubscriptionQ4' + data-bs-toggle='collapse' + data-bs-target='#managingYourSubscriptionQ4' + aria-expanded='false' + aria-controls='managingYourSubscriptionQ4' ) | How do I view/update the credit card being charged for my subscription? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="managingYourSubscriptionQ4") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='managingYourSubscriptionQ4') .custom-accordion-body - | You can view and update the card on file by going to Account > + | You can view and update the card on file by going to Account > a.inline-green-link( - target="_blank" - href="/user/subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/user/subscription' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span Subscription | . - - - -mixin overleafIndividualPlans() +mixin overleafIndividualPlans .ol-accordions-container .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ1" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ1" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ1" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ1' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ1' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ1' ) | How does the free trial work? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ1") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ1') .custom-accordion-body - span You get full access to your chosen plan during your 7-day free trial, and there’s no obligation to continue beyond the trial. Your card will be charged at the end of your trial unless you cancel before then. To cancel, go to - strong Account > + span You get full access to your chosen plan during your 7-day free trial, and there’s no obligation to continue beyond the trial. Your card will be charged at the end of your trial unless you cancel before then. To cancel, go to + strong Account > + | a.inline-green-link( - target="_blank" - href="/user/subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/user/subscription' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span Subscription - span when logged in to Overleaf (the trial will continue for the full 7 days). + span when logged in to Overleaf (the trial will continue for the full 7 days). .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ2" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ2" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ2" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ2' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ2' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ2' ) | What’s a collaborator on an Overleaf individual subscription? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ2") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ2') .custom-accordion-body - | A collaborator is someone you invite to work with you on a project. So, for example, on our Standard plan you can have up to 10 people collaborating with you on any given project. + | A collaborator is someone you invite to work with you on a project. So, for example, on our Standard plan you can have up to 10 people collaborating with you on any given project. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ3" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ3" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ3" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ3' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ3' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ3' ) | The individual Standard plan has 10 project collaborators, does it mean that 10 people will be upgraded? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ3") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ3') .custom-accordion-body - span No. Only the subscriber’s account will be upgraded. An individual Standard subscription allows you to invite 10 people per project to edit the project with you. Your collaborators can access features such as the full document history and extended compile time, but - strong only + span No. Only the subscriber’s account will be upgraded. An individual Standard subscription allows you to invite 10 people per project to edit the project with you. Your collaborators can access features such as the full document history and extended compile time, but + strong only span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.) .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ4" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ4" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ4" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ4' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ4' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ4' ) | Do collaborators also have access to the editing and collaboration features I’ve paid for? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ4") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ4') .custom-accordion-body - span If you have an Overleaf subscription, then your project collaborators will have access to features like real-time track changes and document history, but - strong only + span If you have an Overleaf subscription, then your project collaborators will have access to features like real-time track changes and document history, but + strong only span for the project(s) they’re working on with you. If your collaborators want access to those features on their own projects, they will need to purchase their own subscription. (If you work with the same people regularly, you might find a group subscription more cost effective.) .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ5" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ5" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ5" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ5' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ5' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ5' ) | Can I purchase an individual plan on behalf of someone else? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ5") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ5') .custom-accordion-body - | Individual subscriptions must be purchased by the account that will be the end user. If you want to purchase a plan for someone else, you’ll need to provide them with relevant payment details to enable them to make the purchase. + | Individual subscriptions must be purchased by the account that will be the end user. If you want to purchase a plan for someone else, you’ll need to provide them with relevant payment details to enable them to make the purchase. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ6" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ6" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ6" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ6' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ6' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ6' ) | Who is eligible for the Student plan? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ6") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ6') .custom-accordion-body | As the name suggests, the Student plan is only for students at educational institutions. This includes graduate students. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafIndividualPlansQ7" - data-bs-toggle="collapse" - data-bs-target="#overleafIndividualPlansQ7" - aria-expanded="false" - aria-controls="overleafIndividualPlansQ7" + type='button' + data-toggle='collapse' + data-target='#overleafIndividualPlansQ7' + data-bs-toggle='collapse' + data-bs-target='#overleafIndividualPlansQ7' + aria-expanded='false' + aria-controls='overleafIndividualPlansQ7' ) | Can I transfer an individual subscription to someone else? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafIndividualPlansQ7") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafIndividualPlansQ7') .custom-accordion-body - | No. Individual plans can’t be transferred. + | No. Individual plans can’t be transferred. - - - - -mixin overleafGroupPlans() +mixin overleafGroupPlans .ol-accordions-container .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafGroupPlansQ1" - data-bs-toggle="collapse" - data-bs-target="#overleafGroupPlansQ1" - aria-expanded="false" - aria-controls="overleafGroupPlansQ1" + type='button' + data-toggle='collapse' + data-target='#overleafGroupPlansQ1' + data-bs-toggle='collapse' + data-bs-target='#overleafGroupPlansQ1' + aria-expanded='false' + aria-controls='overleafGroupPlansQ1' ) | What’s the difference between users and collaborators on an Overleaf group subscription? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafGroupPlansQ1") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafGroupPlansQ1') .custom-accordion-body - div On any of our group plans, the number of users refers to the number of people you can invite to join your group. All of these people will have access to the plan’s paid-for features across all their projects, such as real-time track changes and document history. - div.mt-2 Collaborators are people that your group users may invite to work with them on their projects. So, for example, if you have the Group Standard plan, the users in your group can invite up to 10 people to work with them on a project. And if you have the Group Professional plan, your users can invite as many people to work with them as they want. + div On any of our group plans, the number of users refers to the number of people you can invite to join your group. All of these people will have access to the plan’s paid-for features across all their projects, such as real-time track changes and document history. + .mt-2 Collaborators are people that your group users may invite to work with them on their projects. So, for example, if you have the Group Standard plan, the users in your group can invite up to 10 people to work with them on a project. And if you have the Group Professional plan, your users can invite as many people to work with them as they want. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafGroupPlansQ2" - data-bs-toggle="collapse" - data-bs-target="#overleafGroupPlansQ2" - aria-expanded="false" - aria-controls="overleafGroupPlansQ2" + type='button' + data-toggle='collapse' + data-target='#overleafGroupPlansQ2' + data-bs-toggle='collapse' + data-bs-target='#overleafGroupPlansQ2' + aria-expanded='false' + aria-controls='overleafGroupPlansQ2' ) | What is the benefit of purchasing an Overleaf Group plan? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafGroupPlansQ2") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafGroupPlansQ2') .custom-accordion-body - | Our Group subscriptions allow you to purchase access to our premium features for multiple people. They’re easy to manage, help save on paperwork, and allow groups of 5 or more to purchase via purchase order (PO). We also offer discounts on purchases of Group subscriptions for more than 20 users; just get in touch with our + | Our Group subscriptions allow you to purchase access to our premium features for multiple people. They’re easy to manage, help save on paperwork, and allow groups of 5 or more to purchase via purchase order (PO). We also offer discounts on purchases of Group subscriptions for more than 20 users; just get in touch with our a.inline-green-link( - target="_blank" - href="/for/contact-sales" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/for/contact-sales' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span Sales team | . .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafGroupPlansQ3" - data-bs-toggle="collapse" - data-bs-target="#overleafGroupPlansQ3" - aria-expanded="false" - aria-controls="overleafGroupPlansQ3" + type='button' + data-toggle='collapse' + data-target='#overleafGroupPlansQ3' + data-bs-toggle='collapse' + data-bs-target='#overleafGroupPlansQ3' + aria-expanded='false' + aria-controls='overleafGroupPlansQ3' ) | Who is eligible for the educational discount? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafGroupPlansQ3") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafGroupPlansQ3') .custom-accordion-body - | The educational discount for group subscriptions is for students or faculty who are using Overleaf primarily for teaching. + | The educational discount for group subscriptions is for students or faculty who are using Overleaf primarily for teaching. .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafGroupPlansQ4" - data-bs-toggle="collapse" - data-bs-target="#overleafGroupPlansQ4" - aria-expanded="false" - aria-controls="overleafGroupPlansQ4" + type='button' + data-toggle='collapse' + data-target='#overleafGroupPlansQ4' + data-bs-toggle='collapse' + data-bs-target='#overleafGroupPlansQ4' + aria-expanded='false' + aria-controls='overleafGroupPlansQ4' ) | How do I add more licenses to my group subscription, and what will it cost? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafGroupPlansQ4") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafGroupPlansQ4') .custom-accordion-body - div - | You can add up to 20 licenses using the + div + | You can add up to 20 licenses using the a.inline-green-link( - target="_blank" - href="/user/subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/user/subscription' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span subscription management page - | accessed by going to Account > + | + | accessed by going to Account > a.inline-green-link( - target="_blank" - href="/user/subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/user/subscription' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span Subscription - | when logged into Overleaf. The cost per license will be prorated at the current per license rate, and will end with your existing renewal date. - div.mt-2 - | If you need more than 20 licenses added to your subscription, please + | + | when logged into Overleaf. The cost per license will be prorated at the current per license rate, and will end with your existing renewal date. + .mt-2 + | If you need more than 20 licenses added to your subscription, please a.inline-green-link( - target="_blank" - href="/for/contact-sales" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/for/contact-sales' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span contact the Sales team | . .custom-accordion-item button.custom-accordion-header.collapsed( - type="button" - data-toggle="collapse" - data-target="#overleafGroupPlansQ5" - data-bs-toggle="collapse" - data-bs-target="#overleafGroupPlansQ5" - aria-expanded="false" - aria-controls="overleafGroupPlansQ5" + type='button' + data-toggle='collapse' + data-target='#overleafGroupPlansQ5' + data-bs-toggle='collapse' + data-bs-target='#overleafGroupPlansQ5' + aria-expanded='false' + aria-controls='overleafGroupPlansQ5' ) | How do I upgrade my plan from Group Standard to Group Professional? span.custom-accordion-icon - +material-symbol-outlined("keyboard_arrow_down") - .collapse(id="overleafGroupPlansQ5") + +material-symbol-outlined('keyboard_arrow_down') + .collapse(id='overleafGroupPlansQ5') .custom-accordion-body - | You can upgrade your plan from Group Standard to Group Professional on the + | You can upgrade your plan from Group Standard to Group Professional on the a.inline-green-link( - target="_blank" - href="/user/subscription" - event-tracking="plans-page-click" - event-tracking-mb="true" - event-tracking-trigger="click" - event-segmentation={ button: 'contact', location: 'faq' } + target='_blank' + href='/user/subscription' + event-tracking='plans-page-click' + event-tracking-mb='true' + event-tracking-trigger='click' + event-segmentation={button: 'contact', location: 'faq'} ) span subscription management page | . diff --git a/services/web/app/views/subscriptions/preview-change.pug b/services/web/app/views/subscriptions/preview-change.pug index 5330eb8684..2eaca5ac6a 100644 --- a/services/web/app/views/subscriptions/preview-change.pug +++ b/services/web/app/views/subscriptions/preview-change.pug @@ -4,10 +4,14 @@ block entrypointVar - entrypoint = 'pages/user/subscription/preview-change' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview) - meta(name="ol-purchaseReferrer" data-type="string" content=purchaseReferrer) + meta(name='ol-user' data-type='json' content=user) + meta( + name='ol-subscriptionChangePreview' + data-type='json' + content=changePreview + ) + meta(name='ol-purchaseReferrer' data-type='string' content=purchaseReferrer) block content - main.content.content-alt#main-content + main#main-content.content.content-alt #subscription-preview-change diff --git a/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug index 4457383e93..df734232d6 100644 --- a/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug +++ b/services/web/app/views/subscriptions/subtotal-limit-exceeded.pug @@ -4,8 +4,8 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/subtotal-limit-exceeded' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-groupName", data-type="string", content=groupName) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-groupName' data-type='string' content=groupName) block content - main.content.content-alt#subtotal-limit-exceeded-root + main#subtotal-limit-exceeded-root.content.content-alt diff --git a/services/web/app/views/subscriptions/successful-subscription-react.pug b/services/web/app/views/subscriptions/successful-subscription-react.pug index 5ce208b034..9437b93b67 100644 --- a/services/web/app/views/subscriptions/successful-subscription-react.pug +++ b/services/web/app/views/subscriptions/successful-subscription-react.pug @@ -4,9 +4,9 @@ block entrypointVar - entrypoint = 'pages/user/subscription/successful-subscription' block append meta - meta(name="ol-subscription" data-type="json" content=personalSubscription) - meta(name="ol-postCheckoutRedirect" content=postCheckoutRedirect) - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-subscription' data-type='json' content=personalSubscription) + meta(name='ol-postCheckoutRedirect' content=postCheckoutRedirect) + meta(name='ol-user' data-type='json' content=user) block content - main.content.content-alt#subscription-success-root + main#subscription-success-root.content.content-alt diff --git a/services/web/app/views/subscriptions/team/group-invites.pug b/services/web/app/views/subscriptions/team/group-invites.pug index 81c70f1885..18286fd403 100644 --- a/services/web/app/views/subscriptions/team/group-invites.pug +++ b/services/web/app/views/subscriptions/team/group-invites.pug @@ -4,8 +4,8 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-invites' block append meta - meta(name="ol-teamInvites" data-type="json" content=teamInvites) - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-teamInvites' data-type='json' content=teamInvites) + meta(name='ol-user' data-type='json' content=user) block content - main.content.content-alt.team-invite#group-invites-root + main#group-invites-root.content.content-alt.team-invite diff --git a/services/web/app/views/subscriptions/team/invite-managed.pug b/services/web/app/views/subscriptions/team/invite-managed.pug index d31f12656b..4010731c3f 100644 --- a/services/web/app/views/subscriptions/team/invite-managed.pug +++ b/services/web/app/views/subscriptions/team/invite-managed.pug @@ -4,16 +4,20 @@ block entrypointVar - entrypoint = 'pages/user/subscription/invite-managed' block append meta - meta(name="ol-inviteToken" content=inviteToken) - meta(name="ol-inviterName" content=inviterName) - meta(name="ol-expired" data-type="boolean" content=expired) - meta(name="ol-alreadyEnrolled" data-type="boolean" content=alreadyEnrolled) - meta(name="ol-validationStatus" data-type="json" content=validationStatus) - meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) - meta(name="ol-groupSSOActive" data-type="boolean" content=groupSSOActive) - meta(name="ol-subscriptionId" data-type="string" content=subscriptionId) - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-usersSubscription" data-type="json" content=usersSubscription) + meta(name='ol-inviteToken' content=inviteToken) + meta(name='ol-inviterName' content=inviterName) + meta(name='ol-expired' data-type='boolean' content=expired) + meta(name='ol-alreadyEnrolled' data-type='boolean' content=alreadyEnrolled) + meta(name='ol-validationStatus' data-type='json' content=validationStatus) + meta( + name='ol-currentManagedUserAdminEmail' + data-type='string' + content=currentManagedUserAdminEmail + ) + meta(name='ol-groupSSOActive' data-type='boolean' content=groupSSOActive) + meta(name='ol-subscriptionId' data-type='string' content=subscriptionId) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-usersSubscription' data-type='json' content=usersSubscription) block content - main.content.content-alt.team-invite#invite-managed-root + main#invite-managed-root.content.content-alt.team-invite diff --git a/services/web/app/views/subscriptions/team/invite.pug b/services/web/app/views/subscriptions/team/invite.pug index 1b2ecb4646..717ccef611 100644 --- a/services/web/app/views/subscriptions/team/invite.pug +++ b/services/web/app/views/subscriptions/team/invite.pug @@ -4,14 +4,22 @@ block entrypointVar - entrypoint = 'pages/user/subscription/invite' block append meta - meta(name="ol-hasIndividualPaidSubscription" data-type="boolean" content=hasIndividualPaidSubscription) - meta(name="ol-inviterName" data-type="string" content=inviterName) - meta(name="ol-inviteToken" data-type="string" content=inviteToken) - meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) - meta(name="ol-expired" data-type="boolean" content=expired) - meta(name="ol-groupSSOActive" data-type="boolean" content=groupSSOActive) - meta(name="ol-subscriptionId" data-type="string" content=subscriptionId) - meta(name="ol-user" data-type="json" content=user) + meta( + name='ol-hasIndividualPaidSubscription' + data-type='boolean' + content=hasIndividualPaidSubscription + ) + meta(name='ol-inviterName' data-type='string' content=inviterName) + meta(name='ol-inviteToken' data-type='string' content=inviteToken) + meta( + name='ol-currentManagedUserAdminEmail' + data-type='string' + content=currentManagedUserAdminEmail + ) + meta(name='ol-expired' data-type='boolean' content=expired) + meta(name='ol-groupSSOActive' data-type='boolean' content=groupSSOActive) + meta(name='ol-subscriptionId' data-type='string' content=subscriptionId) + meta(name='ol-user' data-type='json' content=user) block content - main.content.content-alt#invite-root + main#invite-root.content.content-alt diff --git a/services/web/app/views/subscriptions/team/invite_logged_out.pug b/services/web/app/views/subscriptions/team/invite_logged_out.pug index e5930aba4f..3e471fb4c9 100644 --- a/services/web/app/views/subscriptions/team/invite_logged_out.pug +++ b/services/web/app/views/subscriptions/team/invite_logged_out.pug @@ -1,12 +1,12 @@ extends ../../layout-marketing block append meta - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-user' data-type='json' content=user) block content - var colClass = bootstrapVersion === 5 ? 'col-lg-8 m-auto' : 'col-md-8 col-md-offset-2' - main.content.content-alt.team-invite#main-content + main#main-content.content.content-alt.team-invite .container .row div(class=colClass) @@ -16,15 +16,19 @@ block content // TODO: Remove `team-invite-name` once we fully migrated to Bootstrap 5 h1.text-center !{translate("invited_to_group", {inviterName: inviterName, appName: appName }, [{name: 'span', attrs: {class: 'team-invite-name'}}])} - if (accountExists) + if accountExists div p #{translate("invited_to_group_login_benefits", {appName: appName})} p #{translate("invited_to_group_login", {emailAddress: emailAddress})} p - a.btn.btn-primary(href=`/login?redir=/subscription/invites/${inviteToken}${groupSSOActive ? "&hide_sso_login=true" : ""}`) #{translate("login_to_accept_invitation")} + a.btn.btn-primary( + href=`/login?redir=/subscription/invites/${inviteToken}${groupSSOActive ? "&hide_sso_login=true" : ""}` + ) #{translate("login_to_accept_invitation")} else div p #{translate("invited_to_group_register_benefits", {appName: appName})} p #{translate("invited_to_group_register", {inviterName: inviterName})} p - a.btn.btn-primary(href=`/register?redir=/subscription/invites/${inviteToken}${groupSSOActive ? "&hide_sso_login=true" : ""}`) #{translate("register_to_accept_invitation")} + a.btn.btn-primary( + href=`/register?redir=/subscription/invites/${inviteToken}${groupSSOActive ? "&hide_sso_login=true" : ""}` + ) #{translate("register_to_accept_invitation")} diff --git a/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug index 4347a2a633..0c7f4ce993 100644 --- a/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug +++ b/services/web/app/views/subscriptions/upgrade-group-subscription-react.pug @@ -4,10 +4,14 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/upgrade-group-subscription' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview) - meta(name="ol-totalLicenses", data-type="number", content=totalLicenses) - meta(name="ol-groupName", data-type="string", content=groupName) + meta(name='ol-user' data-type='json' content=user) + meta( + name='ol-subscriptionChangePreview' + data-type='json' + content=changePreview + ) + meta(name='ol-totalLicenses' data-type='number' content=totalLicenses) + meta(name='ol-groupName' data-type='string' content=groupName) block content - main.content.content-alt#upgrade-group-subscription-root + main#upgrade-group-subscription-root.content.content-alt diff --git a/services/web/app/views/user/accountSuspended.pug b/services/web/app/views/user/accountSuspended.pug index 7231713416..1a03beb4df 100644 --- a/services/web/app/views/user/accountSuspended.pug +++ b/services/web/app/views/user/accountSuspended.pug @@ -6,7 +6,7 @@ block vars - metadata.robotsNoindexNofollow = true block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container-custom-sm.mx-auto .card .card-body diff --git a/services/web/app/views/user/compromised_password.pug b/services/web/app/views/user/compromised_password.pug index c66a07415a..48017b0ea7 100644 --- a/services/web/app/views/user/compromised_password.pug +++ b/services/web/app/views/user/compromised_password.pug @@ -9,5 +9,5 @@ block entrypointVar - entrypoint = 'pages/compromised-password' block content - main.content.content-alt#main-content + main#main-content.content.content-alt #compromised-password diff --git a/services/web/app/views/user/confirmSecondaryEmail.pug b/services/web/app/views/user/confirmSecondaryEmail.pug index 181e58e4ce..4f143c16dc 100644 --- a/services/web/app/views/user/confirmSecondaryEmail.pug +++ b/services/web/app/views/user/confirmSecondaryEmail.pug @@ -8,7 +8,7 @@ block entrypointVar - entrypoint = 'pages/user/confirm-secondary-email' block append meta - meta(name="ol-email" content=email) + meta(name='ol-email' content=email) block content main.content.content-alt diff --git a/services/web/app/views/user/confirm_email.pug b/services/web/app/views/user/confirm_email.pug index 13e911f386..d783996076 100644 --- a/services/web/app/views/user/confirm_email.pug +++ b/services/web/app/views/user/confirm_email.pug @@ -2,56 +2,50 @@ extends ../layout-marketing include ../_mixins/notification block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-lg-8.offset-lg-2.col-xl-6.offset-xl-3 .card .card-body - .page-header(data-ol-hide-on-error-message="confirm-email-wrong-user") + .page-header(data-ol-hide-on-error-message='confirm-email-wrong-user') h1 #{translate("confirm_email")} + form(method='POST' action='/logout' id='logoutForm') + input(name='_csrf' type='hidden' value=csrfToken) + input(name='redirect' type='hidden' value=currentUrlWithQueryParams) form( - method="POST" - action="/logout" - id="logoutForm" + name='confirmEmailForm' + data-ol-async-form + data-ol-auto-submit + action='/user/emails/confirm' + method='POST' + id='confirmEmailForm' ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="redirect", value=currentUrlWithQueryParams) - form( - data-ol-async-form, - data-ol-auto-submit, - name="confirmEmailForm" - action="/user/emails/confirm", - method="POST", - id="confirmEmailForm", - ) - input(type="hidden", name="_csrf", value=csrfToken) - input(type="hidden", name="token", value=token) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='token' type='hidden' value=token) div(data-ol-not-sent) - +formMessages() - div(data-ol-custom-form-message="confirm-email-wrong-user" hidden) + +formMessages + div(data-ol-custom-form-message='confirm-email-wrong-user' hidden) h1.h3 #{translate("we_cant_confirm_this_email")} p !{translate("to_confirm_email_address_you_must_be_logged_in_with_the_requesting_account")} p !{translate("you_are_currently_logged_in_as", {email: getUserEmail()})} .actions - button.btn-primary.btn.w-100( - form="logoutForm" - ) #{translate('log_in_with_a_different_account')} + button.btn-primary.btn.w-100(form='logoutForm') #{translate('log_in_with_a_different_account')} .actions button.btn-primary.btn.w-100( - type='submit', + type='submit' data-ol-disabled-inflight - data-ol-hide-on-error-message="confirm-email-wrong-user" + data-ol-hide-on-error-message='confirm-email-wrong-user' ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('confirm')} - span(hidden data-ol-inflight="pending") - span(role='status').spinner-border.spinner-border-sm.mx-2 + span(hidden data-ol-inflight='pending') + span.spinner-border.spinner-border-sm.mx-2(role='status') div(hidden data-ol-sent) - +notification({ariaLive: 'polite', type: 'success', className: 'mb-3', content: translate("thank_you_email_confirmed")}) - div.text-center - a.btn.btn-primary(href="/user/settings") + +notification({ariaLive: 'polite', type: 'success', className: 'mb-3', content: translate('thank_you_email_confirmed')}) + .text-center + a.btn.btn-primary(href='/user/settings') | #{translate('go_to_account_settings')} diff --git a/services/web/app/views/user/email-preferences.pug b/services/web/app/views/user/email-preferences.pug index 86ebc5f841..2071f64705 100644 --- a/services/web/app/views/user/email-preferences.pug +++ b/services/web/app/views/user/email-preferences.pug @@ -2,7 +2,7 @@ extends ../layout-marketing include ../_mixins/back_to_btns block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 @@ -10,9 +10,9 @@ block content .card-body .page-header h1 #{translate("newsletter_info_title")} - + p #{translate("newsletter_info_summary")} - + - var submitAction if subscribed - submitAction = '/user/newsletter/unsubscribe' @@ -20,28 +20,28 @@ block content else - submitAction = '/user/newsletter/subscribe' p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} - + form( + name='newsletterForm' data-ol-async-form data-ol-reload-on-success - name="newsletterForm" action=submitAction - method="POST" + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages p.actions.text-center if subscribed - button.btn-danger.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("unsubscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… + button.btn-danger.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("unsubscribe")} + span(hidden data-ol-inflight='pending') #{translate("saving")}… else - button.btn-primary.btn(type='submit', data-ol-disabled-inflight) - span(data-ol-inflight="idle") #{translate("subscribe")} - span(hidden data-ol-inflight="pending") #{translate("saving")}… - + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("subscribe")} + span(hidden data-ol-inflight='pending') #{translate("saving")}… + if subscribed p #{translate("newsletter_info_note")} - + .page-separator - +back-to-btns() + +back-to-btns diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 1ad77cb8b4..03112a0e16 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -1,7 +1,7 @@ extends ../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-lg-6.offset-lg-3.col-xl-4.offset-xl-4 @@ -11,10 +11,10 @@ block content if login_support_title h1 !{login_support_title} else - h1 #{translate("log_in")} - form(data-ol-async-form, name="loginForm", action='/login', method="POST") - input(name='_csrf', type='hidden', value=csrfToken) - +formMessagesNewStyle() + h1 #{translate("log_in")} + form(name='loginForm' data-ol-async-form action='/login' method='POST') + input(name='_csrf' type='hidden' value=csrfToken) + +formMessagesNewStyle +customFormMessageNewStyle('invalid-password-retry-or-reset', 'danger') | !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])} span.visually-hidden(id='resetPasswordDescription') @@ -23,28 +23,24 @@ block content | !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}. .form-group input.form-control( - type='email', - name='email', - required, - placeholder='email@example.com', - autofocus="true" + name='email' + type='email' + required + placeholder='email@example.com' + autofocus='true' ) .form-group input.form-control( - type='password', - name='password', - required, - placeholder='********', + name='password' + type='password' + required + placeholder='********' ) .actions - button.btn-primary.btn( - type='submit', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("login")} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("login")} + span(hidden data-ol-inflight='pending') #{translate("logging_in")}… a.float-end(href='/user/password/reset') #{translate("forgot_your_password")}? if login_support_text hr - p.text-center !{login_support_text} - + p.text-center !{login_support_text} diff --git a/services/web/app/views/user/one_time_login.pug b/services/web/app/views/user/one_time_login.pug index 648f6d93c1..e5d50c5a2d 100644 --- a/services/web/app/views/user/one_time_login.pug +++ b/services/web/app/views/user/one_time_login.pug @@ -1,7 +1,7 @@ extends ../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-lg-6.offset-lg-3.col-xl-4.offset-xl-4 @@ -13,6 +13,6 @@ block content p | Please | - a(href="/login") log in + a(href='/login') log in | | to continue working on your projects. diff --git a/services/web/app/views/user/passwordReset-bs5.pug b/services/web/app/views/user/passwordReset-bs5.pug index 08e0a71b9d..f69553b849 100644 --- a/services/web/app/views/user/passwordReset-bs5.pug +++ b/services/web/app/views/user/passwordReset-bs5.pug @@ -11,51 +11,55 @@ block content - var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset) if showCaptcha - script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit") + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=explicit' + ) div( - id="recaptcha" - class="g-recaptcha" + id='recaptcha' + class='g-recaptcha' data-sitekey=settings.recaptcha.siteKey - data-size="invisible" - data-badge="inline" + data-size='invisible' + data-badge='inline' ) - main#main-content(data-ol-captcha-retry-trigger-area="") - a.auth-aux-logo(href="/") - img(src=buildImgPath("ol-brand/overleaf-o-dark.svg") alt=settings.appName) + main#main-content(data-ol-captcha-retry-trigger-area='') + a.auth-aux-logo(href='/') + img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName) .auth-aux-container form( + name='passwordResetForm' + captcha-action-name=showCaptcha ? 'passwordReset' : false data-ol-async-form - name="passwordResetForm" - action="/user/password/reset" - method="POST" - captcha=(showCaptcha ? '' : false) - captcha-action-name=(showCaptcha ? "passwordReset" : false) + action='/user/password/reset' + method='POST' + captcha=showCaptcha ? '' : false ) if error === 'password_reset_token_expired' h1.h3.mb-3.mt-0 #{translate("sorry_your_token_expired")} p #{translate('please_request_a_new_password_reset_email_and_follow_the_link')}. else h1.h3.mb-3.mt-0(data-ol-not-sent) #{translate("password_reset_sentence_case")} - h1.h3.mb-3.mt-0(hidden data-ol-sent) #{translate("check_your_email")} + h1.h3.mb-3.mt-0(hidden data-ol-sent) #{translate("check_your_email")} p.mb-3.pb-3(data-ol-not-sent) #{translate("enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password")}. div(data-ol-not-sent) - +formMessagesNewStyle() + +formMessagesNewStyle if error && error !== 'password_reset_token_expired' +notification({ariaLive: 'assertive', type: 'error', className: 'mb-3', content: translate(error)}) - div(data-ol-custom-form-message="no-password-allowed-due-to-sso" hidden) - +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])}) - input(type="hidden" name="_csrf" value=csrfToken) + div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) + +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_sso', {}, [{name: 'a', attrs: {href: '/sso-login'}}])}) + input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label.form-label(for='email') #{translate("email")} - input.form-control#email( - aria-label="email" - type='email' + input#email.form-control( name='email' + aria-label='email' + type='email' required - autocomplete="username" + autocomplete='username' autofocus ) .actions @@ -64,14 +68,14 @@ block content data-ol-disabled-inflight aria-label=translate('reset_password_sentence_case') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate("reset_password_sentence_case")} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate("requesting_password_reset")}… - a.btn.btn-ghost.w-100.mb-3(href="/login") #{translate("back_to_log_in")} + a.btn.btn-ghost.w-100.mb-3(href='/login') #{translate("back_to_log_in")} div(hidden data-ol-sent) p.mb-4 #{translate('password_reset_email_sent')} - a.btn.btn-primary.w-100.mb-3(href="/login") #{translate('back_to_log_in')} + a.btn.btn-primary.w-100.mb-3(href='/login') #{translate('back_to_log_in')} if showCaptcha +recaptchaConditions diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug index ed806c32cd..e3396a5cc0 100644 --- a/services/web/app/views/user/passwordReset.pug +++ b/services/web/app/views/user/passwordReset.pug @@ -9,77 +9,81 @@ block content - var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset) if showCaptcha - script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit") + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=explicit' + ) div( - id="recaptcha" - class="g-recaptcha" + id='recaptcha' + class='g-recaptcha' data-sitekey=settings.recaptcha.siteKey - data-size="invisible" - data-badge="inline" + data-size='invisible' + data-badge='inline' ) - main.content.content-alt#main-content(data-ol-captcha-retry-trigger-area="") + main#main-content.content.content-alt(data-ol-captcha-retry-trigger-area='') .container-custom-sm.mx-auto .card form( + name='passwordResetForm' + captcha-action-name=showCaptcha ? 'passwordReset' : false data-ol-async-form - name="passwordResetForm" - action="/user/password/reset", - method="POST", - captcha=(showCaptcha ? '' : false), - captcha-action-name=(showCaptcha ? "passwordReset" : false), + action='/user/password/reset' + method='POST' + captcha=showCaptcha ? '' : false ) if error === 'password_reset_token_expired' h3.mt-0.mb-2 #{translate("sorry_your_token_expired")} p #{translate('please_request_a_new_password_reset_email_and_follow_the_link')}. else h3.mt-0.mb-2(data-ol-not-sent) #{translate("password_reset")} - h3.mt-0.mb-2(hidden data-ol-sent) #{translate("check_your_email")} + h3.mt-0.mb-2(hidden data-ol-sent) #{translate("check_your_email")} p(data-ol-not-sent) #{translate("enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password")}. div(data-ol-not-sent) - +formMessages() + +formMessages if error && error !== 'password_reset_token_expired' - div.alert.alert-danger.mb-2( - role="alert" - aria-live="assertive" - ) + .alert.alert-danger.mb-2(role='alert' aria-live='assertive') | #{translate(error)} - div(data-ol-custom-form-message="no-password-allowed-due-to-sso" hidden) - .notification.notification-type-error(aria-live="polite" style="margin-bottom: 10px;") + div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) + .notification.notification-type-error( + aria-live='polite' + style='margin-bottom: 10px' + ) .notification-icon - +material-symbol-rounded("error") + +material-symbol-rounded('error') .notification-content-and-cta .notification-content p | !{translate("you_cant_reset_password_due_to_sso", {}, [{name: 'a', attrs: {href: '/sso-login'}}])} - input(type="hidden", name="_csrf", value=csrfToken) + input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label(for='email') #{translate("email")} - input.form-control#email( - aria-label="email" - type='email', - name='email', - placeholder=translate("enter_your_email_address"), - required, - autocomplete="username", + input#email.form-control( + name='email' + aria-label='email' + type='email' + placeholder=translate('enter_your_email_address') + required + autocomplete='username' autofocus ) .actions button.btn.btn-primary.w-100( - type='submit', - data-ol-disabled-inflight, + type='submit' + data-ol-disabled-inflight aria-label=translate('request_password_reset_to_reconfirm') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate("request_password_reset")} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate("requesting_password_reset")}… div(hidden data-ol-sent) p.mb-4 #{translate('password_reset_email_sent')} - a(href="/login") #{translate('back_to_log_in')} + a(href='/login') #{translate('back_to_log_in')} if showCaptcha +recaptchaConditions diff --git a/services/web/app/views/user/primaryEmailCheck-bs5.pug b/services/web/app/views/user/primaryEmailCheck-bs5.pug index b25136927a..f45f15d0ca 100644 --- a/services/web/app/views/user/primaryEmailCheck-bs5.pug +++ b/services/web/app/views/user/primaryEmailCheck-bs5.pug @@ -7,36 +7,36 @@ block vars block content main#main-content .auth-aux-container - img.w-50.d-block(src=buildImgPath("ol-brand/overleaf.svg") alt=settings.appName) + img.w-50.d-block( + src=buildImgPath('ol-brand/overleaf.svg') + alt=settings.appName + ) h1.h3.mb-3 #{translate("keep_your_account_safe")} div(data-ol-multi-submit) p.small.mb-4 | !{translate("primary_email_check_question", { email: getUserEmail() }, ["strong"])} form( data-ol-async-form - action="/user/emails/primary-email-check" - method="POST" + action='/user/emails/primary-email-check' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessagesNewStyle() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessagesNewStyle - button.btn.btn-primary.w-100.mb-3( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("yes_that_is_correct")} - span(hidden data-ol-inflight="pending") #{translate("confirming")}… + button.btn.btn-primary.w-100.mb-3(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("yes_that_is_correct")} + span(hidden data-ol-inflight='pending') #{translate("confirming")}… a.btn.btn-secondary.w-100.mb-4( - href="/user/settings#add-email" + href='/user/settings#add-email' data-ol-slow-link - event-tracking="primary-email-check-change-email" - event-tracking-mb="true" - event-tracking-trigger="click" + event-tracking='primary-email-check-change-email' + event-tracking-mb='true' + event-tracking-trigger='click' ) - span(data-ol-inflight="idle") #{translate("no_update_email")} - span(hidden data-ol-inflight="pending") #{translate("redirecting")}… + span(data-ol-inflight='idle') #{translate("no_update_email")} + span(hidden data-ol-inflight='pending') #{translate("redirecting")}… p.small.mb-2 - | #{translate("keep_your_email_updated")} + | #{translate("keep_your_email_updated")} p.small - | !{translate("learn_more_about_emails", {}, [{name: 'a', attrs: {href: '/learn/how-to/Keeping_your_account_secure', 'event-tracking': 'primary-email-check-learn-more', 'event-tracking-mb': 'true', 'event-tracking-trigger': 'click' }}])} + | !{translate("learn_more_about_emails", {}, [{name: 'a', attrs: {href: '/learn/how-to/Keeping_your_account_secure', 'event-tracking': 'primary-email-check-learn-more', 'event-tracking-mb': 'true', 'event-tracking-trigger': 'click' }}])} diff --git a/services/web/app/views/user/reconfirm-bs5.pug b/services/web/app/views/user/reconfirm-bs5.pug index fce9a44295..e8e640d10c 100644 --- a/services/web/app/views/user/reconfirm-bs5.pug +++ b/services/web/app/views/user/reconfirm-bs5.pug @@ -5,46 +5,50 @@ block vars - isWebsiteRedesign = true block content - - var email = reconfirm_email ? reconfirm_email : "" + - var email = reconfirm_email ? reconfirm_email : '' - var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset) if showCaptcha - script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit") + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=explicit' + ) div( - id="recaptcha" - class="g-recaptcha" + id='recaptcha' + class='g-recaptcha' data-sitekey=settings.recaptcha.siteKey - data-size="invisible" - data-badge="inline" + data-size='invisible' + data-badge='inline' ) - main#main-content(data-ol-captcha-retry-trigger-area="") - .container.auth-aux-container(style="max-width: 420px;") + main#main-content(data-ol-captcha-retry-trigger-area='') + .container.auth-aux-container(style='max-width: 420px') form( + name='reconfirmAccountForm' + captcha-action-name=showCaptcha ? 'passwordReset' : false data-ol-async-form - name="reconfirmAccountForm" - action="/user/reconfirm" - method="POST" + action='/user/reconfirm' + method='POST' aria-label=translate('request_reconfirmation_email') - captcha=(showCaptcha ? '' : false) - captcha-action-name=(showCaptcha ? "passwordReset" : false) + captcha=showCaptcha ? '' : false ) h1.h5.mb-3 #{translate("reconfirm_account")} p #{translate('reconfirm_explained')} - | + | a(href=`mailto:${settings.adminEmail}`) #{settings.adminEmail} | . - - div(data-ol-not-sent) - +formMessagesNewStyle() - input(type="hidden" name="_csrf" value=csrfToken) + div(data-ol-not-sent) + +formMessagesNewStyle + + input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label.form-label(for='email') #{translate("please_enter_email")} input.form-control( - aria-label="email" - type='email' name='email' + aria-label='email' + type='email' placeholder='email@example.com' required autofocus @@ -52,20 +56,17 @@ block content ) .actions button.btn.btn-primary.w-100( - style="white-space: normal;" + style='white-space: normal' type='submit' data-ol-disabled-inflight aria-label=translate('request_password_reset_to_reconfirm') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('request_password_reset_to_reconfirm')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('request_password_reset_to_reconfirm')}… div(hidden data-ol-sent) - div.alert.alert-success( - role="alert" - aria-live="polite" - ) + .alert.alert-success(role='alert' aria-live='polite') span #{translate('password_reset_email_sent')} if showCaptcha diff --git a/services/web/app/views/user/reconfirm.pug b/services/web/app/views/user/reconfirm.pug index 23b77d278d..8cfe2ec218 100644 --- a/services/web/app/views/user/reconfirm.pug +++ b/services/web/app/views/user/reconfirm.pug @@ -5,20 +5,24 @@ block vars - bootstrap5PageStatus = 'disabled' block content - - var email = reconfirm_email ? reconfirm_email : "" + - var email = reconfirm_email ? reconfirm_email : '' - var showCaptcha = settings.recaptcha && settings.recaptcha.siteKey && !(settings.recaptcha.disabled && settings.recaptcha.disabled.passwordReset) if showCaptcha - script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render=explicit") + script( + type='text/javascript' + nonce=scriptNonce + src='https://www.recaptcha.net/recaptcha/api.js?render=explicit' + ) div( - id="recaptcha" - class="g-recaptcha" + id='recaptcha' + class='g-recaptcha' data-sitekey=settings.recaptcha.siteKey - data-size="invisible" - data-badge="inline" + data-size='invisible' + data-badge='inline' ) - main.content.content-alt#main-content(data-ol-captcha-retry-trigger-area="") + main#main-content.content.content-alt(data-ol-captcha-retry-trigger-area='') .container .row .col-sm-12.col-md-6.col-md-offset-3 @@ -28,44 +32,41 @@ block content a(href=`mailto:${settings.adminEmail}`) #{settings.adminEmail} | . form( + name='reconfirmAccountForm' + captcha-action-name=showCaptcha ? 'passwordReset' : false data-ol-async-form - name="reconfirmAccountForm" - action="/user/reconfirm", - method="POST", + action='/user/reconfirm' + method='POST' aria-label=translate('request_reconfirmation_email') - captcha=(showCaptcha ? '' : false), - captcha-action-name=(showCaptcha ? "passwordReset" : false) + captcha=showCaptcha ? '' : false ) div(data-ol-not-sent) - +formMessages() - - input(type="hidden", name="_csrf", value=csrfToken) + +formMessages + + input(name='_csrf' type='hidden' value=csrfToken) .form-group label(for='email') #{translate("please_enter_email")} input.form-control( - aria-label="email" - type='email', - name='email', - placeholder='email@example.com', - required, + name='email' + aria-label='email' + type='email' + placeholder='email@example.com' + required autofocus value=email ) .actions button.btn.btn-primary( - type='submit', - data-ol-disabled-inflight, + type='submit' + data-ol-disabled-inflight aria-label=translate('request_password_reset_to_reconfirm') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('request_password_reset_to_reconfirm')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('request_password_reset_to_reconfirm')}… div(hidden data-ol-sent) - div.alert.alert-success( - role="alert" - aria-live="polite" - ) + .alert.alert-success(role='alert' aria-live='polite') span #{translate('password_reset_email_sent')} .row .col-sm-12.col-md-6.col-md-offset-3 diff --git a/services/web/app/views/user/register.pug b/services/web/app/views/user/register.pug index c35f3c04f0..8aa40e8b35 100644 --- a/services/web/app/views/user/register.pug +++ b/services/web/app/views/user/register.pug @@ -4,7 +4,7 @@ block vars - bootstrap5PageStatus = 'disabled' block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .registration_message @@ -15,12 +15,12 @@ block content | #{translate("join_sl_to_view_project")}. div | #{translate("already_have_sl_account")} - a(href="/login") #{translate("login_here")} + 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")} + a(href='/login') #{translate("login_here")} .row .col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3 diff --git a/services/web/app/views/user/sessions.pug b/services/web/app/views/user/sessions.pug index ffd65a3548..744b804687 100644 --- a/services/web/app/views/user/sessions.pug +++ b/services/web/app/views/user/sessions.pug @@ -1,15 +1,15 @@ extends ../layout-marketing block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 .card.clear-user-sessions .card-body .page-header - h1 #{translate("your_sessions")} - + h1 #{translate("your_sessions")} + if currentSession.ip_address && currentSession.session_created h3 #{translate("current_session")} div @@ -21,47 +21,43 @@ block content tr td #{currentSession.ip_address} td #{moment(currentSession.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - + h3 #{translate("other_sessions")} div p.small | !{translate("clear_sessions_description")} - - form( - data-ol-async-form - action='/user/sessions/clear' - method='POST' - ) + + form(data-ol-async-form action='/user/sessions/clear' method='POST') input(name='_csrf' type='hidden' value=csrfToken) div(data-ol-not-sent) if sessions.length == 0 p.text-center | #{translate("no_other_sessions")} - + if sessions.length > 0 table.table.table-striped thead tr th #{translate("ip_address")} th #{translate("session_created_at")} - for session in sessions + each session in sessions tr td #{session.ip_address} td #{moment(session.session_created).utc().format('Do MMM YYYY, h:mm a')} UTC - + p.actions .text-center button.btn.btn-lg.btn-primary( - type="submit" + type='submit' data-ol-disable-inflight ) - span(data-ol-inflight="idle") #{translate('clear_sessions')} - span(hidden data-ol-inflight="pending") #{translate("processing")}… - + span(data-ol-inflight='idle') #{translate('clear_sessions')} + span(hidden data-ol-inflight='pending') #{translate("processing")}… + div(hidden data-ol-sent) p.text-center | #{translate("no_other_sessions")} - + p.text-success.text-center | #{translate('clear_sessions_success')} .page-separator diff --git a/services/web/app/views/user/setPassword-bs5.pug b/services/web/app/views/user/setPassword-bs5.pug index 83c3a531bb..5081d22409 100644 --- a/services/web/app/views/user/setPassword-bs5.pug +++ b/services/web/app/views/user/setPassword-bs5.pug @@ -7,28 +7,25 @@ block vars block content main#main-content - a.auth-aux-logo(href="/") - img(src=buildImgPath("ol-brand/overleaf-o-dark.svg") alt=settings.appName) + a.auth-aux-logo(href='/') + img(src=buildImgPath('ol-brand/overleaf-o-dark.svg') alt=settings.appName) .auth-aux-container form( + name='passwordResetForm' data-ol-async-form - name="passwordResetForm" - action="/user/password/set" - method="POST" - data-ol-hide-on-error="token-expired" + action='/user/password/set' + method='POST' + data-ol-hide-on-error='token-expired' ) - div( - hidden - data-ol-sent - ) + div(hidden data-ol-sent) h1.h3.mb-3.mt-0 #{translate("password_updated")} p.mb-4 #{translate("your_password_has_been_successfully_changed")}. a.btn.btn-primary.w-100(href='/login') #{translate("log_in_now")} div(data-ol-not-sent) h1.h3.mb-3.mt-0 #{translate("reset_your_password")} - p(data-ol-hide-on-error-message="token-expired") #{translate("create_a_new_password_for_your_account")}. - +formMessagesNewStyle() + p(data-ol-hide-on-error-message='token-expired') #{translate("create_a_new_password_for_your_account")}. + +formMessagesNewStyle +customFormMessageNewStyle('password-contains-email', 'danger') | #{translate('invalid_password_contains_email')}. @@ -41,18 +38,21 @@ block content +customFormMessageNewStyle('token-expired', 'danger') | #{translate('password_reset_token_expired')} br - a(href="/user/password/reset") + a(href='/user/password/reset') | #{translate('request_new_password_reset_email')} - input(type="hidden" name="_csrf" value=csrfToken) - input(type="text" hidden name="email" autocomplete="username" value=email) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='email' type='text' hidden autocomplete='username' value=email) .form-group.mb-3 - label.form-label(for='passwordField', data-ol-hide-on-error-message="token-expired") #{translate("new_password")} - input.form-control.auth-aux-new-password#passwordField( - type='password' + label.form-label( + for='passwordField' + data-ol-hide-on-error-message='token-expired' + ) #{translate("new_password")} + input#passwordField.form-control.auth-aux-new-password( name='password' - autocomplete="new-password" + type='password' + autocomplete='new-password' autofocus required minlength=settings.passwordStrengthOptions.length.min @@ -68,12 +68,8 @@ block content | !{translate('password_was_detected_on_a_public_list_of_known_compromised_passwords', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}])}. | #{translate('use_a_different_password')}. - input( - type="hidden" - name="passwordResetToken" - value=passwordResetToken - ) - div(data-ol-hide-on-error-message="token-expired") + input(name='passwordResetToken' type='hidden' value=passwordResetToken) + div(data-ol-hide-on-error-message='token-expired') div #{translate('in_order_to_have_a_secure_account_make_sure_your_password')} ul.mb-3.ps-4 li #{translate('is_longer_than_n_characters', {n: settings.passwordStrengthOptions.length.min})} @@ -85,7 +81,7 @@ block content data-ol-disabled-inflight aria-label=translate('set_new_password') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('set_new_password')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('set_new_password')}… diff --git a/services/web/app/views/user/setPassword.pug b/services/web/app/views/user/setPassword.pug index 5da2b6b59a..653bc52045 100644 --- a/services/web/app/views/user/setPassword.pug +++ b/services/web/app/views/user/setPassword.pug @@ -4,28 +4,25 @@ block vars - bootstrap5PageStatus = 'disabled' block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container-custom-sm.mx-auto .card form( - data-ol-async-form, - name="passwordResetForm", - action="/user/password/set", - method="POST", - data-ol-hide-on-error="token-expired" + name='passwordResetForm' + data-ol-async-form + action='/user/password/set' + method='POST' + data-ol-hide-on-error='token-expired' ) - div( - hidden - data-ol-sent - ) + div(hidden data-ol-sent) h3.mt-0.mb-2 #{translate("password_updated")} p.mb-4 #{translate("your_password_has_been_successfully_changed")}. a(href='/login') #{translate("log_in_now")} div(data-ol-not-sent) h3.mt-0.mb-2 #{translate("reset_your_password")} - p(data-ol-hide-on-error-message="token-expired") #{translate("create_a_new_password_for_your_account")}. - +formMessages() + p(data-ol-hide-on-error-message='token-expired') #{translate("create_a_new_password_for_your_account")}. + +formMessages +customFormMessage('password-contains-email', 'danger') | #{translate('invalid_password_contains_email')}. @@ -38,21 +35,30 @@ block content +customFormMessage('token-expired', 'danger') | #{translate('password_reset_token_expired')} br - a(href="/user/password/reset") + a(href='/user/password/reset') | #{translate('request_new_password_reset_email')} - input(type="hidden", name="_csrf", value=csrfToken) - input(type="text" hidden name="email" autocomplete="username" value=email) + input(name='_csrf' type='hidden' value=csrfToken) + input( + name='email' + type='text' + hidden + autocomplete='username' + value=email + ) .form-group - label(for='passwordField', data-ol-hide-on-error-message="token-expired") #{translate("new_password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder=translate("enter_your_new_password"), - autocomplete="new-password", - autofocus, - required, + label( + for='passwordField' + data-ol-hide-on-error-message='token-expired' + ) #{translate("new_password")} + input#passwordField.form-control( + name='password' + type='password' + placeholder=translate('enter_your_new_password') + autocomplete='new-password' + autofocus + required minlength=settings.passwordStrengthOptions.length.min ) @@ -66,12 +72,8 @@ block content | !{translate('password_was_detected_on_a_public_list_of_known_compromised_passwords', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}])}. | #{translate('use_a_different_password')}. - input( - type="hidden", - name="passwordResetToken", - value=passwordResetToken - ) - div(data-ol-hide-on-error-message="token-expired") + input(name='passwordResetToken' type='hidden' value=passwordResetToken) + div(data-ol-hide-on-error-message='token-expired') div #{translate('in_order_to_have_a_secure_account_make_sure_your_password')} ul.mb-4.ps-4 li #{translate('is_longer_than_n_characters', {n: settings.passwordStrengthOptions.length.min})} @@ -79,11 +81,11 @@ block content li #{translate('is_not_used_on_any_other_website')} .actions button.btn.btn-primary.w-100( - type='submit', + type='submit' data-ol-disabled-inflight aria-label=translate('set_new_password') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('set_new_password')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('set_new_password')}… diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 4ac35bef71..dc63e27abe 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -2,38 +2,65 @@ extends ../layout-react block entrypointVar - entrypoint = 'pages/user/settings' - + block vars - isWebsiteRedesign = true block append meta - meta(name="ol-hasPassword" data-type="boolean" content=hasPassword) - meta(name="ol-shouldAllowEditingDetails" data-type="boolean" content=shouldAllowEditingDetails) - meta(name="ol-oauthProviders", data-type="json", content=oauthProviders) - meta(name="ol-institutionLinked", data-type="json", content=institutionLinked) - meta(name="ol-samlError", data-type="json", content=samlError) - meta(name="ol-institutionEmailNonCanonical", content=institutionEmailNonCanonical) + meta(name='ol-hasPassword' data-type='boolean' content=hasPassword) + meta( + name='ol-shouldAllowEditingDetails' + data-type='boolean' + content=shouldAllowEditingDetails + ) + meta(name='ol-oauthProviders' data-type='json' content=oauthProviders) + meta(name='ol-institutionLinked' data-type='json' content=institutionLinked) + meta(name='ol-samlError' data-type='json' content=samlError) + meta( + name='ol-institutionEmailNonCanonical' + content=institutionEmailNonCanonical + ) - meta(name="ol-reconfirmedViaSAML", content=reconfirmedViaSAML) - meta(name="ol-reconfirmationRemoveEmail", content=reconfirmationRemoveEmail) - meta(name="ol-samlBeta", content=samlBeta) - meta(name="ol-ssoErrorMessage", content=ssoErrorMessage) - meta(name="ol-thirdPartyIds", data-type="json", content=thirdPartyIds || {}) - meta(name="ol-passwordStrengthOptions", data-type="json", content=settings.passwordStrengthOptions || {}) - meta(name="ol-isExternalAuthenticationSystemUsed" data-type="boolean" content=externalAuthenticationSystemUsed()) - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-labsExperiments" data-type="json" content=labsExperiments) - meta(name="ol-dropbox" data-type="json" content=dropbox) - meta(name="ol-github" data-type="json" content=github) - meta(name="ol-projectSyncSuccessMessage", content=projectSyncSuccessMessage) - meta(name="ol-personalAccessTokens", data-type="json" content=personalAccessTokens) - meta(name="ol-emailAddressLimit", data-type="json", content=emailAddressLimit) - meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) - meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled) - meta(name="ol-isSaas" data-type="boolean" content=isSaas) - meta(name="ol-memberOfSSOEnabledGroups" data-type="json" content=memberOfSSOEnabledGroups) - meta(name="ol-capabilities" data-type="json" content=capabilities) + meta(name='ol-reconfirmedViaSAML' content=reconfirmedViaSAML) + meta(name='ol-reconfirmationRemoveEmail' content=reconfirmationRemoveEmail) + meta(name='ol-samlBeta' content=samlBeta) + meta(name='ol-ssoErrorMessage' content=ssoErrorMessage) + meta(name='ol-thirdPartyIds' data-type='json' content=thirdPartyIds || {}) + meta( + name='ol-passwordStrengthOptions' + data-type='json' + content=settings.passwordStrengthOptions || {} + ) + meta( + name='ol-isExternalAuthenticationSystemUsed' + data-type='boolean' + content=externalAuthenticationSystemUsed() + ) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-labsExperiments' data-type='json' content=labsExperiments) + meta(name='ol-dropbox' data-type='json' content=dropbox) + meta(name='ol-github' data-type='json' content=github) + meta(name='ol-projectSyncSuccessMessage' content=projectSyncSuccessMessage) + meta( + name='ol-personalAccessTokens' + data-type='json' + content=personalAccessTokens + ) + meta(name='ol-emailAddressLimit' data-type='json' content=emailAddressLimit) + meta( + name='ol-currentManagedUserAdminEmail' + data-type='string' + content=currentManagedUserAdminEmail + ) + meta(name='ol-gitBridgeEnabled' data-type='boolean' content=gitBridgeEnabled) + meta(name='ol-isSaas' data-type='boolean' content=isSaas) + meta( + name='ol-memberOfSSOEnabledGroups' + data-type='json' + content=memberOfSSOEnabledGroups + ) + meta(name='ol-capabilities' data-type='json' content=capabilities) block content - main.content.content-alt#main-content + main#main-content.content.content-alt #settings-page-root diff --git a/services/web/app/views/user_membership/group-managers-react.pug b/services/web/app/views/user_membership/group-managers-react.pug index d227a7a511..34414ddbf2 100644 --- a/services/web/app/views/user_membership/group-managers-react.pug +++ b/services/web/app/views/user_membership/group-managers-react.pug @@ -4,10 +4,10 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/group-managers' block append meta - meta(name="ol-user", data-type="json", content=user) - meta(name="ol-users", data-type="json", content=users) - meta(name="ol-groupId", data-type="string", content=groupId) - meta(name="ol-groupName", data-type="string", content=name) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-users' data-type='json' content=users) + meta(name='ol-groupId' data-type='string' content=groupId) + meta(name='ol-groupName' data-type='string' content=name) block content - main.content.content-alt#subscription-manage-group-root + main#subscription-manage-group-root.content.content-alt diff --git a/services/web/app/views/user_membership/group-members-react.pug b/services/web/app/views/user_membership/group-members-react.pug index 05327c4b6d..4020ebdf58 100644 --- a/services/web/app/views/user_membership/group-members-react.pug +++ b/services/web/app/views/user_membership/group-members-react.pug @@ -2,18 +2,34 @@ extends ../layout-react block entrypointVar - entrypoint = 'pages/user/subscription/group-management/group-members' - + block append meta - meta(name="ol-user", data-type="json", content=user) - meta(name="ol-users", data-type="json", content=users) - meta(name="ol-groupId", data-type="string", content=groupId) - meta(name="ol-groupName", data-type="string", content=name) - meta(name="ol-groupSize", data-type="json", content=groupSize) - meta(name="ol-managedUsersActive", data-type="boolean", content=managedUsersActive) - meta(name="ol-isUserGroupManager", data-type="boolean", content=isUserGroupManager) - meta(name="ol-groupSSOActive", data-type="boolean", content=groupSSOActive) - meta(name="ol-canUseFlexibleLicensing", data-type="boolean", content=canUseFlexibleLicensing) - meta(name="ol-canUseAddSeatsFeature", data-type="boolean", content=canUseAddSeatsFeature) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-users' data-type='json' content=users) + meta(name='ol-groupId' data-type='string' content=groupId) + meta(name='ol-groupName' data-type='string' content=name) + meta(name='ol-groupSize' data-type='json' content=groupSize) + meta( + name='ol-managedUsersActive' + data-type='boolean' + content=managedUsersActive + ) + meta( + name='ol-isUserGroupManager' + data-type='boolean' + content=isUserGroupManager + ) + meta(name='ol-groupSSOActive' data-type='boolean' content=groupSSOActive) + meta( + name='ol-canUseFlexibleLicensing' + data-type='boolean' + content=canUseFlexibleLicensing + ) + meta( + name='ol-canUseAddSeatsFeature' + data-type='boolean' + content=canUseAddSeatsFeature + ) block content - main.content.content-alt#subscription-manage-group-root + main#subscription-manage-group-root.content.content-alt diff --git a/services/web/app/views/user_membership/institution-managers-react.pug b/services/web/app/views/user_membership/institution-managers-react.pug index ee62fcd430..9793058f6f 100644 --- a/services/web/app/views/user_membership/institution-managers-react.pug +++ b/services/web/app/views/user_membership/institution-managers-react.pug @@ -4,10 +4,10 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/institution-managers' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-users", data-type="json", content=users) - meta(name="ol-groupId", data-type="string", content=groupId) - meta(name="ol-groupName", data-type="string", content=name) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-users' data-type='json' content=users) + meta(name='ol-groupId' data-type='string' content=groupId) + meta(name='ol-groupName' data-type='string' content=name) block content - main.content.content-alt#subscription-manage-group-root + main#subscription-manage-group-root.content.content-alt diff --git a/services/web/app/views/user_membership/new.pug b/services/web/app/views/user_membership/new.pug index c59837b107..4e52ea160a 100644 --- a/services/web/app/views/user_membership/new.pug +++ b/services/web/app/views/user_membership/new.pug @@ -4,20 +4,13 @@ block vars - bootstrap5PageStatus = 'disabled' block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container .row .col-md-10.col-md-offset-1 h3 #{entityName} "#{entityId}" does not exists in v2 - form( - data-ol-regular-form - method='post', - action='' - ) - input(name="_csrf", type="hidden", value=csrfToken) - button.btn.btn-primary( - type="submit", - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") Create #{entityName} in v2 - span(hidden data-ol-inflight="pending") #{translate("creating")}… + form(data-ol-regular-form method='post' action='') + input(name='_csrf' type='hidden' value=csrfToken) + button.btn.btn-primary(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') Create #{entityName} in v2 + span(hidden data-ol-inflight='pending') #{translate("creating")}… diff --git a/services/web/app/views/user_membership/publisher-managers-react.pug b/services/web/app/views/user_membership/publisher-managers-react.pug index a956e30c35..2f805079a7 100644 --- a/services/web/app/views/user_membership/publisher-managers-react.pug +++ b/services/web/app/views/user_membership/publisher-managers-react.pug @@ -4,10 +4,10 @@ block entrypointVar - entrypoint = 'pages/user/subscription/group-management/publisher-managers' block append meta - meta(name="ol-user" data-type="json" content=user) - meta(name="ol-users", data-type="json", content=users) - meta(name="ol-groupId", data-type="string", content=groupId) - meta(name="ol-groupName", data-type="string", content=name) + meta(name='ol-user' data-type='json' content=user) + meta(name='ol-users' data-type='json' content=users) + meta(name='ol-groupId' data-type='string' content=groupId) + meta(name='ol-groupName' data-type='string' content=name) block content - main.content.content-alt#subscription-manage-group-root + main#subscription-manage-group-root.content.content-alt diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index fdf0576c4a..ff917eeb74 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -2,39 +2,43 @@ extends ../../../../app/views/layout-marketing mixin launchpad-check(section) div(data-ol-launchpad-check=section) - span(data-ol-inflight="pending") + span(data-ol-inflight='pending') i.fa.fa-fw.fa-spinner.fa-spin span  #{translate('checking')} - - span(hidden data-ol-inflight="idle") - div(data-ol-result="success") + + span(hidden data-ol-inflight='idle') + div(data-ol-result='success') i.fa.fa-check span  #{translate('ok')} button.btn.btn-inline-link span.text-danger  #{translate('retry')} - div(hidden data-ol-result="error") + div(hidden data-ol-result='error') i.fa.fa-exclamation span  #{translate('error')} button.btn.btn-inline-link span.text-danger  #{translate('retry')} - div.alert.alert-danger + .alert.alert-danger span(data-ol-error) block entrypointVar - entrypoint = 'modules/launchpad/pages/launchpad' - + block vars - metadata = metadata || {} - bootstrap5PageStatus = 'disabled' block append meta - meta(name="ol-adminUserExists" data-type="boolean" content=adminUserExists) - meta(name="ol-ideJsPath" content=buildJsPath('ide.js')) + meta(name='ol-adminUserExists' data-type='boolean' content=adminUserExists) + meta(name='ol-ideJsPath' content=buildJsPath('ide.js')) block content - script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js') + script( + type='text/javascript' + nonce=scriptNonce + src=(wsUrl || '/socket.io') + '/socket.io.js' + ) - .content.content-alt#main-content + #main-content.content.content-alt .container .row .col-md-8.col-md-offset-2 @@ -49,8 +53,6 @@ block content .row .col-md-8.col-md-offset-2 - - if !adminUserExists .row(data-ol-not-sent) @@ -62,37 +64,34 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_admin" - method="POST" + action='/launchpad/register_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .form-group label(for='password') #{translate("password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder="********", - autocomplete="new-password" - required, + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + required ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… // Ldap Form if authMethod === 'ldap' @@ -103,28 +102,25 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_ldap_admin" - method="POST" + action='/launchpad/register_ldap_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… // Saml Form if authMethod === 'saml' @@ -135,28 +131,25 @@ block content form( data-ol-async-form data-ol-register-admin - action="/launchpad/register_saml_admin" - method="POST" + action='/launchpad/register_saml_admin' + method='POST' ) - input(name='_csrf', type='hidden', value=csrfToken) - +formMessages() + input(name='_csrf' type='hidden' value=csrfToken) + +formMessages .form-group label(for='email') #{translate("email")} input.form-control( - type='email', - name='email', - placeholder="email@example.com" - autocomplete="username" - required, - autofocus="true" + name='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' ) .actions - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("register")} - span(hidden data-ol-inflight="pending") #{translate("registering")}… + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("register")} + span(hidden data-ol-inflight='pending') #{translate("registering")}… br @@ -164,7 +157,6 @@ block content if adminUserExists .row .col-md-12.status-indicators - h2 #{translate('status_checks')} @@ -185,42 +177,31 @@ block content h3 #{translate('send_test_email')} form.form( data-ol-async-form - action="/launchpad/send_test_email" - method="POST" + action='/launchpad/send_test_email' + method='POST' ) .form-group - label(for="email") Email - input.form-control( - type="text" - id="email" - name="email" - required - ) - button.btn-primary.btn( - type='submit' - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{translate("send")} - span(hidden data-ol-inflight="pending") #{translate("sending")}… + label(for='email') Email + input.form-control(name='email' type='text' id='email' required) + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight='idle') #{translate("send")} + span(hidden data-ol-inflight='pending') #{translate("sending")}… p - +formMessages() - - + +formMessages hr.thin - .row .col-md-12 .text-center br p - a(href="/admin").btn.btn-info + a.btn.btn-info(href='/admin') | Go To Admin Panel |   - a(href="/project").btn.btn-primary + a.btn.btn-primary(href='/project') | Start Using #{settings.appName} br diff --git a/services/web/modules/user-activate/app/views/user/activate.pug b/services/web/modules/user-activate/app/views/user/activate.pug index deebe0b08a..82671f90a7 100644 --- a/services/web/modules/user-activate/app/views/user/activate.pug +++ b/services/web/modules/user-activate/app/views/user/activate.pug @@ -6,14 +6,14 @@ block vars include ../../../../../app/views/_mixins/material_symbol block content - main.content.content-alt#main-content + main#main-content.content.content-alt .container - div.col-lg-6.col-xl-4.m-auto + .col-lg-6.col-xl-4.m-auto .notification-list - .notification.notification-type-success(aria-live="off" role="alert") + .notification.notification-type-success(aria-live='off' role='alert') .notification-content-and-cta .notification-icon - +material-symbol("check_circle") + +material-symbol('check_circle') .notification-content p | #{translate("nearly_activated")} @@ -21,12 +21,12 @@ block content h1.h3 #{translate("please_set_a_password")} form( + name='activationForm' data-ol-async-form - name="activationForm", - action="/user/password/set", - method="POST", + action='/user/password/set' + method='POST' ) - +formMessages() + +formMessages +customFormMessage('token-expired', 'danger') | #{translate("activation_token_expired")} @@ -34,43 +34,39 @@ block content +customFormMessage('invalid-password', 'danger') | #{translate('invalid_password')} - input(name='_csrf', type='hidden', value=csrfToken) - input( - type="hidden", - name="passwordResetToken", - value=token - ) + input(name='_csrf' type='hidden' value=csrfToken) + input(name='passwordResetToken' type='hidden' value=token) .form-group label(for='emailField') #{translate("email")} - input.form-control#emailField( - aria-label="email", - type='email', - name='email', - placeholder="email@example.com", - autocomplete="username" + input#emailField.form-control( + name='email' + aria-label='email' + type='email' + placeholder='email@example.com' + autocomplete='username' value=email - required, + required disabled ) .form-group label(for='passwordField') #{translate("password")} - input.form-control#passwordField( - type='password', - name='password', - placeholder="********", - autocomplete="new-password", - autofocus, - required, + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + autofocus + required minlength=settings.passwordStrengthOptions.length.min ) .actions button.btn.btn-primary( - type='submit', + type='submit' data-ol-disabled-inflight aria-label=translate('activate') ) - span(data-ol-inflight="idle") + span(data-ol-inflight='idle') | #{translate('activate')} - span(hidden data-ol-inflight="pending") + span(hidden data-ol-inflight='pending') | #{translate('activating')}… diff --git a/services/web/modules/user-activate/app/views/user/register.pug b/services/web/modules/user-activate/app/views/user/register.pug index 0f3e5f2f91..27e6f8215c 100644 --- a/services/web/modules/user-activate/app/views/user/register.pug +++ b/services/web/modules/user-activate/app/views/user/register.pug @@ -4,9 +4,9 @@ block entrypointVar - entrypoint = 'modules/user-activate/pages/user-activate-page' block append meta - meta(name="ol-user" data-type="json" content=user) + meta(name='ol-user' data-type='json' content=user) block content - .content.content-alt#main-content + #main-content.content.content-alt .container #user-activate-register-container diff --git a/services/web/package.json b/services/web/package.json index 59825e0e68..bdc42673bb 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -28,6 +28,8 @@ "format:fix": "prettier --write $PWD/'**/*.{js,jsx,mjs,ts,tsx,json}'", "format:styles": "prettier --list-different $PWD/'**/*.{css,less,scss}'", "format:styles:fix": "prettier --write $PWD/'**/*.{css,less,scss}'", + "format:pug": "prettier --list-different $PWD/'**/*.pug'", + "format:pug:fix": "prettier --write $PWD/'**/*.pug'", "lint": "eslint --max-warnings 0 --format unix --ext .js,.jsx,.mjs,.ts,.tsx .", "lint:fix": "eslint --fix --ext .js,.jsx,.mjs,.ts,.tsx .", "lint:styles": "stylelint '**/*.scss'", @@ -209,6 +211,7 @@ "@pollyjs/adapter-node-http": "^6.0.6", "@pollyjs/core": "^6.0.6", "@pollyjs/persister-fs": "^6.0.6", + "@prettier/plugin-pug": "^3.4.0", "@replit/codemirror-emacs": "overleaf/codemirror-emacs#4394c03858f27053f8768258e9493866e06e938e", "@replit/codemirror-indentation-markers": "overleaf/codemirror-indentation-markers#78264032eb286bc47871569ae87bff5ca1c6c161", "@replit/codemirror-vim": "overleaf/codemirror-vim#1bef138382d948018f3f9b8a4d7a70ab61774e4b", From 46555d27b07a9985fe6c569a2f3a644d400568a0 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 23 Jun 2025 11:09:08 +0200 Subject: [PATCH 013/274] [web] Add `window.` prefix to globals and add `no-restricted-globals` rule (#26422) * Add `no-restricted-globals` eslint rule Co-authored-by: Rebeka * Change `self` to `window.self` * Change `innerWidth` to `window.innerWidth` * Change `confirm` to `window.confirm` * Change `location` to `window.location` * Use `location` from `useLocation` hook * Use location from useLocation hook Co-authored-by: Antoine * Disable no-restricted-globals eslint rule for use of 'self' * Use `confusing-browser-globals` from npm * Prevent unexpected globals in workers, using `no-undef` * Use `self` as a global in workers * Use unexpected globals in workers, using `no-restricted-globals` in workers --------- Co-authored-by: Rebeka Co-authored-by: Rebeka GitOrigin-RevId: 526986799f5f2edf53c7d978fa85c1e98189565f --- package-lock.json | 22 +++++++++++++++++++ services/web/.eslintrc.js | 17 ++++++++++++++ .../emails/add-secondary-email-prompt.tsx | 2 ++ .../components/emails/confirm-email-form.tsx | 2 ++ .../components/sharing-updates-root.tsx | 8 ++++--- .../js/infrastructure/error-reporter.ts | 2 +- services/web/package.json | 2 ++ 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53388a5732..c38221dd63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18024,6 +18024,13 @@ "proto-list": "~1.2.1" } }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true, + "license": "MIT" + }, "node_modules/connect-flash": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", @@ -45366,6 +45373,7 @@ "chartjs-plugin-datalabels": "^2.2.0", "cheerio": "^1.0.0-rc.3", "classnames": "^2.2.6", + "confusing-browser-globals": "^1.0.11", "cookie-signature": "^1.2.1", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.41.0", @@ -45392,6 +45400,7 @@ "formik": "^2.2.9", "fuse.js": "^3.0.0", "glob": "^7.1.6", + "globals": "^16.2.0", "handlebars": "^4.7.8", "handlebars-loader": "^1.7.3", "html-webpack-plugin": "^5.5.3", @@ -46342,6 +46351,19 @@ "node": ">=18.11.0" } }, + "services/web/node_modules/globals": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "services/web/node_modules/google-auth-library": { "version": "8.7.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-8.7.0.tgz", diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index ef3cf11de5..7dd154c942 100644 --- a/services/web/.eslintrc.js +++ b/services/web/.eslintrc.js @@ -1,3 +1,7 @@ +const _ = require('lodash') +const confusingBrowserGlobals = require('confusing-browser-globals') +const globals = require('globals') + module.exports = { root: true, parser: '@typescript-eslint/parser', @@ -19,6 +23,7 @@ module.exports = { }, rules: { 'no-constant-binary-expression': 'error', + 'no-restricted-globals': ['error', ...confusingBrowserGlobals], // do not allow importing of implicit dependencies. 'import/no-extraneous-dependencies': 'error', @@ -531,5 +536,17 @@ module.exports = { 'no-console': 'error', }, }, + { + files: ['**/*.worker.{js,ts}'], + rules: { + 'no-restricted-globals': [ + 'error', + ..._.difference( + Object.keys({ ...globals.browser, ...globals.node }), + Object.keys(globals.worker) + ), + ], + }, + }, ], } diff --git a/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx index 04bc4edbd1..8355138a14 100644 --- a/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx +++ b/services/web/frontend/js/features/settings/components/emails/add-secondary-email-prompt.tsx @@ -8,6 +8,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { sendMB } from '@/infrastructure/event-tracking' import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2' import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha' +import { useLocation } from '@/shared/hooks/use-location' import { postJSON } from '../../../../infrastructure/fetch-json' import RecaptchaConditions from '@/shared/components/recaptcha-conditions' @@ -25,6 +26,7 @@ export function AddSecondaryEmailPrompt() { const [error, setError] = useState() const [isSubmitting, setIsSubmitting] = useState(false) const { ref: recaptchaRef, getReCaptchaToken } = useRecaptcha() + const location = useLocation() if (!isReady) { return null diff --git a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx index d82a43315c..bf08b5dcfd 100644 --- a/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx +++ b/services/web/frontend/js/features/settings/components/emails/confirm-email-form.tsx @@ -9,6 +9,7 @@ import MaterialIcon from '@/shared/components/material-icon' import { sendMB } from '@/infrastructure/event-tracking' import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLButton from '@/features/ui/components/ol/ol-button' +import { useLocation } from '@/shared/hooks/use-location' type Feedback = { type: 'input' | 'alert' @@ -267,6 +268,7 @@ function ConfirmEmailSuccessfullForm({ successButtonText: string redirectTo: string }) { + const location = useLocation() const submitHandler = (e: FormEvent) => { e.preventDefault() location.assign(redirectTo) diff --git a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx index e7b0d96c2a..ade94ba304 100644 --- a/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx +++ b/services/web/frontend/js/features/token-access/components/sharing-updates-root.tsx @@ -12,6 +12,7 @@ import { sendMB } from '@/infrastructure/event-tracking' import LeaveProjectModal from './leave-project-modal' import OLButton from '@/features/ui/components/ol/ol-button' +import { useLocation } from '@/shared/hooks/use-location' function SharingUpdatesRoot() { const [showModal, setShowModal] = useState(false) @@ -20,6 +21,7 @@ function SharingUpdatesRoot() { const { isLoading, isSuccess, isError, runAsync } = useAsync() const projectId = getMeta('ol-project_id') + const location = useLocation() const joinProject = useCallback(() => { sendMB('notification-click', { name: 'link-sharing-collaborator', @@ -30,7 +32,7 @@ function SharingUpdatesRoot() { location.assign(`/project/${projectId}`) }) .catch(debugConsole.error) - }, [runAsync, projectId]) + }, [runAsync, projectId, location]) const viewProject = useCallback(() => { sendMB('notification-click', { @@ -42,7 +44,7 @@ function SharingUpdatesRoot() { location.assign(`/project/${projectId}`) }) .catch(debugConsole.error) - }, [runAsync, projectId]) + }, [runAsync, projectId, location]) const leaveProject = useCallback(() => { sendMB('notification-click', { @@ -54,7 +56,7 @@ function SharingUpdatesRoot() { location.assign('/project') }) .catch(debugConsole.error) - }, [runAsync, projectId]) + }, [runAsync, projectId, location]) if (!isReady) { return null diff --git a/services/web/frontend/js/infrastructure/error-reporter.ts b/services/web/frontend/js/infrastructure/error-reporter.ts index 5d5734535a..e70833860c 100644 --- a/services/web/frontend/js/infrastructure/error-reporter.ts +++ b/services/web/frontend/js/infrastructure/error-reporter.ts @@ -78,7 +78,7 @@ function sentryReporter() { const refererUrl = new URL(event.request.headers.Referer) if ( - refererUrl.hostname === location.hostname && + refererUrl.hostname === window.location.hostname && refererUrl.pathname.startsWith('/read/') ) { refererUrl.pathname = '/read/' diff --git a/services/web/package.json b/services/web/package.json index bdc42673bb..d946c62151 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -278,6 +278,7 @@ "chartjs-plugin-datalabels": "^2.2.0", "cheerio": "^1.0.0-rc.3", "classnames": "^2.2.6", + "confusing-browser-globals": "^1.0.11", "cookie-signature": "^1.2.1", "copy-webpack-plugin": "^11.0.0", "core-js": "^3.41.0", @@ -304,6 +305,7 @@ "formik": "^2.2.9", "fuse.js": "^3.0.0", "glob": "^7.1.6", + "globals": "^16.2.0", "handlebars": "^4.7.8", "handlebars-loader": "^1.7.3", "html-webpack-plugin": "^5.5.3", From 2d5a3efc128b0222296dbe6474396d69bdd73b50 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 23 Jun 2025 11:09:26 +0200 Subject: [PATCH 014/274] [web] Add compilation indicator favicon (#25990) * Import changes from Hackathon https://github.com/overleaf/internal/pull/24501 * Update compile status: allow errors * Update favicons. Use the ones from Figma * Optimize and reuse path from favicon.svg * Clear status favicon after 5s on active tab * Rename hook from useCompileNotification to useStatusFavicon * Add tests * Revert changes to favicon.svg * Query favicon on document.head GitOrigin-RevId: 3972b1981abaf6c80273e0ed5b1bc05eb51bd689 --- .../ide-react/components/layout/ide-page.tsx | 2 + .../ide-react/hooks/use-status-favicon.ts | 75 ++++++++++ services/web/public/favicon-compiled.svg | 9 ++ services/web/public/favicon-compiling.svg | 9 ++ services/web/public/favicon-error.svg | 9 ++ .../ide-react/unit/use-status-favicon.test.ts | 131 ++++++++++++++++++ 6 files changed, 235 insertions(+) create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts create mode 100644 services/web/public/favicon-compiled.svg create mode 100644 services/web/public/favicon-compiling.svg create mode 100644 services/web/public/favicon-error.svg create mode 100644 services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx index 6a03d5b205..488d5476a7 100644 --- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx +++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx @@ -16,6 +16,7 @@ import { } from '@/features/ide-redesign/utils/new-editor-utils' import EditorSurvey from '../editor-survey' import { useFeatureFlag } from '@/shared/context/split-test-context' +import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon' const MainLayoutNew = lazy( () => import('@/features/ide-redesign/components/main-layout') @@ -30,6 +31,7 @@ export default function IdePage() { useEditingSessionHeartbeat() // send a batched event when user is active useRegisterUserActivity() // record activity and ensure connection when user is active useHasLintingError() // pass editor:lint hasLintingError to the compiler + useStatusFavicon() // update the favicon based on the compile status const newEditor = useIsNewEditorEnabled() const canAccessNewEditor = canUseNewEditor() diff --git a/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts b/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts new file mode 100644 index 0000000000..c65d49e042 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/hooks/use-status-favicon.ts @@ -0,0 +1,75 @@ +import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context' +import { useEffect, useState } from 'react' +import usePreviousValue from '@/shared/hooks/use-previous-value' + +const RESET_AFTER_MS = 5_000 + +const COMPILE_ICONS = { + ERROR: '/favicon-error.svg', + COMPILING: '/favicon-compiling.svg', + COMPILED: '/favicon-compiled.svg', + UNCOMPILED: '/favicon.svg', +} + +type CompileStatus = keyof typeof COMPILE_ICONS + +const useCompileStatus = (): CompileStatus => { + const compileContext = useCompileContext() + if (compileContext.uncompiled) return 'UNCOMPILED' + if (compileContext.compiling) return 'COMPILING' + if (compileContext.error) return 'ERROR' + return 'COMPILED' +} + +const removeFavicon = () => { + const existingFavicons = document.head.querySelectorAll( + "link[rel='icon']" + ) as NodeListOf + existingFavicons.forEach(favicon => { + if (favicon.href.endsWith('.svg')) favicon.parentNode?.removeChild(favicon) + }) +} + +const updateFavicon = (status: CompileStatus = 'UNCOMPILED') => { + removeFavicon() + const linkElement = document.createElement('link') + linkElement.rel = 'icon' + linkElement.href = COMPILE_ICONS[status] + linkElement.type = 'image/svg+xml' + linkElement.setAttribute('data-compile-status', 'true') + document.head.appendChild(linkElement) +} + +const isActive = () => !document.hidden + +const useIsWindowActive = () => { + const [isWindowActive, setIsWindowActive] = useState(isActive()) + useEffect(() => { + const handleVisibilityChange = () => setIsWindowActive(isActive()) + document.addEventListener('visibilitychange', handleVisibilityChange) + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + return isWindowActive +} + +export const useStatusFavicon = () => { + const compileStatus = useCompileStatus() + const previousCompileStatus = usePreviousValue(compileStatus) + const isWindowActive = useIsWindowActive() + + useEffect(() => { + if (previousCompileStatus !== compileStatus) { + return updateFavicon(compileStatus) + } + + if ( + isWindowActive && + (compileStatus === 'COMPILED' || compileStatus === 'ERROR') + ) { + const timeout = setTimeout(updateFavicon, RESET_AFTER_MS) + return () => clearTimeout(timeout) + } + }, [compileStatus, isWindowActive, previousCompileStatus]) +} diff --git a/services/web/public/favicon-compiled.svg b/services/web/public/favicon-compiled.svg new file mode 100644 index 0000000000..8bee787bb2 --- /dev/null +++ b/services/web/public/favicon-compiled.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-compiling.svg b/services/web/public/favicon-compiling.svg new file mode 100644 index 0000000000..fed675637c --- /dev/null +++ b/services/web/public/favicon-compiling.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/public/favicon-error.svg b/services/web/public/favicon-error.svg new file mode 100644 index 0000000000..5b88401356 --- /dev/null +++ b/services/web/public/favicon-error.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts new file mode 100644 index 0000000000..f3a9294eb1 --- /dev/null +++ b/services/web/test/frontend/features/ide-react/unit/use-status-favicon.test.ts @@ -0,0 +1,131 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { renderHook } from '@testing-library/react' +import * as CompileContext from '@/shared/context/detach-compile-context' + +import { useStatusFavicon } from '@/features/ide-react/hooks/use-status-favicon' + +type Compilation = { uncompiled: boolean; compiling: boolean; error: boolean } + +describe('useStatusFavicon', function () { + let mockUseDetachCompileContext: sinon.SinonStub + let clock: sinon.SinonFakeTimers + let originalHidden: PropertyDescriptor | undefined + + const setCompilation = (compileContext: Compilation) => { + mockUseDetachCompileContext.returns(compileContext) + } + const setHidden = (hidden: boolean) => { + Object.defineProperty(document, 'hidden', { + writable: true, + configurable: true, + value: hidden, + }) + document.dispatchEvent(new Event('visibilitychange')) + } + + const getFaviconElements = () => + document.querySelectorAll('link[data-compile-status="true"]') + + const getCurrentFaviconHref = () => { + const favicon = document.querySelector( + 'link[data-compile-status="true"]' + ) as HTMLLinkElement + return favicon?.href || null + } + + beforeEach(function () { + mockUseDetachCompileContext = sinon.stub( + CompileContext, + 'useDetachCompileContext' + ) + + // Mock timers for timeout testing + clock = sinon.useFakeTimers() + + // Store original document.hidden descriptor + originalHidden = Object.getOwnPropertyDescriptor( + Document.prototype, + 'hidden' + ) + + // Clean up any existing favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + afterEach(function () { + sinon.restore() + clock.restore() + + // Restore original document.hidden + if (originalHidden) { + Object.defineProperty(document, 'hidden', originalHidden) + } + + // Clean up favicon elements + document + .querySelectorAll('link[data-compile-status="true"]') + .forEach(el => el.remove()) + }) + + it('updates favicon to reflect status: UNCOMPILED', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('updates favicon to reflect status: COMPILING', function () { + setCompilation({ uncompiled: false, compiling: true, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) + + it('updates favicon to reflect status: COMPILED', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('updates favicon to reflect status: ERROR', function () { + setCompilation({ uncompiled: false, compiling: false, error: true }) + renderHook(() => useStatusFavicon()) + expect(getCurrentFaviconHref()).to.include('/favicon-error.svg') + }) + + it('keeps the COMPILED favicon for 5 seconds when the window is active', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(false) + rerender() + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(4500) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(1000) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + }) + + it('keeps the COMPILED favicon forever when the window is hidden', function () { + setCompilation({ uncompiled: false, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + setHidden(true) + rerender() + + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + clock.tick(90000) + expect(getCurrentFaviconHref()).to.include('/favicon-compiled.svg') + }) + + it('should only have one favicon element at a time', function () { + setCompilation({ uncompiled: true, compiling: false, error: false }) + const { rerender } = renderHook(() => useStatusFavicon()) + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon.svg') + + setCompilation({ uncompiled: false, compiling: true, error: false }) + rerender() + expect(getFaviconElements()).to.have.length(1) + expect(getCurrentFaviconHref()).to.include('/favicon-compiling.svg') + }) +}) From de1ab31bfd877a3874320938713ce0884a34d114 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:25:35 +0100 Subject: [PATCH 015/274] Merge pull request #26469 from overleaf/td-bs5-sp-ce-register Migrate SP/CE registration page to Bootstrap 5 GitOrigin-RevId: 5e99a6a091d725ea3ab54e7cf6a4d1ea4f6bfab6 --- services/web/app/views/user/register.pug | 26 ++++++++-------- .../frontend/stylesheets/app/register.less | 31 ------------------- .../bootstrap-5/pages/register.scss | 8 +++-- .../web/frontend/stylesheets/main-style.less | 1 - 4 files changed, 18 insertions(+), 48 deletions(-) delete mode 100644 services/web/frontend/stylesheets/app/register.less diff --git a/services/web/app/views/user/register.pug b/services/web/app/views/user/register.pug index 8aa40e8b35..ff53002ccb 100644 --- a/services/web/app/views/user/register.pug +++ b/services/web/app/views/user/register.pug @@ -1,13 +1,10 @@ extends ../layout-marketing -block vars - - bootstrap5PageStatus = 'disabled' - block content main#main-content.content.content-alt .container .row - .registration_message + .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} @@ -15,21 +12,24 @@ block content | #{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-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3 + .col-lg-8.offset-lg-2.col-xl-6.offset-xl-3 .card - .page-header - h1 #{translate("register")} - p - | Please contact - | - strong #{settings.adminEmail} - | - | to create an account. + .card-body + .page-header + h1 #{translate("register")} + p + | Please contact + | + strong #{settings.adminEmail} + | + | to create an account. diff --git a/services/web/frontend/stylesheets/app/register.less b/services/web/frontend/stylesheets/app/register.less deleted file mode 100644 index 61c7b79102..0000000000 --- a/services/web/frontend/stylesheets/app/register.less +++ /dev/null @@ -1,31 +0,0 @@ -.registration_message { - text-align: center; - padding-bottom: 20px; -} -// for focus-registration and focus-login split test variant -.registration_logo { - width: 130px; - padding: 8px 0; -} - -.website-redesign { - .login-register-header-focus { - padding-top: unset; - } -} - -.login-register-header-heading-focus { - color: @neutral-90; - margin-bottom: 0; -} - -.website-redesign { - .login-register-form-focus { - padding: @line-height-computed 0 0 0; - border-bottom: unset; - border-bottom: solid 1px @hr-border; - &:last-child { - border-bottom-width: 0; - } - } -} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss index 85711e1609..90a90f32ce 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/register.scss @@ -1,3 +1,8 @@ +.registration-message { + text-align: center; + padding-bottom: var(--spacing-07); +} + .register-container { h1 { @include heading-sm; @@ -33,9 +38,6 @@ } .registration-message { - text-align: center; - padding-bottom: var(--spacing-07); - .registration-message-heading { color: var(--neutral-70); font-size: var(--font-size-05); diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index 040c6ac695..85368ea36e 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -104,7 +104,6 @@ @import 'app/plans.less'; @import 'app/recurly.less'; @import 'app/bonus.less'; -@import 'app/register.less'; @import 'app/blog.less'; @import 'app/features.less'; @import 'app/templates.less'; From ab140f578d5d41bdd761ef6c5d996dcacb4ede4d Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:25:47 +0100 Subject: [PATCH 016/274] Merge pull request #26244 from overleaf/td-limit-browser-translate-ide Prevent browser translation of stuff that shouldn't be translated in IDE page GitOrigin-RevId: 96a75b51c3c8efc4cbcec7eb17d9e331a03e2c96 --- services/web/app/views/_metadata.pug | 6 +++--- .../chat/components/message-content.tsx | 2 +- .../js/features/chat/components/message.tsx | 2 +- .../components/dictionary-modal-content.tsx | 4 +++- .../components/left-menu-button.tsx | 8 ++++--- .../components/settings/settings-compiler.tsx | 1 + .../components/settings/settings-document.tsx | 1 + .../settings/settings-editor-theme.tsx | 1 + .../settings/settings-font-family.tsx | 1 + .../settings/settings-image-name.tsx | 1 + .../settings/settings-menu-select.tsx | 3 +++ .../settings/settings-pdf-viewer.tsx | 1 + .../components/online-users-widget.tsx | 1 + .../file-tree/components/file-tree-doc.tsx | 1 + .../file-tree/components/file-tree-folder.tsx | 1 + .../components/change-list/changes.tsx | 2 ++ .../components/change-list/tag-tooltip.tsx | 21 +++++++++++++------ .../user-name-with-colored-badge.tsx | 10 ++++++++- .../file-tree/history-file-tree-doc.tsx | 1 + .../file-tree/history-file-tree-folder.tsx | 1 + .../ide-redesign/components/breadcrumbs.tsx | 2 +- .../editor-theme-setting.tsx | 1 + .../font-family-setting.tsx | 1 + .../compiler-settings/compiler-setting.tsx | 1 + .../compiler-settings/image-name-setting.tsx | 1 + .../root-document-setting.tsx | 1 + .../components/settings/dropdown-setting.tsx | 3 +++ .../editor-settings/pdf-viewer-setting.tsx | 1 + .../components/toolbar/project-title.tsx | 4 +++- .../outline/components/outline-item.tsx | 1 + .../components/pdf-log-entry-raw-content.tsx | 2 +- .../components/preview-log-entry-header.tsx | 8 +++++-- .../components/review-panel-entry-user.tsx | 2 +- .../review-panel-expandable-content.tsx | 3 +++ .../components/review-panel-message.tsx | 1 + .../review-panel-resolved-thread.tsx | 6 +++++- .../extensions/toolbar/toolbar-panel.ts | 2 +- .../components/collapsible-file-header.tsx | 1 + .../acceptance/src/ProjectInviteTests.mjs | 8 ++++--- .../src/helpers/expectErrorResponse.mjs | 2 +- .../src/steps/100_loadProjectDashboard.js | 3 ++- 41 files changed, 94 insertions(+), 29 deletions(-) diff --git a/services/web/app/views/_metadata.pug b/services/web/app/views/_metadata.pug index 6d7c599546..b28ca6abd2 100644 --- a/services/web/app/views/_metadata.pug +++ b/services/web/app/views/_metadata.pug @@ -1,10 +1,10 @@ //- Title if metadata && metadata.title - title= metadata.title + ' - ' + settings.appName + ', ' + translate('online_latex_editor') + title(translate='no')= metadata.title + ' - ' + settings.appName + ', ' + translate('online_latex_editor') meta(name='twitter:title' content=metadata.title) meta(name='og:title' content=metadata.title) else if typeof title == 'undefined' - title= settings.appName + ', ' + translate('online_latex_editor') + title(translate='no')= settings.appName + ', ' + translate('online_latex_editor') meta( name='twitter:title' content=settings.appName + ', ' + translate('online_latex_editor') @@ -14,7 +14,7 @@ else if typeof title == 'undefined' content=settings.appName + ', ' + translate('online_latex_editor') ) else - title= translate(title) + ' - ' + settings.appName + ', ' + translate('online_latex_editor') + title(translate='no')= translate(title) + ' - ' + settings.appName + ', ' + translate('online_latex_editor') //- to do - not translate? meta(name='twitter:title' content=translate(title)) meta(name='og:title' content=translate(title)) diff --git a/services/web/frontend/js/features/chat/components/message-content.tsx b/services/web/frontend/js/features/chat/components/message-content.tsx index ad2e6a623f..3d86b4be68 100644 --- a/services/web/frontend/js/features/chat/components/message-content.tsx +++ b/services/web/frontend/js/features/chat/components/message-content.tsx @@ -34,7 +34,7 @@ const MessageContent: FC<{ content: string }> = ({ content }) => { }, [content, mounted]) return ( -

+

{content}

) diff --git a/services/web/frontend/js/features/chat/components/message.tsx b/services/web/frontend/js/features/chat/components/message.tsx index aa2779f1e8..8a410c0d9d 100644 --- a/services/web/frontend/js/features/chat/components/message.tsx +++ b/services/web/frontend/js/features/chat/components/message.tsx @@ -29,7 +29,7 @@ function Message({ message, fromSelf }: MessageProps) { return (
{!fromSelf && ( -
+
{message.user.first_name || message.user.email}
)} diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx index 38795e145c..52664de105 100644 --- a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx +++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx @@ -68,7 +68,9 @@ export default function DictionaryModalContent({
    {[...learnedWords].sort(wordsSortFunction).map(learnedWord => (
  • - {learnedWord} + + {learnedWord} + ['translate'] } function LeftMenuButtonIcon({ @@ -34,12 +35,13 @@ export default function LeftMenuButton({ disabledAccesibilityText, type = 'button', href, + translate, }: PropsWithChildren) { if (disabled) { return (
    - {children} + {children} {disabledAccesibilityText ? ( {disabledAccesibilityText} ) : null} @@ -51,7 +53,7 @@ export default function LeftMenuButton({ return ( ) } else { @@ -63,7 +65,7 @@ export default function LeftMenuButton({ className="left-menu-button" > - {children} + {children} ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx index 8d7076ebd4..2eab7f25b5 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx @@ -34,6 +34,7 @@ export default function SettingsCompiler() { ]} label={t('compiler')} name="compiler" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx index 839bd499eb..8655a63cfc 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx @@ -43,6 +43,7 @@ export default function SettingsDocument() { options={validDocsOptions} label={t('main_document')} name="rootDocId" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx index 5f9ad51869..870ce48ca1 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx @@ -40,6 +40,7 @@ export default function SettingsEditorTheme() { options={options} label={t('editor_theme')} name="editorTheme" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx index 5a327093a4..a0c2ec49dc 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx @@ -29,6 +29,7 @@ export default function SettingsFontFamily() { ]} label={t('font_family')} name="fontFamily" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx index 6b1f06ec36..c48486ec1c 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx @@ -28,6 +28,7 @@ type SettingsMenuSelectProps = { onChange: (val: T) => void value?: T disabled?: boolean + translateOptions?: 'yes' | 'no' } export default function SettingsMenuSelect({ @@ -39,6 +40,7 @@ export default function SettingsMenuSelect({ onChange, value, disabled = false, + translateOptions, }: SettingsMenuSelectProps) { const handleChange: ChangeEventHandler = useCallback( event => { @@ -95,6 +97,7 @@ export default function SettingsMenuSelect({ value={value?.toString()} disabled={disabled} ref={selectRef} + translate={translateOptions} > {options.map(option => (
    @@ -41,6 +42,7 @@ function Changes({ pathnames, projectOps }: ChangesProps) {
    {getProjectOpDoc(op)}
    diff --git a/services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx b/services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx index 18736c4f37..c766c4e25b 100644 --- a/services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx +++ b/services/web/frontend/js/features/history/components/change-list/tag-tooltip.tsx @@ -87,6 +87,7 @@ const ChangeTag = forwardRef( className="history-version-badge" data-testid="history-version-badge" {...props} + translate={isPseudoCurrentStateLabel ? 'yes' : 'no'} > {isPseudoCurrentStateLabel ? t('history_label_project_current_state') @@ -147,24 +148,32 @@ function TagTooltip({ label, currentUserId, showTooltip }: LabelBadgesProps) { const isPseudoCurrentStateLabel = isPseudoLabel(label) const currentLabelData = allLabels?.find(({ id }) => id === label.id) - const labelOwnerName = - currentLabelData && !isPseudoLabel(currentLabelData) - ? currentLabelData.user_display_name - : t('anonymous') + const isAnonymous = !currentLabelData || isPseudoLabel(currentLabelData) + const labelOwnerName = isAnonymous + ? t('anonymous') + : currentLabelData.user_display_name + const labelOwnerNameComponent = isAnonymous ? ( + labelOwnerName + ) : ( + {labelOwnerName} + ) return !isPseudoCurrentStateLabel ? (
    - +   {label.comment}
    - {t('history_label_created_by')} {labelOwnerName} + {t('history_label_created_by')} {labelOwnerNameComponent}
  • diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx index 9949b98c7f..1c21df0f82 100644 --- a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx @@ -83,7 +83,7 @@ export default function Breadcrumbs() { const numOutlineItems = outlineHierarchy.length return ( -
    +
    {folderHierarchy.map(folder => (
    {folder.name}
    diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx index eba0fc5b6c..1a1a3f1c1e 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/editor-theme-setting.tsx @@ -41,6 +41,7 @@ export default function EditorThemeSetting() { options={options} onChange={setEditorTheme} value={editorTheme} + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/font-family-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/font-family-setting.tsx index d0310e2b1b..f1c279cf23 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/font-family-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/font-family-setting.tsx @@ -27,6 +27,7 @@ export default function FontFamilySetting() { onChange={setFontFamily} value={fontFamily} width="wide" + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/compiler-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/compiler-setting.tsx index c007a0608e..1da6a167c6 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/compiler-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/compiler-setting.tsx @@ -38,6 +38,7 @@ export default function CompilerSetting() { options={OPTIONS} onChange={setCompiler} value={compiler} + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/image-name-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/image-name-setting.tsx index 0574657971..0a7166ae68 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/image-name-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/image-name-setting.tsx @@ -38,6 +38,7 @@ export default function ImageNameSetting() { options={options} onChange={setImageName} value={imageName} + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/root-document-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/root-document-setting.tsx index bd493a018a..719ea4da5a 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/root-document-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/root-document-setting.tsx @@ -44,6 +44,7 @@ export default function RootDocumentSetting() { options={validDocsOptions} onChange={setRootDocId} value={rootDocId} + translateOptions="no" /> ) } diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx index 47c5c54ef0..3d9db79c3d 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/dropdown-setting.tsx @@ -31,6 +31,7 @@ type SettingsMenuSelectProps = { disabled?: boolean width?: 'default' | 'wide' loading?: boolean + translateOptions?: 'yes' | 'no' } export default function DropdownSetting({ @@ -44,6 +45,7 @@ export default function DropdownSetting({ disabled = false, width = 'default', loading = false, + translateOptions, }: SettingsMenuSelectProps) { const handleChange: ChangeEventHandler = useCallback( event => { @@ -78,6 +80,7 @@ export default function DropdownSetting({ onChange={handleChange} value={value?.toString()} disabled={disabled} + translate={translateOptions} > {options.map(option => (