Files
Verso/services/web/app/src/Features/User/UserPagesController.mjs
T
claude 2ec6ca827e
Build and Deploy Verso / deploy (push) Successful in 15m0s
Fix upload timeout + apply Lumière to settings/auth pages
Nginx: add dedicated upload location with client_body_timeout 15m,
client_max_body_size 550m, and proxy_request_buffering off. Default
client_body_timeout of 60s was the actual culprit cutting slow uploads.
Node.js requestTimeout (build 228) remains as a backstop.

Lumière: pass isLumiere from UserPagesController (settings),
PasswordResetController (set-password), and UserActivateController
(first-time activation). auth.scss adds card styling for auth pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:47:03 +00:00

363 lines
10 KiB
JavaScript

import UserGetter from './UserGetter.mjs'
import OError from '@overleaf/o-error'
import UserSessionsManager from './UserSessionsManager.mjs'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import AuthenticationController from '../Authentication/AuthenticationController.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
import _ from 'lodash'
import { expressify } from '@overleaf/promise-utils'
import Features from '../../infrastructure/Features.mjs'
import Modules from '../../infrastructure/Modules.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
async function settingsPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const reconfirmationRemoveEmail = req.query.remove
// SSO
const ssoError = req.session.ssoError
if (ssoError) {
delete req.session.ssoError
}
const ssoErrorMessage = req.session.ssoErrorMessage
if (ssoErrorMessage) {
delete req.session.ssoErrorMessage
}
const projectSyncSuccessMessage = req.session.projectSyncSuccessMessage
if (projectSyncSuccessMessage) {
delete req.session.projectSyncSuccessMessage
}
// Institution SSO
let institutionLinked = _.get(req.session, ['saml', 'linked'])
if (institutionLinked) {
// copy object if exists because _.get does not
institutionLinked = Object.assign(
{
hasEntitlement: _.get(req.session, ['saml', 'hasEntitlement']),
},
institutionLinked
)
}
const samlError = _.get(req.session, ['saml', 'error'])
const institutionEmailNonCanonical = _.get(req.session, [
'saml',
'emailNonCanonical',
])
const institutionRequestedEmail = _.get(req.session, [
'saml',
'requestedEmail',
])
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) {
shouldAllowEditingDetails = false
}
const oauthProviders = Settings.oauthProviders || {}
const user = await UserGetter.promises.getUser(userId)
if (!user) {
// The user has just deleted their account.
return UserSessionsManager.removeSessionsFromRedis(
{ _id: userId },
null,
() => res.redirect('/')
)
}
let personalAccessTokens
try {
const results = await Modules.promises.hooks.fire(
'listPersonalAccessTokens',
user._id
)
personalAccessTokens = results?.[0] ?? []
} catch (error) {
const err = OError.tag(error, 'listPersonalAccessTokens hook failed')
logger.error({ err, userId }, err.message)
}
let currentManagedUserAdminEmail
try {
currentManagedUserAdminEmail =
await SubscriptionLocator.promises.getAdminEmail(req.managedBy)
} catch (err) {
logger.error({ err }, 'error getting subscription admin email')
}
let memberOfSSOEnabledGroups = []
try {
memberOfSSOEnabledGroups =
(
await Modules.promises.hooks.fire(
'getUserGroupsSSOEnrollmentStatus',
user._id,
{ teamName: 1 },
['email']
)
)?.[0] || []
memberOfSSOEnabledGroups = memberOfSSOEnabledGroups.map(group => {
return {
groupId: group._id.toString(),
linked: group.linked,
groupName: group.teamName,
adminEmail: group.admin_id?.email,
}
})
} catch (error) {
logger.error(
{ err: error },
'error fetching groups with Group SSO enabled the user may be member of'
)
}
await SplitTestHandler.promises.getAssignment(req, res, 'email-notifications')
await SplitTestHandler.promises.getAssignment(
req,
res,
'domain-captured-by-group'
)
res.render('user/settings', {
title: 'account_settings',
user: {
id: user._id,
isAdmin: user.isAdmin,
email: user.email,
allowedFreeTrial: user.allowedFreeTrial,
first_name: user.first_name,
last_name: user.last_name,
alphaProgram: user.alphaProgram,
betaProgram: user.betaProgram,
labsProgram: user.labsProgram,
features: {
dropbox: user.features.dropbox,
github: user.features.github,
mendeley: user.features.mendeley,
zotero: user.features.zotero,
papers: user.features.papers,
references: user.features.references,
},
refProviders: {
mendeley: Boolean(user.refProviders?.mendeley),
zotero: Boolean(user.refProviders?.zotero),
papers: Boolean(user.refProviders?.papers),
},
},
showAiFeatures: Boolean(user.aiFeatures?.enabled),
labsExperiments: user.labsExperiments ?? [],
hasPassword: !!user.hashedPassword,
shouldAllowEditingDetails,
oauthProviders: UserPagesController._translateProviderDescriptions(
oauthProviders,
req
),
institutionLinked,
samlError,
institutionEmailNonCanonical:
institutionEmailNonCanonical && institutionRequestedEmail
? institutionEmailNonCanonical
: undefined,
reconfirmedViaSAML,
reconfirmationRemoveEmail,
samlBeta: req.session.samlBeta,
ssoErrorMessage,
thirdPartyIds: UserPagesController._restructureThirdPartyIds(user),
projectSyncSuccessMessage,
personalAccessTokens,
emailAddressLimit: Settings.emailAddressLimit,
isManagedAccount: !!req.managedBy,
userRestrictions: Array.from(req.userRestrictions || []),
currentManagedUserAdminEmail,
gitBridgeEnabled: Settings.enableGitBridge,
isSaas: Features.hasFeature('saas'),
memberOfSSOEnabledGroups,
capabilities: [...req.capabilitySet],
isLumiere: (user?.ace?.overallTheme ?? '') === 'lumiere-',
})
}
async function accountSuspended(req, res) {
if (SessionManager.isUserLoggedIn(req.session)) {
return res.redirect('/project')
}
res.render('user/accountSuspended', {
title: 'your_account_is_suspended',
})
}
async function logout(req, res) {
const isLoggedIn = SessionManager.isUserLoggedIn(req.session)
if (!isLoggedIn) {
return res.redirect('/')
}
res.render('user/logout')
}
async function reconfirmAccountPage(req, res) {
const pageData = {
reconfirm_email: req.session.reconfirm_email,
}
res.render('user/reconfirm', pageData)
}
async function emailPreferencesPage(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const user = await UserGetter.promises.getUser(userId, {
_id: 1,
email: 1,
first_name: 1,
last_name: 1,
})
if (!user) {
throw new Error('User not found')
}
let subscribed = false
try {
const [preferences] = await Modules.promises.hooks.fire(
'getSubscriptionPreferences',
userId
)
subscribed = Boolean(preferences?.newsletter)
} catch (err) {
logger.error({ err, userId }, 'Error fetching newsletter subscription')
}
res.render('user/email-preferences', {
title: 'newsletter_info_title',
customerIoEnabled: true,
subscribed,
user,
})
}
const UserPagesController = {
accountSuspended: expressify(accountSuspended),
logout: expressify(logout),
registerPage(req, res) {
const sharedProjectData = req.session.sharedProjectData || {}
const newTemplateData = {}
if (req.session.templateData != null) {
newTemplateData.templateName = req.session.templateData.templateName
}
res.render('user/register', {
title: 'register',
sharedProjectData,
newTemplateData,
samlBeta: req.session.samlBeta,
})
},
loginPage(req, res) {
// if user is being sent to /login with explicit redirect (redir=/foo),
// such as being sent from the editor to /login, then set the redirect explicitly
if (
req.query.redir != null &&
AuthenticationController.getRedirectFromSession(req) == null
) {
AuthenticationController.setRedirectInSession(req, req.query.redir)
}
const metadata = { robotsNoindexNofollow: false }
if (Object.keys(req.query).length !== 0) {
metadata.robotsNoindexNofollow = true
}
res.render('user/login', {
title: Settings.nav?.login_support_title || 'login',
login_support_title: Settings.nav?.login_support_title,
login_support_text: Settings.nav?.login_support_text,
metadata,
})
},
/**
* Landing page for users who may have received one-time login
* tokens from the read-only maintenance site.
*
* We tell them that Overleaf is back up and that they can login normally.
*/
oneTimeLoginPage(req, res, next) {
res.render('user/one_time_login')
},
renderReconfirmAccountPage: expressify(reconfirmAccountPage),
settingsPage: expressify(settingsPage),
sessionsPage(req, res, next) {
const user = SessionManager.getSessionUser(req.session)
logger.debug({ userId: user._id }, 'loading sessions page')
const currentSession = {
ip_address: user.ip_address,
session_created: user.session_created,
}
UserSessionsManager.getAllUserSessions(
user,
[req.sessionID],
(err, sessions) => {
if (err != null) {
OError.tag(err, 'error getting all user sessions', {
userId: user._id,
})
return next(err)
}
res.render('user/sessions', {
title: 'sessions',
currentSession,
sessions,
})
}
)
},
emailPreferencesPage: expressify(emailPreferencesPage),
async compromisedPasswordPage(req, res) {
res.render('user/compromised_password')
},
_restructureThirdPartyIds(user) {
// 3rd party identifiers are an array of objects
// this turn them into a single object, which
// makes data easier to use in template
if (
!user.thirdPartyIdentifiers ||
user.thirdPartyIdentifiers.length === 0
) {
return null
}
return user.thirdPartyIdentifiers.reduce((obj, identifier) => {
obj[identifier.providerId] = identifier.externalUserId
return obj
}, {})
},
_translateProviderDescriptions(providers, req) {
const result = {}
if (providers) {
for (const provider in providers) {
const data = providers[provider]
data.description = req.i18n.translate(
data.descriptionKey,
Object.assign({}, data.descriptionOptions)
)
result[provider] = data
}
}
return result
},
}
export default UserPagesController