feat: cookie+DB language preference, eliminating subdomain requirement
Build and Deploy Verso / deploy (push) Successful in 14m53s

Users can now select their UI language directly without relying on
subdomain routing (fr.verso.alocoq.fr etc.).

Resolution order: (1) verso-lang cookie, (2) subdomain host header,
(3) OVERLEAF_SITE_LANGUAGE default — fully backward compatible.

Changes:
- Translations.mjs: read verso-lang cookie in middleware; include all
  bundled locale files in availableLanguageCodes regardless of subdomain
  config so every loaded locale appears in the picker
- User.mjs: add languageCode field to persist preference per user
- UserController.mjs: setLanguage handler — sets cookie (1 year) and
  writes languageCode to DB when called by a logged-in user
- AuthenticationController.mjs: on login, sync DB languageCode to cookie
  so preference follows the user to any new browser/device after login
- ExpressLocals.mjs: expose availableLanguages to all Pug templates
- router.mjs: GET /set-language?lng=<code> (anonymous + logged-in),
  POST /user/language (logged-in, REST-style)
- language-picker.pug: replace subdomain href links with /set-language
  redirect links; iterate availableLanguages instead of subdomainLang
- thin-footer.pug: show picker whenever availableLanguages.length > 1,
  not only when multiple subdomains are configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-15 11:44:56 +00:00
