diff --git a/CHANGES b/CHANGES index 6dfb394f0..179aec385 100644 --- a/CHANGES +++ b/CHANGES @@ -2751,3 +2751,5 @@ * mail: update solr to 8.11.3 * mail: spam acl should allow underscore and question mark * Fix streaming of logs with `logPaths` +* profile: store user language setting in the database + diff --git a/dashboard/src/js/client.js b/dashboard/src/js/client.js index c6d75c4c8..a1b269ad1 100644 --- a/dashboard/src/js/client.js +++ b/dashboard/src/js/client.js @@ -453,7 +453,7 @@ angular.module('Application').filter('tr', translateFilterFactory); // Cloudron REST API wrapper // ---------------------------------------------- -angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', function ($http, $interval, $timeout, md5, Notification) { +angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', '$translate', function ($http, $interval, $timeout, md5, Notification, $translate) { var client = null; // variable available only here to avoid this._property pattern @@ -2327,6 +2327,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; + Client.prototype.setProfileLanguage = function (language, callback) { + post('/api/v1/profile/language', { language: language }, null, function (error, data, status) { + if (error) return callback(error); + if (status !== 204) return callback(new ClientError(status, data)); + + callback(null, data); + }); + }; + Client.prototype.setProfileEmail = function (email, password, callback) { post('/api/v1/profile/email', { email: email, password: password }, null, function (error, data, status) { if (error) return callback(error); @@ -2530,6 +2539,11 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout this.getProfile(function (error, result) { if (error) return callback(error); + if (result.language !== '' && $translate.use() !== result.language) { + console.log('Changing users language from ' + $translate.use() + ' to ', result.language); + $translate.use(result.language); + } + that.setUserInfo(result); callback(null); }); diff --git a/dashboard/src/views/profile.js b/dashboard/src/views/profile.js index 2b7ca1d01..f3fc00513 100644 --- a/dashboard/src/views/profile.js +++ b/dashboard/src/views/profile.js @@ -15,7 +15,12 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans $scope.$watch('language', function (newVal, oldVal) { if (newVal === oldVal) return; - $translate.use(newVal.id); + + Client.setProfileLanguage(newVal.id, function (error) { + if (error) return console.error('Failed to reset password:', error); + }); + + $translate.use(newVal.id); // this switches the language and saves locally in localStorage['NG_TRANSLATE_LANG_KEY'] }); $scope.sendPasswordReset = function () { diff --git a/migrations/20240226111308-users-add-language.js b/migrations/20240226111308-users-add-language.js new file mode 100644 index 000000000..b9351e95d --- /dev/null +++ b/migrations/20240226111308-users-add-language.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = async function (db) { + await db.runSql('ALTER TABLE users ADD COLUMN language VARCHAR(8) NOT NULL DEFAULT ""'); +}; + +exports.down = async function (db) { + await db.runSql('ALTER TABLE users DROP COLUMN language'); +}; diff --git a/src/routes/profile.js b/src/routes/profile.js index 68e833a1c..c49009ce2 100644 --- a/src/routes/profile.js +++ b/src/routes/profile.js @@ -8,6 +8,7 @@ exports = module.exports = { setFallbackEmail, getAvatar, setAvatar, + setLanguage, getBackgroundImage, setBackgroundImage, setPassword, @@ -65,6 +66,7 @@ async function get(req, res, next) { role: req.user.role, source: req.user.source, hasBackgroundImage: !!backgroundImage, + language: req.user.language, avatarUrl: `https://${dashboardFqdn}/api/v1/profile/avatar/${req.user.id}`, avatarType: avatarType.toString() // this is a Buffer })); @@ -106,6 +108,18 @@ async function setDisplayName(req, res, next) { next(new HttpSuccess(204)); } +async function setLanguage(req, res, next) { + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.body, 'object'); + + if ('language' in req.body && typeof req.body.language !== 'string') return next(new HttpError(400, 'language must be string')); + + const [error] = await safe(users.update(req.user, { language: req.body.language }, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(204)); +} + async function setAvatar(req, res, next) { assert.strictEqual(typeof req.user, 'object'); diff --git a/src/routes/test/profile-test.js b/src/routes/test/profile-test.js index 9a86b52fe..52d7b12a2 100644 --- a/src/routes/test/profile-test.js +++ b/src/routes/test/profile-test.js @@ -56,6 +56,7 @@ describe('Profile API', function () { expect(response.body.displayName).to.be.a('string'); expect(response.body.password).to.not.be.ok(); expect(response.body.salt).to.not.be.ok(); + expect(response.body.language).to.be(''); }); it('fails with expired token', async function () { @@ -370,4 +371,54 @@ describe('Profile API', function () { expect(response.body).to.eql(defaultAvatar); }); }); + + describe('language', function () { + it('fails to set unknown language', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/language`) + .query({ access_token: user.token }) + .send({ language: 'ta' }) + .ok(() => true); + expect(response.statusCode).to.be(400); + }); + + it('fails to set bad language', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/language`) + .query({ access_token: user.token }) + .send({ language: 123 }) + .ok(() => true); + expect(response.statusCode).to.be(400); + }); + + it('fails to set unknown language', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/language`) + .query({ access_token: user.token }) + .send({ language: 'ta' }) + .ok(() => true); + expect(response.statusCode).to.be(400); + }); + + it('set valid language', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/language`) + .query({ access_token: user.token }) + .send({ language: 'en' }); + expect(response.statusCode).to.be(204); + }); + + it('did set language', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile`).query({ access_token: user.token }); + expect(response.body.language).to.contain('en'); + }); + + it('reset valid language', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/profile/language`) + .query({ access_token: user.token }) + .send({ language: '' }); + expect(response.statusCode).to.be(204); + }); + + it('did reset language', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/profile`).query({ access_token: user.token }); + expect(response.body.language).to.contain(''); + }); + }); }); diff --git a/src/server.js b/src/server.js index 75693d7d4..fd2cb57f8 100644 --- a/src/server.js +++ b/src/server.js @@ -163,6 +163,7 @@ async function initializeExpressSync() { router.post('/api/v1/profile/display_name', json, token, authorizeUser, routes.profile.canEditProfile, routes.profile.setDisplayName); router.post('/api/v1/profile/email', json, token, authorizeUser, routes.profile.canEditProfile, routes.users.verifyPassword, routes.profile.setEmail); router.post('/api/v1/profile/fallback_email', json, token, authorizeUser, routes.profile.canEditProfile, routes.users.verifyPassword, routes.profile.setFallbackEmail); + router.post('/api/v1/profile/language', json, token, authorizeUser, routes.profile.setLanguage); router.get ('/api/v1/profile/avatar/:identifier', routes.profile.getAvatar); // this is not scoped so it can used directly in img tag router.post('/api/v1/profile/avatar', json, token, authorizeUser, (req, res, next) => { return typeof req.body.avatar === 'string' ? next() : multipart(req, res, next); }, routes.profile.setAvatar); // avatar is not exposed in LDAP. so it's personal and not locked router.get ('/api/v1/profile/background_image', token, authorizeUser, routes.profile.getBackgroundImage); diff --git a/src/test/users-test.js b/src/test/users-test.js index 5e13db915..002a05806 100644 --- a/src/test/users-test.js +++ b/src/test/users-test.js @@ -553,6 +553,32 @@ describe('User', function () { }); }); + describe('language', function () { + before(createOwner); + + it('default language is empty', async function () { + const result = await users.get(admin.id); + expect(result.language).to.be(''); + }); + + it('cannot set bad language', async function () { + const [error] = await safe(users.update(admin, { language: 'ta '}, auditSource)); + expect(error.reason).to.be(BoxError.BAD_FIELD); + }); + + it('can set language', async function () { + await users.update(admin, { language: 'en' }, auditSource); + const result = await users.get(admin.id); + expect(result.language).to.be('en'); + }); + + it('can reset language', async function () { + await users.update(admin, { language: '' }, auditSource); + const result = await users.get(admin.id); + expect(result.language).to.be(''); + }); + }); + describe('invite', function () { before(createOwner); diff --git a/src/translation.js b/src/translation.js index 2a4296d7d..a741f423a 100644 --- a/src/translation.js +++ b/src/translation.js @@ -66,17 +66,13 @@ async function getTranslations() { } async function listLanguages() { - // we always return english to avoid dashboard breakage - let languages = ['en']; - const [error, result] = await safe(fs.promises.readdir(TRANSLATION_FOLDER)); if (error) { debug('listLanguages: Failed to list translations. %o', error); - return languages; + return [ 'en' ]; // we always return english to avoid dashboard breakage } const jsonFiles = result.filter(function (file) { return path.extname(file) === '.json'; }); - languages = jsonFiles.map(function (file) { return path.basename(file, '.json'); }); - + const languages = jsonFiles.map(function (file) { return path.basename(file, '.json'); }); return languages; } diff --git a/src/users.js b/src/users.js index c73878ecd..a78d00b2d 100644 --- a/src/users.js +++ b/src/users.js @@ -72,7 +72,7 @@ exports = module.exports = { const ORDERED_ROLES = [ exports.ROLE_USER, exports.ROLE_USER_MANAGER, exports.ROLE_MAIL_MANAGER, exports.ROLE_ADMIN, exports.ROLE_OWNER ]; // the avatar and backgroundImage fields are special and not added here to reduce response sizes -const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', +const USERS_FIELDS = [ 'id', 'username', 'email', 'fallbackEmail', 'password', 'salt', 'creationTime', 'inviteToken', 'resetToken', 'displayName', 'language', 'twoFactorAuthenticationEnabled', 'twoFactorAuthenticationSecret', 'active', 'source', 'role', 'resetTokenCreationTime', 'loginLocationsJson' ].join(','); const DEFAULT_GHOST_LIFETIME = 6 * 60 * 60 * 1000; // 6 hours @@ -96,6 +96,7 @@ const appPasswords = require('./apppasswords.js'), settings = require('./settings.js'), speakeasy = require('speakeasy'), tokens = require('./tokens.js'), + translation = require('./translation.js'), uuid = require('uuid'), uaParser = require('ua-parser-js'), superagent = require('superagent'), @@ -164,6 +165,17 @@ function validateDisplayName(name) { return null; } +async function validateLanguage(language) { + assert.strictEqual(typeof language, 'string'); + + if (language === '') return null; // reset to platform default + + const languages = await translation.listLanguages(); + if (!languages.includes(language)) return new BoxError(BoxError.BAD_FIELD, 'Invalid language'); + + return null; +} + function validatePassword(password) { assert.strictEqual(typeof password, 'string'); @@ -249,11 +261,12 @@ async function add(email, data, auditSource) { displayName: displayName, source: source, role: role, - avatar: constants.AVATAR_NONE + avatar: constants.AVATAR_NONE, + language: '' }; - const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; - const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, user.displayName, user.source, user.role, user.avatar ]; + const query = 'INSERT INTO users (id, username, password, email, fallbackEmail, salt, resetToken, inviteToken, displayName, source, role, avatar, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + const args = [ user.id, user.username, user.password, user.email, user.fallbackEmail, user.salt, user.resetToken, user.inviteToken, user.displayName, user.source, user.role, user.avatar, user.language ]; [error] = await safe(database.query(query, args)); if (error && error.code === 'ER_DUP_ENTRY' && error.sqlMessage.indexOf('users_email') !== -1) throw new BoxError(BoxError.ALREADY_EXISTS, 'email already exists'); @@ -608,8 +621,13 @@ async function update(user, data, auditSource) { if (error) throw error; } - let args = [ ]; - let fields = [ ]; + if (data.language) { + error = await validateLanguage(data.language); + if (error) throw error; + } + + let args = []; + let fields = []; for (const k in data) { if (k === 'twoFactorAuthenticationEnabled' || k === 'active') { fields.push(k + ' = ?');