diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 66e005f0d6..7efe794f8d 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs @@ -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) diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index ac88451221..6f7437899e 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -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), } diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 6c8b3734d1..6d33cc764d 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -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() }) diff --git a/services/web/app/src/infrastructure/Translations.mjs b/services/web/app/src/infrastructure/Translations.mjs index d6e01910a3..48f59e624e 100644 --- a/services/web/app/src/infrastructure/Translations.mjs +++ b/services/web/app/src/infrastructure/Translations.mjs @@ -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, } diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 4e21612f18..35eac1d9a8 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -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: [ diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 62b4a4b510..0eddf33db9 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -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) diff --git a/services/web/app/views/layout/language-picker.pug b/services/web/app/views/layout/language-picker.pug index 312b13ea18..3d73daf9f0 100644 --- a/services/web/app/views/layout/language-picker.pug +++ b/services/web/app/views/layout/language-picker.pug @@ -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') diff --git a/services/web/app/views/layout/thin-footer.pug b/services/web/app/views/layout/thin-footer.pug index 01ef2d2c42..fa890e22a4 100644 --- a/services/web/app/views/layout/thin-footer.pug +++ b/services/web/app/views/layout/thin-footer.pug @@ -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