parent 589c60a325
commit 1a0197812d
8 changed files with 76 additions and 16 deletions
@@ -26,6 +26,7 @@ import { expressify, promisify } from '@overleaf/promise-utils'
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import Translations from '../../infrastructure/Translations.mjs'
const { hasAdminAccess } = AdminAuthorizationHelper
@@ -221,6 +222,17 @@ const AuthenticationController = {
await _afterLoginSessionSetupAsync(req, user)
// Sync user's stored language preference to cookie on login
if (user.languageCode) {
res.cookie(Translations.LANG_COOKIE_NAME, user.languageCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
}
AuthenticationController._clearRedirectFromSession(req)
AnalyticsRegistrationSourceHelper.clearSource(req.session)
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
@@ -1,3 +1,5 @@
import Settings from '@overleaf/settings'
import Translations from '../../infrastructure/Translations.mjs'
import UserHandler from './UserHandler.mjs'
import UserDeleter from './UserDeleter.mjs'
import UserGetter from './UserGetter.mjs'
@@ -562,6 +564,26 @@ async function expireDeletedUsersAfterDuration(req, res, next) {
res.sendStatus(204)
}
async function setLanguage(req, res) {
const lngCode = req.body.lngCode ?? req.query.lng
if (!Translations.availableLanguageCodes.includes(lngCode)) {
return res.status(400).end()
}
res.cookie(Translations.LANG_COOKIE_NAME, lngCode, {
maxAge: 365 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: Settings.secureCookie,
sameSite: 'Lax',
domain: Settings.cookieDomain,
})
const userId = SessionManager.getLoggedInUserId(req.session)
if (userId) {
await User.findByIdAndUpdate(userId, { languageCode: lngCode }).exec()
}
const redir = req.get('Referer') || '/project'
res.redirect(302, redir)
}
export default {
clearSessions: expressify(clearSessions),
changePassword: expressify(changePassword),
@@ -574,4 +596,5 @@ export default {
expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration),
ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware),
ensureAffiliation,
setLanguage: expressify(setLanguage),
}
@@ -17,6 +17,7 @@ import AdminAuthorizationHelper from '../Features/Helpers/AdminAuthorizationHelp
import { addOptionalCleanupHandlerAfterDrainingConnections } from './GracefulShutdown.mjs'
import { sanitizeSessionUserForFrontEnd } from './FrontEndUser.mjs'
import { expressify } from '@overleaf/promise-utils'
import Translations from './Translations.mjs'
const {
canRedirectToAdminDomain,
@@ -352,6 +353,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.settings = Settings
res.locals.availableLanguages = Translations.availableLanguageCodes
next()
})
@@ -44,6 +44,8 @@ const locales = {
'zh-CN': zhCN,
}
const LANG_COOKIE_NAME = 'verso-lang'
const fallbackLanguageCode = Settings.i18n.defaultLng || 'en'
const availableLanguageCodes = []
const availableHosts = new Map()
@@ -62,15 +64,20 @@ Object.values(Settings.i18n.subdomainLang || {}).forEach(function (spec) {
subdomainConfigs.set(spec.lngCode, spec)
}
})
if (!availableLanguageCodes.includes(fallbackLanguageCode)) {
// always load the fallback locale
availableLanguageCodes.push(fallbackLanguageCode)
// Make all bundled locale files available regardless of subdomain config.
// This allows the cookie-based language picker to offer every loaded locale
// even when no subdomains are configured.
for (const lngCode of Object.keys(locales)) {
if (!availableLanguageCodes.includes(lngCode)) {
availableLanguageCodes.push(lngCode)
}
}
const resources = Object.fromEntries(
Object.entries(locales)
.filter(([lngCode]) => availableLanguageCodes.includes(lngCode))
.map(([lngCode, translations]) => [lngCode, { translation: translations }])
Object.entries(locales).map(([lngCode, translations]) => [
lngCode,
{ translation: translations },
])
)
i18n
@@ -107,8 +114,13 @@ i18n
})
function setLangBasedOnDomainMiddleware(req, res, next) {
// Determine language from subdomain
const lang = availableHosts.get(req.headers.host) ?? fallbackLanguageCode
// Priority: (1) user-set cookie, (2) subdomain, (3) env-var default
const cookieLng = req.cookies[LANG_COOKIE_NAME]
const hostLng = availableHosts.get(req.headers.host)
const lang =
(availableLanguageCodes.includes(cookieLng) ? cookieLng : null) ??
hostLng ??
fallbackLanguageCode
req.i18n = {
language: lang,
@@ -173,4 +185,6 @@ i18n.translate = i18n.t
export default {
setLangBasedOnDomainMiddleware,
i18n,
LANG_COOKIE_NAME,
availableLanguageCodes,
}
+1
View File
@@ -52,6 +52,7 @@ export const UserSchema = new Schema(
},
role: { type: String, default: '' },
institution: { type: String, default: '' },
languageCode: { type: String, default: null },
hashedPassword: String,
enrollment: {
sso: [
+8
View File
@@ -284,6 +284,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
'/read-only/one-time-login'
)
// Language preference — works for both anonymous (GET) and logged-in (POST)
webRouter.get('/set-language', UserController.setLanguage)
webRouter.post(
'/user/language',
AuthenticationController.requireLogin(),
UserController.setLanguage
)
webRouter.get('/logout', UserPagesController.logout)
webRouter.post('/logout', UserController.logout)
@@ -13,23 +13,23 @@ li.dropdown.dropup.subdued.language-picker(dropdown)
)
+material-symbol('translate')
| &nbsp;
span.language-picker-text #{settings.translatedLanguages[currentLngCode]}
span.language-picker-text #{settings.translatedLanguages[currentLngCode] || currentLngCode}
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
each lngCode in availableLanguages
if settings.translatedLanguages[lngCode]
- let isActive = lngCode === currentLngCode
li.lng-option
a.menu-indent(
href=subdomainDetails.url + currentUrlWithQueryParams
href='/set-language?lng=' + lngCode
role='menuitem'
class=['dropdown-item', {active: isActive}]
aria-selected=isActive ? 'true' : 'false'
)
| #{settings.translatedLanguages[subdomainDetails.lngCode]}
if subdomainDetails.lngCode === currentLngCode
| #{settings.translatedLanguages[lngCode]}
if isActive
+material-symbol('check', 'dropdown-item-trailing-icon')
@@ -1,5 +1,5 @@
footer.site-footer
- var showLanguagePicker = Object.keys(settings.i18n.subdomainLang).length > 1
- var showLanguagePicker = availableLanguages.length > 1
- var hasCustomLeftNav = nav.left_footer && nav.left_footer.length > 0
.site-footer-content.hidden-print
.row