feat: cookie+DB language preference, eliminating subdomain requirement
Build and Deploy Verso / deploy (push) Successful in 14m53s
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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')
|
||||
|
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user