profile: store preferred language in the database

This commit is contained in:
Girish Ramakrishnan
2024-02-26 12:32:14 +01:00
parent 6d6107161e
commit 6525504923
10 changed files with 150 additions and 14 deletions
+14
View File
@@ -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');
+51
View File
@@ -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('');
});
});
});
+1
View File
@@ -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);
+26
View File
@@ -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);
+2 -6
View File
@@ -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;
}
+24 -6
View File
@@ -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 + ' = ?');