From 75eb0912b284eaa95af996e156e7431927ff5b8e Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 16 Aug 2026 16:28:57 +0200 Subject: [PATCH 01/16] feat: make sure field value in settings is trimmed when necessary --- .../src/components/dashboard/Settings.vue | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/dashboard/Settings.vue b/frontend/src/components/dashboard/Settings.vue index 48932b91d..e1a1b2be9 100644 --- a/frontend/src/components/dashboard/Settings.vue +++ b/frontend/src/components/dashboard/Settings.vue @@ -198,6 +198,11 @@ const SUBSECTION_ORDER = { system: ["Token expiry"], }; +/** + * If there is whitespace in the following types, the system will not trim the value. + */ +const PRESERVE_WHITESPACE_SETTING_TYPES = new Set(["edits", "text"]); + export default { name: "DashboardSettings", subscribeTable: ["template"], @@ -336,6 +341,21 @@ export default { } this.originalSettingsSnapshot = JSON.stringify(this.settings); }, + shouldTrimSetting(setting) { + return !PRESERVE_WHITESPACE_SETTING_TYPES.has(setting?.type); + }, + normalizeSettingValue(setting, value) { + if (typeof value === "string" && this.shouldTrimSetting(setting)) { + return value.trim(); + } + return value; + }, + normalizeSettings() { + if (!this.settings) return; + this.settings.forEach((setting) => { + setting.value = this.normalizeSettingValue(setting, setting.value); + }); + }, sendMailTest() { const to = (this.mailTestTo || "").trim(); if (!to || !this.$socket) return; @@ -354,6 +374,7 @@ export default { }); }, save() { + this.normalizeSettings(); this.$socket.emit("settingSave", this.settings, (res) => { if (res.success) { this.settings.forEach((s) => { @@ -469,7 +490,7 @@ export default { // Overwrite only existing keys this.settings = this.settings.map((setting) => { if (visibleKeys.has(setting.key) && Object.prototype.hasOwnProperty.call(flat, setting.key)) { - setting.value = flat[setting.key]; + setting.value = this.normalizeSettingValue(setting, flat[setting.key]); updatedCount++; } return setting; From 4bbba53e462e0ec8392994b9bff3756c4301522f Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 16 Aug 2026 16:54:47 +0200 Subject: [PATCH 02/16] feat: move space-trimming logic to backend --- backend/webserver/utils/settingSave.js | 63 +++++++++++++++++-- .../src/components/dashboard/Settings.vue | 28 +-------- 2 files changed, 59 insertions(+), 32 deletions(-) diff --git a/backend/webserver/utils/settingSave.js b/backend/webserver/utils/settingSave.js index 10526259f..9c83ffd93 100644 --- a/backend/webserver/utils/settingSave.js +++ b/backend/webserver/utils/settingSave.js @@ -3,6 +3,17 @@ const { assertStableEmailTemplateContent } = require("../../utils/helper/templateResolver"); const MAIL_SERVICE_KEY_PREFIX = "system.mailService."; +const PRESERVE_WHITESPACE_SETTING_TYPES = new Set(["edits", "text"]); + +/** + * Returns whether a setting value should be trimmed before saving. + * + * @param {Object} setting setting entry + * @returns {boolean} + */ +function shouldTrimSetting(setting) { + return !PRESERVE_WHITESPACE_SETTING_TYPES.has(setting?.type); +} /** * Reject email.template.* settings that point at a missing or incomplete template. @@ -66,16 +77,49 @@ 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 = ""; + // TODO: Should we allow value type as object? + } else if (typeof value === "object") { + normalized = JSON.stringify(value); + } else { + normalized = String(value); } - if (typeof value === "object") { - return JSON.stringify(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 = {}) { + if (typeof Setting.findAll !== "function") { + return new Map(); + } + + const keys = [...new Set(settings + .filter((setting) => setting && typeof setting.key === "string" && !setting.type) + .map((setting) => setting.key))]; + if (!keys.length) { + return new Map(); } - 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 +140,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 +157,9 @@ async function saveSettings(Setting, settings, options = {}) { } module.exports = { + getSettingTypeByKey, payloadTouchesMailService, normalizeSettingValue, saveSettings, + shouldTrimSetting, }; diff --git a/frontend/src/components/dashboard/Settings.vue b/frontend/src/components/dashboard/Settings.vue index e1a1b2be9..3ca201afe 100644 --- a/frontend/src/components/dashboard/Settings.vue +++ b/frontend/src/components/dashboard/Settings.vue @@ -198,11 +198,6 @@ const SUBSECTION_ORDER = { system: ["Token expiry"], }; -/** - * If there is whitespace in the following types, the system will not trim the value. - */ -const PRESERVE_WHITESPACE_SETTING_TYPES = new Set(["edits", "text"]); - export default { name: "DashboardSettings", subscribeTable: ["template"], @@ -341,21 +336,6 @@ export default { } this.originalSettingsSnapshot = JSON.stringify(this.settings); }, - shouldTrimSetting(setting) { - return !PRESERVE_WHITESPACE_SETTING_TYPES.has(setting?.type); - }, - normalizeSettingValue(setting, value) { - if (typeof value === "string" && this.shouldTrimSetting(setting)) { - return value.trim(); - } - return value; - }, - normalizeSettings() { - if (!this.settings) return; - this.settings.forEach((setting) => { - setting.value = this.normalizeSettingValue(setting, setting.value); - }); - }, sendMailTest() { const to = (this.mailTestTo || "").trim(); if (!to || !this.$socket) return; @@ -374,18 +354,14 @@ export default { }); }, save() { - this.normalizeSettings(); 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", @@ -490,7 +466,7 @@ export default { // Overwrite only existing keys this.settings = this.settings.map((setting) => { if (visibleKeys.has(setting.key) && Object.prototype.hasOwnProperty.call(flat, setting.key)) { - setting.value = this.normalizeSettingValue(setting, flat[setting.key]); + setting.value = flat[setting.key]; updatedCount++; } return setting; From f3a6a0c34d2e5c367b46e5c0c094fa1a70e11dd4 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Sun, 16 Aug 2026 17:03:29 +0200 Subject: [PATCH 03/16] chore: remove TODO comment --- backend/webserver/utils/settingSave.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/webserver/utils/settingSave.js b/backend/webserver/utils/settingSave.js index 9c83ffd93..521e0fd2a 100644 --- a/backend/webserver/utils/settingSave.js +++ b/backend/webserver/utils/settingSave.js @@ -84,8 +84,8 @@ function normalizeSettingValue(value, setting = {}) { let normalized; if (value === null || value === undefined) { normalized = ""; - // TODO: Should we allow value type as object? } 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); From 67388892c530c38a5bebd50b40887a9d2cb2a0e5 Mon Sep 17 00:00:00 2001 From: Linyin Huang Date: Mon, 17 Aug 2026 14:50:14 +0200 Subject: [PATCH 04/16] feat: support user defined care-moodle role mapping --- .../dashboard/users/ImportModal.vue | 119 ++++++++++++------ .../dashboard/users/MoodleRoleMappingStep.vue | 91 ++++++++++++++ .../dashboard/users/moodleRoleMapping.js | 36 ++++++ 3 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/dashboard/users/MoodleRoleMappingStep.vue create mode 100644 frontend/src/components/dashboard/users/moodleRoleMapping.js diff --git a/frontend/src/components/dashboard/users/ImportModal.vue b/frontend/src/components/dashboard/users/ImportModal.vue index 4d03cc82e..db71b2dbb 100644 --- a/frontend/src/components/dashboard/users/ImportModal.vue +++ b/frontend/src/components/dashboard/users/ImportModal.vue @@ -10,10 +10,9 @@ - -