diff --git a/backend/db/migrations/20260525220113-transform-placeholder-i18n-keys.js b/backend/db/migrations/20260525220113-transform-placeholder-i18n-keys.js new file mode 100644 index 000000000..4a08012b9 --- /dev/null +++ b/backend/db/migrations/20260525220113-transform-placeholder-i18n-keys.js @@ -0,0 +1,87 @@ +"use strict"; + +const { translateMaybeKey } = require("../../utils/i18n"); + +/** + * Replace English placeholderLabel / placeholderDescription with i18n keys. + * Help tooltips stay in templates.json (frontend derives help key from description key). + * + * @type {import('sequelize-cli').Migration} + */ + +const TYPE_SLUG = { + 1: "emailGeneral", + 2: "emailStudySession", + 3: "emailAssignment", + 6: "emailStudyClose", +}; + +const PLACEHOLDER_ROWS = [ + { type: 1, placeholderKey: "username" }, + { type: 1, placeholderKey: "firstName" }, + { type: 1, placeholderKey: "lastName" }, + { type: 1, placeholderKey: "link" }, + { type: 1, placeholderKey: "otp" }, + { type: 1, placeholderKey: "tokenExpiry" }, + { type: 2, placeholderKey: "username" }, + { type: 2, placeholderKey: "link" }, + { type: 3, placeholderKey: "username" }, + { type: 3, placeholderKey: "assignmentType" }, + { type: 3, placeholderKey: "assignmentName" }, + { type: 3, placeholderKey: "link" }, + { type: 6, placeholderKey: "username" }, + { type: 6, placeholderKey: "studyName" }, +]; + +function keysFor(type, placeholderKey) { + const slug = TYPE_SLUG[type]; + const base = "templates.placeholders"; + return { + placeholderLabel: `${base}.labels.${slug}.${placeholderKey}`, + placeholderDescription: `${base}.descriptions.${slug}.${placeholderKey}`, + }; +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + for (const row of PLACEHOLDER_ROWS) { + await queryInterface.bulkUpdate( + "placeholder", + { + ...keysFor(row.type, row.placeholderKey), + updatedAt: new Date(), + }, + { + type: row.type, + placeholderKey: row.placeholderKey, + }, + {}, + ); + } + }, + + async down(queryInterface) { + for (const row of PLACEHOLDER_ROWS) { + const keys = keysFor(row.type, row.placeholderKey); + const placeholderLabel = translateMaybeKey(keys.placeholderLabel); + const placeholderDescription = translateMaybeKey(keys.placeholderDescription); + if (!placeholderLabel || !placeholderDescription) { + continue; + } + await queryInterface.bulkUpdate( + "placeholder", + { + placeholderLabel, + placeholderDescription, + updatedAt: new Date(), + }, + { + type: row.type, + placeholderKey: row.placeholderKey, + }, + {} + ); + } + }, +}; diff --git a/backend/db/migrations/20260530120000-add-setting-allowUserOverride.js b/backend/db/migrations/20260530120000-add-setting-allowUserOverride.js new file mode 100644 index 000000000..7032b39bb --- /dev/null +++ b/backend/db/migrations/20260530120000-add-setting-allowUserOverride.js @@ -0,0 +1,32 @@ +'use strict'; + +/** Keys in `setting` that users may override via `user_setting`. */ +const USER_OVERRIDABLE_SETTING_KEYS = [ + 'projects.default', + 'tags.tagSet.default', + 'annotator.nlp.activated', +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('setting', 'allowUserOverride', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + + const now = new Date(); + for (const key of USER_OVERRIDABLE_SETTING_KEYS) { + await queryInterface.bulkUpdate( + 'setting', + { allowUserOverride: true, updatedAt: now }, + { key } + ); + } + }, + + async down(queryInterface) { + await queryInterface.removeColumn('setting', 'allowUserOverride'); + }, +}; diff --git a/backend/db/migrations/20260530130000-add-setting-app-locale.js b/backend/db/migrations/20260530130000-add-setting-app-locale.js new file mode 100644 index 000000000..c93a24e79 --- /dev/null +++ b/backend/db/migrations/20260530130000-add-setting-app-locale.js @@ -0,0 +1,35 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const now = new Date(); + const existing = await queryInterface.rawSelect( + 'setting', + { where: { key: 'app.locale' } }, + ['key'] + ); + if (existing) { + await queryInterface.bulkUpdate( + 'setting', + { allowUserOverride: true, updatedAt: now }, + { key: 'app.locale' } + ); + return; + } + + await queryInterface.bulkInsert('setting', [{ + key: 'app.locale', + value: 'en', + type: 'string', + description: 'Default UI language. Users may override via user_setting.', + allowUserOverride: true, + createdAt: now, + updatedAt: now, + }]); + }, + + async down(queryInterface) { + await queryInterface.bulkDelete('setting', { key: 'app.locale' }); + }, +}; diff --git a/backend/db/migrations/20260530140000-add-disable-language-selection-right.js b/backend/db/migrations/20260530140000-add-disable-language-selection-right.js new file mode 100644 index 000000000..b9f4b88d9 --- /dev/null +++ b/backend/db/migrations/20260530140000-add-disable-language-selection-right.js @@ -0,0 +1,54 @@ +'use strict'; + +const RIGHT = { + name: "frontend.preferences.disableLanguageSelection", + description: "disables the ability to change the UI language in preferences", +}; + +const ROLE_NAME = "guest"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert( + "user_right", + [{ + ...RIGHT, + createdAt: new Date(), + updatedAt: new Date(), + }], + {} + ); + + const userRoles = await queryInterface.sequelize.query('SELECT id, name FROM "user_role"', { + type: queryInterface.sequelize.QueryTypes.SELECT, + }); + + const guestRole = userRoles.find((role) => role.name === ROLE_NAME); + + await queryInterface.bulkInsert( + "role_right_matching", + [{ + userRoleId: guestRole.id, + userRightName: RIGHT.name, + createdAt: new Date(), + updatedAt: new Date(), + }], + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + "role_right_matching", + { userRightName: RIGHT.name }, + {} + ); + + await queryInterface.bulkDelete( + "user_right", + { name: RIGHT.name }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260530150000-add-setting-app-locale-display.js b/backend/db/migrations/20260530150000-add-setting-app-locale-display.js new file mode 100644 index 000000000..c69163187 --- /dev/null +++ b/backend/db/migrations/20260530150000-add-setting-app-locale-display.js @@ -0,0 +1,32 @@ +'use strict'; + +const KEY = 'app.locale'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + const now = new Date(); + await queryInterface.sequelize.query( + `UPDATE setting SET "displayName" = :dn, "displayGroup" = :dg, "displaySubsection" = :ds, "updatedAt" = :now WHERE key = :k`, + { + replacements: { + dn: 'Default UI language', + dg: 'Interface', + ds: 'Localization', + k: KEY, + now, + }, + } + ); + }, + + async down(queryInterface) { + const now = new Date(); + await queryInterface.sequelize.query( + `UPDATE setting SET "displayName" = NULL, "displayGroup" = NULL, "displaySubsection" = NULL, "updatedAt" = :now WHERE key = :k`, + { + replacements: { k: KEY, now }, + } + ); + }, +}; diff --git a/backend/db/migrations/20260601120000-transform-email-submission-upload-placeholder-i18n-keys.js b/backend/db/migrations/20260601120000-transform-email-submission-upload-placeholder-i18n-keys.js new file mode 100644 index 000000000..84c695e0a --- /dev/null +++ b/backend/db/migrations/20260601120000-transform-email-submission-upload-placeholder-i18n-keys.js @@ -0,0 +1,72 @@ +"use strict"; + +const { translateMaybeKey } = require("../../utils/i18n"); + +/** + * Replace English placeholderLabel / placeholderDescription for email template type 7. + * + * @type {import('sequelize-cli').Migration} + */ + +const TYPE_SLUG = "emailSubmissionUpload"; + +const PLACEHOLDER_ROWS = [ + { type: 7, placeholderKey: "username" }, + { type: 7, placeholderKey: "assignmentName" }, + { type: 7, placeholderKey: "eventType" }, + { type: 7, placeholderKey: "assignmentId" }, + { type: 7, placeholderKey: "submissionId" }, + { type: 7, placeholderKey: "timestamp" }, +]; + +function keysFor(placeholderKey) { + const base = "templates.placeholders"; + return { + placeholderLabel: `${base}.labels.${TYPE_SLUG}.${placeholderKey}`, + placeholderDescription: `${base}.descriptions.${TYPE_SLUG}.${placeholderKey}`, + }; +} + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + for (const row of PLACEHOLDER_ROWS) { + await queryInterface.bulkUpdate( + "placeholder", + { + ...keysFor(row.placeholderKey), + updatedAt: new Date(), + }, + { + type: row.type, + placeholderKey: row.placeholderKey, + }, + {} + ); + } + }, + + async down(queryInterface) { + for (const row of PLACEHOLDER_ROWS) { + const keys = keysFor(row.placeholderKey); + const placeholderLabel = translateMaybeKey(keys.placeholderLabel); + const placeholderDescription = translateMaybeKey(keys.placeholderDescription); + if (!placeholderLabel || !placeholderDescription) { + continue; + } + await queryInterface.bulkUpdate( + "placeholder", + { + placeholderLabel, + placeholderDescription, + updatedAt: new Date(), + }, + { + type: row.type, + placeholderKey: row.placeholderKey, + }, + {} + ); + } + }, +}; diff --git a/backend/db/models/assignment.js b/backend/db/models/assignment.js index c11a01e7d..7ead6a4d5 100644 --- a/backend/db/models/assignment.js +++ b/backend/db/models/assignment.js @@ -14,38 +14,38 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Assignment Name:", - placeholder: "Assignment 1", + label: "assignments.fields.name.label", + placeholder: "assignments.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "description", - label: "Description:", - help: "Optional description shown for this assignment.", + label: "assignments.fields.description.label", + help: "assignments.fields.description.help", type: "textarea", required: false, }, { key: "maxRevisions", - label: "Maximum Revisions:", + label: "assignments.fields.maxRevisions.label", type: "slider", class: "custom-slider-class", min: 1, max: 20, step: 1, - unit: "revision(s)", + unit: "assignments.fields.maxRevisions.unit", unlimitedAtMax: true, - unlimitedLabel: "unlimited", + unlimitedLabel: "assignments.fields.maxRevisions.unlimitedLabel", unlimitedStoredValue: 0, required: true, default: 1, - help: "Maximum number of allowed revision copies for this assignment. Move to the end for unlimited.", + help: "assignments.fields.maxRevisions.help", }, { key: "start", - label: "Start Time:", + label: "assignments.fields.start.label", type: "datetime", size: 6, default: null, @@ -53,7 +53,7 @@ module.exports = (sequelize, DataTypes) => { }, { key: "end", - label: "End Time:", + label: "assignments.fields.end.label", type: "datetime", size: 6, default: null, @@ -61,7 +61,7 @@ module.exports = (sequelize, DataTypes) => { }, { key: "validationConfigurationId", - label: "Validation Configuration:", + label: "assignments.fields.validationConfigurationId.label", type: "select", options: { table: "configuration", @@ -72,23 +72,23 @@ module.exports = (sequelize, DataTypes) => { ], }, required: true, - help: "Validation is applied before submission upload.", + help: "assignments.fields.validationConfigurationId.help", }, { key: "allowReUpload", - label: "Allow Re-Upload:", + label: "assignments.fields.allowReUpload.label", type: "switch", default: false, required: false, - help: "If enabled, users can replace or delete uploaded submissions.", + help: "assignments.fields.allowReUpload.help", }, { key: "notifyOnSubmissionUpload", - label: "Notify on Submission Upload:", + label: "assignments.fields.notifyOnSubmissionUpload.label", type: "switch", default: false, required: false, - help: "If enabled, sends an email when a student uploads or re-uploads a submission.", + help: "assignments.fields.notifyOnSubmissionUpload.help", }, ]; diff --git a/backend/db/models/configuration.js b/backend/db/models/configuration.js index 57caa4cc8..9cb7b8985 100644 --- a/backend/db/models/configuration.js +++ b/backend/db/models/configuration.js @@ -13,23 +13,23 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name", - placeholder: "Configuration name", + label: "basic.configuration.fields.name.label", + placeholder: "basic.configuration.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "description", - label: "Description", - placeholder: "Optional description", + label: "basic.configuration.fields.description.label", + placeholder: "basic.configuration.fields.description.placeholder", type: "text", required: false, default: "", }, { key: "userId", - label: "User ID", + label: "basic.configuration.fields.userId.label", placeholder: "#", type: "text", required: true, @@ -37,25 +37,25 @@ module.exports = (sequelize, DataTypes) => { }, { key: "hideInFrontend", - label: "Hide in Frontend", + label: "basic.configuration.fields.hideInFrontend.label", type: "switch", required: false, default: false, }, { key: "type", - label: "Type", + label: "basic.configuration.fields.type.label", placeholder: "0", type: "select", options: [ - { name: "Assessment", value: 0 }, - { name: "Validation", value: 1 }, + { name: "basic.configuration.types.assessment", value: 0 }, + { name: "basic.configuration.types.validation", value: 1 }, ], required: true, }, { key: "content", - label: "Config (JSON)", + label: "basic.configuration.fields.content.label", placeholder: "{ }", type: "json", required: true, diff --git a/backend/db/models/document.js b/backend/db/models/document.js index 90a7adeda..b88f34c3b 100644 --- a/backend/db/models/document.js +++ b/backend/db/models/document.js @@ -3,6 +3,7 @@ const MetaModel = require("../MetaModel.js"); const path = require("path"); const fs = require('fs') const SequelizeSimpleCache = require("sequelize-simple-cache"); +const TranslatableError = require("../../utils/TranslatableError"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -30,15 +31,15 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name of the document:", - placeholder: "My document", + label: "documents.fields.name.label", + placeholder: "documents.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "hash", - label: "Hash ID of the document", + label: "documents.fields.hash.label", placeholder: "#", type: "text", required: false, @@ -46,7 +47,7 @@ module.exports = (sequelize, DataTypes) => { }, { key: "userId", - label: "User ID of the document", + label: "documents.fields.userId.label", placeholder: "#", type: "text", required: false, @@ -54,7 +55,7 @@ module.exports = (sequelize, DataTypes) => { }, { key: "public", - label: "Is the document published?", + label: "documents.fields.publicSwitch", type: "switch", required: false, default: false @@ -92,7 +93,7 @@ module.exports = (sequelize, DataTypes) => { const originalDoc = await Document.findByPk(documentId, {transaction: options.transaction}); if (!originalDoc) { - throw new Error(`Document with id ${documentId} not found`); + throw new TranslatableError("errors.documents.withIdNotFound", {documentId}); } // Create base document data @@ -269,12 +270,15 @@ module.exports = (sequelize, DataTypes) => { const doc = await Document.findByPk(documentId, {raw: true}); if (!doc) { - throw new Error(`Document ${documentId} not found`); + throw new TranslatableError("errors.documents.byIdNotFound", {documentId}); } return await Document.encodeDocumentFileToBase64(doc); } catch (err) { - throw new Error(`Error processing document ${documentId}: ${err.message}`) + if (err.key) { + throw err; + } + throw new TranslatableError("errors.documents.processingError", {documentId, message: err.message}) } } @@ -300,14 +304,14 @@ module.exports = (sequelize, DataTypes) => { try { await fs.promises.access(docFilePath, fs.constants.F_OK); } catch { - throw new Error(`File not found for document ${doc.id}: ${docFilePath}`); + throw new TranslatableError("errors.documents.fileNotFound", {documentId: doc.id, filePath: docFilePath}); } try { const fileBuffer = await fs.promises.readFile(docFilePath); return fileBuffer.toString('base64'); } catch (err) { - throw new Error(`Error reading document file ${doc.id}: ${err.message}`); + throw new TranslatableError("errors.documents.readError", {documentId: doc.id, message: err.message}); } } diff --git a/backend/db/models/project.js b/backend/db/models/project.js index 073724ba8..c263527ef 100644 --- a/backend/db/models/project.js +++ b/backend/db/models/project.js @@ -10,23 +10,23 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name of the project:", - placeholder: "My project", + label: "dashboard.projects.fields.name.label", + placeholder: "dashboard.projects.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "description", - label: "Description of the project:", - placeholder: "My project description", + label: "dashboard.projects.fields.description.label", + placeholder: "dashboard.projects.fields.description.placeholder", type: "textarea", required: false, default: "", }, { key: "public", - label: "Is the project public?", + label: "dashboard.projects.fields.publicSwitch", type: "switch", required: false, default: false diff --git a/backend/db/models/setting.js b/backend/db/models/setting.js index d4e6861c6..e691e4066 100644 --- a/backend/db/models/setting.js +++ b/backend/db/models/setting.js @@ -163,6 +163,7 @@ module.exports = (sequelize, DataTypes) => { displayGroup: DataTypes.STRING, displaySubsection: DataTypes.STRING, onlyAdmin: DataTypes.BOOLEAN, + allowUserOverride: DataTypes.BOOLEAN, showInWizard: DataTypes.BOOLEAN, wizardOrder: DataTypes.INTEGER, requiredInWizard: DataTypes.BOOLEAN, diff --git a/backend/db/models/study.js b/backend/db/models/study.js index ca7e32101..3d0fbf65a 100644 --- a/backend/db/models/study.js +++ b/backend/db/models/study.js @@ -25,8 +25,8 @@ module.exports = (sequelize, DataTypes) => { static fields = [{ key: "name", - label: "Name of the study:", - placeholder: "My user study", + label: "studies.fields.name.label", + placeholder: "studies.fields.name.placeholder", type: "text", required: true, //pattern: "^(\\d+)", //invalidText: "Test invalid text", @@ -35,7 +35,7 @@ module.exports = (sequelize, DataTypes) => { maxlength: 5 }, { key: "workflowId", - label: "Select Workflow for Study:", + label: "studies.fields.workflowId.label", type: "select", options: { table: "workflow", @@ -49,10 +49,10 @@ module.exports = (sequelize, DataTypes) => { }, icon: "list", required: true, - help: "Choose a workflow template for the study steps." + help: "studies.fields.workflowId.help" }, { key: "tagSetId", - label: "Tag set for the study:", + label: "studies.fields.tagSetId.label", type: "select", options: { table: "tag_set", @@ -66,10 +66,10 @@ module.exports = (sequelize, DataTypes) => { }, icon: "list", required: true, - help: "Select a tag set to use in the study." + help: "studies.fields.tagSetId.help" }, { key: "stepDocuments", - label: "Assign Documents to Workflow Steps:", + label: "studies.fields.stepDocuments.label", type: "choice", options: { table: "study_step", @@ -104,93 +104,93 @@ module.exports = (sequelize, DataTypes) => { required: true, }, { key: "description", - label: "Description of the study:", - help: "This text will be displayed at the beginning of the user study!", + label: "studies.fields.description.label", + help: "studies.fields.description.help", type: "editor", required: true }, { key: "enableEmailNotifications", - label: "Send email notification on session start/finish", + label: "studies.fields.enableEmailNotifications.label", type: "switch", default: false, - help: "When enabled, the study owner receives an email each time a participant starts or finishes a session." + help: "studies.fields.enableEmailNotifications.help" }, { key: "timeLimit", type: "slider", - label: "How much time does a participant have for the study?", - help: "0 = disable time limitation", + label: "studies.fields.timeLimit.label", + help: "studies.fields.timeLimit.help", size: 12, - unit: "min", + unit: "studies.units.minutes", min: 0, max: 180, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}], + textMapping: [{from: 0, to: "studies.values.unlimited"}], advanced: true }, { key: "limitSessions", type: "slider", - label: "Limit the number of sessions for the study:", - help: "Set the maximum number of times participants can start or resume the study. Each attempt to complete the study is called a session. 0 = unlimited number of sessions.", + label: "studies.fields.limitSessions.label", + help: "studies.fields.limitSessions.help", size: 12, - unit: "Session(s)", + unit: "studies.units.sessions", min: 0, max: 200, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}], + textMapping: [{from: 0, to: "studies.values.unlimited"}], advanced: true }, { key: "limitSessionsPerUser", type: "slider", - label: "Limit the number of sessions per user for the study:", - help: "Set the maximum number of times each participant can start or resume the study. Each attempt to complete the study is called a session. 0 = unlimited number of sessions per user.", + label: "studies.fields.limitSessionsPerUser.label", + help: "studies.fields.limitSessionsPerUser.help", size: 12, - unit: "Session(s)", + unit: "studies.units.sessions", min: 0, max: 200, step: 1, default: 0, - textMapping: [{from: 0, to: "unlimited"}], + textMapping: [{from: 0, to: "studies.values.unlimited"}], advanced: true }, { key: "start", - label: "Study sessions can't start before", + label: "studies.fields.start.label", type: "datetime", size: 6, default: null, advanced: true }, { key: "end", - label: "Study sessions can't start after:", + label: "studies.fields.end.label", type: "datetime", size: 6, default: null, advanced: true }, { key: "collab", - label: "Should the study be collaborative?", + label: "studies.fields.collab.label", type: "switch", default: false, advanced: true }, { key: "anonymize", - label: "Should the comments be anonymized?", + label: "studies.fields.anonymize.label", type: "switch", default: false, advanced: true }, { key: "resumable", - label: "Should the study be resumable?", + label: "studies.fields.resumable.label", type: "switch", default: false, advanced: true }, { key: "multipleSubmit", - label: "Allow multiple submissions?", + label: "studies.fields.multipleSubmit.label", type: "switch", default: false, - help: "Specify whether participants can submit their study multiple times.", + help: "studies.fields.multipleSubmit.help", advanced: true },]; @@ -204,13 +204,13 @@ module.exports = (sequelize, DataTypes) => { const study = await sequelize.models.study.getById(studyId); if (study) { if (study.closed) { - throw new Error('This study is closed'); + throw new Error('errors.studies.studyClosed'); } if (!study.multipleSubmit && study.end && new Date(study.end) < new Date()) { - throw new Error('This study has ended'); + throw new Error('errors.studies.studyEnded'); } } else { - throw new Error('Study not found'); + throw new Error('errors.studies.studyNotFound'); } } @@ -432,7 +432,7 @@ module.exports = (sequelize, DataTypes) => { afterCreate: async (study, options) => { if (!options.context || !options.context.stepDocuments) { - throw new Error("Missing context or stepDocuments in options. Cancelling transaction."); + throw new Error("errors.studies.missingContextOrStepDocuments"); } await Study.createStudySteps(study, options); diff --git a/backend/db/models/study_session.js b/backend/db/models/study_session.js index 51855ddcd..a04874bb0 100644 --- a/backend/db/models/study_session.js +++ b/backend/db/models/study_session.js @@ -1,6 +1,7 @@ 'use strict'; const MetaModel = require("../MetaModel.js"); const SequelizeSimpleCache = require("sequelize-simple-cache"); +const TranslatableError = require("../../utils/TranslatableError"); module.exports = (sequelize, DataTypes) => { class StudySession extends MetaModel { @@ -35,7 +36,7 @@ module.exports = (sequelize, DataTypes) => { static async checkSessionAvailability(studyId, userId, options) { const study = await sequelize.models.study.getById(studyId, {transaction: options.transaction}); if (!study) { - throw new Error('Study not found'); + throw new Error('errors.studies.studyNotFound'); } // Check for limited study sessions if (study.limitSessions !== null && study.limitSessions > 0) { @@ -43,7 +44,7 @@ module.exports = (sequelize, DataTypes) => { where: {studyId: studyId} }, {transaction: options.transaction}); if (totalExistingSessionCount >= study.limitSessions) { - throw new Error(`Cannot create more than ${study.limitSessions} sessions for this study.`); + throw new TranslatableError('errors.studies.sessionLimitExceeded', {limit: study.limitSessions}); } } // Check for limited study sessions per user @@ -52,15 +53,15 @@ module.exports = (sequelize, DataTypes) => { where: {studyId: studyId, userId: userId} }, {transaction: options.transaction}); if (existingSessionCountPerUser >= study.limitSessionsPerUser) { - throw new Error(`Cannot create more than ${study.limitSessionsPerUser} sessions for this user.`); + throw new TranslatableError('errors.studies.sessionLimitPerUserExceeded', {limit: study.limitSessionsPerUser}); } } // Check for study closed or end date and start date if (study.closed && Date.now() > new Date(study.end)) { - throw new Error('This study is closed'); + throw new Error('errors.studies.studyClosed'); } if (study.start !== null && new Date() < new Date(study.start)) { - throw new Error('This study has not started yet'); + throw new Error('errors.studies.studyNotStarted'); } } @@ -76,7 +77,7 @@ module.exports = (sequelize, DataTypes) => { static async duplicateStudySession(studySessionId, overrides= {}, options) { const studySession = await this.getById(studySessionId, {transaction: options.transaction}); if (!studySession) { - throw new Error('Study session not found'); + throw new Error('errors.studies.studySession.notFound'); } let data = { studyId: studySession.studyId, diff --git a/backend/db/models/study_step.js b/backend/db/models/study_step.js index 5c3d7f50e..61cf60698 100644 --- a/backend/db/models/study_step.js +++ b/backend/db/models/study_step.js @@ -3,6 +3,7 @@ const MetaModel = require("../MetaModel.js"); const path = require("path"); const {promises: fs} = require("fs"); const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); +const TranslatableError = require("../../utils/TranslatableError"); const UPLOAD_PATH = `${__dirname}/../../../files`; const stepTypes = Object.freeze({ @@ -31,7 +32,7 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "documentId", - label: "Select Document", + label: "dashboard.documents.fields.selectDoc", type: "select", options: { table: "document", @@ -53,7 +54,7 @@ module.exports = (sequelize, DataTypes) => { additionalOptions: [ { type: sequelize.models.document.docTypes.DOC_TYPE_HTML, - name: "New Empty Document", + name: "dashboard.documents.fields.newEmptyDoc", value: null } ] @@ -76,7 +77,7 @@ module.exports = (sequelize, DataTypes) => { }, ...options, }); if (!firstStep) { - throw new Error("No first step found for this study"); + throw new Error("errors.studies.studyStep.notFound"); } else { return firstStep; } @@ -136,7 +137,7 @@ module.exports = (sequelize, DataTypes) => { }) if (!referencedStudyStep) { - throw new Error(`Referenced study step not found for workflow step ${originalEntry.id}`); + throw new TranslatableError("errors.studies.studyStep.referencedStepNotFound", {originalEntryId: originalEntry.id}); } const referencedDocument = await sequelize.models.document.getById( @@ -144,7 +145,7 @@ module.exports = (sequelize, DataTypes) => { {transaction: options.transaction}); if (!referencedDocument) { - throw new Error(`Referenced document not found for study step ${referencedStudyStep.id}`); + throw new TranslatableError("errors.studies.studyStep.referencedDocumentNotFound", {referencedStudyStepId: referencedStudyStep.id}); } // Copy the delta file from the referenced document to the new document @@ -199,11 +200,17 @@ module.exports = (sequelize, DataTypes) => { // Check if document exists! if (!document) { - throw new Error(`Document not found: documentId ${data.documentId} is missing for step ${data.workflowStepId}`); + throw new TranslatableError("errors.studies.studyStep.documentNotFoundExact", { + dataDocumentId: data.documentId, + dataWorkflowStepId: data.workflowStepId, + }); } // Check document mismatch if (document.type !== expectedDocType) { - throw new Error(`Document type mismatch: step ${data.workflowStepId} expects an Editor document (type 1), but found type ${document.type}.`); + throw new TranslatableError("errors.studies.studyStep.documentTypeMismatch", { + dataWorkflowStepId: data.workflowStepId, + documentType: document.type, + }); } const newDocument = await sequelize.models.document.add({ diff --git a/backend/db/models/submission.js b/backend/db/models/submission.js index 9f6760d41..60666c52b 100644 --- a/backend/db/models/submission.js +++ b/backend/db/models/submission.js @@ -3,6 +3,7 @@ const MetaModel = require("../MetaModel.js"); const {Op} = require("sequelize"); const fs = require("fs"); const path = require("path"); +const TranslatableError = require("../../utils/TranslatableError"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -148,7 +149,7 @@ module.exports = (sequelize, DataTypes) => { }); if (!originalSubmission) { - throw new Error(`Submission with id ${originalSubmissionId} not found`); + throw new TranslatableError("errors.submission.notFound", {submissionId: originalSubmissionId}); } // Create the copied submission with parentSubmissionId and apply submission overrides @@ -196,9 +197,10 @@ module.exports = (sequelize, DataTypes) => { ); copiedDocuments.push(copiedDoc); } catch (error) { - throw new Error( - `Failed to copy document with id ${originalDoc.id}: ${error.message}` - ); + if (error.key) { + throw error; + } + throw new TranslatableError("errors.submission.documentCopyFailed", {documentId: originalDoc.id, message: error.message}); } } @@ -241,7 +243,7 @@ module.exports = (sequelize, DataTypes) => { ); if (Object.keys(submissionFiles).length === 0) { - throw new Error(`No valid files found for submission ${submissionId}`); + throw new TranslatableError("errors.submission.noValidFiles", {submissionId}); } return submissionFiles; diff --git a/backend/db/models/tag.js b/backend/db/models/tag.js index c351afa3f..85eb0c12a 100644 --- a/backend/db/models/tag.js +++ b/backend/db/models/tag.js @@ -7,35 +7,35 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name", - placeholder: "Name of the Tag", + label: "common.name", + placeholder: "tags.fields.tag.name.placeholder", type: "text", required: true, default: "", }, { key: "colorCode", - label: "Color", + label: "tags.fields.tag.color.label", type: "select", default: "info", options: [ { - name: "info", + name: "tags.tag.form.colorCode.options.info", value: "info", class: "border border-info" }, { - name: "warning", + name: "tags.tag.form.colorCode.options.warning", value: "warning", class: "border border-warning" }, { - name: "success", + name: "tags.tag.form.colorCode.options.success", value: "success", class: "border-2 border-success" }, { - name: "danger", + name: "tags.tag.form.colorCode.options.danger", value: "danger", class: "border-2 border-danger" }, @@ -44,8 +44,8 @@ module.exports = (sequelize, DataTypes) => { }, { key: "description", - label: "Description", - placeholder: "Tag description", + label: "common.description", + placeholder: "tags.fields.tag.description.placeholder", type: "text", required: true, default: "", diff --git a/backend/db/models/tag_set.js b/backend/db/models/tag_set.js index 2c02b9369..bc7e185d1 100644 --- a/backend/db/models/tag_set.js +++ b/backend/db/models/tag_set.js @@ -7,23 +7,23 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name of the Tagset:", - placeholder: "My tagset", + label: "tags.fields.name.label", + placeholder: "tags.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "description", - label: "Description of the Tagset:", - placeholder: "Tagset description", + label: "tags.fields.description.label", + placeholder: "tags.fields.description.placeholder", type: "text", required: true, default: "", }, { key: "tags", - label: "Tags:", + label: "tags.fields.tags.label", type: "table", options: { table: "tag", id: "tagSetId" diff --git a/backend/db/models/template.js b/backend/db/models/template.js index bc160884a..dd5d7767e 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -1,5 +1,6 @@ 'use strict'; const MetaModel = require("../MetaModel.js"); +const TranslatableError = require("../../utils/TranslatableError"); const { assertStableEmailTemplateContent } = require("../../utils/templateResolver"); module.exports = (sequelize, DataTypes) => { @@ -112,61 +113,61 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name", + label: "common.name", type: "text", required: true, }, { key: "description", - label: "Description", + label: "common.description", type: "textarea", required: true }, // Published field is excluded from form (handled via table action buttons only) { key: "type", - label: "Type", + label: "common.type", type: "select", required: true, options: [ { - name: "Choose type", + name: "templates.fields.type.options.chooseType", value: null, disabled: true }, { - name: "Email - General", + name: "templates.fields.type.options.emailGeneral", value: 1 }, { - name: "Email - Study Session", + name: "templates.fields.type.options.emailStudySession", value: 2 }, { - name: "Email - Assignment", + name: "templates.fields.type.options.emailAssignment", value: 3 }, { - name: "Email - Study Close", + name: "templates.fields.type.options.emailStudyClose", value: 6 }, { - name: "Email - Submission upload", + name: "templates.fields.type.options.emailSubmissionUpload", value: 7 }, { - name: "Document - General", + name: "templates.fields.type.options.documentGeneral", value: 4 }, { - name: "Document - Study", + name: "templates.fields.type.options.documentStudy", value: 5 } ], }, { key: "defaultLanguage", - label: "Default language", + label: "templates.fields.defaultLanguage.label", type: "select", required: true, options: [ @@ -192,13 +193,13 @@ module.exports = (sequelize, DataTypes) => { const source = await Template.findByPk(sourceTemplateId, { transaction }); if (!source) { - throw new Error(`Template with id ${sourceTemplateId} not found`); + throw new TranslatableError("errors.templates.withIdNotFound", {sourceTemplateId}); } if (!source.public) { - throw new Error("Only public templates can be copied"); + throw new Error("errors.templates.onlyPublicCanBeCopied"); } if (source.userId === userId) { - throw new Error("Cannot copy your own template"); + throw new Error("errors.templates.cannotCopyOwn"); } // Prevent duplicate copy (unless overrides.force is true) @@ -208,7 +209,7 @@ module.exports = (sequelize, DataTypes) => { transaction, }); if (existing) { - throw new Error("You have already copied this template"); + throw new Error("errors.templates.alreadyCopied"); } } @@ -336,12 +337,12 @@ module.exports = (sequelize, DataTypes) => { const copy = await Template.findByPk(copyId, { transaction }); if (!copy || !copy.sourceId) { - throw new Error("Template is not a copy or does not exist"); + throw new Error("errors.templates.notCopyOrDoesNotExist"); } const source = await Template.findByPk(copy.sourceId, { transaction }); if (!source || source.deleted) { - throw new Error("Source template is no longer available"); + throw new Error("errors.templates.sourceNoLongerAvailable"); } // 1. Get all source language content @@ -422,10 +423,10 @@ module.exports = (sequelize, DataTypes) => { static async detach(copyId, options = {}) { const copy = await Template.findByPk(copyId, { transaction: options.transaction }); if (!copy) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } if (!copy.sourceId) { - throw new Error("Template is not a copy"); + throw new Error("errors.templates.notACopy"); } await copy.update({ sourceId: null }, { transaction: options.transaction }); return await Template.findByPk(copyId, { transaction: options.transaction }); @@ -469,7 +470,7 @@ module.exports = (sequelize, DataTypes) => { template.public === false ) { throw new Error( - "Cannot make a template non-public once it has been made public" + "errors.templates.cannotMakeNonPublic" ); } @@ -492,7 +493,7 @@ module.exports = (sequelize, DataTypes) => { if (template.userId !== options.callerUserId) { throw new Error( - "You can only update templates that you own" + "errors.templates.updateOwnOnly" ); } @@ -503,7 +504,7 @@ module.exports = (sequelize, DataTypes) => { prevSourceId != null && nextSourceId != null ) { - throw new Error("Copied templates cannot be edited"); + throw new Error("errors.templates.copiedCannotBeEdited"); } } } diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 8677cb114..edd052ec0 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -359,7 +359,7 @@ module.exports = (sequelize, DataTypes) => { }); if (!user) { - throw new Error("User not found"); + throw new Error("errors.users.userNotFound"); } const userDetails = user.get({plain: true}); userDetails.roles = userDetails.roles.map((role) => { @@ -449,16 +449,16 @@ module.exports = (sequelize, DataTypes) => { */ static validatePasswordContent(pwd) { if (typeof pwd !== "string" || pwd.length < 8) { - throw new Error("Password must be at least 8 characters."); + throw new Error("errors.validation.auth.passwordMinLengthNoLong"); } if (/^\s*$/.test(pwd)) { - throw new Error("Password cannot be only spaces or whitespace."); + throw new Error("errors.validation.auth.passwordWhitespaceOnly"); } if (/[\x00-\x1F\x7F]/.test(pwd)) { - throw new Error("Password cannot contain control or non-printable characters."); + throw new Error("errors.validation.auth.passwordControlChars"); } if ([...pwd].some((c) => (c.codePointAt(0) || 0) > 0xFFFF)) { - throw new Error("Password cannot contain emojis or special symbols. Use letters, numbers, and standard punctuation."); + throw new Error("errors.validation.auth.passwordUnsupportedCharacters"); } } @@ -482,7 +482,7 @@ module.exports = (sequelize, DataTypes) => { ); if (updatedRowsCount === 0) { - throw new Error("Failed to update user: User not found"); + throw new Error("errors.users.failedToUpdateUser"); } // clear the user cache so the updated pwd is loaded immediately if (User.cache){ diff --git a/backend/db/models/user_setting.js b/backend/db/models/user_setting.js index 88f909ff8..8fc8cc999 100644 --- a/backend/db/models/user_setting.js +++ b/backend/db/models/user_setting.js @@ -1,6 +1,7 @@ 'use strict'; const MetaModel = require("../MetaModel.js"); const {Op} = require("sequelize"); +const TranslatableError = require("../../utils/TranslatableError"); module.exports = (sequelize, DataTypes) => { class UserSetting extends MetaModel { @@ -16,6 +17,37 @@ module.exports = (sequelize, DataTypes) => { }); } + /** + * Reject user_setting rows that would override a system setting without allowUserOverride. + * Admin bulk updates pass { bypassSystemSettingCheck: true } in Sequelize options. + * + * @param {string} key + * @param {Object} [options] + * @returns {Promise} + */ + static async assertKeyAllowedForUserSetting(key, options = {}) { + if (options.bypassSystemSettingCheck) { + return; + } + + const Setting = sequelize.models["setting"]; + if (!Setting) { + return; + } + + const systemSetting = await Setting.findOne({ + where: { key, deleted: false }, + attributes: ["key", "allowUserOverride"], + raw: true, + }); + + if (!systemSetting || systemSetting.allowUserOverride) { + return; + } + + throw new TranslatableError("errors.settings.cannotOverrideSystemSetting", { key }); + } + /** * Get a setting by key and user id * @param {string} key @@ -39,9 +71,10 @@ module.exports = (sequelize, DataTypes) => { * @param {string} key * @param {string} value * @param {number} userId + * @param {Object} [options] passed to create/update (e.g. bypassSystemSettingCheck for admin) * @returns {Promise} value */ - static async set(key, value, userId) { + static async set(key, value, userId, options = {}) { try { const dbObj = { userId: userId, @@ -49,10 +82,10 @@ module.exports = (sequelize, DataTypes) => { value: value }; - return await UserSetting.create(dbObj).then((msg) => { + return await UserSetting.create(dbObj, options).then((msg) => { return msg; }).catch(async (err) => { - return await UserSetting.update({value: value}, {where: {[Op.and]: [{userId: userId}, {key: key}]}}); + return await UserSetting.update({value: value}, {where: {[Op.and]: [{userId: userId}, {key: key}]}, ...options}); }); } catch (e) { console.log(e); @@ -68,7 +101,15 @@ module.exports = (sequelize, DataTypes) => { }, { sequelize, modelName: 'user_setting', - tableName: 'user_setting' + tableName: 'user_setting', + hooks: { + beforeCreate: async (instance, options) => { + await UserSetting.assertKeyAllowedForUserSetting(instance.key, options); + }, + beforeUpdate: async (instance, options) => { + await UserSetting.assertKeyAllowedForUserSetting(instance.key, options); + }, + }, }); UserSetting.removeAttribute('id'); diff --git a/backend/db/models/workflow.js b/backend/db/models/workflow.js index c3dfa3e94..e0c3f0b69 100644 --- a/backend/db/models/workflow.js +++ b/backend/db/models/workflow.js @@ -14,26 +14,26 @@ module.exports = (sequelize, DataTypes) => { static fields = [ { key: "name", - label: "Name of the Workflow:", - placeholder: "My workflow", + label: "workflow.fields.name.label", + placeholder: "workflow.fields.name.placeholder", type: "text", required: true, default: "", }, { key: "description", - label: "Description of the Workflow:", - placeholder: "Workflow description", + label: "workflow.fields.description.label", + placeholder: "workflow.fields.description.placeholder", type: "text", required: true, default: "", }, { key: "hideInFrontend", - label: "Hide Workflow in Frontend:", + label: "workflow.fields.hideInFrontend.label", type: "switch", default: false, - help: "If enabled, this workflow will be hidden from users in the frontend." + help: "workflow.fields.hideInFrontend.help" } ] diff --git a/backend/db/models/workflow_step.js b/backend/db/models/workflow_step.js index 5dbe13693..546a2acdf 100644 --- a/backend/db/models/workflow_step.js +++ b/backend/db/models/workflow_step.js @@ -17,8 +17,8 @@ module.exports = (sequelize, DataTypes) => { static fields = [{ key: "name", - label: "Name of the workflow step:", - placeholder: "My workflow step", + label: "workflow.fields.step.name.label", + placeholder: "workflow.fields.step.name.placeholder", type: "text", required: true, default: "", @@ -26,31 +26,31 @@ module.exports = (sequelize, DataTypes) => { maxlength: 100 }, { key: "stepType", - label: "Select Step Type:", + label: "workflow.fields.step.stepType.label", type: "select", options: [ - { value: 1, name: "Annotator" }, - { value: 2, name: "Editor" }, - { value: 3, name: "Modal" } + { value: 1, name: "workflow.stepTypes.annotator" }, + { value: 2, name: "workflow.stepTypes.editor" }, + { value: 3, name: "workflow.stepTypes.modal" } ], icon: "list", required: true, default: 2, - help: "Choose the type of workflow step." + help: "workflow.fields.step.stepType.help" }, { key: "allowBackward", - label: "Allow Backward Navigation:", + label: "workflow.fields.step.allowBackward.label", type: "switch", default: false, - help: "Allow users to navigate back to this step." + help: "workflow.fields.step.allowBackward.help" },{ key: "configuration", - label: "Configuration:", - placeholder: "Enter JSON configuration for this step", + label: "workflow.fields.step.configuration.label", + placeholder: "workflow.fields.step.configuration.placeholder", type: "json", required: false, default: {}, - help: "Additional configuration settings for this workflow step in JSON format." + help: "workflow.fields.step.configuration.help" }]; /** diff --git a/backend/package-lock.json b/backend/package-lock.json index c8ac72d58..e10abd5fa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,7 +56,8 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "jest": "^30.2.0" + "jest": "^30.2.0", + "nodemon": "^3.1.10" }, "engines": { "node": ">=18.0.0" @@ -2451,6 +2452,19 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2496,6 +2510,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2706,6 +2733,31 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3848,6 +3900,19 @@ "moment": "^2.29.1" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4143,6 +4208,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4289,6 +4367,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -4382,6 +4467,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -4403,6 +4501,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4422,6 +4530,29 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5792,6 +5923,110 @@ "node": ">=6.0.0" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -6498,6 +6733,13 @@ "node": ">=10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -6619,6 +6861,32 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7213,6 +7481,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7806,6 +8100,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7821,6 +8128,16 @@ "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", "license": "MIT" }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -7914,6 +8231,13 @@ "node": ">=6.0.0" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", diff --git a/backend/utils/TranslatableError.js b/backend/utils/TranslatableError.js new file mode 100644 index 000000000..3099c7384 --- /dev/null +++ b/backend/utils/TranslatableError.js @@ -0,0 +1,59 @@ +/** + * TranslatableError - Custom error class for i18n support + * + * Throw with an i18n key (and optional params / code) instead of a hardcoded English sentence. + * Socket catch handlers read key, params, and code for the client response. + * + * @example + * throw new TranslatableError('errors.auth.invalidCredentials'); + * + * @example + * throw new TranslatableError('errors.nlp.timeout', { skill: 'summarization' }); + * + * @example + * throw new TranslatableError('errors.assignment.unableToAssignEnoughDocuments', { + * roleName: 'Reviewer', + * count: 5 + * }, 'ASSIGNMENT_FAILED'); + * + * @example + * // Key + params + optional code (when params are present) + * throw new TranslatableError('errors.documents.fileMissingFromServer', { filename }, 'FILE_MISSING'); + * + * For code + key only (no params), use generateError(code, key) instead — avoids null placeholders. + */ + +class TranslatableError extends Error { + /** + * Creates a new TranslatableError + * + * @param {string} key - The translation key (e.g., 'errors.auth.invalidCredentials') + * @param {Object|null} [params] - Optional parameters for interpolation + * @param {string|null} [code] - Optional machine-readable code (use when params are also present; + * for code + key without params, prefer generateError(code, key)) + */ + constructor(key, params = null, code = null) { + // Call parent constructor with the key as message + // This ensures stack traces show the key + super(key); + + // Maintain proper stack trace for where the error was thrown (V8 engines) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, TranslatableError); + } + + this.name = 'TranslatableError'; + this.key = key; + if (params) { + this.params = params; + } + if (code) { + this.code = code; + } + + // Flag to identify translatable errors in catch blocks + this.isTranslatable = true; + } +} + +module.exports = TranslatableError; diff --git a/backend/utils/generic.js b/backend/utils/generic.js index 5692f2095..442da69e0 100644 --- a/backend/utils/generic.js +++ b/backend/utils/generic.js @@ -49,16 +49,26 @@ exports.inject = async function inject(data, func, targetName, key = null) { ); } +const { hasKey } = require('./i18n'); + /** - * Create an Error object with a machine-readable code. + * Create an Error with a machine-readable code and an i18n key (no interpolation params). + * + * Use when the frontend needs both `code` (branching) and `key` (translation), e.g. + * `generateError('DOCUMENT_NOT_FOUND', 'errors.documents.doesNotExistOrDeleted')`. * - * @param {string} code Error code for frontend handling - * @param {string} message Human-readable error message + * For key + params, or key + params + code, use TranslatableError instead. + * + * @param {string} code Machine-readable error code for frontend handling + * @param {string} message i18n key (must exist in en locale JSON) * @returns {Error} */ exports.generateError = function generateError(code, message) { const error = new Error(message); error.code = code; + if (hasKey(message)) { + error.key = message; + } return error; } diff --git a/backend/utils/i18n.js b/backend/utils/i18n.js new file mode 100644 index 000000000..c59dcaaa5 --- /dev/null +++ b/backend/utils/i18n.js @@ -0,0 +1,106 @@ +/** + * Backend i18n Utility + * + * Loads English translations from the shared i18n module directory + * and provides translation helpers for backend English output (logs, socket fallbacks, migrations). + */ + +const fs = require('fs'); +const path = require('path'); + +let translations = null; + +function flattenObject(obj, prefix = '') { + const result = {}; + for (const key of Object.keys(obj)) { + const value = obj[key]; + const newKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(result, flattenObject(value, newKey)); + } else { + result[newKey] = value; + } + } + return result; +} + +function loadTranslations() { + if (translations !== null) { + return translations; + } + + const i18nDir = path.resolve(__dirname, '../../utils/modules/i18n/en'); + const merged = {}; + + try { + if (!fs.existsSync(i18nDir)) { + translations = {}; + return translations; + } + + const files = fs.readdirSync(i18nDir); + for (const file of files) { + if (file.endsWith('.json')) { + const filePath = path.join(i18nDir, file); + const namespace = file.replace('.json', ''); + const content = fs.readFileSync(filePath, 'utf-8'); + merged[namespace] = JSON.parse(content); + } + } + + translations = flattenObject(merged); + } catch (_error) { + translations = {}; + } + + return translations; +} + +function interpolate(text, params = {}) { + if (!text || typeof text !== 'string') { + return text; + } + + return text.replace(/\{(\w+)\}/g, (match, key) => { + if (Object.prototype.hasOwnProperty.call(params, key)) { + return String(params[key]); + } + return match; + }); +} + +/** + * Checks whether a dot-notation key exists in the English catalog. + * + * @param {string} key - e.g. `errors.auth.invalidCredentials` + * @returns {boolean} `true` when the key is defined in `utils/modules/i18n/en/*.json` + */ +function hasKey(key) { + const trans = loadTranslations(); + return Object.prototype.hasOwnProperty.call(trans, key); +} + +/** + * Translates a value when it is a known i18n key; otherwise returns it unchanged. + * Always uses the English catalog (not the user's UI locale). + * + * Used for dashboard logs, socket callback legacy ``message`` fields, + * and migration `down` steps that restore English source text when rolling back rows + * that store i18n keys in the database. + * + * @param {string} value - i18n key or plain English string + * @param {Object} [params={}] - Optional placeholder values for keys like "Hello {name}" + * @returns {string|null|undefined} English text when `value` is a known key; otherwise `value` unchanged + */ +function translateMaybeKey(value, params = {}) { + if (value === undefined || value === null) { + return value; + } + if (hasKey(value)) { + const trans = loadTranslations(); + return interpolate(trans[value], params); + } + return value; +} + +module.exports = { hasKey, loadTranslations, translateMaybeKey }; diff --git a/backend/utils/logger.js b/backend/utils/logger.js index 3f99bf13a..ffb9921bb 100644 --- a/backend/utils/logger.js +++ b/backend/utils/logger.js @@ -1,6 +1,7 @@ const winston = require('winston'); const Transport = require('winston-transport'); require('winston-daily-rotate-file'); +const { translateMaybeKey } = require('./i18n'); const logging_dir = process.env.LOGGING_PATH; @@ -22,8 +23,14 @@ class SQLTransport extends Transport { message = message.slice(0, 1021) + '...'; } + // Dashboard logs (Log.vue) are always stored in English. + const i18nParams = info.i18nParams || {}; + message = translateMaybeKey(message, i18nParams); + // 🟢 Use safe, short message for DB insert const safeInfo = {...info, message}; + // delete i18n metadata for translation only + delete safeInfo.i18nParams; for (const [k, v] of Object.entries(safeInfo)) { if (typeof v === 'number' && !Number.isFinite(v)) { diff --git a/backend/utils/templateResolver.js b/backend/utils/templateResolver.js index f01afeedf..051872134 100644 --- a/backend/utils/templateResolver.js +++ b/backend/utils/templateResolver.js @@ -8,6 +8,7 @@ */ const Delta = require("quill-delta"); const {deltaToPlainText} = require("editor-delta-conversion"); +const TranslatableError = require("./TranslatableError"); /** * Extract plain text from Quill Delta operations @@ -194,16 +195,16 @@ async function getTemplateContentForLanguage(templateId, language, models, optio */ async function resolveTemplate(templateId, context, models, options = {}) { if (!templateId) { - throw new Error("Template ID is required"); + throw new Error("errors.templates.templateIdRequired"); } if (!models) { - throw new Error("Models object is required"); + throw new Error("errors.templates.modelsObjectRequired"); } const template = await models["template"].getById(templateId, options); if (!template) { - throw new Error(`Template with ID ${templateId} not found`); + throw new TranslatableError( "errors.templates.withUpperIdNotFound", {templateId}); } if (context.studyId && context.anonymize === undefined) { @@ -263,16 +264,16 @@ async function resolveTemplate(templateId, context, models, options = {}) { */ async function resolveTemplateToDelta(templateId, context, models, options = {}) { if (!templateId) { - throw new Error("Template ID is required"); + throw new Error("errors.templates.templateIdRequired"); } if (!models) { - throw new Error("Models object is required"); + throw new Error("errors.templates.modelsObjectRequired"); } const template = await models["template"].getById(templateId, options); if (!template) { - throw new Error(`Template with ID ${templateId} not found`); + throw new TranslatableError( "errors.templates.withUpperIdNotFound", {templateId}); } if (context.studyId && context.anonymize === undefined) { @@ -356,12 +357,34 @@ async function getMissingRequiredPlaceholders(content, templateType, models, opt return missing; } +/** + * Build i18n key and params for a missing-placeholder validation failure. + * + * @param {string[]} missing placeholder keys still required + * @param {Object} [options] + * @param {string} [options.action="saving"] saving | publishing | assigning + * @param {string} [options.language] template_content language code, when checking one locale + * @returns {{ key: string, params: Object }} i18n key + params (tokens; language if set) for the caller to throw + */ function formatMissingPlaceholderError(missing, { action = "saving", language } = {}) { const tokens = missing.map((k) => `~${k}~`).join(", "); + const inLanguageKeys = { + publishing: "errors.templates.missingRequiredPlaceholdersInLanguagePublishing", + assigning: "errors.templates.missingRequiredPlaceholdersInLanguageAssigning", + }; + const plainKeys = { + saving: "errors.templates.missingRequiredPlaceholders", + publishing: "errors.templates.missingRequiredPlaceholdersPublishing", + assigning: "errors.templates.missingRequiredPlaceholdersAssigning", + }; + if (language) { - return `This email template must include the required placeholder(s): ${tokens} in ${language} before ${action}. Add them from the toolbar in the template editor.`; + const key = inLanguageKeys[action] || inLanguageKeys.publishing; + return { key, params: { tokens, language } }; } - return `This email template must include the required placeholder(s): ${tokens}. Add them from the toolbar before ${action}.`; + + const key = plainKeys[action] || plainKeys.saving; + return { key, params: { tokens } }; } /** @@ -377,7 +400,7 @@ async function assertStableEmailTemplateContent(templateId, models, options = {} const action = options.action || "publishing"; const template = await models["template"].getById(templateId, options); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } if (![1, 2, 3, 6, 7].includes(template.type)) { return; @@ -392,7 +415,8 @@ async function assertStableEmailTemplateContent(templateId, models, options = {} if (!rows || rows.length === 0) { const missing = await getMissingRequiredPlaceholders({ ops: [] }, template.type, models, options); if (missing.length > 0) { - throw new Error(formatMissingPlaceholderError(missing, { action })); + const { key, params } = formatMissingPlaceholderError(missing, { action }); + throw new TranslatableError(key, params); } return; } @@ -401,7 +425,8 @@ async function assertStableEmailTemplateContent(templateId, models, options = {} const content = row.content && row.content.ops ? { ops: row.content.ops } : { ops: [] }; const missing = await getMissingRequiredPlaceholders(content, template.type, models, options); if (missing.length > 0) { - throw new Error(formatMissingPlaceholderError(missing, { action, language: row.language })); + const { key, params } = formatMissingPlaceholderError(missing, { action, language: row.language }); + throw new TranslatableError(key, params); } } } diff --git a/backend/utils/text.js b/backend/utils/text.js index 8f40433b9..de6f8e108 100644 --- a/backend/utils/text.js +++ b/backend/utils/text.js @@ -1,3 +1,5 @@ +const TranslatableError = require("./TranslatableError"); + /** * Get the start and end positions of exact text within whole text, along with prefix and suffix * @param {string} exactText - The text to find positions for @@ -8,7 +10,7 @@ function getTextPositions(exactText, wholeText) { const start = wholeText.indexOf(exactText); if (start === -1) { - throw new Error('Exact text not found in whole text'); + throw new TranslatableError( "errors.documents.exactTextNotFound"); } const end = start + exactText.length; diff --git a/backend/utils/validator.js b/backend/utils/validator.js index 97cfbc29b..fd09dbea1 100644 --- a/backend/utils/validator.js +++ b/backend/utils/validator.js @@ -1,6 +1,7 @@ const yauzl = require("yauzl"); const path = require("path"); const fs = require("fs"); +const TranslatableError = require("./TranslatableError"); const UPLOAD_PATH = `${__dirname}/../../files`; /** @@ -39,7 +40,7 @@ class Validator { tempFiles.push(tempFile); } catch (error) { - throw new Error(`Failed to download file ${file.fileName}: ${error.message}`); + throw new TranslatableError( "errors.validation.fileDownloadFailed", {fileName: file.fileName, message: error.message}); } } @@ -62,9 +63,20 @@ class Validator { return validationResult; } catch (error) { + const message = error.key || error.message; + // Keep i18n keys as messages; wrapping them would turn the key into plain text. + if (message && /^errors\./.test(message)) { + return { + success: false, + message, + params: error.params, + }; + } + return { success: false, - message: `Validation error: ${error.message}`, + message: "errors.validation.validationErrorWithMessage", + params: {message: error.message}, }; } } @@ -77,7 +89,7 @@ class Validator { async getValidationSchema(validationConfigurationId) { const configuration = await this.models["configuration"].getById(validationConfigurationId); if (!configuration) { - throw new Error(`Validation schema not found: ${validationConfigurationId}`); + throw new TranslatableError("errors.validation.schemaNotFound", {validationConfigurationId}); } const { content } = configuration; return content; @@ -111,7 +123,8 @@ class Validator { if (!rules.additionalFilesAreAllowed && others.length > 0) { return { success: false, - message: `Additional files not allowed. Found: ${others.map((f) => f.fileName).join(", ")}`, + message: "errors.validation.additionalFilesNotAllowed", + params: {files: others.map((f) => f.fileName).join(", ")}, }; } @@ -141,7 +154,8 @@ class Validator { if (required && matchingFiles.length === 0) { return { success: false, - message: `Required file missing: ${description || pattern}`, + message: "errors.validation.requiredFileMissing", + params: {file: description || pattern}, }; } @@ -174,7 +188,8 @@ class Validator { if (err) { return resolve({ success: false, - message: `Cannot open ZIP file ${zipFile.fileName}: ${err.message}`, + message: "errors.validation.cannotOpenZip", + params: {fileName: zipFile.fileName, message: err.message}, }); } @@ -226,14 +241,21 @@ class Validator { if (includeFile.required && matches.length === 0) { return resolve({ success: false, - message: `Required file missing in ZIP ${zipFile.fileName}: ${includeFile.description || includeFile.pattern}`, + message: "errors.validation.requiredFileMissingInZip", + params: {zipFileName: zipFile.fileName, file: includeFile.description || includeFile.pattern}, }); } if (includeFile.maxMatches && matches.length > includeFile.maxMatches) { return resolve({ success: false, - message: `Too many matches for ${includeFile.description || includeFile.pattern} in ${zipFile.fileName}: found ${matches.length}, max ${includeFile.maxMatches}`, + message: "errors.validation.tooManyMatchesInZip", + params: { + file: includeFile.description || includeFile.pattern, + zipFileName: zipFile.fileName, + found: matches.length, + max: includeFile.maxMatches, + }, }); } @@ -249,7 +271,8 @@ class Validator { if (pathParts.length > 1) { return resolve({ success: false, - message: `Files must be located either at the ZIP root or within a single top-level directory in ${zipFile.fileName}. Found file in a nested subdirectory: ${entry}`, + message: "errors.validation.filesMustBeLocatedInZipRoot", + params: {zipFileName: zipFile.fileName, entry}, }); } @@ -258,9 +281,12 @@ class Validator { if (!extension || !allowAdditionalFiles.includes(extension)) { return resolve({ success: false, - message: `Disallowed file found in ZIP ${ - zipFile.fileName - }: ${entry}. Allowed additional file types: ${allowAdditionalFiles.join(", ")}`, + message: "errors.validation.disallowedFileFoundInZip", + params: { + zipFileName: zipFile.fileName, + entry, + types: allowAdditionalFiles.join(", "), + }, }); } } @@ -272,7 +298,8 @@ class Validator { zipfile.on("error", (error) => { resolve({ success: false, - message: `Error reading ZIP file ${zipFile.fileName}: ${error.message}`, + message: "errors.validation.zipReadError", + params: {fileName: zipFile.fileName, message: error.message}, }); }); }); diff --git a/backend/webserver/RPC.js b/backend/webserver/RPC.js index a9abad3b8..583ac80a8 100644 --- a/backend/webserver/RPC.js +++ b/backend/webserver/RPC.js @@ -158,11 +158,11 @@ module.exports = class RPC { this.logger.info("Emitting event to RPC service..."); if (!this.socket) { - throw new Error("RPC service not connected"); + throw new Error("errors.rpc.serviceNotConnected"); } if (!this.socket) { - throw new Error("RPC service not connected"); + throw new Error("errors.rpc.serviceNotConnected"); } try { diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 027bc0952..24fe27778 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -1,4 +1,5 @@ const {inject} = require("../utils/generic"); +const i18n = require("../utils/i18n"); const {Sequelize, Op} = require("sequelize"); const _ = require("lodash"); const {EWMAMonitor} = require("../utils/EWMAMonitor") @@ -96,14 +97,56 @@ module.exports = class Socket { } console.log(err); - this.logger.error(err.message); - if (callback) { - const response = {success: false, message: err.message}; - if (err.code) { - response.code = err.code; + // i18n error hub: TranslatableError, generateError(code, key), Error("errors.*") + // Log the key; SQLTransport translates to English. Frontend gets key+params (resolveApiMessage). + let key; + let params = {}; + if (err.isTranslatable || err.key) { + // TranslatableError: err.key + optional err.params (+ optional err.code) + key = err.key; + if (err.params) { + params = err.params; + } + } else if (typeof err.message === "string" && i18n.hasKey(err.message)) { + // throw new Error("errors.namespace.key") or generateError(code, "errors.*") + key = err.message; + if (err.params) { + params = err.params; + } + } + + if (key) { + this.logger.error(key, { i18nParams: params }); + if (callback) { + // key/params → localized UI; message (EN) → legacy fallback + const response = { + success: false, + key, + params, + message: i18n.translateMaybeKey(key, params), + }; + if (err.code) { + response.code = err.code; + } + callback(response); + } + } else { + // Not an i18n key (bug, DB failure, etc.): log full detail, generic message to user + this.logger.error({ + message: err.message, + stack: err.stack, + name: err.name, + }); + if (callback) { + const unexpectedKey = "errors.server.unexpectedError"; + callback({ + success: false, + key: unexpectedKey, + params: {}, + message: i18n.translateMaybeKey(unexpectedKey), + }); } - callback(response); } } finally { diff --git a/backend/webserver/auth/strategies.js b/backend/webserver/auth/strategies.js index 0de61ec80..6b41cc20c 100644 --- a/backend/webserver/auth/strategies.js +++ b/backend/webserver/auth/strategies.js @@ -40,12 +40,12 @@ async function setupLocalStrategy(server) { passport.use('local-login', new LocalStrategy(async (username, password, cb) => { try { const user = await server.db.models['user'].find(username); - if (!user) return cb(null, false, { message: 'Incorrect username or password.' }); + if (!user) return cb(null, false, { message: 'auth.api.incorrectUsernameOrPassword' }); crypto.pbkdf2(password, user.salt, 310000, 32, 'sha256', (err, hashedPassword) => { if (err) return cb(err); if (!crypto.timingSafeEqual(Buffer.from(user.passwordHash, 'hex'), hashedPassword)) { - return cb(null, false, { message: 'Incorrect username or password.' }); + return cb(null, false, { message: 'auth.api.incorrectUsernameOrPassword' }); } // filter row object, because not everything is the right information for website return cb(null, relevantFields(user)); diff --git a/backend/webserver/routes/auth/login.js b/backend/webserver/routes/auth/login.js index ed3b0610b..5f11ae4e1 100644 --- a/backend/webserver/routes/auth/login.js +++ b/backend/webserver/routes/auth/login.js @@ -21,7 +21,7 @@ function registerLoginRoutes(server, helpers) { passport.authenticate('local-login', async (err, user, info) => { if (err) { server.logger.error('Login failed: ' + err); - return res.status(500).send('Failed to login'); + return res.status(500).json({ message: 'auth.api.failedToLogin' }); } if (!user) { server.logger.info('User not found: ' + JSON.stringify(info)); @@ -31,7 +31,7 @@ function registerLoginRoutes(server, helpers) { const emailVerificationEnabled = String(await server.db.models['setting'].get('app.register.emailVerification')) === 'true'; if (emailVerificationEnabled && !user.emailVerified) { return res.status(401).json({ - message: 'Please verify your email address before logging in.', + message: 'auth.api.emailNotVerifiedBeforeLogin', emailNotVerified: true, email: user.email, }); @@ -52,12 +52,12 @@ function registerLoginRoutes(server, helpers) { shared.setPostLoginRedirectPath(req, req.body?.redirectedFrom || req.query?.redirectedFrom); if (!(await shared.isLoginMethodEnabled('ldap'))) { - return res.status(403).json({ message: 'LDAP login is disabled by the administrator.' }); + return res.status(403).json({ message: 'auth.api.ldapLoginDisabled' }); } if (!server.isAuthProviderReady('ldap')) { const status = server.getAuthProviderStatus('ldap'); return res.status(503).json({ - message: 'LDAP login is enabled but not fully configured. Please contact an administrator.', + message: 'auth.api.ldapLoginNotReady', reason: status.reason, }); } @@ -65,10 +65,10 @@ function registerLoginRoutes(server, helpers) { passport.authenticate('ldap-login', async (err, user, info) => { if (err) { server.logger.error('LDAP login failed: ' + err); - return res.status(500).send('Failed to login'); + return res.status(500).json({ message: 'auth.api.failedToLogin' }); } if (!user) { - return res.status(401).send(info || { message: 'LDAP login failed.' }); + return res.status(401).json(info || { message: 'auth.api.ldapLoginFailed' }); } const handled = await twoFactor.startTwoFactorLogin(req, res, user.id, { mode: 'json', loginMethod: 'ldap' }); @@ -227,7 +227,7 @@ function registerLoginRoutes(server, helpers) { } req.session.destroy(); res.clearCookie('connect.sid'); - return res.status(200).send('Session destroyed!'); + return res.status(200).json({ message: 'auth.api.sessionDestroyed' }); }); }); @@ -257,7 +257,7 @@ function registerLoginRoutes(server, helpers) { }); } catch (err) { server.logger.error('auth/check error: ' + err); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.api.internalServerError' }); } }); } diff --git a/backend/webserver/routes/auth/password.js b/backend/webserver/routes/auth/password.js index 72982ae94..fc8a538c0 100644 --- a/backend/webserver/routes/auth/password.js +++ b/backend/webserver/routes/auth/password.js @@ -18,16 +18,16 @@ function registerPasswordRoutes(server, helpers) { const { email: userEmail } = req.body; if (await server.db.models['setting'].get('app.login.forgotPassword') !== 'true') { - return res.status(400).json({ message: 'Password reset is disabled.' }); + return res.status(400).json({ message: 'auth.api.passwordResetDisabled' }); } if (!userEmail) { - return res.status(400).json({ message: 'Please provide an email.' }); + return res.status(400).json({ message: 'auth.api.provideEmail' }); } try { const user = await server.db.models['user'].findOne({ where: { email: userEmail } }); if (!user) { - return res.status(401).json({ message: 'User with this email does not exist.' }); + return res.status(401).json({ message: 'auth.api.userNotFoundByEmail' }); } // Rate limiting: check if a password reset email was sent recently @@ -35,7 +35,8 @@ function registerPasswordRoutes(server, helpers) { const rateLimitCheck = email.checkEmailRateLimit(user, 'passwordReset', rateLimitMinutes); if (!rateLimitCheck.allowed) { return res.status(400).json({ - message: `Please wait ${rateLimitCheck.remainingTime} minute(s) before requesting another password reset email.`, + message: 'auth.api.passwordResetRateLimited', + params: { minutes: rateLimitCheck.remainingTime }, }); } @@ -64,10 +65,10 @@ function registerPasswordRoutes(server, helpers) { ); await server.sendMail(user.email, emailContent.subject, emailContent.body, { isHtml: emailContent.isHtml }); - return res.status(200).json({ message: 'A password reset link has been sent.' }); + return res.status(200).json({ message: 'auth.api.passwordResetLinkSent' }); } catch (err) { server.logger.error('Failed to find user:', err); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.api.internalServerError' }); } }); @@ -77,10 +78,10 @@ function registerPasswordRoutes(server, helpers) { server.app.post('/auth/reset-password', async (req, res) => { const { token, newPassword } = req.body; if (await server.db.models['setting'].get('app.login.forgotPassword') !== 'true') { - return res.status(400).json({ message: 'Password reset is disabled.' }); + return res.status(400).json({ message: 'auth.api.passwordResetDisabled' }); } if (!token || !newPassword) { - return res.status(400).json({ message: 'Token and new password are required.' }); + return res.status(400).json({ message: 'auth.api.tokenAndPasswordRequired' }); } try { @@ -93,16 +94,16 @@ function registerPasswordRoutes(server, helpers) { // Decode the token and check expiry const decoded = decodeToken(token); if (!decoded.isValid) { - return res.status(400).json({ message: 'Invalid token format.' }); + return res.status(400).json({ message: 'auth.api.invalidTokenFormat' }); } if (decoded.expired) { - return res.status(400).json({ message: 'Token has expired.' }); + return res.status(400).json({ message: 'auth.api.tokenExpired' }); } // Find user by the full token stored in database const user = await server.db.models['user'].findOne({ where: { resetToken: token } }); if (!user) { - return res.status(400).json({ message: 'Invalid token.' }); + return res.status(400).json({ message: 'auth.api.invalidToken' }); } // Reset password and clear the reset token @@ -124,10 +125,10 @@ function registerPasswordRoutes(server, helpers) { emailContent.body, { isHtml: emailContent.isHtml } ); - return res.status(200).json({ message: 'Password has been reset successfully.' }); + return res.status(200).json({ message: 'auth.api.passwordResetSuccess' }); } catch (err) { server.logger.error('Failed to reset password:', err); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.api.internalServerError' }); } }); @@ -137,32 +138,32 @@ function registerPasswordRoutes(server, helpers) { server.app.get('/auth/check-reset-token', async (req, res) => { const { token } = req.query; if (!token) { - return res.status(400).json({ message: 'Token is required.' }); + return res.status(400).json({ message: 'auth.api.tokenRequired' }); } try { // Decode and validate token format/expiry const decoded = decodeToken(token); if (!decoded.isValid) { - return res.status(400).json({ message: 'Invalid token format.' }); + return res.status(400).json({ message: 'auth.api.invalidTokenFormat' }); } if (decoded.expired) { - return res.status(400).json({ message: 'Token has expired.' }); + return res.status(400).json({ message: 'auth.api.tokenExpired' }); } // Check if token exists in database const user = await server.db.models['user'].findOne({ where: { resetToken: token } }); if (!user) { - return res.status(404).json({ message: 'Token not found.' }); + return res.status(404).json({ message: 'auth.api.tokenNotFound' }); } return res.status(200).json({ - message: 'Token is valid.', + message: 'auth.api.tokenValid', expiryTime: decoded.expiryTime, }); } catch (error) { server.logger.error('Failed to check reset token:', error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.api.internalServerError' }); } }); } diff --git a/backend/webserver/routes/auth/registration.js b/backend/webserver/routes/auth/registration.js index 8af5b7f17..6bdfe030c 100644 --- a/backend/webserver/routes/auth/registration.js +++ b/backend/webserver/routes/auth/registration.js @@ -20,31 +20,31 @@ function registerRegistrationRoutes(server, helpers) { // Check if self-registration is enabled const isSelfRegistrationEnabled = await server.db.models['setting'].get('app.register.enabled'); if (!isSelfRegistrationEnabled) { - return res.status(403).json({ message: 'Self-registration is currently disabled. Please contact an administrator to create an account.' }); + return res.status(403).json({ message: 'auth.api.selfRegistrationDisabled' }); } // Check if name fields are required by settings if ((await server.db.models['setting'].get('app.register.requestName')) === 'true') { if (!data.firstName) { - return res.status(400).json({ message: 'Please provide a first name.' }); + return res.status(400).json({ message: 'auth.api.provideFirstName' }); } if (!data.lastName) { - return res.status(400).json({ message: 'Please provide a last name.' }); + return res.status(400).json({ message: 'auth.api.provideLastName' }); } } // Check if email is present and not already taken if (!data.email) { - return res.status(400).json({ message: 'Please provide a email.' }); + return res.status(400).json({ message: 'auth.api.provideAEmail' }); } const emailUser = await server.db.models['user'].getUserIdByEmail(data.email); if (emailUser !== 0) { - return res.status(400).json({ message: 'E-Mail already taken.' }); + return res.status(400).json({ message: 'auth.api.emailAlreadyTaken' }); } // Validate password presence and policy if (!data.password) { - return res.status(400).json({ message: 'Please provide a password.' }); + return res.status(400).json({ message: 'auth.api.providePassword' }); } try { server.db.models['user'].validatePasswordContent(data.password); @@ -53,17 +53,17 @@ function registerRegistrationRoutes(server, helpers) { } if (!data.acceptTerms && !data.isCreatedByAdmin) { - return res.status(400).json({ message: 'Please agree to the terms of use.' }); + return res.status(400).json({ message: 'auth.api.agreeToTerms' }); } // Check if username is present and not already taken if (!data.userName) { - return res.status(400).json({ message: 'Please provide a user name.' }); + return res.status(400).json({ message: 'auth.api.provideUserName' }); } const userNameUser = await server.db.models['user'].getUserIdByName(data.userName); if (userNameUser !== 0) { server.logger.info('Username already taken: ' + data.userName); - return res.status(400).json({ message: 'Username already taken.' }); + return res.status(400).json({ message: 'auth.api.usernameTaken' }); } let transaction; @@ -116,19 +116,19 @@ function registerRegistrationRoutes(server, helpers) { ); await transaction.commit(); return res.status(201).json({ - message: 'User was successfully created. Please check your email to verify your account.', + message: 'auth.api.userCreatedVerifyEmail', emailVerificationRequired: true, }); } await transaction.commit(); - return res.status(201).send('User was successfully created'); + return res.status(201).json({ message: 'auth.api.userCreated' }); } catch (err) { if (transaction) { await transaction.rollback(); } server.logger.error('Cannot create user:', err); - return res.status(400).json({ message: 'Failed to create user', error: err.message }); + return res.status(400).json({ message: 'auth.api.failedToCreateUser', error: err.message }); } }); } diff --git a/backend/webserver/routes/auth/shared.js b/backend/webserver/routes/auth/shared.js index 93ed80867..f3bf6e460 100644 --- a/backend/webserver/routes/auth/shared.js +++ b/backend/webserver/routes/auth/shared.js @@ -130,7 +130,7 @@ function createSharedHelpers(server) { req.logIn(user, async (err) => { if (err) { server.logger.error(`[Auth] Passport login failed for user ${user.id}: ${err}`); - return res.status(500).json({ message: 'Failed to establish login session.' }); + return res.status(500).json({ message: 'auth.api.failedToEstablishLoginSession' }); } // Pending 2FA state is only needed during login and should not survive a successful login. @@ -176,7 +176,7 @@ function createSharedHelpers(server) { if (req.isAuthenticated()) { return next(); } - return res.status(401).json({ message: 'Authentication required' }); + return res.status(401).json({ message: 'auth.api.authenticationRequired' }); } return { diff --git a/backend/webserver/routes/auth/twoFactor/loginFlow.js b/backend/webserver/routes/auth/twoFactor/loginFlow.js index 677d2af68..39c26d00a 100644 --- a/backend/webserver/routes/auth/twoFactor/loginFlow.js +++ b/backend/webserver/routes/auth/twoFactor/loginFlow.js @@ -19,15 +19,15 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { const { method } = req.body; if (!req.session || !req.session.twoFactorPending) { - return res.status(400).json({ message: 'No pending 2FA verification found. Please login again.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingVerification' }); } const pending = req.session.twoFactorPending; if (!method || !pending.methods || !Array.isArray(pending.methods)) { - return res.status(400).json({ message: 'Missing 2FA method selection.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.missingMethodSelection' }); } if (!pending.methods.includes(method)) { - return res.status(400).json({ message: 'Selected 2FA method is not enabled for this user.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.selectedMethodNotEnabled' }); } const userRecord = await server.db.models['user'].findOne({ @@ -36,7 +36,7 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { }); if (!userRecord) { delete req.session.twoFactorPending; - return res.status(400).json({ message: 'User not found.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.userNotFound' }); } pending.method = method; @@ -46,7 +46,7 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { try { if (method === 'email') { if (!userRecord.email) { - return res.status(400).json({ message: 'Email address not found. Cannot use email 2FA.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailAddressNotFound' }); } await twoFactor.sendEmailOtp(userRecord); twoFactor.resetTwoFactorFailedAttempts(pending); @@ -56,15 +56,15 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { if (method === 'totp') { if (!userRecord.totpSecret) { - return res.status(400).json({ message: 'TOTP is enabled but not configured (missing secret).' }); + return res.status(400).json({ message: 'auth.twoFactor.api.totpMissingSecret' }); } return req.session.save(() => res.status(200).json({ requiresTwoFactor: true, method: 'totp' })); } - return res.status(400).json({ message: `Unsupported 2FA method: ${method}` }); + return res.status(400).json({ message: 'auth.twoFactor.api.unsupportedMethod', params: { method } }); } catch (error) { server.logger.error('Failed to apply 2FA selection: ' + error); - return res.status(500).json({ message: 'Failed to start selected 2FA method.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.startSelectedMethodFailed' }); } }); @@ -74,13 +74,13 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { server.app.post('/auth/2fa/email/verify', async (req, res) => { const { otp } = req.body; if (!otp) { - return res.status(400).json({ message: 'OTP is required.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.otpRequired' }); } if (!req.session || !req.session.twoFactorPending) { - return res.status(400).json({ message: 'No pending 2FA verification found. Please login again.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingVerification' }); } if (req.session.twoFactorPending.method !== 'email') { - return res.status(400).json({ message: 'Email 2FA is not the selected method.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailNotSelected' }); } try { @@ -91,15 +91,15 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { if (!user) { delete req.session.twoFactorPending; - return res.status(400).json({ message: 'User not found.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.userNotFound' }); } if (!user.twoFactorOtp || user.twoFactorOtp !== otp) { return await twoFactor.handleFailedTwoFactorAttempt(req, res, { userId: user.id, clearEmailOtp: true, - errorMessage: 'Invalid OTP code.', - tooManyAttemptsErrorMessage: 'Too many invalid OTP attempts. Please login again.', + errorMessage: 'auth.twoFactor.api.invalidOtpAttemptsRemaining', + tooManyAttemptsErrorMessage: 'auth.twoFactor.api.tooManyInvalidOtpAttempts', }); } @@ -109,7 +109,7 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { { where: { id: user.id } } ); delete req.session.twoFactorPending; - return res.status(400).json({ message: 'OTP has expired. Please request a new one.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.otpExpired' }); } await server.db.models['user'].update( @@ -121,7 +121,7 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { return shared.finalizeLogin(req, res, user, { mode: 'json' }); } catch (error) { server.logger.error('Failed to verify OTP: ' + error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.twoFactor.api.internalServerError' }); } }); @@ -131,10 +131,10 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { server.app.get('/auth/2fa/email/status', async (req, res) => { const pending = req.session?.twoFactorPending; if (!pending) { - return res.status(400).json({ message: 'No pending 2FA verification found. Please login again.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingVerification' }); } if (pending.method !== 'email') { - return res.status(400).json({ message: 'Email 2FA is not the selected method.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailNotSelected' }); } return res.status(200).json(twoFactor.getEmailOtpCooldownInfo(pending)); @@ -151,12 +151,12 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { server.app.post('/auth/2fa/totp/verify', async (req, res, next) => { const token = String(req.body.token || '').replace(/\s/g, ''); if (!token) { - return res.status(400).json({ message: 'TOTP token is required.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.totpTokenRequired' }); } const pending = req.session?.twoFactorPending; if (!pending?.userId) { - return res.status(401).json({ message: 'Invalid TOTP code' }); + return res.status(401).json({ message: 'auth.twoFactor.api.invalidTotpCodeNoPeriod' }); } try { @@ -170,18 +170,18 @@ function registerTwoFactorLoginFlowRoutes(server, helpers) { return req.session.save((saveErr) => { if (saveErr) { server.logger.error('Failed to save session: ' + saveErr); - return res.status(500).json({ message: 'Session error during 2FA.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.sessionErrorDuring2fa' }); } - return res.status(401).json({ message: 'Invalid TOTP code' }); + return res.status(401).json({ message: 'auth.twoFactor.api.invalidTotpCodeNoPeriod' }); }); } const totp = new TOTP({ secret: user.totpSecret, digits: 6, period: 30 }); if (totp.validate({ token, window: 1 }) === null) { return await twoFactor.handleFailedTwoFactorAttempt(req, res, { - errorMessage: 'Invalid TOTP code.', - tooManyAttemptsErrorMessage: 'Too many invalid TOTP attempts. Please login again.', + errorMessage: 'auth.twoFactor.api.invalidTotpAttemptsRemaining', + tooManyAttemptsErrorMessage: 'auth.twoFactor.api.tooManyInvalidTotpAttempts', }); } diff --git a/backend/webserver/routes/auth/twoFactor/settings.js b/backend/webserver/routes/auth/twoFactor/settings.js index f513fc633..e8b93f85c 100644 --- a/backend/webserver/routes/auth/twoFactor/settings.js +++ b/backend/webserver/routes/auth/twoFactor/settings.js @@ -18,7 +18,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { server.app.post('/auth/2fa/totp/setup/initiate', shared.ensureAuthenticated, async (req, res) => { const user = await server.db.models['user'].findOne({ where: { id: req.user.id }, raw: true }); if (!user) { - return res.status(404).json({ message: 'User not found.' }); + return res.status(404).json({ message: 'auth.twoFactor.api.userNotFound' }); } const secret = new Secret({ size: 20 }); @@ -35,7 +35,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { return req.session.save((err) => { if (err) { server.logger.error('Failed to save session for TOTP setup: ' + err); - return res.status(500).json({ message: 'Failed to initiate TOTP setup.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.totpSetupInitiateFailed' }); } return res.status(200).json({ otpauthUrl: totp.toString(), @@ -50,16 +50,16 @@ function registerTwoFactorSettingsRoutes(server, helpers) { server.app.post('/auth/2fa/totp/setup/verify', shared.ensureAuthenticated, async (req, res, next) => { const { token } = req.body; if (!token) { - return res.status(400).json({ message: 'TOTP token is required.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.totpTokenRequired' }); } if (!req.session?.totpSetupPending?.secretBase32) { - return res.status(400).json({ message: 'No pending TOTP setup found.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingTotpSetup' }); } const secretBase32 = req.session.totpSetupPending.secretBase32; const totp = new TOTP({ secret: secretBase32, digits: 6, period: 30 }); if (totp.validate({ token: String(token).trim(), window: 1 }) === null) { - return res.status(401).json({ message: 'Invalid TOTP code.' }); + return res.status(401).json({ message: 'auth.twoFactor.api.invalidTotpCode' }); } try { @@ -67,7 +67,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { where: { id: req.user.id }, }); if (!dbUser) { - return res.status(404).json({ message: 'User not found.' }); + return res.status(404).json({ message: 'auth.twoFactor.api.userNotFound' }); } const currentMethods = Array.isArray(dbUser.twoFactorMethods) ? [...dbUser.twoFactorMethods] : []; @@ -86,7 +86,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { delete req.session.totpSetupPending; return res.status(200).json({ - message: 'TOTP configured successfully.', + message: 'auth.twoFactor.api.totpConfiguredSuccessfully', twoFactorMethods: currentMethods, }); } catch (err) { @@ -99,7 +99,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { */ server.app.get('/auth/2fa/status', async (req, res) => { if (!req.user) { - return res.status(401).json({ message: 'You must be logged in to check 2FA status.' }); + return res.status(401).json({ message: 'auth.twoFactor.api.loginRequiredCheckStatus' }); } try { @@ -108,7 +108,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { attributes: ['twoFactorMethods', 'totpSecret', 'email', 'orcidId'], }); if (!user) { - return res.status(404).json({ message: 'User not found.' }); + return res.status(404).json({ message: 'auth.twoFactor.api.userNotFound' }); } const methods = twoFactor.getTwoFactorMethods(user); @@ -123,7 +123,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { }); } catch (error) { server.logger.error('Failed to get 2FA status: ' + error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.twoFactor.api.internalServerError' }); } }); @@ -132,12 +132,12 @@ function registerTwoFactorSettingsRoutes(server, helpers) { */ server.app.post('/auth/2fa/enable', async (req, res) => { if (!req.user) { - return res.status(401).json({ message: 'You must be logged in to enable 2FA.' }); + return res.status(401).json({ message: 'auth.twoFactor.api.loginRequiredEnable' }); } const { method } = req.body; if (!method || !['email', 'totp'].includes(method)) { - return res.status(400).json({ message: 'Valid 2FA method is required (email or totp).' }); + return res.status(400).json({ message: 'auth.twoFactor.api.validMethodRequired' }); } try { @@ -145,13 +145,13 @@ function registerTwoFactorSettingsRoutes(server, helpers) { where: { id: req.user.id }, }); if (!user) { - return res.status(404).json({ message: 'User not found.' }); + return res.status(404).json({ message: 'auth.twoFactor.api.userNotFound' }); } if (method === 'email' && !user.email) { - return res.status(400).json({ message: 'Email address is required to enable email 2FA.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailRequiredFor2fa' }); } if (method === 'totp') { - return res.status(400).json({ message: 'Use /auth/2fa/totp/setup/initiate and /auth/2fa/totp/setup/verify to enable TOTP (requires setup + verification).' }); + return res.status(400).json({ message: 'auth.twoFactor.api.useTotpSetup' }); } const currentMethods = Array.isArray(user.twoFactorMethods) ? user.twoFactorMethods.slice() : []; @@ -165,12 +165,13 @@ function registerTwoFactorSettingsRoutes(server, helpers) { ); return res.status(200).json({ - message: `2FA has been enabled with ${method} method.`, + message: 'auth.twoFactor.api.enabledWithMethod', + params: { method }, twoFactorMethods: currentMethods, }); } catch (error) { server.logger.error('Failed to enable 2FA: ' + error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.twoFactor.api.internalServerError' }); } }); @@ -181,7 +182,7 @@ function registerTwoFactorSettingsRoutes(server, helpers) { server.app.post('/auth/2fa/disable/:method', shared.ensureAuthenticated, async (req, res) => { const method = req.params.method; if (!['email', 'totp'].includes(method)) { - return res.status(400).json({ message: 'Valid 2FA method is required (email or totp).' }); + return res.status(400).json({ message: 'auth.twoFactor.api.validMethodRequired' }); } try { @@ -189,12 +190,12 @@ function registerTwoFactorSettingsRoutes(server, helpers) { where: { id: req.user.id }, }); if (!user) { - return res.status(404).json({ message: 'User not found.' }); + return res.status(404).json({ message: 'auth.twoFactor.api.userNotFound' }); } const currentMethods = twoFactor.getTwoFactorMethods(user); if (!currentMethods.includes(method)) { - return res.status(400).json({ message: `2FA method '${method}' is not enabled for this user.` }); + return res.status(400).json({ message: 'auth.twoFactor.api.methodNotEnabledForUser', params: { method } }); } const updatedMethods = currentMethods.filter((entry) => entry !== method); @@ -216,12 +217,13 @@ function registerTwoFactorSettingsRoutes(server, helpers) { await server.db.models['user'].update(updateData, { where: { id: user.id } }); return res.status(200).json({ - message: `2FA method '${method}' has been disabled.`, + message: 'auth.twoFactor.api.methodDisabled', + params: { method }, twoFactorMethods: updatedMethods, }); } catch (error) { server.logger.error('Failed to disable 2FA method: ' + error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.twoFactor.api.internalServerError' }); } }); } diff --git a/backend/webserver/routes/auth/twoFactor/shared.js b/backend/webserver/routes/auth/twoFactor/shared.js index 9b640995a..3fe2a6f9a 100644 --- a/backend/webserver/routes/auth/twoFactor/shared.js +++ b/backend/webserver/routes/auth/twoFactor/shared.js @@ -103,13 +103,13 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { const { userId = null, clearEmailOtp = false, - errorMessage = 'Invalid verification code.', - tooManyAttemptsErrorMessage = 'Too many invalid verification attempts. Please login again.', + errorMessage = 'auth.twoFactor.api.invalidCodeAttemptsRemaining', + tooManyAttemptsErrorMessage = 'auth.twoFactor.api.tooManyInvalidAttempts', } = options; const pending = req.session?.twoFactorPending; if (!pending) { - return res.status(400).json({ message: 'No pending 2FA verification found. Please login again.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingVerification' }); } pending.failedAttempts = Number(pending.failedAttempts || 0) + 1; @@ -128,7 +128,7 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { return req.session.save((err) => { if (err) { server.logger.error('Failed to save session: ' + err); - return res.status(500).json({ message: 'Session error during 2FA.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.sessionErrorDuring2fa' }); } return res.status(403).json({ @@ -144,11 +144,12 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { return req.session.save((err) => { if (err) { server.logger.error('Failed to save session: ' + err); - return res.status(500).json({ message: 'Session error during 2FA.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.sessionErrorDuring2fa' }); } return res.status(401).json({ - message: `${errorMessage} ${attemptsRemaining} attempt(s) remaining.`, + message: errorMessage, + params: { attemptsRemaining }, attemptsRemaining, maxAttempts: MAX_2FA_VERIFY_ATTEMPTS, }); @@ -207,10 +208,10 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { */ async function resendEmailOtp(req, res) { if (!req.session || !req.session.twoFactorPending) { - return res.status(400).json({ message: 'No pending 2FA verification found. Please login again.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.noPendingVerification' }); } if (req.session.twoFactorPending.method !== 'email') { - return res.status(400).json({ message: 'Email 2FA is not the selected method. Please select email first.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailNotSelectedSelectFirst' }); } try { @@ -220,7 +221,7 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { if (!cooldownInfo.canResend) { setRetryAfterHeader(res, cooldownInfo.retryAfterSeconds); return res.status(429).json({ - message: 'Please wait before requesting another code.', + message: 'auth.twoFactor.verifyEmail.errors.waitBeforeResend', ...cooldownInfo, }); } @@ -232,11 +233,11 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { if (!user || !methods.includes('email')) { delete req.session.twoFactorPending; - return res.status(400).json({ message: 'Email 2FA is not enabled for this user.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.emailNotEnabledForUser' }); } if (!user.email) { - return res.status(400).json({ message: 'User email not found. Cannot send OTP.' }); + return res.status(400).json({ message: 'auth.twoFactor.api.userEmailNotFound' }); } await sendEmailOtp(user); @@ -246,18 +247,18 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { return req.session.save((err) => { if (err) { server.logger.error('Failed to save session: ' + err); - return res.status(500).json({ message: 'Session error during 2FA.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.sessionErrorDuring2fa' }); } return res.status(200).json({ - message: 'OTP has been sent to your email address.', + message: 'auth.twoFactor.verifyEmail.success.codeSent', expiresIn: 10, ...nextCooldownInfo, }); }); } catch (error) { server.logger.error('Failed to request OTP: ' + error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.twoFactor.api.internalServerError' }); } } @@ -271,7 +272,7 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { async function performTwoFactorAction(user, method) { if (method === 'email') { if (!user.email) { - throw { status: 400, message: 'Email not found for this user.' }; + throw { status: 400, message: 'auth.twoFactor.api.emailNotFoundForUser' }; } await sendEmailOtp(user); return; @@ -279,12 +280,12 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { if (method === 'totp') { if (!user.totpSecret) { - throw { status: 400, message: 'TOTP is not configured.' }; + throw { status: 400, message: 'auth.twoFactor.api.totpNotConfigured' }; } return; } - throw { status: 400, message: `Unsupported 2FA method: ${method}` }; + throw { status: 400, message: 'auth.twoFactor.api.unsupportedMethod', params: { method } }; } /** @@ -355,7 +356,7 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { redirectPath = getTwoFactorRedirectPath(method); } catch (error) { server.logger.error(`2FA Initialization failed: ${error.message}`); - return res.status(error.status || 500).json({ message: error.message }); + return res.status(error.status || 500).json({ message: error.message, params: error.params }); } } else { // Multiple methods available; requiring frontend to display selection page @@ -368,7 +369,7 @@ function createTwoFactorHelpers(server, sharedHelpers, emailHelpers) { req.session.save((err) => { if (err) { server.logger.error('Failed to save session: ' + err); - return res.status(500).json({ message: 'Session error during 2FA.' }); + return res.status(500).json({ message: 'auth.twoFactor.api.sessionErrorDuring2fa' }); } if (mode === 'redirect') { diff --git a/backend/webserver/routes/auth/verification.js b/backend/webserver/routes/auth/verification.js index f11c5a076..7bf84c5b5 100644 --- a/backend/webserver/routes/auth/verification.js +++ b/backend/webserver/routes/auth/verification.js @@ -18,26 +18,26 @@ function registerVerificationRoutes(server, helpers) { const { token } = req.query; if (String(await server.db.models['setting'].get('app.register.emailVerification')) !== 'true') { - return res.status(400).send({ message: 'Email verification is disabled.' }); + return res.status(400).send({ message: 'auth.api.emailVerificationDisabled' }); } if (!token) { - return res.status(400).send({ message: 'Missing token.' }); + return res.status(400).send({ message: 'auth.api.missingToken' }); } try { // Decode and validate token const decoded = decodeToken(token); if (!decoded.isValid) { - return res.status(400).send({ message: 'Invalid token format.' }); + return res.status(400).send({ message: 'auth.api.invalidTokenFormat' }); } if (decoded.expired) { - return res.status(400).send({ message: 'Token has expired.' }); + return res.status(400).send({ message: 'auth.api.tokenExpired' }); } // Find user by verification token const user = await server.db.models['user'].findOne({ where: { emailVerificationToken: token } }); if (!user) { - return res.status(400).send({ message: 'Invalid token.' }); + return res.status(400).send({ message: 'auth.api.invalidToken' }); } // Mark email as verified and clear token @@ -45,10 +45,10 @@ function registerVerificationRoutes(server, helpers) { { emailVerified: true, emailVerificationToken: null }, { where: { id: user.id } } ); - return res.status(200).send({ message: 'Email successfully verified. You can now log in.' }); + return res.status(200).send({ message: 'auth.api.emailVerifiedLogin' }); } catch (error) { server.logger.error('Failed to verify email:', error); - return res.status(500).send({ message: 'Internal server error' }); + return res.status(500).send({ message: 'auth.api.internalServerError' }); } }); @@ -58,25 +58,25 @@ function registerVerificationRoutes(server, helpers) { server.app.post('/auth/resend-verification', async (req, res) => { const { email: userEmail } = req.body; if (!userEmail) { - return res.status(400).json({ message: 'Email address is required.' }); + return res.status(400).json({ message: 'auth.api.emailAddressRequired' }); } try { // Check if email verification is enabled const emailVerificationEnabled = String(await server.db.models['setting'].get('app.register.emailVerification')) === 'true'; if (!emailVerificationEnabled) { - return res.status(400).json({ message: 'Email verification is disabled.' }); + return res.status(400).json({ message: 'auth.api.emailVerificationDisabled' }); } // Find user by email const user = await server.db.models['user'].findOne({ where: { email: userEmail } }); if (!user) { - return res.status(400).json({ message: 'User with this email does not exist.' }); + return res.status(400).json({ message: 'auth.api.userNotFoundByEmail' }); } // Check if already verified if (user.emailVerified) { - return res.status(400).json({ message: 'Email address is already verified.' }); + return res.status(400).json({ message: 'auth.api.emailAlreadyVerified' }); } // Rate limiting: check if a verification email was sent recently @@ -84,7 +84,8 @@ function registerVerificationRoutes(server, helpers) { const rateLimitCheck = email.checkEmailRateLimit(user, 'verification', rateLimitMinutes); if (!rateLimitCheck.allowed) { return res.status(400).json({ - message: `Please wait ${rateLimitCheck.remainingTime} minute(s) before requesting another verification email.`, + message: 'auth.api.verificationRateLimited', + params: { minutes: rateLimitCheck.remainingTime }, }); } @@ -120,10 +121,10 @@ function registerVerificationRoutes(server, helpers) { { isHtml: emailContent.isHtml } ); - return res.status(200).json({ message: 'Verification email has been sent.' }); + return res.status(200).json({ message: 'auth.api.verificationEmailSent' }); } catch (error) { server.logger.error('Failed to resend verification email:', error); - return res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'auth.api.internalServerError' }); } }); } diff --git a/backend/webserver/rpcs/moodleRPC.js b/backend/webserver/rpcs/moodleRPC.js index e5e3b9e87..c284f03aa 100644 --- a/backend/webserver/rpcs/moodleRPC.js +++ b/backend/webserver/rpcs/moodleRPC.js @@ -1,5 +1,6 @@ const RPC = require("../RPC.js"); const {io: io_client} = require("socket.io-client"); +const TranslatableError = require("../../utils/TranslatableError"); /** * Connects to the Moodle RPC service @@ -33,7 +34,7 @@ module.exports = class MoodleRPC extends RPC { const response = await this.emit(eventName, data); if (!response['success']) { this.logger.error("Error in request " + eventName + ": " + response['message']); - throw new Error(response['message']); + throw new TranslatableError( "errors.rpc.requestFailed", {eventName, message: response['message']}); } return response; } @@ -127,7 +128,7 @@ module.exports = class MoodleRPC extends RPC { async downloadSubmissionsFromUrl(data) { const response = await this.request("downloadSubmissionsFromUrl", data); if (!response.success) { - throw new Error(response.message); + throw new TranslatableError( "errors.rpc.downloadFailed", {message: response.message}); } return response['data']; } diff --git a/backend/webserver/rpcs/pdfRPC.js b/backend/webserver/rpcs/pdfRPC.js index fa7b07713..de51fe280 100644 --- a/backend/webserver/rpcs/pdfRPC.js +++ b/backend/webserver/rpcs/pdfRPC.js @@ -1,5 +1,6 @@ const RPC = require("../RPC.js"); const {io: io_client} = require("socket.io-client"); +const TranslatableError = require("../../utils/TranslatableError"); /** * PDFRPC - Handles PDF annotation operations via a remote RPC service @@ -35,7 +36,7 @@ module.exports = class PDFRPC extends RPC { const response = await this.emit(eventName, data); if (!response['success']) { this.logger.error("Error in request " + eventName + ": " + response['message']); - throw new Error(response['message']); + throw new TranslatableError( "errors.rpc.requestFailed", {eventName, message: response['message']}); } return response; } @@ -54,7 +55,7 @@ module.exports = class PDFRPC extends RPC { const response = await this.request("annotationsExtract", data); if (!response['success']) { this.logger.error("Error in request " + eventName + ": " + response['message']); - throw new Error(response['message']); + throw new TranslatableError( "errors.rpc.annotationExtractionFailed", {message: response['message']}); } this.logger.info("Response from RPC service: " + response['message']); return response['data']; @@ -77,7 +78,7 @@ module.exports = class PDFRPC extends RPC { const response = await this.request("embedAnnotations", data); if (!response['success']) { this.logger.error("Error in request " + eventName + ": " + response['message']); - throw new Error(response['message']); + throw new TranslatableError( "errors.rpc.annotationEmbeddingFailed", {message: response['message']}); } this.logger.info("Response from RPC service: " + response['message']); return response['data']; @@ -100,7 +101,7 @@ module.exports = class PDFRPC extends RPC { const response = await this.request("deleteAllAnnotations", data); if (!response['success']) { this.logger.error("Error in request deleteAllAnnotations: " + response['message']); - throw new Error(response['message']); + throw new TranslatableError( "errors.rpc.annotationDeletionFailed", {message: response['message']}); } this.logger.info("Response from RPC service: " + response['message']); return response['data']; diff --git a/backend/webserver/services/backgroundTask.js b/backend/webserver/services/backgroundTask.js index 31128a497..2e12eecc5 100644 --- a/backend/webserver/services/backgroundTask.js +++ b/backend/webserver/services/backgroundTask.js @@ -93,15 +93,15 @@ module.exports = class BackgroundTaskService extends Service { const documentSocket = this.server.availSockets[client.socket.id]?.DocumentSocket; if (!documentSocket) { this.server.logger.error("No DocumentSocket found for client"); - throw new Error("No DocumentSocket found for client"); + throw new Error("errors.backgroundTask.noDocumentSocketForClient"); } if (!preprocessingData || !preprocessingData.skillName || !preprocessingData.skillParameterMappings) { - throw new Error("Invalid request data: missing skillName or skillParameterMappings"); + throw new Error("errors.backgroundTask.invalidPreprocessRequest"); } if (!(await documentSocket.isAdmin())) { - throw new Error("You do not have permission to preprocess submissions"); + throw new Error("errors.permission.noPreprocessSubmissionPermission"); } await this.initializePreprocessingState(); @@ -195,7 +195,7 @@ module.exports = class BackgroundTaskService extends Service { const {skillName, skillParameterMappings, baseFileParameter, baseFiles} = preprocessingData; if (!skillParameterMappings) { - throw new Error("No skill parameter mappings provided"); + throw new Error("errors.submission.noSkillParameterMappings"); } this.preprocessItems = []; @@ -450,10 +450,10 @@ module.exports = class BackgroundTaskService extends Service { async cancelPreprocessing(client) { const documentSocket = this.server.availSockets[client.socket.id]?.DocumentSocket; if (!documentSocket || !(await documentSocket.isAdmin())) { - throw new Error("Cannot cancel preprocessing: missing admin rights"); + throw new Error("errors.submission.missingAdminRights"); } if (!this.backgroundTask.preprocess) { - throw new Error("Cannot cancel preprocessing: no active preprocessing"); + throw new Error("errors.submission.noActivePreprocessing"); } this.backgroundTask.preprocess.cancelled = true; @@ -473,12 +473,12 @@ module.exports = class BackgroundTaskService extends Service { async confirmCompletion(client) { const preprocess = this.backgroundTask.preprocess; if (!preprocess?.completed) { - throw new Error("Cannot confirm completion: no completed preprocessing to confirm"); + throw new Error("errors.backgroundTask.noCompletedPreprocessing"); } const documentSocket = this.server.availSockets[client.socket.id]?.DocumentSocket; if (!(documentSocket && await Socket.prototype.isAdmin.call(documentSocket))) { - throw new Error("Cannot confirm completion: missing admin rights"); + throw new Error("errors.backgroundTask.confirmCompletionMissingAdminRights"); } delete this.backgroundTask.preprocess; @@ -517,7 +517,8 @@ module.exports = class BackgroundTaskService extends Service { const currentRequest = currentRequestId ? this.backgroundTask.preprocess.requests[currentRequestId] : null; this.backgroundTask.preprocess.errors.push({ - message: data.error?.message || 'Unknown error', + message: data.error?.key || data.error?.message || "errors.server.unknownError", + params: data.error?.params, requestId: currentRequestId, submissionId: currentRequest?.submissionId, documentId: currentRequest?.documentId, diff --git a/backend/webserver/sockets/annotation.js b/backend/webserver/sockets/annotation.js index 827beae71..c675936c3 100644 --- a/backend/webserver/sockets/annotation.js +++ b/backend/webserver/sockets/annotation.js @@ -29,7 +29,7 @@ class AnnotationSocket extends Socket { const anno = await this.models['annotation'].getById(data.annotationId); if (!(await this.checkDocumentAccess(anno.documentId))) { - throw new Error("You have no permission to change this annotation"); + throw new Error("errors.permission.noAnnotationChangePermission"); } await this.loadCommentsByAnnotation(anno.id); @@ -49,7 +49,7 @@ class AnnotationSocket extends Socket { this.emitDoc(comment.documentId, "commentRefresh", comment); } catch (e) { this.logger.error("Error during loading of comments: " + e); - this.sendToast("Internal server error. Failed to load comments.", "Internal server error", "danger"); + this.sendToast("errors.annotator.commentsLoadFailedInternal", "errors.server.internalServerError", "danger"); } } @@ -76,7 +76,7 @@ class AnnotationSocket extends Socket { const origAnnotation = await this.models['annotation'].getById(data.annotationId, {transaction: options.transaction}); if (!(await this.checkUserAccess(origAnnotation.userId))) { - throw Error("You have no permission to change this annotation"); + throw Error("errors.permission.noAnnotationChangePermission"); } data.draft = false; @@ -164,7 +164,7 @@ class AnnotationSocket extends Socket { const filePath = path.join(UPLOAD_PATH, `${document.hash}.pdf`); if (!fs.existsSync(filePath)) { - throw new Error("PDF file not found"); + throw new Error("errors.file.pdfNotFound"); } const file = fs.readFileSync(filePath); diff --git a/backend/webserver/sockets/app.js b/backend/webserver/sockets/app.js index 5bf7f7cea..176615a46 100644 --- a/backend/webserver/sockets/app.js +++ b/backend/webserver/sockets/app.js @@ -5,6 +5,7 @@ const {v4: uuidv4} = require("uuid"); const {mergeFilter} = require("../../utils/data.js"); const {mergeInjects} = require("../../utils/data"); const {generateError} = require("../../utils/generic.js"); +const TranslatableError = require("../../utils/TranslatableError"); /** * Send data for building the frontend app @@ -84,7 +85,7 @@ class AppSocket extends Socket { // check or set user information if ("userId" in data.data && !await this.checkUserAccess(data.data.userId)) { - throw new Error("You are not allowed to update the table " + data.table + " for another user!"); + throw new TranslatableError("errors.permission.cannotUpdateOtherUserTable", {dataTable: data.table}, "ACCESS_DENIED"); } // check data exists for required fields @@ -95,9 +96,7 @@ class AppSocket extends Socket { data.data[field.key] === null || data.data[field.key] === "" ) { - { - throw new Error("Required field missing: " + field.key); - } + throw new TranslatableError("errors.validation.requiredFieldMissing", {fieldKey: field.key}, "VALIDATION_ERROR"); } } // defaults @@ -130,7 +129,7 @@ class AppSocket extends Socket { } if (!newEntry) { - throw new Error("Failed to update data"); + throw generateError("UPDATE_FAILED", "errors.database.failedToUpdateData"); } // check if table has a field with table options @@ -310,7 +309,7 @@ class AppSocket extends Socket { if (!record) { // Record doesn't exist or was deleted - throw generateError("NOT_FOUND", "The requested resource does not exist or has been deleted."); + throw generateError("NOT_FOUND", "errors.common.resourceNotFound"); } // Now check permissions by attempting to send via filtered sendTable @@ -321,7 +320,7 @@ class AppSocket extends Socket { if (result.length === 0) { // Record exists but user doesn't have permission - throw generateError("ACCESS_DENIED", "You do not have rights to access this data."); + throw generateError("ACCESS_DENIED", "errors.common.accessDenied"); } } @@ -344,7 +343,7 @@ class AppSocket extends Socket { */ async subscribeAppData(data, options) { if (!data.table) { - throw new Error("Table name is required"); + throw generateError("VALIDATION_ERROR", "errors.validation.tableNameRequired"); } // add subscription to the list @@ -439,13 +438,25 @@ class AppSocket extends Socket { */ async sendOverallSetting(data, options) { const { key, value } = data; + + // Users with disableLanguageSelection cannot save app.locale as a user preference + if ( + key === "app.locale" + && !(await this.isAdmin()) + && await this.hasAccess("frontend.preferences.disableLanguageSelection") + ) { + throw new TranslatableError("errors.settings.cannotChangeLanguage"); + } + // Admin can set settings for other users (single or bulk) if (Array.isArray(data.userIds) && data.userIds.length > 0 && await this.isAdmin()) { for (const uid of data.userIds) { - await this.models["user_setting"].set(key, value, uid); + // Admin may assign any key; skip allowUserOverride guard in user_setting hooks + await this.models["user_setting"].set(key, value, uid, { bypassSystemSettingCheck: true }); } } else if (data.userId && await this.isAdmin()) { - await this.models["user_setting"].set(key, value, data.userId); + // Admin may assign any key; skip allowUserOverride guard in user_setting hooks + await this.models["user_setting"].set(key, value, data.userId, { bypassSystemSettingCheck: true }); } else { // Default: set for current user and refresh their settings console.log(`Setting ${key} for user ${this.userId} to ${value}`); diff --git a/backend/webserver/sockets/assignment.js b/backend/webserver/sockets/assignment.js index f07ddce4b..e946d0d2e 100644 --- a/backend/webserver/sockets/assignment.js +++ b/backend/webserver/sockets/assignment.js @@ -2,6 +2,7 @@ const Socket = require("../Socket.js"); const {v4: uuidv4} = require("uuid"); const _ = require("lodash"); const {getEmailContent} = require("../../utils/emailHelper"); +const TranslatableError = require("../../utils/TranslatableError"); /** * Handle user through websocket @@ -246,17 +247,17 @@ class AssignmentSocket extends Socket { // create a shuffle copy of the users array for each role let userQueue = _.shuffle(users); if (userQueue.length === 0) { - throw new Error(`No users found for role ${data['roles'].find((role) => role.id === roleId).name}. Please add users to the role.`); + throw new TranslatableError( "errors.assignment.noUsersFoundForRole", {roleName: data['roles'].find((role) => role.id === roleId).name}); } // check if there are enough assignment for each user, that are not from the user itself if (neededAssignments > shuffledAssignments.length) { - throw new Error(`Not enough documents to review for role ${data['roles'].find((role) => role.id === roleId).name}. Please add more documents.`); + throw new TranslatableError( "errors.assignment.notEnoughDocumentsForRole", {roleName: data['roles'].find((role) => role.id === roleId).name}); } for (const user of userQueue) { if (shuffledAssignments.filter((assignment) => assignment.userId !== user.id).length < neededAssignments) { - throw new Error(`Not enough documents to review for ${user.firstName} ${user.lastName}. Please add more documents.`); + throw new TranslatableError( "errors.assignment.notEnoughDocumentsForReviewer", {reviewerName: `${user.firstName} ${user.lastName}`}); } } @@ -329,7 +330,10 @@ class AssignmentSocket extends Socket { } if (!swapped) { - throw new Error(`Unable to assign enough documents for ${user.firstName} ${user.lastName} in role ${data['roles'].find((role) => role.id === roleId).name}`); + throw new TranslatableError( "errors.assignment.unableToAssignEnoughDocuments", { + reviewerName: `${user.firstName} ${user.lastName}`, + roleName: data['roles'].find((role) => role.id === roleId).name, + }); } } @@ -448,7 +452,7 @@ class AssignmentSocket extends Socket { } if (!swapped) { - throw new Error("Could not assign all reviewers. Please try again."); + throw new Error("errors.assignment.couldNotAssignReviewers"); } } } @@ -497,7 +501,7 @@ class AssignmentSocket extends Socket { // Check if the reviewer exists in selectedReviewer const reviewer = data.selectedReviewer.find((r) => r.id === reviewerId); if (!reviewer) { - throw new Error(`Study session owner (User ID: ${reviewerId}) is not in the selected reviewers list. Please add them to the reviewer selection.`); + throw new TranslatableError( "errors.assignment.studySessionOwnerNotSelectedReviewer", {reviewerId}); } // Initialize array for this reviewer if not exists @@ -541,7 +545,7 @@ class AssignmentSocket extends Socket { return finalAssignments; } else { - throw new Error("Invalid mode provided for assignment creation."); + throw new Error("errors.assignment.invalidMode"); } } @@ -724,7 +728,7 @@ class AssignmentSocket extends Socket { if( targetWorkflowStepId === 'previousSubmission'){ // Get the original submission first if(previousSubmissionId === null){ - throw new Error("First step selected does not map to a submission."); + throw new Error("errors.assignment.firstStepNotSubmission"); } let originalSubmission = await this.models['submission'].findOne( { where: { id: previousSubmissionId } }, @@ -741,7 +745,7 @@ class AssignmentSocket extends Socket { ); //get document based on validation file for now getting pdf if(!latestSubmission){ - throw new Error("The latest version of the chosen submission could not be found."); + throw new Error("errors.assignment.latestSubmissionNotFound"); } const document = await this.models['document'].findOne( { where: { submissionId: latestSubmission.id, type: 0} }, diff --git a/backend/webserver/sockets/comment.js b/backend/webserver/sockets/comment.js index fb4a477df..0eac53913 100644 --- a/backend/webserver/sockets/comment.js +++ b/backend/webserver/sockets/comment.js @@ -26,10 +26,10 @@ class CommentSocket extends Socket { if (origComment.userId === await this.models['user'].getUserIdByName("Bot")) { const parentComment = await this.models['comment'].getById(origComment.parentCommentId, {transaction: options.transaction}); if (!(await this.checkUserAccess(parentComment.userId))) { - throw Error("You are not allowed to edit this comment."); + throw Error("errors.permission.noCommentEditPermission"); } } else if (!(await this.checkUserAccess(origComment.userId))) { - throw Error("You are not allowed to edit this comment."); + throw Error("errors.permission.noCommentEditPermission"); } const newComment = await this.models['comment'].updateById(data.commentId, Object.assign(data, {draft: false}), {transaction: options.transaction}); @@ -57,13 +57,13 @@ class CommentSocket extends Socket { if (data.userId === 'Bot') { const parentComment = await this.models['comment'].getById(data.parentCommentId); if (!(await this.checkUserAccess(parentComment.userId))) { - throw Error("You are not allowed to add a comment."); + throw Error("errors.permission.noCommentPermission"); } else { data.userId = await this.models['user'].getUserIdByName("Bot"); data.draft = false; } } else if (!(await this.checkUserAccess(data.userId))) { - throw Error("You are not allowed to add a comment."); + throw Error("errors.permission.noCommentPermission"); } } else { data.userId = this.userId; @@ -101,7 +101,7 @@ class CommentSocket extends Socket { async sendComment(data, options) { const comment = await this.models['comment'].getById(data.commentId); if (!(await this.checkDocumentAccess(comment.documentId))) { - throw new Error("You don't have access to this comment"); + throw new Error("errors.permission.noCommentAccess"); } this.emit("commentRefresh", comment); } diff --git a/backend/webserver/sockets/configuration.js b/backend/webserver/sockets/configuration.js index 9eaf37a5a..fe9da7131 100644 --- a/backend/webserver/sockets/configuration.js +++ b/backend/webserver/sockets/configuration.js @@ -22,18 +22,18 @@ class ConfigurationSocket extends Socket { const { configurationId, content } = data; if (!configurationId) { - throw new Error("Configuration ID is required"); + throw new Error("errors.configuration.idRequiredNoPeriod"); } if (!content) { - throw new Error("Configuration content is required"); + throw new Error("errors.configuration.contentRequiredNoPeriod"); } // Get the existing configuration const existingConfig = await this.models["configuration"].getById(configurationId); if (!existingConfig) { - throw new Error("Configuration not found"); + throw new Error("errors.configuration.notFound"); } // Check if user has access to update this configuration @@ -45,7 +45,7 @@ class ConfigurationSocket extends Socket { ); if (!accessAllowed) { - throw new Error("Access denied"); + throw new Error("errors.configuration.accessDenied"); } // Update only the content field @@ -71,7 +71,7 @@ class ConfigurationSocket extends Socket { */ async createConfiguration(data, options) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to create configurations for other users."); + throw new Error("errors.configuration.createForOtherUsersDenied"); } const payload = { name: data.name, diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index aad2947ad..ff7f01df9 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -11,6 +11,8 @@ const Validator = require("../../utils/validator.js"); const {Op} = require('sequelize'); const {applyTemplateToDocument} = require("../../utils/documentTemplateHelper.js"); const {generateError} = require("../../utils/generic.js"); +const TranslatableError = require("../../utils/TranslatableError"); +const i18n = require("../../utils/i18n.js"); const {getEmailContent} = require("../../utils/emailHelper.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -98,12 +100,12 @@ class DocumentSocket extends Socket { // Check if document exists in database (deleted or never existed) if (!document || document.deleted) { - throw generateError("DOCUMENT_NOT_FOUND", "The document does not exist or has been deleted."); + throw generateError("DOCUMENT_NOT_FOUND", "errors.documents.doesNotExistOrDeleted"); } // Check user access permission if (!(await this.checkDocumentAccess(document.id))) { - throw generateError("ACCESS_DENIED", "You do not have access to this document."); + throw generateError("ACCESS_DENIED", "errors.documents.noAccess"); } // Check if file exists on disk (optional, skip for metadata-only operations) @@ -111,7 +113,7 @@ class DocumentSocket extends Socket { const filePath = `${UPLOAD_PATH}/${document.hash}.pdf`; const filename = filePath.split("/").pop(); if (!fs.existsSync(filePath)) { - throw generateError("FILE_MISSING", `The document file ${filename} is missing from the server.`) + throw new TranslatableError("errors.documents.fileMissingFromServer", {filename}, "FILE_MISSING"); } } return document; @@ -140,16 +142,16 @@ class DocumentSocket extends Socket { let errors = []; if (!data['file']) { - throw new Error("No file uploaded"); + throw new Error("errors.file.noFileUploaded"); } const fileType = data['name'].substring(data['name'].lastIndexOf(".")).toLowerCase(); if (![".pdf", ".delta", ".json", ".zip"].includes(fileType)) { - throw new Error("Invalid file type"); + throw new Error("errors.file.invalidFileType"); } if ((data['userid'] && data['userid'] !== this.userId) && !(await this.checkUserAccess(data['userId']))) { - throw new Error("User does not have access to upload documents"); + throw new Error("errors.documents.noUploadAccess"); } if (fileType === ".delta") { @@ -219,12 +221,16 @@ class DocumentSocket extends Socket { document: doc }); if (!file) { - throw new Error("Couldn't delete original annotations"); + // Use TranslatableError so the catch below sees `.key` and does not embed this i18n key as a plain-text param. + throw new TranslatableError( "errors.documents.annotationDeleteOriginalFailed"); } fs.writeFileSync(target, file); } catch (annotationRpcErr) { - errors.push("Error deleting annotations: " + annotationRpcErr.message); + this.logger.warn("Failed to delete annotations from PDF", annotationRpcErr); + errors.push(annotationRpcErr.key === "errors.documents.annotationDeleteOriginalFailed" + ? {message: annotationRpcErr.key} + : {message: "errors.documents.annotationDeleteFailedGeneric"}); fs.writeFileSync(target, data.file); } @@ -243,7 +249,17 @@ class DocumentSocket extends Socket { try { textPositions = getTextPositions(extracted.text, data.wholeText); } catch (error) { - errors.push("Error extracting text positions for text " + extracted.text + ": " + error.message); + if (error.key) { + errors.push({ + message: error.key, + params: {text: extracted.text, ...(error.params || {})}, + }); + continue; + } + errors.push({ + message: "errors.documents.textPositionExtractionFailed", + params: {text: extracted.text}, + }); continue; } @@ -298,13 +314,22 @@ class DocumentSocket extends Socket { await this.models['comment'].add(newComment, {transaction: options.transaction}); } catch (annotationErr) { - errors.push("Error adding annotation: " + annotationErr.message); + if (annotationErr.key) { + errors.push({ + message: annotationErr.key, + params: annotationErr.params, + }); + } else if (typeof annotationErr.message === "string" && i18n.hasKey(annotationErr.message)) { + errors.push({message: annotationErr.message}); + } else { + errors.push({message: "errors.documents.annotationAddFailed"}); + } continue; } } } } catch (annotationRpcErr) { - errors.push("The document was uploaded, but automatic annotation extraction failed. You can still use the document, but annotations may be missing."); + errors.push({message: "errors.documents.automaticAnnotationExtractionFailed"}); } } } @@ -325,7 +350,7 @@ class DocumentSocket extends Socket { async updateDocument(data, options) { const doc = await this.models['document'].getById(data['id']); if (!(await this.checkUserAccess(doc.userId))) { - throw new Error("You are not allowed to update this document"); + throw new Error("errors.documents.updateNotAllowed"); } const newDocument = await this.models['document'].updateById(doc.id, data); @@ -450,10 +475,10 @@ class DocumentSocket extends Socket { this.socket.emit("documentFileMerged", {document: doc, deltas: delta}); return delta; } else { - throw new Error("Non-HTML/Modal documents are not supported for this operation"); + throw new Error("errors.documents.nonHtmlModalUnsupported"); } } else { - throw new Error("You do not have access to this document"); + throw new Error("errors.documents.noAccess"); } } @@ -537,7 +562,7 @@ class DocumentSocket extends Socket { this.logger.info("Deltas file updated successfully."); } else { - throw new Error("Non-HTML/MODAL documents are not supported for this operation"); + throw new Error("errors.documents.nonHtmlModalUnsupportedUppercase"); } } @@ -562,7 +587,7 @@ class DocumentSocket extends Socket { */ async getData(data, options) { if (!data.documentId) { - throw new Error("Document ID is required."); + throw new Error("errors.documents.idRequired"); } const document = await this.validateDocument(data.documentId, 'id', true); @@ -722,7 +747,7 @@ class DocumentSocket extends Socket { if (await this.checkUserAccess(doc.userId)) { this.emit("documentRefresh", await this.models['document'].updateById(doc.id, {public: true})); } else { - throw new Error("You do not have access to this document"); + throw new Error("errors.documents.noAccess"); } } @@ -867,7 +892,7 @@ class DocumentSocket extends Socket { if (assignmentId) { assignment = await this.models["assignment"].getById(assignmentId, {}); if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); + throw new TranslatableError( "errors.assignment.notFound", {assignmentId}); } } @@ -884,7 +909,10 @@ class DocumentSocket extends Socket { const validationResult = await this.validator.validateSubmissionFiles(tempFiles, data.validationConfigurationId? data.validationConfigurationId : (assignment ? assignment.validationConfigurationId : null)); if (!validationResult.success) { - throw new Error(validationResult.message || "Validation failed"); + if (validationResult.params) { + throw new TranslatableError( validationResult.message, validationResult.params); + } + throw new Error(validationResult.message || "errors.validation.validationFailedGeneric"); } // 3. Determine previousSubmissionId @@ -898,7 +926,11 @@ class DocumentSocket extends Socket { // Check revision limit (0 = unlimited) if (assignment && assignment.maxRevisions > 0 && assignmentSubmissions.length >= assignment.maxRevisions) { - throw new Error(`Revision limit reached: user ${submission.userId} already has ${assignmentSubmissions.length} submission(s) for this assignment (max: ${assignment.maxRevisions}).`); + throw new TranslatableError( "errors.assignment.revisionLimitReached", { + userId: submission.userId, + submissionCount: assignmentSubmissions.length, + maxRevisions: assignment.maxRevisions, + }); } const childByParentId = new Map(); @@ -967,7 +999,9 @@ class DocumentSocket extends Socket { userExtId: submission.userExtId, firstName: submission.firstName, lastName: submission.lastName, - message: err.message, + message: err.key + || (typeof err.message === "string" && i18n.hasKey(err.message) ? err.message : "errors.server.unknownError"), + params: err.params, }); } @@ -1091,7 +1125,10 @@ class DocumentSocket extends Socket { const result = await this.validator.validateSubmissionFiles(files, validationConfigurationId); if (!result.success) { - throw new Error(result.message || "Validation failed"); + if (result.params) { + throw new TranslatableError( result.message, result.params); + } + throw new Error(result.message || "errors.validation.validationFailedGeneric"); } if (assignmentId && submissionId) { @@ -1117,7 +1154,7 @@ class DocumentSocket extends Socket { if (assignmentId) { const assignment = await this.models["assignment"].getById(assignmentId, {transaction}); if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); + throw new TranslatableError( "errors.assignment.notFound", {assignmentId}); } resolvedProjectId = resolvedProjectId ?? assignment.projectId ?? null; @@ -1166,9 +1203,10 @@ class DocumentSocket extends Socket { } if (chainDepth >= assignment.maxRevisions) { - throw new Error( - `Maximum revisions reached for this assignment (${chainDepth}/${assignment.maxRevisions})` - ); + throw new TranslatableError( "errors.assignment.maxRevisionsReached", { + currentCount: chainDepth, + maxRevisions: assignment.maxRevisions, + }); } } } else { @@ -1219,7 +1257,8 @@ class DocumentSocket extends Socket { } } catch (error) { this.logger.error(error); - throw new Error(error); + // Preserve i18n keys/params from the original error; wrapping it would turn keys into "Error: ...". + throw error; } } @@ -1247,11 +1286,11 @@ class DocumentSocket extends Socket { const assignment = await this.models["assignment"].getById(assignmentId, {transaction}); if (!assignment) { - throw new Error(`Assignment with id ${assignmentId} not found`); + throw new TranslatableError( "errors.assignment.notFound", {assignmentId}); } if (assignment.closed) { - throw new Error("Cannot replace submission because the assignment is closed."); + throw new Error("errors.documents.replaceAssignmentClosed"); } const oldSubmission = await this.models["submission"].findOne({ @@ -1266,14 +1305,14 @@ class DocumentSocket extends Socket { }); if (!oldSubmission) { - throw new Error(`Submission with id ${submissionId} not found for this assignment`); + throw new TranslatableError( "errors.submission.notFoundForAssignment", {submissionId}); } const resolvedProjectId = projectId ?? oldSubmission.projectId ?? assignment.projectId ?? null; const isOwner = this.userId === oldSubmission.userId; const hasRight = await this.hasAccess('frontend.dashboard.assignments.replaceDeleteSubmissions'); if (!isOwner && !hasRight) { - throw new Error("You are not allowed to replace this submission."); + throw new Error("errors.documents.replaceNotAllowed"); } const oldSubmissionDocuments = await this.models["document"].findAll({ @@ -1288,7 +1327,7 @@ class DocumentSocket extends Socket { (document) => Number(document.studyUsageCount || 0) > 0 ); if (hasStudyLinkedDocument) { - throw new Error("Cannot replace submission because one or more linked documents are used in studies."); + throw new Error("errors.documents.replaceDocumentsUsedInStudies"); } const newSubmission = await this.models["submission"].add({ @@ -1443,7 +1482,7 @@ class DocumentSocket extends Socket { const filePath = `${UPLOAD_PATH}/${document.hash}${fileExtension}`; if (!fs.existsSync(filePath)) { - throw new Error(`File ${document.hash}${fileExtension} not found`); + throw new TranslatableError( "errors.documents.fileNotFoundByName", {fileName: `${document.hash}${fileExtension}`}); } let file = fs.readFileSync(filePath); // Buffer @@ -1561,7 +1600,7 @@ class DocumentSocket extends Socket { */ async publishReviewLinks(data) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to upload review links"); + throw new Error("errors.documents.noReviewLinkUploadPermission"); } return await this.server.rpcs["MoodleRPC"].publishAssignmentTextFeedback({ options: data.options, diff --git a/backend/webserver/sockets/setting.js b/backend/webserver/sockets/setting.js index 3c220b681..19eb3406d 100644 --- a/backend/webserver/sockets/setting.js +++ b/backend/webserver/sockets/setting.js @@ -37,7 +37,7 @@ class SettingSocket extends Socket { */ async sendSettings(data, options) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to access settings."); + throw new Error("errors.settings.noAccessPermission"); } return await this.getDashboardSettingsPayload(); @@ -55,7 +55,7 @@ class SettingSocket extends Socket { */ async saveSettings(data, options) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to save settings."); + throw new Error("errors.settings.noSavePermission"); } const { touchesMailService } = await saveSettings(this.models["setting"], data, { @@ -71,7 +71,7 @@ class SettingSocket extends Socket { this.emit("settingData", await this.getDashboardSettingsPayload()); // Refresh settings on this socket }); - return "Settings saved successfully."; + return "settings.messages.settingsSavedSuccessfully"; } /** @@ -86,7 +86,7 @@ class SettingSocket extends Socket { */ async mailSendTest(data, options) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to send test mail."); + throw new Error("errors.settings.noTestMailPermission"); } const rows = await this.models["setting"].getMailServiceSettings(); diff --git a/backend/webserver/sockets/statistic.js b/backend/webserver/sockets/statistic.js index 2754bfcd2..8464f10fb 100644 --- a/backend/webserver/sockets/statistic.js +++ b/backend/webserver/sockets/statistic.js @@ -41,7 +41,7 @@ class StatisticSocket extends Socket { this.socket.emit("statsDataByUser", { success: false, userId: data.userId, - message: "User rights and argument mismatch" + message: "errors.statistics.userRightsArgumentMismatch" }); this.logger.error("User right and request parameter mismatch. User did not agree to stats collection."); } @@ -62,7 +62,7 @@ class StatisticSocket extends Socket { */ async getStats(data, options) { if (!await this.isAdmin()) { - throw new Error("You don't have permission to access this data"); + throw new Error("errors.permission.noPermissionToAccesData"); } if (data.userId) { diff --git a/backend/webserver/sockets/study.js b/backend/webserver/sockets/study.js index b6bb76488..43088ebee 100644 --- a/backend/webserver/sockets/study.js +++ b/backend/webserver/sockets/study.js @@ -67,7 +67,7 @@ class StudySocket extends Socket { context: { stepDocuments: stepDocuments } }); } else { - throw new Error("No permission to save study as template"); + throw new Error("errors.studies.noPermissionSaveAsTemplate"); } } } @@ -144,16 +144,16 @@ class StudySocket extends Socket { */ async closeStudy(data, options) { if (!data.studyId) { - throw new Error("studyId is required"); + throw new Error("errors.studies.studyIdRequired"); } const study = await this.models["study"].getById(data.studyId, {transaction: options.transaction}); if (!study) { - throw new Error("Study not found"); + throw new Error("errors.studies.studyNotFound"); } if (study.closed) { - throw new Error("Study is already closed"); + throw new Error("errors.studies.studyAlreadyClosed"); } const updatedStudy = await this.models["study"].updateById( diff --git a/backend/webserver/sockets/study_session.js b/backend/webserver/sockets/study_session.js index 3458eed4e..07783e7ed 100644 --- a/backend/webserver/sockets/study_session.js +++ b/backend/webserver/sockets/study_session.js @@ -25,7 +25,7 @@ class StudySessionSocket extends Socket { if (await this.checkUserAccess(study.userId)) { this.emit("study_sessionRefresh", await this.models['study_session'].getAllByKey("studyId", studyId)); } else { - this.sendToast("You are not allowed to see this study", "Error", "Danger"); + this.sendToast("errors.studies.notAllowedToSeeStudy", "errors.studies.errorTitle", "Danger"); } } @@ -46,7 +46,7 @@ class StudySessionSocket extends Socket { if (data.studySessionId && data.studySessionId !== 0) { const existing = await this.models["study_session"].getById(data.studySessionId, {transaction: options.transaction}); if (!existing) { - throw new Error("Study session not found"); + throw new Error("errors.studies.studySession.notFound"); } shouldSendSessionStartEmail = existing.start == null; session = await this.models["study_session"].updateById(data.studySessionId, @@ -178,21 +178,21 @@ class StudySessionSocket extends Socket { */ async finishStudySession(data, options) { if (!data.studySessionId) { - throw new Error("studySessionId is required"); + throw new Error("errors.studies.studySession.idRequired"); } const session = await this.models["study_session"].getById(data.studySessionId, {transaction: options.transaction}); if (!session) { - throw new Error("Study session not found"); + throw new Error("errors.studies.studySession.notFound"); } if (session.end) { - throw new Error("Study session has already been finished"); + throw new Error("errors.studies.studySession.alreadyFinished"); } const study = await this.models["study"].getById(session.studyId, {transaction: options.transaction}); if (study && study.closed) { - throw new Error("Cannot finish session: The study has been closed. Sessions are automatically terminated when a study is closed."); + throw new Error("errors.studies.studySession.cannotFinishClosedStudy"); } const updatedSession = await this.models["study_session"].updateById( diff --git a/backend/webserver/sockets/submission.js b/backend/webserver/sockets/submission.js index 57ae2ba06..c0da55988 100644 --- a/backend/webserver/sockets/submission.js +++ b/backend/webserver/sockets/submission.js @@ -1,4 +1,5 @@ const Socket = require("../Socket.js"); +const TranslatableError = require("../../utils/TranslatableError"); /** * Handle submissions through websocket @@ -31,7 +32,10 @@ class SubmissionSocket extends Socket { const copyResult = await this.models["submission"].copySubmission(submissionId, this.userId, {transaction}); copiedResults.push(copyResult); } catch (error) { - throw new Error(`Failed to copy submission with ${submissionId}: ${error.message}`); + if (error.key) { + throw error; + } + throw new TranslatableError( "errors.submission.copyWithIdFailed", {submissionId, message: error.message}); } } const copiedSubmissionIds = copiedResults.map(({copiedSubmission}) => copiedSubmission.id); @@ -64,15 +68,15 @@ class SubmissionSocket extends Socket { const submission = await this.models['submission'].getById(id, { transaction }); if (!submission) { - throw new Error("Submission not found."); + throw new Error("errors.submission.notFoundGeneric"); } if (!(await this.checkUserAccess(submission.userId))) { - throw new Error("You are not allowed to delete this submission."); + throw new Error("errors.submission.updateNotAllowed"); } const assignment = await this.models['assignment'].getById(submission.assignmentId, { transaction }); if (assignment && assignment.closed) { - throw new Error("Cannot delete submission because the assignment is closed."); + throw new Error("errors.submission.deleteAssignmentClosed"); } const documents = await this.models['document'].findAll({ @@ -82,7 +86,7 @@ class SubmissionSocket extends Socket { }); const isStudyLocked = documents.some(doc => Number(doc.studyUsageCount || 0) > 0); if (isStudyLocked) { - throw new Error("Cannot delete submission because one or more linked documents are used in studies."); + throw new Error("errors.submission.deleteDocumentsUsedInStudies"); } return await this.models['submission'].deleteById(id, { force, transaction }); @@ -100,7 +104,7 @@ class SubmissionSocket extends Socket { */ async publishGrades(data) { if (!(await this.isAdmin())) { - throw new Error("You do not have permission to upload grades"); + throw new Error("errors.submission.noGradeUploadPermission"); } return await this.server.rpcs["MoodleRPC"].publishAssignmentGrade({ options: data.options, diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 1b0ef5e86..c1b17d2b2 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -9,6 +9,7 @@ const { getMissingRequiredPlaceholders, formatMissingPlaceholderError, } = require("../../utils/templateResolver"); +const TranslatableError = require("../../utils/TranslatableError"); /** * Handle templates through websocket @@ -36,10 +37,10 @@ class TemplateSocket extends Socket { */ async createTemplate(data, options) { if (!data.name || !data.description || data.type === undefined || data.content === undefined) { - throw new Error("Missing required fields: name, description, type, content"); + throw new Error("errors.templates.missingCreateFields"); } if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(data.type)) { - throw new Error("Access denied: Only administrators can create email templates"); + throw new Error("errors.templates.adminOnlyEmailTemplateCreate"); } const defaultLanguage = data.defaultLanguage || "en"; @@ -82,19 +83,19 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async getContent(data, options){ - if (!data.templateId) throw new Error("Template ID is required"); - if (!data.language) throw new Error("Language is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); + if (!data.language) throw new Error("errors.templates.languageRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } const isOwner = template.userId === this.userId; const isPublicFromOthers = template.public === true && !isOwner; if (!isOwner && !isPublicFromOthers) { - throw new Error("You can only view templates that you own or public templates from others"); + throw new Error("errors.templates.viewOwnOrPublicOnly"); } const langRow = await this.models["template_content"].findOne({ @@ -173,25 +174,25 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async editContent(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); - if (!data.language) throw new Error("Language is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); + if (!data.language) throw new Error("errors.templates.languageRequired"); if (!data.ops || !Array.isArray(data.ops)) { - throw new Error("Delta operations are required"); + throw new Error("errors.templates.deltaOperationsRequired"); } const template = await this.models["template"].getById(data.templateId); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } // Check ownership: users (including admins) can only edit content of their own templates if (template.userId !== this.userId) { - throw new Error("You can only edit content of templates that you own"); + throw new Error("errors.templates.editOwnContentOnly"); } // Copied templates cannot be edited if (template.sourceId) { - throw new Error("Copied templates cannot be edited"); + throw new Error("errors.templates.copiedCannotBeEdited"); } const bulkEdits = data.ops.map((op, idx) => ({ @@ -218,7 +219,8 @@ class TemplateSocket extends Socket { * @param {Object} data The data object * @param {number} data.templateType Template type (required, 1-5) * @param {string} data.placeholderKey Placeholder key (required, e.g., "username") - * @param {string} data.placeholderLabel Placeholder label (required, e.g., "Username") + * @param {string} data.placeholderLabel i18n key for label (required, e.g. "templates.placeholders.labels.emailGeneral.username") + * @param {string} [data.placeholderDescription] i18n key for short description * @param {string} data.placeholderType Placeholder type (required, e.g., "text") * @param {boolean} [data.required=false] Whether placeholder is required * @param {Object} options @@ -226,12 +228,12 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async addPlaceholder(data, options) { - if (!(await this.isAdmin())) throw new Error("Access denied"); + if (!(await this.isAdmin())) throw new Error("errors.templates.accessDenied"); if (!data.templateType || ![1, 2, 3, 4, 5, 6, 7].includes(data.templateType)) { - throw new Error("Template type is required and must be 1-7"); + throw new Error("errors.templates.typeRequired"); } if (!data.placeholderKey || !data.placeholderLabel || !data.placeholderType) { - throw new Error("Missing required fields: placeholderKey, placeholderLabel, placeholderType"); + throw new Error("errors.templates.missingPlaceholderFields"); } const payload = { @@ -241,6 +243,9 @@ class TemplateSocket extends Socket { placeholderType: data.placeholderType, required: data.required ?? false, }; + if (data.placeholderDescription !== undefined) { + payload.placeholderDescription = data.placeholderDescription; + } return await this.models["placeholder"].add( payload, @@ -262,16 +267,16 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async updatePlaceholder(data, options) { - if (!(await this.isAdmin())) throw new Error("Access denied"); - if (!data.id) throw new Error("Placeholder ID is required"); - + if (!(await this.isAdmin())) throw new Error("errors.templates.accessDenied"); + if (!data.id) throw new Error("errors.templates.placeholderIdRequired"); + const updateData = {}; if (data.placeholderLabel !== undefined) updateData.placeholderLabel = data.placeholderLabel; if (data.placeholderType !== undefined) updateData.placeholderType = data.placeholderType; if (data.required !== undefined) updateData.required = data.required; if (Object.keys(updateData).length === 0) { - throw new Error("No fields to update"); + throw new Error("errors.templates.noFieldsToUpdate"); } return await this.models["placeholder"].updateById( @@ -293,11 +298,11 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async getAllPlaceholders(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } // Check access: users (including admins) can view placeholders for their own templates or public templates from others @@ -305,7 +310,7 @@ class TemplateSocket extends Socket { const isPublicFromOthers = template.public === true && !isOwner; if (!isOwner && !isPublicFromOthers) { - throw new Error("Access denied: You can only view placeholders for templates that you own or public templates from others"); + throw new Error("errors.templates.viewPlaceholdersOwnOrPublicOnly"); } return await this.models["placeholder"].getAllByKey( @@ -327,16 +332,16 @@ class TemplateSocket extends Socket { * @returns {Promise>} */ async getLanguages(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } const isOwner = template.userId === this.userId; const isPublicFromOthers = template.public === true && !isOwner; if (!isOwner && !isPublicFromOthers) { - throw new Error("You can only view templates that you own or public templates from others"); + throw new Error("errors.templates.viewOwnOrPublicOnly"); } const rows = await this.models["template_content"].findAll({ @@ -367,15 +372,15 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async addContent(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); - if (!data.language) throw new Error("Language is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); + if (!data.language) throw new Error("errors.templates.languageRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } if (template.userId !== this.userId) { - throw new Error("You can only add language content to templates that you own"); + throw new Error("errors.templates.addLanguageOwnOnly"); } const templateContentModel = this.models["template_content"]; @@ -425,10 +430,10 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async resolveTemplatePlaceholders(data, options) { - if (!(await this.isAdmin())) throw new Error("Access denied"); - if (!data.templateId) throw new Error("Template ID is required"); + if (!(await this.isAdmin())) throw new Error("errors.templates.accessDenied"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); if (!data.context || typeof data.context !== 'object') { - throw new Error("Context object is required"); + throw new Error("errors.templates.contextObjectRequired"); } // Get baseUrl from settings if not provided in context @@ -505,7 +510,8 @@ class TemplateSocket extends Socket { options ); if (missing.length > 0) { - throw new Error(formatMissingPlaceholderError(missing, { action: "saving" })); + const { key, params } = formatMissingPlaceholderError(missing, { action: "saving" }); + throw new TranslatableError(key, params); } } return; @@ -534,7 +540,8 @@ class TemplateSocket extends Socket { options ); if (missing.length > 0) { - throw new Error(formatMissingPlaceholderError(missing, { action: "saving" })); + const { key, params } = formatMissingPlaceholderError(missing, { action: "saving" }); + throw new TranslatableError(key, params); } } @@ -583,8 +590,8 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async closeTemplate(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); - if (!data.language) throw new Error("Language is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); + if (!data.language) throw new Error("errors.templates.languageRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) return; @@ -608,8 +615,8 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async discardDrafts(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); - if (!data.language) throw new Error("Language is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); + if (!data.language) throw new Error("errors.templates.languageRequired"); const template = await this.models["template"].getById(data.templateId); if (!template) return; @@ -642,12 +649,12 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async detachTemplate(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); const copy = await this.models["template"].getById(data.templateId); - if (!copy) throw new Error("Template not found"); - if (copy.userId !== this.userId) throw new Error("You can only detach your own copies"); - if (!copy.sourceId) throw new Error("Template is not a copy"); + if (!copy) throw new Error("errors.templates.notFound"); + if (copy.userId !== this.userId) throw new Error("errors.templates.detachOwnCopiesOnly"); + if (!copy.sourceId) throw new Error("errors.templates.notACopy"); return await this.models["template"].detach( data.templateId, @@ -668,11 +675,11 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async copyTemplate(data, options) { - if (!data.sourceTemplateId) throw new Error("Source template ID is required"); + if (!data.sourceTemplateId) throw new Error("errors.templates.sourceTemplateIdRequired"); const source = await this.models["template"].getById(data.sourceTemplateId); if (!(await this.isAdmin()) && [1, 2, 3, 6, 7].includes(source?.type)) { - throw new Error("Access denied: Only administrators can copy email templates"); + throw new Error("errors.templates.adminOnlyEmailTemplateCopy"); } const copiedTemplate = await this.models["template"].copyTemplate( @@ -706,11 +713,11 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async updateFromSource(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); const copy = await this.models["template"].getById(data.templateId); - if (!copy) throw new Error("Template not found"); - if (copy.userId !== this.userId) throw new Error("You can only update your own copies"); + if (!copy) throw new Error("errors.templates.notFound"); + if (copy.userId !== this.userId) throw new Error("errors.templates.updateOwnCopiesOnly"); return await this.models["template"].updateFromSource( data.templateId, @@ -729,19 +736,19 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async deleteTemplate(data, options) { - if (!data.templateId) throw new Error("Template ID is required"); + if (!data.templateId) throw new Error("errors.templates.templateIdRequired"); const template = await this.models["template"].getById(data.templateId, {transaction: options.transaction}); if (!template) { - throw new Error("Template not found"); + throw new Error("errors.templates.notFound"); } if (template.userId !== this.userId) { - throw new Error("You can only delete templates that you own"); + throw new Error("errors.templates.deleteOwnOnly"); } if (template.public && [1, 2, 3, 6, 7].includes(template.type)) { - throw new Error("Public email templates cannot be deleted"); + throw new Error("errors.templates.publicEmailCannotDelete"); } if ([1, 2, 3, 6, 7].includes(template.type)) { @@ -755,7 +762,7 @@ class TemplateSocket extends Socket { }); if (usedBySettings.length > 0) { const settingNames = usedBySettings.map(s => s.key.replace("email.template.", "")).join(", "); - throw new Error(`Template is currently assigned as an email template (${settingNames}). Please unassign it in Settings before deleting.`); + throw new TranslatableError( "errors.templates.assignedEmailTemplate", {settingNames}); } } diff --git a/backend/webserver/sockets/user.js b/backend/webserver/sockets/user.js index 4b00b8179..f04df86c4 100644 --- a/backend/webserver/sockets/user.js +++ b/backend/webserver/sockets/user.js @@ -72,7 +72,7 @@ class UserSocket extends Socket { */ async createUser(data, options) { if (!(await this.isAdmin())) { - throw new Error("User rights and argument mismatch"); + throw new Error("errors.users.userRightsArgumentMismatch"); } const user = await this.models["user"].add(data, {transaction: options.transaction}); // TODO: update frontend user data, don't overwrite it was is currently done (see also refreshState in store/utils.js) @@ -92,7 +92,7 @@ class UserSocket extends Socket { */ async getUserDetails(data, options) { if (!(await this.isAdmin())) { - throw new Error("User rights and argument mismatch"); + throw new Error("errors.users.userRightsArgumentMismatch"); } return await this.models["user"].getUserDetails(data); } @@ -112,7 +112,7 @@ class UserSocket extends Socket { */ async updateUserDetails(data, options) { if (!(await this.isAdmin())) { - throw new Error("User rights and argument mismatch"); + throw new Error("errors.users.userRightsArgumentMismatch"); } return await this.models["user"].updateUserDetails(data, options); } @@ -129,7 +129,7 @@ class UserSocket extends Socket { if (!(await this.hasAccess(rightToFetch))) { const msg = "This user does not have the right to load users by their role."; this.logger.error(msg); - throw new Error(msg); + throw new Error("errors.users.noRightToLoadUsersByRole"); } return role === "all" ? await this.models["user"].getAll() : await this.models["user"].getUsersByRole(role); } catch (error) { @@ -248,7 +248,9 @@ class UserSocket extends Socket { }); } else { errors.push({ - email: user.email, message: "User with mail " + user.email + " not found", + email: user.email, + message: "errors.users.userWithMailNotFound", + params: {email: user.email}, }); } } @@ -264,16 +266,21 @@ class UserSocket extends Socket { try { if (error.name === "SequelizeUniqueConstraintError" && error.errors[0].path === "email") { errors.push({ - extId: user.extId, message: "duplicate email", + extId: user.extId, + message: "errors.users.duplicateEmail", }); } else { errors.push({ - extId: user.extId, message: error.errors[0].message, + extId: user.extId, + message: "errors.users.bulkCreateValidationFailed", + params: {message: error.errors[0].message}, }); } } catch (e) { errors.push({ - extId: user.extId, message: e.message, + extId: user.extId, + message: "errors.users.bulkCreateValidationFailed", + params: {message: e.message}, }); } this.logger.error("Failed to bulk create user: " + user.email); @@ -307,7 +314,7 @@ class UserSocket extends Socket { async updateUserConsent(data, options) { const user = await this.models['user'].getById(this.userId); if (!user) { - throw new Error("Failed to update user: User not found"); + throw new Error("errors.users.failedToUpdateUser"); } return _.omit(await this.models["user"].updateById(user.id, @@ -338,7 +345,7 @@ class UserSocket extends Socket { const {userId, password, oldPassword} = data; if (!(await this.isAdmin()) || this.userId === userId) { if (userId !== this.userId) { - throw new Error("User rights and argument mismatch"); + throw new Error("errors.users.userRightsArgumentMismatch"); } const user = await this.models["user"].findOne({where:{ id: userId @@ -346,7 +353,7 @@ class UserSocket extends Socket { const hashedOldPassword = await genPwdHash(oldPassword, user.salt); const iscorrectPassword = user.passwordHash === hashedOldPassword; if(!iscorrectPassword){ - throw new Error("You entered an incorrect Password") + throw new Error("errors.users.incorrectPassword") } } await this.models["user"].resetUserPwd(userId, password); @@ -392,7 +399,7 @@ class UserSocket extends Socket { async getRoleRights(data, options) { if(!(await this.isAdmin())){ - throw new Error("no permission to get role rights"); + throw new Error("errors.users.noPermissionGetRoleRights"); } const rights = await this.models["role_right_matching"].findAll({ where: {userRoleId: data.roleId, deleted: false}, @@ -403,7 +410,7 @@ class UserSocket extends Socket { } async getAllRights(data, options) { if(!(await this.isAdmin())){ - throw new Error("no permission to get all rights"); + throw new Error("errors.users.noPermissionGetAllRights"); } const rights = await this.models["user_right"].findAll({ where: {deleted: false}, @@ -414,7 +421,7 @@ class UserSocket extends Socket { } async assignRoleRights(data, options) { if(!(await this.isAdmin())){ - throw new Error("no permission to assign role rights"); + throw new Error("errors.users.noPermissionAssignRoleRights"); } const roleId = data.roleId; const newRights = data.newRights || []; // array of right names to add @@ -470,7 +477,7 @@ class UserSocket extends Socket { * @param {Object} options Additional configuration parameters. */ async subscribeToUserMonitor(data, options) { - if (!(await this.isAdmin())) throw new Error("Admin access required"); + if (!(await this.isAdmin())) throw new Error("errors.users.adminAccessRequired"); this.socket.join(MONITOR_USERS_ROOM); return await this.buildStats(); } @@ -482,7 +489,7 @@ class UserSocket extends Socket { * @socketEvent userMonitorUnsubscribe */ async unsubscribeFromUserMonitor(data, options) { - if (!(await this.isAdmin())) throw new Error("Admin access required"); + if (!(await this.isAdmin())) throw new Error("errors.users.adminAccessRequired"); this.socket.leave(MONITOR_USERS_ROOM); } diff --git a/docs/source/for_developers/i18n.rst b/docs/source/for_developers/i18n.rst new file mode 100644 index 000000000..8dc23a07b --- /dev/null +++ b/docs/source/for_developers/i18n.rst @@ -0,0 +1,1006 @@ +Internationalization (i18n) +=========================== + +This chapter covers how CARE localizes UI text: frontend and backend patterns, and how to add a new language. + + +Where files live +---------------- + +All translation keys live under ``utils/modules/i18n/``: + +* ``en/*.json``, ``de/*.json`` — key catalogs per UI language (one file = one namespace, e.g. ``users.json``) +* ``en/index.js``, ``de/index.js`` — import JSON files and bundle namespaces for the frontend +* ``i18n-bundles.js`` — bundles ``en`` / ``de`` namespaces for ``vue-i18n`` + +Related code: + +* ``frontend/src/assets/utils.js`` — ``resolveApiMessage``, ``translateMaybeKey``, ``formatLocalized*`` +* ``frontend/src/assets/locale.js`` — language switcher, ``app.locale`` +* ``backend/utils/i18n.js`` — ``translateMaybeKey``, ``hasKey`` (backend runtime and migrations) + +Translation guidelines +---------------------- + +Do **not** translate +~~~~~~~~~~~~~~~~~~~~ + +* **Table content** — study titles, document names, workflow names, etc. +* **Server logs** — dashboard log table and log files are **always English** (see :ref:`logging-i18n` below) +* Developer ``console.log`` / debug output +* **Template body content** — multi-language content, not UI strings + +How to translate +---------------- + +.. code-block:: text + + ┌─────────────────────────┐ + │ What do you │ + │ need? │ + └───────────┬─────────────┘ + ┌─────────────────┴─────────────────┐ + │ │ + New UI language UI text + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────┐ + │ 5. Adding a new UI │ │ 1. Adding a key │ + │ language │ │ & │ + │ │ │ 2. Adding a │ + └──────────┬───────────┘ │ namespace │ + │ └────────┬─────────┘ + │ │ + │ ┌──────────────────┘ + │ ▼ + │ ┌─────────────────────────┐ + │ │ Frontend or │ + │ │ backend? │ + │ └───────────┬─────────────┘ + │ ┌───────┴───────┐ + │ ▼ ▼ + │ 3. Frontend 4. Backend + │ │ │ + └─────────┴───────┬───────┘ + ▼ + ┌─────────────────────────┐ + │ 6. Test it out │ + └─────────────────────────┘ + +Sections in this chapter : + +| :ref:`i18n-add-key` — new key in an existing JSON file +| :ref:`i18n-add-namespace` — new ``*.json`` namespace file +| :ref:`i18n-frontend` — ``$t``, ``resolveApiMessage``, table columns, … +| :ref:`i18n-backend` — ``TranslatableError``, backend ``translateMaybeKey``, … +| :ref:`i18n-add-language` — new UI language +| :ref:`i18n-test` — verify in the UI and logs + +.. _i18n-add-key: + +1. Adding a key +--------------- + +.. note:: + + Need a **new JSON file** for a dashboard or feature? See :ref:`i18n-add-namespace`. + +**Example:** you have a short hint for users in ``Users.vue`` — hardcoded English in a ``

``: + +.. code-block:: html + +

Manage team members from this page.

+ +To show it in the user's UI language, replace the literal text with ``$t(...)``. That requires a **key** in the +locale JSON files first. + +**1. Choose a key name** + +Use dot notation: ``namespace.rest.of.key``. The first segment is the JSON filename (namespace), e.g. +``users.labels.manageHint`` → file ``users.json``, nested path ``labels.manageHint``. + +**2. Add the key in every active locale** + +``utils/modules/i18n/en/users.json``: + +.. code-block:: json + + { + "labels": { + "manageHint": "Manage team members from this page." + } + } + +``utils/modules/i18n/de/users.json`` — same key, translated value: + +.. code-block:: json + + { + "labels": { + "manageHint": "Verwalten Sie Teammitglieder auf dieser Seite." + } + } + +Repeat for every locale folder (``en/``, ``de/``, …). **Keys must match** across locales; only the text +values differ. + +**3. Use the key in the template** + +.. code-block:: html + +

{{ $t('users.labels.manageHint') }}

+ +When the user switches language in **Preferences**, the paragraph updates automatically. + +More frontend patterns (placeholders, toasts, table headers, …): :ref:`i18n-frontend`. +Backend errors and logging use keys too: :ref:`i18n-backend`. + +Key layout inside a JSON file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Organize keys by **screen or feature**. Group related strings under clear subsections: + +.. code-block:: json + + { + "workflows": { + "title": "Workflows", + "labels": { + "addWorkflow": "Add Workflow" + }, + "errors": { + "importFailed": "Import failed" + } + } + } + +Useful subsections: + +* ``title`` — page or modal title +* ``labels`` — field labels, short UI phrases, hints +* ``errors`` — validation or action errors for that screen +* ``columns`` — table header labels +* ``toasts`` — toast title/message pairs +* ``messages`` — confirm dialogs, longer text + +Reuse ``common`` for words that appear everywhere. Before adding a new key, check +``utils/modules/i18n/en/common.json`` — shared labels such as ``id``, ``email``, ``save``, ``cancel``. + +Backend error keys usually go in ``errors.json`` (see :ref:`i18n-backend`). + +.. _i18n-add-namespace: + +2. Adding a new namespace +------------------------- + +Each ``*.json`` file in a locale folder is one **namespace**. The filename (without ``.json``) becomes the +first part of every key inside it. + +Example: ``reports.json`` with ``"title": "Reports"`` → use ``$t('reports.title')``. + +**1. Create the file in every active locale** (same keys, translated values): + +.. code-block:: text + + utils/modules/i18n/en/reports.json + utils/modules/i18n/de/reports.json + +.. code-block:: json + + { + "title": "Reports", + "labels": { + "export": "Export" + } + } + +**2. Register in** ``index.js`` **for each locale** (``en/index.js``, ``de/index.js``, …) — required for +the frontend: + +.. code-block:: javascript + + import reports from './reports.json' + + export default { + // ...existing namespaces... + reports, + } + +**3. Use the keys** in Vue components: + +.. code-block:: html + +

{{ $t('reports.title') }}

+ + +.. _i18n-frontend: + +3. Frontend +----------- + +Common patterns — **before** (hardcoded English) and **after** (i18n). Add keys first as in +:ref:`i18n-add-key`. + +Static text in a template ($t) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + +

Manage team members from this page.

+ + +

{{ $t('users.labels.manageHint') }}

+ +Button or attribute label +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + + + + + + +Text with a placeholder +~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: html + + +

Hello, {{ userName }}!

+ + +

{{ $t('greeting.hello', { name: userName }) }}

+ + + +Lists of strings ($tm) +~~~~~~~~~~~~~~~~~~~~~~ + +When a key holds an **array** of messages (e.g. rotating status text), use ``$tm``: + +.. code-block:: json + + { + "loading": { + "messages": [ + "Thinking through your request...", + "Almost there..." + ] + } + } + +.. code-block:: javascript + + data() { + return { + messages: this.$tm('loading.messages'), + index: 0, + }; + } + +.. _i18n-t-rich-text: + +Rich text in translations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When a sentence needs **inline markup** (:code:``, :code:`
`, a clickable link) **or** dynamic values +that must stay safe, use the vue-i18n :code:`` + +keypath and tag +~~~~~~~~~~~~~~~ + +* :code:`keypath` — the translation key (same string you would pass to :code:`$t('…')`). +* :code:`tag` — the **HTML element that wraps the finished sentence**. + + :code:`i18n-t` does not render a bare text node. It builds the full phrase (text + slots) and wraps it in + one element — the one named by :code:`tag`: + + .. list-table:: + :widths: 28 72 + :header-rows: 0 + + * - :code:`tag="p"` + - paragraph wrapper → :code:`

` + * - :code:`tag="span"` + - inline wrapper → :code:`` + * - :code:`tag="div"` + - block wrapper → :code:`
` + + Pick the tag for layout/semantics (paragraph, inline hint, block summary). + + +**How it fits together** + +**1. Locale** — one key, placeholder :code:`{0}` where the slot goes: + +.. code-block:: json + + { + "csvTemplateHint": "Please check the format or {0} here.", + "downloadTemplate": "download the template" + } + +**2. Vue** — :code:`keypath` loads the key; :code:`tag="p"` wraps the output; the :code:`` fills :code:`{0}`: + +.. code-block:: vue + + + + {{ $t('dashboard.users.downloadTemplate') }} + +
+ +**3. Result in the browser:** + +.. code-block:: html + +

+ Please check the format or + download the template + here. +

+ +Named slots +~~~~~~~~~~~ + +Use when placeholders in the JSON have **names** (:code:`{newCount}`, :code:`{br}`, :code:`{dupCount}`). Each name maps +to a :code:`