diff --git a/backend/db/models/user.js b/backend/db/models/user.js index b56cb3b83..0b528f627 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -641,7 +641,7 @@ module.exports = (sequelize, DataTypes) => { afterUpdate: async (user, options) => { const {context, transaction} = options; const {userRoles, roleMap} = context || {}; - if (userRoles && roleMap) { + if (userRoles) { await assignUserRoles(user, userRoles, roleMap, true, transaction); } }, diff --git a/backend/webserver/sockets/user.js b/backend/webserver/sockets/user.js index 1fbb6aa2d..895926e6f 100644 --- a/backend/webserver/sockets/user.js +++ b/backend/webserver/sockets/user.js @@ -221,6 +221,7 @@ class UserSocket extends Socket { */ async bulkCreateUsers(data) { const users = data["users"]; + const roleMap = data["roleMap"]; const createdUsers = []; const errors = []; @@ -233,7 +234,7 @@ class UserSocket extends Socket { if (!user.exists) { createdUser = await this.models["user"].add(user, { transaction, context: { - userRoles: user.roles, roleMap: data["moodleCareRoleMap"], + userRoles: user.roles, roleMap, }, }) @@ -244,7 +245,7 @@ class UserSocket extends Socket { firstName: user.firstName, lastName: user.lastName, extId: user.extId, emailVerified: true, }, { transaction, context: { - userRoles: user.roles, roleMap: data["moodleCareRoleMap"], + userRoles: user.roles, roleMap, } }); } else { @@ -525,4 +526,4 @@ class UserSocket extends Socket { } }; -module.exports = UserSocket; \ No newline at end of file +module.exports = UserSocket; diff --git a/backend/webserver/utils/settingSave.js b/backend/webserver/utils/settingSave.js index 10526259f..371b1cba4 100644 --- a/backend/webserver/utils/settingSave.js +++ b/backend/webserver/utils/settingSave.js @@ -3,6 +3,19 @@ const { assertStableEmailTemplateContent } = require("../../utils/helper/templateResolver"); const MAIL_SERVICE_KEY_PREFIX = "system.mailService."; +// Only list setting types where leading/trailing whitespace has no semantic value. +// Unknown or newly added types preserve whitespace by default. +const TRIM_WHITESPACE_SETTING_TYPES = new Set(["boolean", "color", "integer", "number", "string"]); + +/** + * Returns whether a setting value should be trimmed before saving. + * + * @param {Object} setting setting entry + * @returns {boolean} + */ +function shouldTrimSetting(setting) { + return TRIM_WHITESPACE_SETTING_TYPES.has(setting?.type); +} /** * Reject email.template.* settings that point at a missing or incomplete template. @@ -66,16 +79,48 @@ function payloadTouchesMailService(settings) { * Normalize setting values to string payload format expected by the settings model. * * @param {*} value setting value + * @param {Object} [setting] setting entry * @returns {string} */ -function normalizeSettingValue(value) { +function normalizeSettingValue(value, setting = {}) { + let normalized; if (value === null || value === undefined) { - return ""; + normalized = ""; + } else if (typeof value === "object") { + // NOTE: Coerce object/array payloads to JSON; persisted settings are always strings. + normalized = JSON.stringify(value); + } else { + normalized = String(value); + } + return shouldTrimSetting(setting) ? normalized.trim() : normalized; +} + +/** + * Load persisted setting types for payload entries that do not include type metadata. + * + * @param {Object} Setting setting model + * @param {Object[]} settings setting entries + * @param {Object} [options] additional options + * @returns {Promise>} + */ +async function getSettingTypeByKey(Setting, settings, options = {}) { + const keys = [...new Set(settings + .filter((setting) => setting && typeof setting.key === "string" && !setting.type) + .map((setting) => setting.key))]; + if (!keys.length) { + return new Map(); } - if (typeof value === "object") { - return JSON.stringify(value); + if (!Setting || typeof Setting.findAll !== "function") { + throw new TypeError("getSettingTypeByKey requires a Setting model with findAll."); } - return String(value); + + const rows = await Setting.findAll({ + where: { key: keys }, + attributes: ["key", "type"], + raw: true, + transaction: options.transaction, + }); + return new Map(rows.map((row) => [row.key, row.type])); } /** @@ -96,11 +141,16 @@ async function saveSettings(Setting, settings, options = {}) { await validateEmailTemplateSettings(options.models, list, options); } const touchesMailService = payloadTouchesMailService(list); + const settingTypeByKey = await getSettingTypeByKey(Setting, list, options); for (const setting of list) { if (!setting || typeof setting.key !== "string" || setting.key.trim() === "") { continue; } - await Setting.set(setting.key, normalizeSettingValue(setting.value), { + const settingWithType = setting.type ? setting : { + ...setting, + type: settingTypeByKey.get(setting.key), + }; + await Setting.set(setting.key, normalizeSettingValue(setting.value, settingWithType), { transaction: options.transaction, }); } @@ -108,7 +158,9 @@ async function saveSettings(Setting, settings, options = {}) { } module.exports = { + getSettingTypeByKey, payloadTouchesMailService, normalizeSettingValue, saveSettings, + shouldTrimSetting, }; diff --git a/docs/source/for_researchers/moodle_usage.rst b/docs/source/for_researchers/moodle_usage.rst index 1bdb4384b..c90c18335 100644 --- a/docs/source/for_researchers/moodle_usage.rst +++ b/docs/source/for_researchers/moodle_usage.rst @@ -77,7 +77,9 @@ Before using this feature, make sure Moodle API access is configured as describe User data can be imported from Moodle using the course ID: 1. In the Dashboard navigate to ``Users > Import via Moodle`` -2. CARE will match users by email address +2. After CARE retrieves the Moodle users, review the role mapping step +3. Map each Moodle role label to the corresponding CARE role, or choose ``Do not assign additional role`` if the role should not grant additional CARE permissions +4. CARE will match users by email address CARE handles three scenarios: @@ -85,6 +87,10 @@ CARE handles three scenarios: - Duplicate users are merged based on email match - Conflicts (e.g., mismatched emails) require manual correction +Moodle role labels can vary by Moodle instance and may include multilingual markup such as ``{mlang de}Lehrende{mlang}{mlang other}Lecturer{mlang}``. CARE hides this markup in the mapping step while keeping the original Moodle role string for the import. + +CSV user import also includes a role mapping step. CARE reads the distinct values from the CSV ``roles`` column and asks you to map each value to a CARE role before previewing the import. Multiple roles in one CSV cell should be separated by commas. + .. warning:: Never delete a user with an ``extId`` unless you are certain it won't be needed. This could prevent future updates or synchronization. diff --git a/frontend/src/components/dashboard/Settings.vue b/frontend/src/components/dashboard/Settings.vue index 48932b91d..3ca201afe 100644 --- a/frontend/src/components/dashboard/Settings.vue +++ b/frontend/src/components/dashboard/Settings.vue @@ -356,15 +356,12 @@ export default { save() { this.$socket.emit("settingSave", this.settings, (res) => { if (res.success) { - this.settings.forEach((s) => { - this.$store.commit("settings/set", { key: s.key, value: s.value }); - }); this.eventBus.emit("toast", { title: "Success", message: res.data, variant: "success", }); - this.setSettingsSnapshot(); + this.load(false); } else { this.eventBus.emit("toast", { title: "Error Saving Settings", diff --git a/frontend/src/components/dashboard/users/ImportModal.vue b/frontend/src/components/dashboard/users/ImportModal.vue index 4d03cc82e..66ae567d7 100644 --- a/frontend/src/components/dashboard/users/ImportModal.vue +++ b/frontend/src/components/dashboard/users/ImportModal.vue @@ -10,10 +10,9 @@ - -