diff --git a/.env.dev b/.env.dev index 498921b58..79502d324 100644 --- a/.env.dev +++ b/.env.dev @@ -15,6 +15,9 @@ ADMIN_PWD=admincare GUEST_EMAIL=guest@guest.com GUEST_PWD=guestguest +# Skip first-time setup wizard: create admin from ADMIN_EMAIL / ADMIN_PWD and mark wizard complete (demo / dev pipelines). +DEV_SKIP_WIZARD=true + # nlp server SERVICE_NLP_ENABLED=true SERVICE_NLP_URL=http://care.ukp.informatik.tu-darmstadt.de:4853 diff --git a/.env.main b/.env.main index e4396f067..34c0cca74 100644 --- a/.env.main +++ b/.env.main @@ -15,6 +15,9 @@ ADMIN_PWD=admincare GUEST_EMAIL=guest@guest.com GUEST_PWD=guestguest +# Skip first-time setup wizard: create admin from ADMIN_EMAIL / ADMIN_PWD and mark wizard complete (demo / dev pipelines). +DEV_SKIP_WIZARD=true + # nlp server SERVICE_NLP_ENABLED=true SERVICE_NLP_URL=http://care.ukp.informatik.tu-darmstadt.de:4852 diff --git a/Makefile b/Makefile index a3366b31a..be35edb60 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,8 @@ default: help .PHONY: help help: @echo "make help Show this help message" - @echo "make dev Run in development mode (only unix)" + @echo "make dev Run in development mode, skipping the first-time setup wizard (only unix)" + @echo "make dev-wizard Run dev mode and go through the first-time setup wizard" @echo "make doc Build the documentation" @echo "make dev-build Build the frontend (make dev-build-frontend) and run the backend in development mode" @echo "make dev-backend Run backend in development mode" @@ -79,6 +80,10 @@ init: modules db .PHONY: dev dev: frontend/node_modules/.uptodate backend/node_modules/.uptodate + cd frontend && npm run frontend-dev & cd backend && DEV_SKIP_WIZARD=true npm run start + +.PHONY: dev-wizard +dev-wizard: frontend/node_modules/.uptodate backend/node_modules/.uptodate cd frontend && npm run frontend-dev & cd backend && npm run start .PHONY: dev-frontend diff --git a/backend/db/migrations/20260119185828-create-wizard_step.js b/backend/db/migrations/20260119185828-create-wizard_step.js new file mode 100644 index 000000000..53a484b2c --- /dev/null +++ b/backend/db/migrations/20260119185828-create-wizard_step.js @@ -0,0 +1,56 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('wizard_step', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + key: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + order: { + type: Sequelize.INTEGER, + allowNull: false, + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.STRING, + allowNull: true, + }, + type: { + type: Sequelize.STRING, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + defaultValue: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + deletedAt: { + allowNull: true, + type: Sequelize.DATE, + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('wizard_step'); + }, +}; diff --git a/backend/db/migrations/20260119185850-extend-setting-wizard.js b/backend/db/migrations/20260119185850-extend-setting-wizard.js new file mode 100644 index 000000000..8bd12b81d --- /dev/null +++ b/backend/db/migrations/20260119185850-extend-setting-wizard.js @@ -0,0 +1,51 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('setting', 'showInWizard', { + type: Sequelize.BOOLEAN, + defaultValue: false, + }); + await queryInterface.addColumn('setting', 'wizardOrder', { + type: Sequelize.INTEGER, + allowNull: true, + }); + await queryInterface.addColumn('setting', 'requiredInWizard', { + type: Sequelize.BOOLEAN, + defaultValue: false, + }); + await queryInterface.addColumn('setting', 'wizardStepId', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'wizard_step', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + await queryInterface.addColumn('setting', 'displayName', { + type: Sequelize.STRING(256), + allowNull: true, + }); + await queryInterface.addColumn('setting', 'displayGroup', { + type: Sequelize.STRING(128), + allowNull: true, + }); + await queryInterface.addColumn('setting', 'displaySubsection', { + type: Sequelize.STRING(128), + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('setting', 'displaySubsection'); + await queryInterface.removeColumn('setting', 'displayGroup'); + await queryInterface.removeColumn('setting', 'displayName'); + await queryInterface.removeColumn('setting', 'showInWizard'); + await queryInterface.removeColumn('setting', 'wizardOrder'); + await queryInterface.removeColumn('setting', 'requiredInWizard'); + await queryInterface.removeColumn('setting', 'wizardStepId'); + }, +}; diff --git a/backend/db/migrations/20260119192230-basic-wizard_steps.js b/backend/db/migrations/20260119192230-basic-wizard_steps.js new file mode 100644 index 000000000..4ec0ad199 --- /dev/null +++ b/backend/db/migrations/20260119192230-basic-wizard_steps.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + await queryInterface.bulkInsert('wizard_step', [ + { key: 'admin', order: 1, title: 'Admin Account', description: 'Create the administrator account', type: 'admin', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + { key: 'general', order: 2, title: 'General Settings', description: 'Copyright, consent, guest login, external links', type: 'general', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + { key: 'mail', order: 3, title: 'Mail Configuration', description: 'Enable email service and configure SMTP/sendmail', type: 'mail', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + { key: 'registration', order: 4, title: 'User Registration', description: 'What is required at signup', type: 'registration', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + { key: 'moodle', order: 5, title: 'Moodle Settings', description: 'Optional Moodle API settings', type: 'moodle', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + { key: 'summary', order: 6, title: 'Summary', description: 'Review your choices before finishing', type: 'summary', deleted: false, createdAt: now, updatedAt: now, deletedAt: null }, + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('wizard_step', { + key: ['admin', 'general', 'mail', 'registration', 'moodle', 'summary'], + }, {}); + }, +}; diff --git a/backend/db/migrations/20260119192356-transform-user-admin.js b/backend/db/migrations/20260119192356-transform-user-admin.js new file mode 100644 index 000000000..dc0c1e8b8 --- /dev/null +++ b/backend/db/migrations/20260119192356-transform-user-admin.js @@ -0,0 +1,80 @@ +'use strict'; + +const { genSalt, genPwdHash } = require('../../utils/auth'); + +/** + * Removes the default admin user created by basic-users so the first admin + * must be created via the setup wizard. Runs after basic-configuration; all + * configurations owned by the default admin are first reassigned to Bot (userId 2) + * to satisfy the configuration.userId FK, then the admin is deleted. + * POST /auth/setup-admin reassigns those configurations from Bot to the new admin. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + const rows = await queryInterface.sequelize.query( + 'SELECT id FROM "user" WHERE "userName" = \'admin\' AND "deleted" = false', + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + const admin = rows && rows[0]; + if (!admin) { + return; + } + const adminId = admin.id; + const BOT_USER_ID = 2; + + await queryInterface.sequelize.query( + `UPDATE configuration SET "userId" = :botId, "updatedAt" = :now WHERE "userId" = :adminId`, + { + replacements: { botId: BOT_USER_ID, adminId, now: new Date() }, + type: Sequelize.QueryTypes.UPDATE, + } + ); + + await queryInterface.bulkDelete('user_role_matching', { userId: adminId }, {}); + await queryInterface.bulkDelete('user', { userName: 'admin' }, {}); + }, + + async down(queryInterface, Sequelize) { + const salt = genSalt(); + const passwordHash = await genPwdHash(process.env.ADMIN_PWD || 'admin', salt); + const email = process.env.ADMIN_EMAIL || 'admin@localhost'; + const now = new Date(); + + await queryInterface.bulkInsert('user', [{ + firstName: 'admin', + lastName: 'user', + userName: 'admin', + email: email, + passwordHash: passwordHash, + salt: salt, + acceptStats: true, + acceptTerms: true, + deleted: false, + createdAt: now, + updatedAt: now, + }], {}); + + const userRows = await queryInterface.sequelize.query( + 'SELECT id FROM "user" WHERE "userName" = \'admin\'', + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + const roleRows = await queryInterface.sequelize.query( + 'SELECT id FROM "user_role" WHERE name = \'admin\'', + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + const inserted = userRows && userRows[0]; + const adminRole = roleRows && roleRows[0]; + if (inserted && adminRole) { + await queryInterface.bulkInsert('user_role_matching', [{ + userId: inserted.id, + userRoleId: adminRole.id, + deleted: false, + createdAt: now, + updatedAt: now, + deletedAt: null, + }], {}); + } + }, +}; diff --git a/backend/db/migrations/20260119194252-transform-setting-wizard.js b/backend/db/migrations/20260119194252-transform-setting-wizard.js new file mode 100644 index 000000000..f8e4befc6 --- /dev/null +++ b/backend/db/migrations/20260119194252-transform-setting-wizard.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * Wizard settings per step requiredInWizard: true only where the setting must be filled. + */ +const WIZARD_SETTINGS = [ + // general + { key: 'app.config.copyright', wizardStep: 'general', wizardOrder: 1, requiredInWizard: true }, + { key: 'app.config.consent.enabled', wizardStep: 'general', wizardOrder: 2, requiredInWizard: false }, + { key: 'app.login.guest', wizardStep: 'general', wizardOrder: 3, requiredInWizard: false }, + { key: 'app.login.forgotPassword', wizardStep: 'general', wizardOrder: 4, requiredInWizard: false }, + { key: 'app.landing.showDocs', wizardStep: 'general', wizardOrder: 6, requiredInWizard: false }, + { key: 'app.landing.linkDocs', wizardStep: 'general', wizardOrder: 7, requiredInWizard: true }, + { key: 'app.landing.showProject', wizardStep: 'general', wizardOrder: 8, requiredInWizard: false }, + { key: 'app.landing.linkProject', wizardStep: 'general', wizardOrder: 9, requiredInWizard: false }, + { key: 'app.landing.showFeedback', wizardStep: 'general', wizardOrder: 10, requiredInWizard: false }, + { key: 'app.landing.linkFeedback', wizardStep: 'general', wizardOrder: 11, requiredInWizard: false }, + // mail + { key: 'system.mailService.enabled', wizardStep: 'mail', wizardOrder: 12, requiredInWizard: false }, + { key: 'system.mailService.sendMail.enabled', wizardStep: 'mail', wizardOrder: 13, requiredInWizard: false }, + { key: 'system.mailService.sendMail.path', wizardStep: 'mail', wizardOrder: 14, requiredInWizard: false }, + { key: 'system.mailService.senderAddress', wizardStep: 'mail', wizardOrder: 15, requiredInWizard: false }, + { key: 'system.mailService.smtp.enabled', wizardStep: 'mail', wizardOrder: 16, requiredInWizard: false }, + { key: 'system.mailService.smtp.host', wizardStep: 'mail', wizardOrder: 17, requiredInWizard: false }, + { key: 'system.mailService.smtp.port', wizardStep: 'mail', wizardOrder: 18, requiredInWizard: false }, + { key: 'system.mailService.smtp.secure', wizardStep: 'mail', wizardOrder: 19, requiredInWizard: false }, + { key: 'system.mailService.smtp.auth.enabled', wizardStep: 'mail', wizardOrder: 20, requiredInWizard: false }, + { key: 'system.mailService.smtp.auth.user', wizardStep: 'mail', wizardOrder: 21, requiredInWizard: false }, + { key: 'system.mailService.smtp.auth.pass', wizardStep: 'mail', wizardOrder: 22, requiredInWizard: false }, + { key: 'system.baseUrl', wizardStep: 'mail', wizardOrder: 23, requiredInWizard: false }, + { key: 'app.register.emailVerification', wizardStep: 'mail', wizardOrder: 24, requiredInWizard: false }, + // app.login.forgotPassword already in general; in mail step it's a toggle in UI, same key + // registration + { key: 'app.register.enabled', wizardStep: 'registration', wizardOrder: 25, requiredInWizard: false }, + { key: 'app.register.requestName', wizardStep: 'registration', wizardOrder: 26, requiredInWizard: false }, + { key: 'app.register.requestStats', wizardStep: 'registration', wizardOrder: 27, requiredInWizard: false }, + { key: 'app.register.requestData', wizardStep: 'registration', wizardOrder: 28, requiredInWizard: false }, + { key: 'app.register.acceptStats.default', wizardStep: 'registration', wizardOrder: 29, requiredInWizard: false }, + { key: 'app.register.acceptDataSharing.default', wizardStep: 'registration', wizardOrder: 30, requiredInWizard: false }, + { key: 'app.register.terms', wizardStep: 'general', wizardOrder: 5, requiredInWizard: false }, + // Optional Moodle (wizardStep moodle keeps Settings > Moodle grouping; shown on General screen in SetupWizard) + { key: 'rpc.moodleAPI.apiUrl', wizardStep: 'moodle', wizardOrder: 31, requiredInWizard: false }, + { key: 'rpc.moodleAPI.apiKey', wizardStep: 'moodle', wizardOrder: 32, requiredInWizard: false }, + { key: 'rpc.moodleAPI.courseID', wizardStep: 'moodle', wizardOrder: 33, requiredInWizard: false }, + { key: 'rpc.moodleAPI.showInput.apiUrl', wizardStep: 'moodle', wizardOrder: 34, requiredInWizard: false }, + { key: 'rpc.moodleAPI.showInput.apiKey', wizardStep: 'moodle', wizardOrder: 35, requiredInWizard: false }, + { key: 'rpc.moodleAPI.showInput.courseID', wizardStep: 'moodle', wizardOrder: 36, requiredInWizard: false }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + for (const { key, wizardStep, wizardOrder, requiredInWizard } of WIZARD_SETTINGS) { + await queryInterface.sequelize.query( + `UPDATE setting + SET "showInWizard" = true, + "wizardStepId" = (SELECT id FROM wizard_step WHERE key = :wizardStep), + "wizardOrder" = :wizardOrder, + "requiredInWizard" = :requiredInWizard, + "updatedAt" = :now + WHERE key = :key`, + { + replacements: { key, wizardStep, wizardOrder, requiredInWizard, now: new Date() }, + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + }, + + async down(queryInterface, Sequelize) { + for (const { key } of WIZARD_SETTINGS) { + await queryInterface.sequelize.query( + `UPDATE setting SET "showInWizard" = false, "wizardStepId" = NULL, "wizardOrder" = NULL, "requiredInWizard" = false, "updatedAt" = :now WHERE key = :key`, + { + replacements: { key, now: new Date() }, + type: Sequelize.QueryTypes.UPDATE, + } + ); + } + }, +}; diff --git a/backend/db/migrations/20260223130010-basic-setting-app_setup.js b/backend/db/migrations/20260223130010-basic-setting-app_setup.js new file mode 100644 index 000000000..3ff8fc2e5 --- /dev/null +++ b/backend/db/migrations/20260223130010-basic-setting-app_setup.js @@ -0,0 +1,42 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + await queryInterface.bulkInsert('setting', [ + { + key: 'app.setup.wizardCompleted', + value: 'false', + type: 'boolean', + description: 'Internal setup wizard completion state.', + onlyAdmin: true, + showInWizard: false, + requiredInWizard: false, + deleted: false, + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + { + key: 'app.setup.wizardCurrentStep', + value: '0', + type: 'integer', + description: 'Internal setup wizard current step.', + onlyAdmin: true, + showInWizard: false, + requiredInWizard: false, + deleted: false, + createdAt: now, + updatedAt: now, + deletedAt: null, + }, + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('setting', { + key: ['app.setup.wizardCompleted', 'app.setup.wizardCurrentStep'], + }, {}); + }, +}; diff --git a/backend/db/migrations/20260225234559-basic-setting-displayName.js b/backend/db/migrations/20260225234559-basic-setting-displayName.js new file mode 100644 index 000000000..655d3e936 --- /dev/null +++ b/backend/db/migrations/20260225234559-basic-setting-displayName.js @@ -0,0 +1,349 @@ +'use strict'; + +/** + * Populate displayName, displayGroup, and displaySubsection columns. + * displayName: user-facing label from CSV Suggested Name + * displayGroup: section grouping for non-wizard settings from CSV Suggested group naming + * displaySubsection: subsection within a section for Settings page and SetupWizard + * + * @type {import('sequelize-cli').Migration} + */ + +const KEY_TO_DISPLAY_SUBSECTION = { + 'annotator.collab.response': 'Comments', + 'annotator.comments.defaultNumsShown.levelOneUp': 'Comments', + 'annotator.comments.defaultNumsShown.levelZero': 'Comments', + 'annotator.comments.votes.enabled': 'Comments', + 'annotator.comments.votes.onlyUpvote': 'Comments', + 'annotator.download.enabledBeforeStudyClosing': 'Download', + 'annotator.nlp.activated': 'NLP in annotations', + 'annotator.nlp.request.timeout': 'NLP in annotations', + 'annotator.nlp.sentiment_analysis.activated': 'NLP in annotations', + 'annotator.nlp.summarization.activated': 'NLP in annotations', + 'annotator.nlp.summarization.annoLength': 'NLP in annotations', + 'annotator.nlp.summarization.maxLength': 'NLP in annotations', + 'annotator.nlp.summarization.minLength': 'NLP in annotations', + 'annotator.nlp.summarization.skillName': 'NLP in annotations', + 'annotator.sidebar.maxWidth': 'Sidebar', + 'annotator.sidebar.minWidth': 'Sidebar', + 'app.config.consent.enabled': 'Copyright and consent', + 'app.config.copyright': 'Copyright and consent', + 'app.landing.linkDocs': 'Landing page links', + 'app.landing.linkFeedback': 'Landing page links', + 'app.landing.linkProject': 'Landing page links', + 'app.landing.showDocs': 'Landing page links', + 'app.landing.showFeedback': 'Landing page links', + 'app.landing.showProject': 'Landing page links', + 'app.login.forgotPassword': 'Login options', + 'app.login.guest': 'Login options', + 'app.login.passwordResetRateLimit': 'Login options', + 'app.register.acceptDataSharing.default': 'Consent options', + 'app.register.acceptStats.default': 'Consent options', + 'app.register.emailVerification': 'Base URL and verification', + 'app.register.emailVerificationRateLimit': 'Email verification rate limit', + 'app.register.enabled': 'Enable registration', + 'app.register.requestData': 'Information requested at registration', + 'app.register.requestName': 'Information requested at registration', + 'app.register.requestStats': 'Information requested at registration', + 'app.register.terms': 'Terms and conditions', + 'app.study.enabled': 'Study mode', + 'dashboard.navigation.component.default': 'Navigation and dashboard', + 'editor.document.showButtonCreate': 'Document buttons', + 'editor.document.showButtonDeltaDownload': 'Document buttons', + 'editor.document.showButtonHTMLDownload': 'Document buttons', + 'editor.document.showButtonPDFDownload': 'Document buttons', + 'editor.edits.debounceTime': 'Edit history', + 'editor.edits.historyGroupTime': 'Edit history', + 'editor.edits.showHistoryForUser': 'Edit history', + 'editor.toolbar.showHTMLDownload': 'Toolbar', + 'editor.toolbar.tools.align': 'Toolbar', + 'editor.toolbar.tools.background': 'Toolbar', + 'editor.toolbar.tools.blockquote': 'Toolbar', + 'editor.toolbar.tools.bold': 'Toolbar', + 'editor.toolbar.tools.checkList': 'Toolbar', + 'editor.toolbar.tools.clean': 'Toolbar', + 'editor.toolbar.tools.code-block': 'Toolbar', + 'editor.toolbar.tools.color': 'Toolbar', + 'editor.toolbar.tools.direction': 'Toolbar', + 'editor.toolbar.tools.font': 'Toolbar', + 'editor.toolbar.tools.formula': 'Toolbar', + 'editor.toolbar.tools.header': 'Toolbar', + 'editor.toolbar.tools.image': 'Toolbar', + 'editor.toolbar.tools.indent': 'Toolbar', + 'editor.toolbar.tools.italic': 'Toolbar', + 'editor.toolbar.tools.link': 'Toolbar', + 'editor.toolbar.tools.orderedList': 'Toolbar', + 'editor.toolbar.tools.size': 'Toolbar', + 'editor.toolbar.tools.strike': 'Toolbar', + 'editor.toolbar.tools.subscript': 'Toolbar', + 'editor.toolbar.tools.superscript': 'Toolbar', + 'editor.toolbar.tools.underline': 'Toolbar', + 'editor.toolbar.tools.unorderedList': 'Toolbar', + 'editor.toolbar.tools.video': 'Toolbar', + 'editor.toolbar.visibility': 'Toolbar', + 'modal.nlp.request.timeout': 'Modal NLP', + 'modal.nlp.rotation_timer.long': 'Modal NLP', + 'modal.nlp.rotation_timer.short': 'Modal NLP', + 'projects.default': 'Projects', + 'rpc.moodleAPI.apiKey': 'Connection', + 'rpc.moodleAPI.apiUrl': 'Connection', + 'rpc.moodleAPI.courseID': 'Course', + 'rpc.moodleAPI.showInput.apiKey': 'Show inputs', + 'rpc.moodleAPI.showInput.apiUrl': 'Show inputs', + 'rpc.moodleAPI.showInput.courseID': 'Show inputs', + 'service.nlp.enabled': 'NLP service', + 'service.nlp.retryDelay': 'NLP service', + 'service.nlp.test.fallback': 'NLP service', + 'service.nlp.timeout': 'NLP service', + 'service.nlp.url': 'NLP service', + 'statistics.batch.size': 'Statistics and tags', + 'statistics.tracking.mouseDebounceTime': 'Statistics and tags', + 'system.auth.tokenExpiry.emailVerification': 'Token expiry', + 'system.auth.tokenExpiry.passwordReset': 'Token expiry', + 'system.baseUrl': 'Base URL and verification', + 'system.mailService.enabled': 'Mail service', + 'system.mailService.sendMail.enabled': 'Sendmail', + 'system.mailService.sendMail.path': 'Sendmail', + 'system.mailService.senderAddress': 'Mail service', + 'system.mailService.smtp.auth.enabled': 'SMTP', + 'system.mailService.smtp.auth.pass': 'SMTP', + 'system.mailService.smtp.auth.user': 'SMTP', + 'system.mailService.smtp.enabled': 'SMTP', + 'system.mailService.smtp.host': 'SMTP', + 'system.mailService.smtp.port': 'SMTP', + 'system.mailService.smtp.secure': 'SMTP', + 'tags.recencySortingIsOn': 'Statistics and tags', + 'tags.tagSet.default': 'Statistics and tags', + 'topBar.projects.hideProjectButton': 'Projects', +}; + +const KEY_TO_DISPLAY_GROUP = { + 'annotator.collab.response': 'Annotations', + 'annotator.comments.defaultNumsShown.levelOneUp': 'Annotations', + 'annotator.comments.defaultNumsShown.levelZero': 'Annotations', + 'annotator.comments.votes.enabled': 'Annotations', + 'annotator.comments.votes.onlyUpvote': 'Annotations', + 'annotator.download.enabledBeforeStudyClosing': 'Annotations', + 'annotator.nlp.activated': 'Annotations', + 'annotator.nlp.request.timeout': 'Annotations', + 'annotator.nlp.sentiment_analysis.activated': 'Annotations', + 'annotator.nlp.summarization.activated': 'Annotations', + 'annotator.nlp.summarization.annoLength': 'Annotations', + 'annotator.nlp.summarization.maxLength': 'Annotations', + 'annotator.nlp.summarization.minLength': 'Annotations', + 'annotator.nlp.summarization.skillName': 'Annotations', + 'annotator.sidebar.maxWidth': 'Annotations', + 'annotator.sidebar.minWidth': 'Annotations', + 'app.login.passwordResetRateLimit': 'General', + 'app.register.emailVerificationRateLimit': 'Registration', + 'dashboard.navigation.component.default': 'Interface', + 'editor.document.showButtonCreate': 'Text Editor', + 'editor.document.showButtonDeltaDownload': 'Text Editor', + 'editor.document.showButtonHTMLDownload': 'Text Editor', + 'editor.document.showButtonPDFDownload': 'Text Editor', + 'editor.edits.debounceTime': 'Text Editor', + 'editor.edits.historyGroupTime': 'Text Editor', + 'editor.edits.showHistoryForUser': 'Text Editor', + 'editor.toolbar.showHTMLDownload': 'Text Editor', + 'editor.toolbar.tools.align': 'Text Editor', + 'editor.toolbar.tools.background': 'Text Editor', + 'editor.toolbar.tools.blockquote': 'Text Editor', + 'editor.toolbar.tools.bold': 'Text Editor', + 'editor.toolbar.tools.checkList': 'Text Editor', + 'editor.toolbar.tools.clean': 'Text Editor', + 'editor.toolbar.tools.code-block': 'Text Editor', + 'editor.toolbar.tools.color': 'Text Editor', + 'editor.toolbar.tools.direction': 'Text Editor', + 'editor.toolbar.tools.font': 'Text Editor', + 'editor.toolbar.tools.formula': 'Text Editor', + 'editor.toolbar.tools.header': 'Text Editor', + 'editor.toolbar.tools.image': 'Text Editor', + 'editor.toolbar.tools.indent': 'Text Editor', + 'editor.toolbar.tools.italic': 'Text Editor', + 'editor.toolbar.tools.link': 'Text Editor', + 'editor.toolbar.tools.orderedList': 'Text Editor', + 'editor.toolbar.tools.size': 'Text Editor', + 'editor.toolbar.tools.strike': 'Text Editor', + 'editor.toolbar.tools.subscript': 'Text Editor', + 'editor.toolbar.tools.superscript': 'Text Editor', + 'editor.toolbar.tools.underline': 'Text Editor', + 'editor.toolbar.tools.unorderedList': 'Text Editor', + 'editor.toolbar.tools.video': 'Text Editor', + 'editor.toolbar.visibility': 'Text Editor', + 'modal.nlp.request.timeout': 'AI & NLP', + 'modal.nlp.rotation_timer.long': 'AI & NLP', + 'modal.nlp.rotation_timer.short': 'AI & NLP', + 'projects.default': 'Interface', + 'rpc.moodleAPI.showInput.apiKey': 'Moodle', + 'rpc.moodleAPI.showInput.apiUrl': 'Moodle', + 'rpc.moodleAPI.showInput.courseID': 'Moodle', + 'service.nlp.enabled': 'AI & NLP', + 'service.nlp.retryDelay': 'AI & NLP', + 'service.nlp.test.fallback': 'AI & NLP', + 'service.nlp.timeout': 'AI & NLP', + 'service.nlp.url': 'AI & NLP', + 'statistics.batch.size': 'Interface', + 'statistics.tracking.mouseDebounceTime': 'Interface', + 'system.auth.tokenExpiry.emailVerification': 'System', + 'system.auth.tokenExpiry.passwordReset': 'System', + 'tags.recencySortingIsOn': 'Interface', + 'tags.tagSet.default': 'Interface', + 'topBar.projects.hideProjectButton': 'Interface', +}; + +const DISPLAY_NAMES = { + 'annotator.collab.response': 'Comment replies', + 'annotator.comments.defaultNumsShown.levelOneUp': 'Level 1+ comments shown by default', + 'annotator.comments.defaultNumsShown.levelZero': 'Level 0 comments shown by default', + 'annotator.comments.votes.enabled': 'Voting on comments', + 'annotator.comments.votes.onlyUpvote': 'Only upvote on comments', + 'annotator.download.enabledBeforeStudyClosing': 'Download before study closing', + 'annotator.nlp.activated': 'NLP in annotation view', + 'annotator.nlp.request.timeout': 'NLP request timeout', + 'annotator.nlp.sentiment_analysis.activated': 'Sentiment analysis in comments', + 'annotator.nlp.summarization.activated': 'Summarization activated', + 'annotator.nlp.summarization.annoLength': 'Summarization annotation length', + 'annotator.nlp.summarization.maxLength': 'Summarization max length', + 'annotator.nlp.summarization.minLength': 'Summarization min length', + 'annotator.nlp.summarization.skillName': 'Summarization skill name', + 'annotator.sidebar.maxWidth': 'Sidebar max width', + 'annotator.sidebar.minWidth': 'Sidebar min width', + 'app.config.consent.enabled': 'Consent update feature', + 'app.config.copyright': 'Copyright notice', + 'app.landing.linkDocs': 'Documentation URL', + 'app.landing.linkFeedback': 'Feedback form URL', + 'app.landing.linkProject': 'Project page URL', + 'app.landing.showDocs': 'Show documentation link', + 'app.landing.showFeedback': 'Show feedback link', + 'app.landing.showProject': 'Show project link', + 'app.login.forgotPassword': 'Forgot password', + 'app.login.guest': 'Allow guest login', + 'app.login.passwordResetRateLimit': 'Password reset rate limit', + 'app.register.acceptDataSharing.default': 'Default accept data sharing', + 'app.register.acceptStats.default': 'Default accept tracking', + 'app.register.emailVerification': 'Email verification required', + 'app.register.enabled': 'Enable self-registration', + 'app.register.emailVerificationRateLimit': 'Email verification rate limit', + 'app.register.requestData': 'Request data sharing at registration', + 'app.register.requestName': 'Request name at registration', + 'app.register.requestStats': 'Request usage-stats consent at registration', + 'app.register.terms': 'Terms and conditions', + 'app.study.enabled': 'Enable study mode', + 'dashboard.navigation.component.default': 'Default dashboard component', + 'editor.document.showButtonCreate': 'Show create document button', + 'editor.document.showButtonDeltaDownload': 'Show delta download button', + 'editor.document.showButtonHTMLDownload': 'Show HTML download button', + 'editor.document.showButtonPDFDownload': 'Show PDF download button', + 'editor.edits.debounceTime': 'Edit debounce time', + 'editor.edits.historyGroupTime': 'Edit history group time', + 'editor.edits.showHistoryForUser': 'Show edit history to users', + 'editor.toolbar.showHTMLDownload': 'Toolbar HTML download', + 'editor.toolbar.tools.align': 'Toolbar align', + 'editor.toolbar.tools.background': 'Toolbar background', + 'editor.toolbar.tools.blockquote': 'Toolbar blockquote', + 'editor.toolbar.tools.bold': 'Toolbar bold', + 'editor.toolbar.tools.checkList': 'Toolbar check list', + 'editor.toolbar.tools.clean': 'Toolbar clean', + 'editor.toolbar.tools.code-block': 'Toolbar code-block', + 'editor.toolbar.tools.color': 'Toolbar color', + 'editor.toolbar.tools.direction': 'Toolbar direction', + 'editor.toolbar.tools.font': 'Toolbar font', + 'editor.toolbar.tools.formula': 'Toolbar formula', + 'editor.toolbar.tools.header': 'Toolbar header', + 'editor.toolbar.tools.image': 'Toolbar image', + 'editor.toolbar.tools.indent': 'Toolbar indent', + 'editor.toolbar.tools.italic': 'Toolbar italic', + 'editor.toolbar.tools.link': 'Toolbar link', + 'editor.toolbar.tools.orderedList': 'Toolbar ordered list', + 'editor.toolbar.tools.size': 'Toolbar size', + 'editor.toolbar.tools.strike': 'Toolbar strike', + 'editor.toolbar.tools.subscript': 'Toolbar subscript', + 'editor.toolbar.tools.superscript': 'Toolbar superscript', + 'editor.toolbar.tools.underline': 'Toolbar underline', + 'editor.toolbar.tools.unorderedList': 'Toolbar unordered list', + 'editor.toolbar.tools.video': 'Toolbar video', + 'editor.toolbar.visibility': 'Toolbar visibility', + 'modal.nlp.request.timeout': 'Modal NLP request timeout', + 'modal.nlp.rotation_timer.long': 'Modal NLP rotation long', + 'modal.nlp.rotation_timer.short': 'Modal NLP rotation short', + 'projects.default': 'Default project', + 'rpc.moodleAPI.apiKey': 'Moodle API key', + 'rpc.moodleAPI.apiUrl': 'Moodle API URL', + 'rpc.moodleAPI.courseID': 'Moodle course ID', + 'rpc.moodleAPI.showInput.apiKey': 'Show Moodle API key input', + 'rpc.moodleAPI.showInput.apiUrl': 'Show Moodle API URL input', + 'rpc.moodleAPI.showInput.courseID': 'Show Moodle course ID input', + 'service.nlp.enabled': 'Enable NLP features', + 'service.nlp.retryDelay': 'NLP retry delay', + 'service.nlp.test.fallback': 'NLP test fallback', + 'service.nlp.timeout': 'NLP timeout', + 'service.nlp.url': 'NLP service URL', + 'statistics.batch.size': 'Statistics batch size', + 'statistics.tracking.mouseDebounceTime': 'Mouse debounce time', + 'system.auth.tokenExpiry.emailVerification': 'Email verification token expiry', + 'system.auth.tokenExpiry.passwordReset': 'Password reset token expiry', + 'system.baseUrl': 'Base URL for emails', + 'system.mailService.enabled': 'Mail service enabled', + 'system.mailService.sendMail.enabled': 'Sendmail enabled (prioritized over SMTP)', + 'system.mailService.sendMail.path': 'Sendmail path', + 'system.mailService.senderAddress': 'Mail sender address', + 'system.mailService.smtp.auth.enabled': 'SMTP auth enabled', + 'system.mailService.smtp.auth.pass': 'SMTP auth password', + 'system.mailService.smtp.auth.user': 'SMTP auth user', + 'system.mailService.smtp.enabled': 'SMTP enabled', + 'system.mailService.smtp.host': 'SMTP host', + 'system.mailService.smtp.port': 'SMTP port', + 'system.mailService.smtp.secure': 'SMTP secure', + 'tags.recencySortingIsOn': 'Tag recency sorting', + 'tags.tagSet.default': 'Default tag set', + 'topBar.projects.hideProjectButton': 'Hide project button in topbar', +}; + +module.exports = { + async up(queryInterface, Sequelize) { + const [results] = await queryInterface.sequelize.query( + "SELECT key FROM setting WHERE deleted = false" + ); + + for (const row of results) { + const key = row.key; + const displayName = DISPLAY_NAMES[key]; + const displayGroup = KEY_TO_DISPLAY_GROUP[key]; + if (displayName) { + await queryInterface.sequelize.query( + 'UPDATE setting SET "displayName" = :displayName WHERE key = :key', + { replacements: { displayName, key } } + ); + } + if (displayGroup) { + await queryInterface.sequelize.query( + 'UPDATE setting SET "displayGroup" = :displayGroup WHERE key = :key', + { replacements: { displayGroup, key } } + ); + } + const displaySubsection = KEY_TO_DISPLAY_SUBSECTION[key]; + if (displaySubsection) { + await queryInterface.sequelize.query( + 'UPDATE setting SET "displaySubsection" = :displaySubsection WHERE key = :key', + { replacements: { displaySubsection, key } } + ); + } + } + }, + + async down(queryInterface, Sequelize) { + const [results] = await queryInterface.sequelize.query( + "SELECT key FROM setting WHERE deleted = false" + ); + + for (const row of results) { + const key = row.key; + if (DISPLAY_NAMES[key] || KEY_TO_DISPLAY_GROUP[key] || KEY_TO_DISPLAY_SUBSECTION[key]) { + await queryInterface.sequelize.query( + 'UPDATE setting SET "displayName" = NULL, "displayGroup" = NULL, "displaySubsection" = NULL WHERE key = :key', + { replacements: { key } } + ); + } + } + }, +}; \ No newline at end of file diff --git a/backend/db/migrations/20260419183159-extend-setting-displayName-email_templates.js b/backend/db/migrations/20260419183159-extend-setting-displayName-email_templates.js new file mode 100644 index 000000000..8915c0d16 --- /dev/null +++ b/backend/db/migrations/20260419183159-extend-setting-displayName-email_templates.js @@ -0,0 +1,72 @@ +'use strict'; + +/** + * Set displayName, displayGroup, and displaySubsection for settings whose rows are + * created or extended after extend-setting-displayName (email templates including + * twoFactorOtp / passwordResetSuccess, logo, external auth / 2FA). + * + * @type {import('sequelize-cli').Migration} + */ + +const UPDATES = [ + { key: 'email.template.passwordReset', displayName: 'Password reset email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.verification', displayName: 'Email verification', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.registration', displayName: 'Registration welcome email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.sessionStart', displayName: 'Study session start email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.sessionFinish', displayName: 'Study session finish email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.assignment', displayName: 'Assignment notification email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.studyClosed', displayName: 'Study closed email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.twoFactorOtp', displayName: 'Two-factor OTP email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + { key: 'email.template.passwordResetSuccess', displayName: 'Password reset success email', displayGroup: 'Mail', displaySubsection: 'Email templates' }, + + { key: 'logo.reBgColor', displayName: 'Logo RE section background colour', displayGroup: 'Interface', displaySubsection: 'Branding' }, + + { key: 'system.auth.orcid.enabled', displayName: 'Enable ORCID login', displayGroup: 'General', displaySubsection: 'ORCID login' }, + { key: 'system.auth.orcid.clientId', displayName: 'ORCID client ID', displayGroup: 'General', displaySubsection: 'ORCID login' }, + { key: 'system.auth.orcid.clientSecret', displayName: 'ORCID client secret', displayGroup: 'General', displaySubsection: 'ORCID login' }, + { key: 'system.auth.orcid.callbackUrl', displayName: 'ORCID callback URL', displayGroup: 'General', displaySubsection: 'ORCID login' }, + { key: 'system.auth.orcid.sandbox', displayName: 'ORCID sandbox mode', displayGroup: 'General', displaySubsection: 'ORCID login' }, + + { key: 'system.auth.ldap.enabled', displayName: 'Enable LDAP login', displayGroup: 'General', displaySubsection: 'LDAP login' }, + { key: 'system.auth.ldap.url', displayName: 'LDAP server URL', displayGroup: 'General', displaySubsection: 'LDAP login' }, + { key: 'system.auth.ldap.bindDN', displayName: 'LDAP bind DN', displayGroup: 'General', displaySubsection: 'LDAP login' }, + { key: 'system.auth.ldap.bindCredentials', displayName: 'LDAP bind password', displayGroup: 'General', displaySubsection: 'LDAP login' }, + { key: 'system.auth.ldap.searchBase', displayName: 'LDAP search base', displayGroup: 'General', displaySubsection: 'LDAP login' }, + { key: 'system.auth.ldap.searchFilter', displayName: 'LDAP search filter', displayGroup: 'General', displaySubsection: 'LDAP login' }, + + { key: 'system.auth.saml.enabled', displayName: 'Enable SAML login', displayGroup: 'General', displaySubsection: 'SAML login' }, + { key: 'system.auth.saml.entryPoint', displayName: 'SAML IdP entry point', displayGroup: 'General', displaySubsection: 'SAML login' }, + { key: 'system.auth.saml.issuer', displayName: 'SAML SP issuer', displayGroup: 'General', displaySubsection: 'SAML login' }, + { key: 'system.auth.saml.cert', displayName: 'SAML IdP certificate', displayGroup: 'General', displaySubsection: 'SAML login' }, + { key: 'system.auth.saml.callbackUrl', displayName: 'SAML callback URL', displayGroup: 'General', displaySubsection: 'SAML login' }, + + { key: 'system.auth.local.2fa.required', displayName: 'Require 2FA for local login', displayGroup: 'General', displaySubsection: 'Two-factor authentication' }, + { key: 'system.auth.orcid.2fa.required', displayName: 'Require 2FA for ORCID login', displayGroup: 'General', displaySubsection: 'Two-factor authentication' }, + { key: 'system.auth.ldap.2fa.required', displayName: 'Require 2FA for LDAP login', displayGroup: 'General', displaySubsection: 'Two-factor authentication' }, + { key: 'system.auth.saml.2fa.required', displayName: 'Require 2FA for SAML login', displayGroup: 'General', displaySubsection: 'Two-factor authentication' }, + + { key: 'system.auth.redirect.baseUrl', displayName: 'Frontend base URL for auth redirects', displayGroup: 'General', displaySubsection: 'Auth redirects' }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + for (const u of UPDATES) { + await queryInterface.sequelize.query( + `UPDATE setting SET "displayName" = :dn, "displayGroup" = :dg, "displaySubsection" = :ds, "updatedAt" = :now WHERE key = :k`, + { replacements: { dn: u.displayName, dg: u.displayGroup, ds: u.displaySubsection, k: u.key, now } } + ); + } + }, + + async down(queryInterface, Sequelize) { + const now = new Date(); + const keys = UPDATES.map((u) => u.key); + for (const k of keys) { + await queryInterface.sequelize.query( + `UPDATE setting SET "displayName" = NULL, "displayGroup" = NULL, "displaySubsection" = NULL, "updatedAt" = :now WHERE key = :k`, + { replacements: { k, now } } + ); + } + }, +}; diff --git a/backend/db/migrations/20260504161006-create-wizard-step-type.js b/backend/db/migrations/20260504161006-create-wizard-step-type.js new file mode 100644 index 000000000..5a7a24652 --- /dev/null +++ b/backend/db/migrations/20260504161006-create-wizard-step-type.js @@ -0,0 +1,94 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + await queryInterface.createTable('wizard_step_type', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + key: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + await queryInterface.bulkInsert('wizard_step_type', [ + { key: 'admin', createdAt: now, updatedAt: now }, + { key: 'general', createdAt: now, updatedAt: now }, + { key: 'mail', createdAt: now, updatedAt: now }, + { key: 'registration', createdAt: now, updatedAt: now }, + { key: 'moodle', createdAt: now, updatedAt: now }, + { key: 'summary', createdAt: now, updatedAt: now }, + ], {}); + + await queryInterface.addColumn('wizard_step', 'wizardStepTypeId', { + type: Sequelize.INTEGER, + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE "wizard_step" AS ws + SET "wizardStepTypeId" = wst.id + FROM "wizard_step_type" AS wst + WHERE ws.type = wst.key + `); + + await queryInterface.removeColumn('wizard_step', 'type'); + + await queryInterface.changeColumn('wizard_step', 'wizardStepTypeId', { + type: Sequelize.INTEGER, + allowNull: false, + }); + + await queryInterface.addConstraint('wizard_step', { + fields: ['wizardStepTypeId'], + type: 'foreign key', + name: 'wizard_step_wizardStepTypeId_fkey', + references: { + table: 'wizard_step_type', + field: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'RESTRICT', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeConstraint('wizard_step', 'wizard_step_wizardStepTypeId_fkey'); + + await queryInterface.addColumn('wizard_step', 'type', { + type: Sequelize.STRING, + allowNull: true, + }); + + await queryInterface.sequelize.query(` + UPDATE "wizard_step" AS ws + SET type = wst.key + FROM "wizard_step_type" AS wst + WHERE ws."wizardStepTypeId" = wst.id + `); + + await queryInterface.changeColumn('wizard_step', 'type', { + type: Sequelize.STRING, + allowNull: false, + }); + + await queryInterface.removeColumn('wizard_step', 'wizardStepTypeId'); + + await queryInterface.dropTable('wizard_step_type'); + }, +}; diff --git a/backend/db/models/setting.js b/backend/db/models/setting.js index 45ec7e6af..d4e6861c6 100644 --- a/backend/db/models/setting.js +++ b/backend/db/models/setting.js @@ -10,7 +10,10 @@ module.exports = (sequelize, DataTypes) => { * The `models/index` file will call this method automatically. */ static associate(models) { - // define association here + Setting.belongsTo(models["wizard_step"], { + foreignKey: "wizardStepId", + as: "wizardStep", + }); } /** @@ -48,20 +51,99 @@ module.exports = (sequelize, DataTypes) => { } } + /** + * Get settings that are shown in the setup wizard, ordered by wizardOrder. + * @returns {Promise} + */ + static async getWizardSettings() { + try { + return await Setting.findAll({ + where: { showInWizard: true, deleted: false }, + order: [['wizardOrder', 'ASC']], + attributes: [ + 'key', + 'value', + 'type', + 'description', + 'displayName', + 'displaySubsection', + 'requiredInWizard', + [sequelize.col('wizardStep.key'), 'wizardStep'], + ], + include: [{ + model: sequelize.models["wizard_step"], + as: "wizardStep", + attributes: [], + required: false, + }], + raw: true, + }); + } catch (e) { + console.log(e); + return []; + } + } + + /** + * Get wizard settings grouped by wizardStep for frontend consumption. + * Settings without wizardStep are placed in 'general'. + * @returns {Promise} + */ + static async getWizardSettingsByStep() { + try { + const settings = await Setting.getWizardSettings(); + const byStep = { general: [], mail: [], registration: [], moodle: [] }; + for (const s of settings) { + const step = (s.wizardStep && Object.prototype.hasOwnProperty.call(byStep, s.wizardStep)) + ? s.wizardStep + : 'general'; + byStep[step].push(s); + } + return byStep; + } catch (e) { + console.log(e); + return { general: [], mail: [], registration: [], moodle: [] }; + } + } + + /** + * Mail service settings only (keys under system.mailService.*). + * Used for test mail and mail transport helpers without loading all settings. + * @returns {Promise} + */ + static async getMailServiceSettings() { + try { + return await Setting.findAll({ + where: { + deleted: false, + key: {[Op.like]: 'system.mailService.%'}, + }, + attributes: ['key', 'value'], + raw: true, + }); + } catch (e) { + console.log(e); + return []; + } + } + /** * Set setting value by key - * @param {string} key setting key - * @param {string} value setting value - * @returns {Promise} setting object + * @param {string} key setting key + * @param {string} value setting value + * @param {Object} [options] additional sequelize options + * @param {Object} [options.transaction] sequelize transaction + * @returns {Promise} */ - static async set(key, value) { + static async set(key, value, options = {}) { try { - const [instance, created] = + const [instance] = await Setting.upsert({ key: key, value: value, }, { - conflictFields: ['key'] + conflictFields: ['key'], + transaction: options.transaction, }); return instance['dataValues']; } catch (e) { @@ -77,7 +159,14 @@ module.exports = (sequelize, DataTypes) => { value: DataTypes.TEXT, type: DataTypes.STRING, description: DataTypes.STRING, + displayName: DataTypes.STRING, + displayGroup: DataTypes.STRING, + displaySubsection: DataTypes.STRING, onlyAdmin: DataTypes.BOOLEAN, + showInWizard: DataTypes.BOOLEAN, + wizardOrder: DataTypes.INTEGER, + requiredInWizard: DataTypes.BOOLEAN, + wizardStepId: DataTypes.INTEGER, deleted: DataTypes.BOOLEAN, deletedAt: DataTypes.DATE diff --git a/backend/db/models/wizard_step.js b/backend/db/models/wizard_step.js new file mode 100644 index 000000000..f359d6d35 --- /dev/null +++ b/backend/db/models/wizard_step.js @@ -0,0 +1,66 @@ +'use strict'; + +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class WizardStep extends MetaModel { + static autoTable = false; + + static associate(models) { + WizardStep.hasMany(models["setting"], { + foreignKey: "wizardStepId", + as: "settings", + }); + WizardStep.belongsTo(models["wizard_step_type"], { + foreignKey: "wizardStepTypeId", + as: "wizardStepType", + }); + } + } + + WizardStep.init( + { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER, + }, + key: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + order: { + type: DataTypes.INTEGER, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.STRING, + allowNull: true, + }, + wizardStepTypeId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + deleted: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + deletedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: 'wizard_step', + tableName: 'wizard_step', + } + ); + + return WizardStep; +}; diff --git a/backend/db/models/wizard_step_type.js b/backend/db/models/wizard_step_type.js new file mode 100644 index 000000000..942e94a4b --- /dev/null +++ b/backend/db/models/wizard_step_type.js @@ -0,0 +1,41 @@ +'use strict'; + +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class WizardStepType extends MetaModel { + static autoTable = false; + + static associate(models) { + WizardStepType.hasMany(models["wizard_step"], { + foreignKey: "wizardStepTypeId", + as: "wizardSteps", + }); + } + } + + WizardStepType.init( + { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: DataTypes.INTEGER, + }, + key: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "wizard_step_type", + tableName: "wizard_step_type", + } + ); + + return WizardStepType; +}; diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index dff1a5491..d7458a2e6 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -17,6 +17,7 @@ const Service = require(path.resolve(__dirname, "./Service.js")); const RPC = require(path.resolve(__dirname,"./RPC.js")); const statsScheduler = require('../db/stats'); const nodemailer = require('nodemailer'); +const { setupDevAdmin } = require('./utils/devAdmin'); const { initializeAuth } = require("./auth"); /** @@ -92,6 +93,7 @@ module.exports = class Server { require('./routes/export')(this); require("./routes/config")(this); require('./routes/auth')(this); + require("./routes/setup")(this); this.app.use((req, res, next) => { if (req.method !== "GET") { @@ -104,13 +106,16 @@ module.exports = class Server { }); this.httpServer = http.createServer(this.app); - Promise.resolve(this.#initMailServer()).then(() => { + Promise.resolve(this.initMailServer()).then(() => { if (this.mailer) { this.logger.info("Mail server initialized"); } else { this.logger.warn("Mail server not available!"); } + }).catch((err) => { + this.logger.error("initMailServer failed: " + err); }); + Promise.resolve(setupDevAdmin(this)); // When DEV_SKIP_WIZARD=true only: creates first admin from env and marks wizard complete. this.#initWebsocketServer(); this.#discoverComponents("./rpcs", RPC, this.addRPC.bind(this)); this.#discoverComponents("./sockets", Socket, this.addSocket.bind(this)); @@ -137,10 +142,12 @@ module.exports = class Server { } /** - * Initialize the mail server + * Initialize the mail server from current DB settings. + * Clears any previous transport first so disabled mail or changed mode is reflected. * @returns {Promise} */ - async #initMailServer() { + async initMailServer() { + this.mailer = null; if (await this.db.models['setting'].get("system.mailService.enabled") === "true") { if (await this.db.models['setting'].get("system.mailService.sendMail.enabled") === "true") { diff --git a/backend/webserver/routes/auth/index.js b/backend/webserver/routes/auth/index.js index 4c2cc5c06..adc64fd30 100644 --- a/backend/webserver/routes/auth/index.js +++ b/backend/webserver/routes/auth/index.js @@ -5,6 +5,7 @@ const { registerVerificationRoutes } = require('./verification'); const { registerLoginRoutes } = require('./login'); const { registerPasswordRoutes } = require('./password'); const { registerRegistrationRoutes } = require('./registration'); +const { registerSetupRoutes } = require('./setup'); const { createSharedHelpers } = require('./shared'); const { registerTwoFactorLoginFlowRoutes } = require('./twoFactor/loginFlow'); const { createTwoFactorHelpers } = require('./twoFactor/shared'); @@ -23,6 +24,7 @@ module.exports = function registerAuthRoutes(server) { registerLoginRoutes(server, helpers); registerRegistrationRoutes(server, helpers); + registerSetupRoutes(server); registerPasswordRoutes(server, helpers); registerVerificationRoutes(server, helpers); registerTwoFactorLoginFlowRoutes(server, helpers); diff --git a/backend/webserver/routes/auth/login.js b/backend/webserver/routes/auth/login.js index e262208a2..ed3b0610b 100644 --- a/backend/webserver/routes/auth/login.js +++ b/backend/webserver/routes/auth/login.js @@ -1,6 +1,7 @@ 'use strict'; const passport = require('passport'); +const { relevantFields } = require('../../../utils/auth'); /** * Register login/logout/session-check routes, including local and external provider entrypoints. @@ -231,16 +232,33 @@ function registerLoginRoutes(server, helpers) { }); /** - * Return the current authenticated user, if any. + * Return the current authenticated user (if any), needsSetup and wizardCompleted. */ - server.app.get('/auth/check', (req, res) => { - if (req.user) { - res.status(200).send({ user: req.user }); - } else { - res.status(401); + server.app.get('/auth/check', async (req, res) => { + try { + const admins = await server.db.models['user'].getUsersByRole('admin'); + const needsSetup = admins.length === 0; + const wizardCompleted = (await server.db.models['setting'].get('app.setup.wizardCompleted')) === 'true'; + + server.logger.debug(`req.session.passport: ${JSON.stringify(req.session && req.session.passport)}`); + server.logger.debug(`req.user: ${JSON.stringify(req.user)}`); + + if (req.user) { + return res.status(200).json({ + user: relevantFields(req.user), + needsSetup, + wizardCompleted, + }); + } + return res.status(200).json({ + user: null, + needsSetup, + wizardCompleted, + }); + } catch (err) { + server.logger.error('auth/check error: ' + err); + return res.status(500).json({ message: 'Internal server error' }); } - server.logger.debug(`req.session.passport: ${JSON.stringify(req.session.passport)}`); - server.logger.debug(`req.user: ${JSON.stringify(req.user)}`); }); } diff --git a/backend/webserver/routes/auth/setup.js b/backend/webserver/routes/auth/setup.js new file mode 100644 index 000000000..15df5cc1f --- /dev/null +++ b/backend/webserver/routes/auth/setup.js @@ -0,0 +1,52 @@ +'use strict'; + +const { relevantFields } = require('../../../utils/auth'); +const { createInitialAdmin } = require('../../utils/setupAdmin'); + +/** + * Register first-time setup routes. + * + * @param {Server} server main server instance + */ +function registerSetupRoutes(server) { + /** + * Create the first admin account (setup wizard step 1). Allowed only when no admin exists. + * Reassigns setup-time Bot-owned configurations to the new admin. + */ + server.app.post('/auth/setup-admin', async function (req, res) { + const { userName, email, password } = req.body || {}; + + try { + const admins = await server.db.models['user'].getUsersByRole('admin'); + if (admins.length > 0) { + return res.status(403).json({ message: 'An admin account already exists.' }); + } + + let user; + try { + user = await createInitialAdmin(server, { userName, email, password }); + } catch (err) { + if (err && err.statusCode === 400) { + return res.status(400).json({ message: err.message }); + } + server.logger.error('Cannot create setup admin: ' + err); + return res.status(400).json({ message: 'Failed to create admin.', error: err.message }); + } + + req.logIn(user, function (err) { + if (err) { + server.logger.error('setup-admin logIn error: ' + err); + return res.status(500).json({ message: 'Failed to complete setup.' }); + } + return res.status(200).json({ user: relevantFields(user) }); + }); + } catch (err) { + server.logger.error('setup-admin error: ' + err); + return res.status(500).json({ message: 'Internal server error.' }); + } + }); +} + +module.exports = { + registerSetupRoutes, +}; diff --git a/backend/webserver/routes/setup.js b/backend/webserver/routes/setup.js new file mode 100644 index 000000000..1141f749c --- /dev/null +++ b/backend/webserver/routes/setup.js @@ -0,0 +1,185 @@ +/** + * Setup wizard routes. GET /setup/config returns needsSetup, steps, wizardSettings, and + * wizardSettingsByStep while initial setup is in progress. Once an admin exists and the + * wizard is marked complete, returns empty steps and settings. + * + * @author Mohammad Elwan + */ + +/** + * Register setup routes + * @param {import("../Server").Server} server + */ +module.exports = function (server) { + const mailTest = require("../utils/mailTest.js"); + const { saveSettings } = require("../utils/settingSave.js"); + /** + * GET /setup/config + * Returns wizard config while initial setup is in progress: needsSetup is true when no + * admin exists; steps, wizardSettings, and wizardSettingsByStep (grouped by general, + * mail, registration) are returned until app.setup.wizardCompleted is true. + * Each step includes typeId (FK to wizard_step_type). wizardStepTypes lists id for setup UI. + * Moodle fields appear in the General wizard step in the UI. When setup is fully + * complete, returns empty steps and wizardSettings. + */ + server.app.get("/setup/config", async function (req, res) { + try { + const admins = await server.db.models["user"].getUsersByRole("admin"); + const needsSetup = admins.length === 0; + const wizardCompleted = (await server.db.models["setting"].get("app.setup.wizardCompleted")) === "true"; + + if (!needsSetup && wizardCompleted) { + return res.status(200).json({ + needsSetup: false, + steps: [], + wizardSettings: [], + wizardStepTypes: [], + }); + } + + const WizardStep = server.db.models["wizard_step"]; + const WizardStepType = server.db.models["wizard_step_type"]; + const stepRows = await WizardStep.findAll({ + where: { deleted: false }, + order: [["order", "ASC"]], + attributes: ["key", "title", "description", "order", "wizardStepTypeId"], + raw: true, + }); + const steps = stepRows.map((row) => { + const { wizardStepTypeId, ...rest } = row; + return { ...rest, typeId: wizardStepTypeId }; + }); + + const wizardStepTypes = await WizardStepType.findAll({ + attributes: ["id", "key"], + order: [["id", "ASC"]], + raw: true, + }); + + const wizardSettings = await server.db.models["setting"].getWizardSettings(); + const wizardSettingsByStep = await server.db.models["setting"].getWizardSettingsByStep(); + const allSettings = await server.db.models["setting"].getAll(false); + + return res.status(200).json({ + needsSetup, + steps, + wizardStepTypes, + wizardSettings, + wizardSettingsByStep, + allSettings, + }); + } catch (err) { + server.logger.error("GET /setup/config error: " + err); + return res.status(500).json({ message: "Internal server error." }); + } + }); + + /** + * POST /setup/test-mail + * Sends a fixed test message using mail settings from DB with optional body.settings overrides. + * Only allowed while needsSetup is true (no admin account). + */ + server.app.post("/setup/test-mail", async function (req, res) { + try { + const admins = await server.db.models["user"].getUsersByRole("admin"); + if (admins.length > 0) { + return res.status(403).json({ success: false, message: "Test mail is only available during initial setup." }); + } + + const rows = await server.db.models["setting"].getMailServiceSettings(); + const baseMap = mailTest.buildMailMapFromSettingsRows(rows); + const overlay = req.body && req.body.settings && typeof req.body.settings === "object" && !Array.isArray(req.body.settings) + ? req.body.settings + : {}; + const map = { ...baseMap }; + for (const [k, v] of Object.entries(overlay)) { + if (k && String(k).startsWith("system.mailService.")) { + map[String(k)] = v != null && v !== undefined ? String(v) : ""; + } + } + + const transport = mailTest.buildTransportFromMailSettings(map); + const from = map["system.mailService.senderAddress"] || ""; + const to = req.body && req.body.to != null ? String(req.body.to).trim() : ""; + await mailTest.sendFixedTestMail(transport, { from, to }); + return res.status(200).json({ success: true, message: "Test email sent." }); + } catch (err) { + server.logger.error("POST /setup/test-mail error: " + err); + return res.status(400).json({ success: false, message: err?.message || "Failed to send test email." }); + } + }); + + /** + * PATCH /setup/state + * Updates wizard state in settings. + * Body: { wizardCompleted?: string, wizardCurrentStep?: string } + */ + server.app.patch("/setup/state", async function (req, res) { + if (!req.user) { + return res.status(401).json({ message: "Authentication required." }); + } + try { + const admins = await server.db.models["user"].getUsersByRole("admin"); + const isAdmin = admins.some((a) => a.id === req.user.id); + if (!isAdmin) { + return res.status(403).json({ message: "Admin access required." }); + } + + const { wizardCompleted, wizardCurrentStep } = req.body || {}; + const Setting = server.db.models["setting"]; + + if (wizardCompleted !== undefined) { + await Setting.set("app.setup.wizardCompleted", String(wizardCompleted)); + } + if (wizardCurrentStep !== undefined) { + await Setting.set("app.setup.wizardCurrentStep", String(wizardCurrentStep)); + } + + return res.status(200).json({ success: true }); + } catch (err) { + server.logger.error("PATCH /setup/state error: " + err); + return res.status(500).json({ message: "Internal server error." }); + } + }); + + /** + * POST /setup/complete + * Saves wizard settings and marks setup complete in one flow. + */ + server.app.post("/setup/complete", async function (req, res) { + if (!req.user) { + return res.status(401).json({ message: "Authentication required." }); + } + const settings = req.body && Array.isArray(req.body.settings) ? req.body.settings : null; + if (!settings) { + return res.status(400).json({ message: "A settings array is required." }); + } + try { + const admins = await server.db.models["user"].getUsersByRole("admin"); + const isAdmin = admins.some((a) => a.id === req.user.id); + if (!isAdmin) { + return res.status(403).json({ message: "Admin access required." }); + } + + const transaction = await server.db.sequelize.transaction(); + try { + const { touchesMailService } = await saveSettings(server.db.models["setting"], settings, { + transaction, + }); + await server.db.models["setting"].set("app.setup.wizardCompleted", "true", { transaction }); + await transaction.commit(); + if (touchesMailService) { + await server.initMailServer(); + } + } catch (err) { + await transaction.rollback(); + throw err; + } + + return res.status(200).json({ success: true }); + } catch (err) { + server.logger.error("POST /setup/complete error: " + err); + return res.status(500).json({ message: "Internal server error." }); + } + }); +}; diff --git a/backend/webserver/sockets/setting.js b/backend/webserver/sockets/setting.js index 096712e1b..3c1c3e7b8 100644 --- a/backend/webserver/sockets/setting.js +++ b/backend/webserver/sockets/setting.js @@ -1,4 +1,6 @@ const Socket = require("../Socket.js"); +const mailTest = require("../utils/mailTest.js"); +const { saveSettings } = require("../utils/settingSave.js"); /** * Handle settings through websocket @@ -8,6 +10,20 @@ const Socket = require("../Socket.js"); * @class SettingSocket */ class SettingSocket extends Socket { + /** + * Build dashboard settings payload and enrich wizard settings with wizardStep key + * (string), so frontend grouping can use the same shape as setup wizard config. + * @returns {Promise} + */ + async getDashboardSettingsPayload() { + const settings = await this.models["setting"].getAll(true); + const wizardSettings = await this.models["setting"].getWizardSettings(); + const wizardStepByKey = new Map(wizardSettings.map((s) => [s.key, s.wizardStep])); + return settings.map((setting) => ({ + ...setting, + wizardStep: wizardStepByKey.get(setting.key) || setting.wizardStep || null, + })); + } /** * Fetches all system settings from the database. @@ -24,7 +40,7 @@ class SettingSocket extends Socket { throw new Error("You do not have permission to access settings."); } - return await this.models["setting"].getAll(true); + return await this.getDashboardSettingsPayload(); } /** @@ -42,28 +58,49 @@ class SettingSocket extends Socket { throw new Error("You do not have permission to save settings."); } - for (const setting of data) { - let value = setting.value; - if (typeof value === "object") { - value = JSON.stringify(value); - } - - await this.models["setting"].set(setting.key, value, { - transaction: options.transaction, - }); - } + const { touchesMailService } = await saveSettings(this.models["setting"], data, { + transaction: options.transaction, + }); options.transaction.afterCommit(async () => { + if (touchesMailService) { + await this.server.initMailServer(); + } await this.getSocket("AppSocket").sendSettings(true); // Notify all clients of new settings - this.emit("settingData", await this.models["setting"].getAll(true)); // Refresh settings on this socket + this.emit("settingData", await this.getDashboardSettingsPayload()); // Refresh settings on this socket }); return "Settings saved successfully."; } + /** + * Sends a fixed test email using current mail settings from the database. + * + * @socketEvent mailSendTest + * @param {object} data The input data from the frontend + * @param {string} data.to Recipient email address + * @param {object} options + * @returns {Promise} A promise that resolves with a success message once the test email is sent. + * @throws {Error} If the user is not an admin or mail fails + */ + async mailSendTest(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to send test mail."); + } + + const rows = await this.models["setting"].getMailServiceSettings(); + const mailSettings = mailTest.buildMailMapFromSettingsRows(rows); + const transport = mailTest.buildTransportFromMailSettings(mailSettings); + const from = mailSettings["system.mailService.senderAddress"] || ""; + const to = data && data.to != null ? String(data.to).trim() : ""; + await mailTest.sendFixedTestMail(transport, { from, to }); + return "Test email sent."; + } + init() { this.createSocket("settingGetData", this.sendSettings, {}, false); this.createSocket("settingSave", this.saveSettings, {}, true); + this.createSocket("mailSendTest", this.mailSendTest, {}, false); } } diff --git a/backend/webserver/utils/devAdmin.js b/backend/webserver/utils/devAdmin.js new file mode 100644 index 000000000..7a5413e69 --- /dev/null +++ b/backend/webserver/utils/devAdmin.js @@ -0,0 +1,40 @@ +"use strict"; + +const { createInitialAdmin } = require("./setupAdmin"); + +/** + * Set up a dev admin from ADMIN_EMAIL/ADMIN_PWD and mark the setup wizard + * complete, skipping the first-time wizard. Active only when DEV_SKIP_WIZARD=true. + * No-op if an admin already exists. + * + * @param {Server} server + * @returns {Promise} + */ +async function setupDevAdmin(server) { + if (process.env.DEV_SKIP_WIZARD !== "true") { + return; + } + + try { + const admins = await server.db.models["user"].getUsersByRole("admin"); + if (admins.length > 0) { + return; + } + + const email = process.env.ADMIN_EMAIL; + const password = process.env.ADMIN_PWD; + if (!email || !password) { + server.logger.warn("DEV_SKIP_WIZARD set but ADMIN_EMAIL/ADMIN_PWD are missing."); + return; + } + + await createInitialAdmin(server, { userName: "admin", email, password }); + await server.db.models["setting"].set("app.setup.wizardCompleted", "true"); + + server.logger.info(`DEV_SKIP_WIZARD: created admin <${email}> and marked wizard complete.`); + } catch (err) { + server.logger.error("DEV_SKIP_WIZARD failed: " + (err && err.message ? err.message : err)); + } +} + +module.exports = { setupDevAdmin }; diff --git a/backend/webserver/utils/mailTest.js b/backend/webserver/utils/mailTest.js new file mode 100644 index 000000000..131a1047d --- /dev/null +++ b/backend/webserver/utils/mailTest.js @@ -0,0 +1,124 @@ +"use strict"; + +const nodemailer = require("nodemailer"); + +const TEST_MAIL_SUBJECT = "CARE test email"; +const TEST_MAIL_TEXT = + "This is a test message from CARE. If you received this, your outgoing mail configuration works."; + +/** Basic email shape check (same rule as setup/test-mail and dashboard test mail). */ +const RECIPIENT_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Read a string value from the mail settings object. + * @param {Object} mailSettings object with string keys and string values + * @param {string} key + * @returns {string|null} + */ +function get(mailSettings, key) { + const v = mailSettings[key]; + if (v === null || v === undefined) { + return null; + } + return String(v); +} + +/** + * Build a nodemailer transport from DB mail settings (system.mailService.* map). + * @param {Object} mailSettings object with string keys and string values + * @returns {Object} + */ +function buildTransportFromMailSettings(mailSettings) { + if (get(mailSettings, "system.mailService.enabled") !== "true") { + throw new Error("Email service is not enabled."); + } + if (get(mailSettings, "system.mailService.sendMail.enabled") === "true") { + const sendmailPath = get(mailSettings, "system.mailService.sendMail.path"); + if (!sendmailPath) { + throw new Error("Sendmail path is not configured."); + } + return nodemailer.createTransport({ + sendmail: true, + newline: "unix", + path: sendmailPath, + }); + } + if (get(mailSettings, "system.mailService.smtp.enabled") === "true") { + const host = get(mailSettings, "system.mailService.smtp.host"); + const portStr = get(mailSettings, "system.mailService.smtp.port"); + const secure = get(mailSettings, "system.mailService.smtp.secure") === "true"; + const authEnabled = get(mailSettings, "system.mailService.smtp.auth.enabled") === "true"; + if (!host || !portStr) { + throw new Error("SMTP host and port are required."); + } + const port = parseInt(portStr, 10); + if (Number.isNaN(port)) { + throw new Error("SMTP port must be a number."); + } + const transportConfig = { + host, + port, + secure, + }; + if (authEnabled) { + const user = get(mailSettings, "system.mailService.smtp.auth.user"); + const pass = get(mailSettings, "system.mailService.smtp.auth.pass"); + if (user && pass) { + transportConfig.auth = { user, pass }; + } + } + return nodemailer.createTransport(transportConfig); + } + throw new Error("Neither sendmail nor SMTP is enabled for mail delivery."); +} + +/** + * Send a fixed plain-text test message. Validates recipient format; `from` must be non-empty. + * @param {Object} transport + * @param {Object} addresses + * @param {string} addresses.from + * @param {string} addresses.to + * @returns {Promise} + */ +async function sendFixedTestMail(transport, { from, to }) { + if (!from || !String(from).trim()) { + throw new Error("From address is required."); + } + const toTrim = to != null ? String(to).trim() : ""; + if (!toTrim || !RECIPIENT_EMAIL_RE.test(toTrim)) { + throw new Error("A valid recipient email address is required."); + } + await transport.sendMail({ + from, + to: toTrim, + subject: TEST_MAIL_SUBJECT, + text: TEST_MAIL_TEXT, + }); +} + +/** + * Build a mail settings map from setting rows (key/value), only system.mailService.* keys. + * @param {Object[]} rows + * @param {string} rows.key + * @param {string} rows.value + * @returns {Object} + */ +function buildMailMapFromSettingsRows(rows) { + const mailSettings = {}; + for (const row of rows || []) { + if (row.key && String(row.key).startsWith("system.mailService.")) { + mailSettings[row.key] = + row.value != null && row.value !== undefined ? String(row.value) : ""; + } + } + return mailSettings; +} + +module.exports = { + TEST_MAIL_SUBJECT, + TEST_MAIL_TEXT, + RECIPIENT_EMAIL_RE, + buildTransportFromMailSettings, + sendFixedTestMail, + buildMailMapFromSettingsRows, +}; diff --git a/backend/webserver/utils/settingSave.js b/backend/webserver/utils/settingSave.js new file mode 100644 index 000000000..0b7857b18 --- /dev/null +++ b/backend/webserver/utils/settingSave.js @@ -0,0 +1,73 @@ +"use strict"; + +const MAIL_SERVICE_KEY_PREFIX = "system.mailService."; + +/** + * Returns whether a settings payload includes any key under system.mailService.*. + * + * @param {Object[]} settings setting entries + * @param {string} settings.key setting key + * @param {*} settings.value setting value + * @returns {boolean} + */ +function payloadTouchesMailService(settings) { + if (!Array.isArray(settings)) { + return false; + } + for (const setting of settings) { + if (!setting || typeof setting.key !== "string") { + continue; + } + if (setting.key.startsWith(MAIL_SERVICE_KEY_PREFIX)) { + return true; + } + } + return false; +} + +/** + * Normalize setting values to string payload format expected by the settings model. + * + * @param {*} value setting value + * @returns {string} + */ +function normalizeSettingValue(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +/** + * Persist settings through the setting model and report if mail transport must refresh. + * + * @param {Object} Setting setting model + * @param {Object[]} settings setting entries + * @param {string} settings.key setting key + * @param {*} settings.value setting value + * @param {Object} [options] additional options + * @param {Object} [options.transaction] sequelize transaction + * @returns {Promise} result with touchesMailService flag + */ +async function saveSettings(Setting, settings, options = {}) { + const list = Array.isArray(settings) ? settings : []; + const touchesMailService = payloadTouchesMailService(list); + for (const setting of list) { + if (!setting || typeof setting.key !== "string" || setting.key.trim() === "") { + continue; + } + await Setting.set(setting.key, normalizeSettingValue(setting.value), { + transaction: options.transaction, + }); + } + return { touchesMailService }; +} + +module.exports = { + payloadTouchesMailService, + normalizeSettingValue, + saveSettings, +}; diff --git a/backend/webserver/utils/setupAdmin.js b/backend/webserver/utils/setupAdmin.js new file mode 100644 index 000000000..c8af5ff7f --- /dev/null +++ b/backend/webserver/utils/setupAdmin.js @@ -0,0 +1,99 @@ +'use strict'; + +/** + * Shared helper for creating the first admin account, used by the wizard route + * (POST /auth/setup-admin) and the dev admin setup (see devAdmin.js). + */ + +/** + * Build a validation error tagged with statusCode=400 so HTTP callers can map it + * to a client error without inspecting the message. + * @param {string} message + * @returns {Error} + */ +function validationError(message) { + const err = new Error(message); + err.statusCode = 400; + return err; +} + +/** + * Create the first admin account and reassign configurations from Bot (userId=2) + * to the new admin in a single transaction. + * + * @param {Server} server main server instance + * @param {Object} input setup-admin input + * @param {string} input.userName admin user name + * @param {string} input.email admin email address + * @param {string} input.password admin password + * @returns {Promise} + */ +async function createInitialAdmin(server, { userName, email, password }) { + if (!userName || (typeof userName === 'string' && !userName.trim())) { + throw validationError('Please provide a user name.'); + } + const existingByName = await server.db.models['user'].getUserIdByName(userName); + if (existingByName !== 0) { + throw validationError('Username already taken.'); + } + + if (!email || (typeof email === 'string' && !email.trim())) { + throw validationError('Please provide an email.'); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw validationError('Please provide a valid email.'); + } + const existingByEmail = await server.db.models['user'].getUserIdByEmail(email); + if (existingByEmail !== 0) { + throw validationError('E-Mail already taken.'); + } + + const p = typeof password === "string" ? password : ""; + const validPassword = p.length >= 8 + && !/^\s*$/.test(p) + && !/[\x00-\x1F\x7F]/.test(p) + && ![...p].some((c) => (c.codePointAt(0) || 0) > 0xFFFF); + if (!validPassword) { + throw validationError("Password must be at least 8 characters. Use letters, numbers, and standard punctuation; no spaces-only or emojis."); + } + + const User = server.db.models['user']; + const Configuration = server.db.models['configuration']; + const BOT_USER_ID = 2; + + const transaction = await User.sequelize.transaction(); + try { + const user = await User.add( + { + userName: userName.trim(), + email: email.trim(), + password, + firstName: userName.trim(), + lastName: 'User', + acceptTerms: true, + acceptStats: true, + emailVerified: true, + }, + { transaction, context: { userRoles: 'admin' } } + ); + + await Configuration.update( + { userId: user.id }, + { + where: { + userId: BOT_USER_ID, + }, + transaction, + } + ); + + await transaction.commit(); + return user; + } catch (err) { + await transaction.rollback(); + throw err; + } +} + +module.exports = { createInitialAdmin }; diff --git a/docs/source/for_developers/basics/user_stories.rst b/docs/source/for_developers/basics/user_stories.rst index b6db61c67..5bd3b6105 100644 --- a/docs/source/for_developers/basics/user_stories.rst +++ b/docs/source/for_developers/basics/user_stories.rst @@ -19,6 +19,58 @@ Authentication ----- +First-Time Setup Wizard +----------------------- + +.. container:: user-story + + :Story: + I as an administrator, am guided through an initial setup wizard on a fresh CARE instance instead of being dropped into fragmented configuration screens. + + :Acceptance: + When no admin account exists, opening CARE leads to a setup wizard with steps for admin account creation, general settings, mail settings, registration settings, and a summary. I can move through the steps, review values on summary, and finish setup. After completion, the instance is marked as configured and normal login/dashboard access is used. + +----- + +Import Setup Settings from JSON +------------------------------- + +.. container:: user-story + + :Story: + I as an administrator, can import setup values from a JSON file exported from another CARE instance to avoid re-entering settings manually. + + :Acceptance: + In setup wizard steps after admin creation, I can open the import modal and select a JSON file with setting key/value pairs. Valid keys are loaded into the wizard state and reflected on the summary page. Unknown keys are ignored and this is shown in feedback. If the file is invalid JSON, I receive an error message and no values are imported. + +----- + +Export Setup Settings to JSON +----------------------------- + +.. container:: user-story + + :Story: + I as an administrator, can export the current setup configuration as a JSON snapshot to reuse it in another CARE instance. + + :Acceptance: + In setup wizard steps after admin creation, I can use the download action to export a JSON file containing the current setup values. The exported file can be used as input for the setup import flow on another instance. + +----- + +Test Mail During Setup +---------------------- + +.. container:: user-story + + :Story: + I as an administrator, can test mail delivery during setup before finishing the wizard. + + :Acceptance: + In the mail step, I can enter a recipient address and trigger a test email. I receive clear success or error feedback based on the response of the test mail endpoint and can continue editing mail settings before finishing setup. + +----- + Landing Page ------------ @@ -1441,6 +1493,65 @@ Customise the Platform Logo ----- +Admin Settings +-------------- + +.. container:: user-story + + :Story: + I as an admin user want to access and modify the settings of the system in the frontend. + :Acceptance: + As an admin, I can see the Settings component in the navigation sidebar. When I access this, I can see the list of settings, reload them from the server backend, make changes and save these on the server. The changes have immediate effect on the behavior of the application. + +----- + +Wizard-Style Settings Configuration +----------------------------------- + +.. container:: user-story + + :Story: + I as an admin can edit system settings in a structured, step-based layout consistent with first-time setup, including mail and registration dependencies. + + :Acceptance: + In the Settings area, I see grouped, user-facing labels and subsections aligned with the setup flow. Mail-dependent options are shown consistently with the mail service state. A test mail action is available to validate mail configuration. When I change mail settings and click "Save Settings", the new values are applied immediately without requiring a backend restart. + +----- + +Admin Logs +---------- + +.. container:: user-story + + :Story: + As an admin user, I can see the server logs in the frontend. + :Acceptance: + As an admin, I can see the Logs component in the navigation sidebar. When I access this, I can see the list of logs with meta information including timestamps. + +----- + +User Statistics +--------------- + +.. container:: user-story + + :Story: + I as an admin user can view the users and user behavior statistics in the frontend. + :Acceptance: + As an admin user, I can see the User Statistics component in the navigation sidebar. When accessing the component I can see a list of all registered users. For each user, I can view the last login time. When selecting a user another panel shows me the list of behavior logs. I can export all behavior logs of all users via a button click. All user behavior statistics can be downloaded as CSV or JSON. + +----- + +Submissions Import via Moodle +----------------------------- + +.. container:: user-story + + :Story: + I as an admin can import assignment submissions via the Moodle API into CARE and assign a group id to the submissions when importing. + + :Acceptance: + As an admin, I can access the Submissions Import component in the frontend. There I can test the Moodle API credentials and fetch assignment submissions from a selected Moodle assignment. During import, I can provide a group id that gets assigned to all imported submissions. Manage Workflows ----------------- diff --git a/docs/source/for_developers/before_you_start.rst b/docs/source/for_developers/before_you_start.rst index d86c1c52b..ef8f6873b 100644 --- a/docs/source/for_developers/before_you_start.rst +++ b/docs/source/for_developers/before_you_start.rst @@ -128,11 +128,16 @@ the backend, just run the basic build using the following commands in different make docker # starts the docker containers needed for development make init # initializes the database - make dev # starts the development server (backend & frontend) - only linux! + make dev # starts the development server (backend & frontend, wizard skipped) - only Linux! This will start the development server for the backend as well as the frontend. This also starts up a database in a docker container and populates it with the necessary schemas. +.. note:: + + ``make dev`` runs with ``DEV_SKIP_WIZARD=true`` for faster iterative development. + If you need to test the first-time setup flow, use ``make dev-wizard``. + .. note:: When starting the application for the first time, you need to initialize the database! @@ -228,7 +233,9 @@ More Commands * - ``make doc_clean`` - Clean the Sphinx documentation. * - ``make dev`` - - Run frontend (dev) and backend (dev) together. (Unix only) + - Run frontend (dev) and backend (dev) together, with setup wizard skipped. (Unix only) + * - ``make dev-wizard`` + - Run frontend (dev) and backend (dev) together, with setup wizard enabled. (Unix only) * - ``make dev-backend`` - Run backend in development mode. * - ``make dev-frontend`` diff --git a/docs/source/for_developers/examples/settings.rst b/docs/source/for_developers/examples/settings.rst index f1b467f3f..106130a7a 100644 --- a/docs/source/for_developers/examples/settings.rst +++ b/docs/source/for_developers/examples/settings.rst @@ -155,4 +155,31 @@ You must manually parse them as needed based on the setting's ``type``. Changes made to settings in the frontend **are not automatically saved** to the database. After modifying any setting through the UI, you **must** click the ``Save Settings`` button. - Otherwise, your changes will be lost and not persisted. \ No newline at end of file + Otherwise, your changes will be lost and not persisted. + +Wizard and Settings UI Metadata +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Settings can expose additional metadata so they render with user-friendly labels and +grouping in both setup and dashboard settings views. + +Relevant fields on ``setting`` rows: + +- ``displayName``: human-readable field label +- ``displayGroup``: top-level settings group +- ``displaySubsection``: subsection title inside the group +- ``showInWizard``: include in setup wizard +- ``wizardStep``: wizard step assignment (e.g. ``general``, ``mail``, ``registration``) +- ``wizardOrder``: field order within the step +- ``requiredInWizard``: required flag for setup validation + +When adding new settings, populate these fields during migration so the setup wizard +and the dashboard settings page stay aligned. + +Mail Settings Runtime Behavior +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Mail configuration changes made from the dashboard settings page are applied on +``Save Settings`` and do not require a backend restart. + +This includes toggles and provider-specific values under ``system.mailService.*``. \ No newline at end of file diff --git a/docs/source/for_developers/frontend/frontend.rst b/docs/source/for_developers/frontend/frontend.rst index 76d6c8745..b17e91c92 100644 --- a/docs/source/for_developers/frontend/frontend.rst +++ b/docs/source/for_developers/frontend/frontend.rst @@ -10,7 +10,7 @@ This includes adding new dashboard components, loading settings and extending th basic/basic components/components - auth + setup_wizard plugins vuex_store app diff --git a/docs/source/for_developers/frontend/setup_wizard.rst b/docs/source/for_developers/frontend/setup_wizard.rst new file mode 100644 index 000000000..4042b0b3c --- /dev/null +++ b/docs/source/for_developers/frontend/setup_wizard.rst @@ -0,0 +1,99 @@ +Setup Wizard +============ + +The setup wizard provides the first-time configuration flow in CARE and is implemented in +``frontend/src/auth/SetupWizard.vue``. + +When it is used +--------------- + +The wizard is shown for fresh instances where no admin account exists. +Instead of going directly to login, the user is guided through setup and then redirected +to the regular application flow. + +Backend endpoints used by this flow: + +- ``GET /setup/config``: fetch steps (``key``, ``title``, ``description``, ``order``, ``typeId``), ``wizardStepTypes`` (``id``, ``key``), wizard settings, and full settings snapshot. +- ``POST /auth/setup-admin``: create the initial admin account. +- ``POST /setup/test-mail``: send a test mail during setup. +- ``POST /setup/complete``: persist setup settings and mark setup as completed. + +Wizard Steps +------------ + +The current step sequence is: + +1. **Admin** (create first admin account) +2. **General** (base app behavior) +3. **Mail** (mail service and provider fields) +4. **Registration** (registration and consent-related settings) +5. **Summary** (final review and save) + +.. note:: + + Moodle-related wizard settings are currently grouped under **General** via subsection metadata. + +Frontend components +------------------- + +Step components live under ``frontend/src/components/wizard/``. + +- ``SetupWizardAdminStep.vue``: create the first admin account +- ``SetupWizardGeneralStep.vue``: general settings and Moodle block +- ``SetupWizardMailStep.vue``: mail service, provider fields and send test email +- ``SetupWizardRegistrationStep.vue``: registration and consent fields +- ``SetupWizardSummaryStep.vue``: review and finish + +``SetupWizard.vue`` keeps ``formData`` for the admin account fields and ``formSettings`` for all other setting values edited in the wizard. +``wizardSettings`` supplies display names and subsection grouping for the settings and summary steps. +Credential changes use ``update-admin-field``; setting field changes run through ``setWizardSettingValue``. Navigation, JSON import, and test mail use additional events on the parent. + +How Settings Are Rendered +------------------------- + +Wizard fields come from setting metadata in the database. For the setup flow and the +dashboard settings rework to stay aligned, settings should define: + +- ``displayName`` +- ``displayGroup`` +- ``displaySubsection`` +- ``showInWizard`` +- ``wizardStep`` +- ``wizardOrder`` +- ``requiredInWizard`` + +For details and migration examples, see :doc:`../examples/settings`. + +Import and Export +----------------- + +The wizard supports JSON import/export to simplify migration from an existing CARE instance. + +- **Download** exports a JSON snapshot based on loaded settings plus current wizard form values. +- **Import** accepts key/value JSON and normalizes values to strings for settings persistence. +- Imported keys are validated against known setting keys from ``/setup/config``. +- Unknown keys are ignored and reported in toast feedback. +- After successful import, the wizard moves to **Summary** for review before finishing. + +Mail Test During Setup +---------------------- + +The mail step includes a test action that calls ``POST /setup/test-mail`` with the current +mail configuration and recipient address. + +This allows validating SMTP/sendmail setup before finishing setup. + +Extending the Wizard +-------------------- + +To add or change setup fields: + +1. Add/update setting rows via migration (including wizard and display metadata). +2. Ensure the new keys are returned by ``/setup/config``. +3. If needed, update step-specific rendering in ``frontend/src/components/wizard/`` and orchestration logic in ``frontend/src/auth/SetupWizard.vue``. +4. Verify import/export behavior for the new keys. + +To change the setup order or visible steps: + +- Update wizard step definitions returned by ``/setup/config``. +- Keep frontend step filtering consistent with dependency rules. diff --git a/docs/source/for_researchers/basics.rst b/docs/source/for_researchers/basics.rst index 194d286d8..97a77410e 100644 --- a/docs/source/for_researchers/basics.rst +++ b/docs/source/for_researchers/basics.rst @@ -16,6 +16,12 @@ For more advanced deployment scenarios such as conducting multiple user studies, Please check out the details of hosting the CARE server described in the :doc:`getting started chapter <../getting_started/installation>`. +.. note:: + + On a fresh CARE instance, the application opens a setup wizard before the regular login is available. + Complete this initial setup before inviting participants to your study. + After setup is finished, CARE continues with the standard login and dashboard workflow. + .. note:: For running internal pilot studies, running an NGINX server along with CARE is not strictly necessary, but this is highly recommended diff --git a/docs/source/getting_started/installation.rst b/docs/source/getting_started/installation.rst index 19d4c034e..aa9cba839 100644 --- a/docs/source/getting_started/installation.rst +++ b/docs/source/getting_started/installation.rst @@ -112,6 +112,7 @@ The email can be sent again when trying to login without being verified. The Del If you want to change the settings (e.g., using an external SMTP server or disable it), you can change the settings in the frontend dashboard under "Settings". If you disable the mail server, make sure you also disable email notifications/verification. + Changes to ``system.mailService.*`` are applied when you click ``Save Settings`` and do not require a backend restart. diff --git a/docs/source/getting_started/quickstart.rst b/docs/source/getting_started/quickstart.rst index 2a74e3f5f..dcd2633e7 100644 --- a/docs/source/getting_started/quickstart.rst +++ b/docs/source/getting_started/quickstart.rst @@ -42,13 +42,14 @@ The code is structured accordingly: The Frontend ------------ -The frontend essentially consists of three major views: - - 1. the landing page (login and register view) - 2. the dashboard (connecting all other views) - 3. the annotator (view for annotating documents) - 4. the editor (view for editing documents) - 5. the studies including study dashboard and study sessions (view for managing and using user studies) +The frontend essentially consists of the following major views: + + 1. the first-time setup wizard (shown when no admin account exists) + 2. the landing page (login and register view) + 3. the dashboard (connecting all other views) + 4. the annotator (view for annotating documents) + 5. the editor (view for editing documents) + 6. the studies including study dashboard and study sessions (view for managing and using user studies) All management functionality is realized within the :doc:`dashboard <../for_developers/frontend/components/dashboard>`. If you intend to extend CARE, you usually add new :doc:`components <../for_developers/frontend/components/components>` here. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 30e4a06ee..d0128dcb8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -192,6 +192,9 @@ export default { }, watch: { $route(to, from) { + if (to.meta && to.meta.checkLogin) { + this.runCheckLoginFlow(); + } if (to.fullPath !== from.fullPath && this.behaviorLogger) { this.behaviorLogger.reportRouteChange(from, to); } @@ -230,13 +233,7 @@ export default { }, async mounted() { if (this.$route.meta.checkLogin) { - // Check if user already authenticated, if so, we redirect him to the dashboard. - const response = await axios.get(getServerURL() + "/auth/check", { - withCredentials: true, - }); - if (response.data.user) { - await this.$router.push(this.$route.query.redirectedFrom || "/dashboard"); - } + await this.runCheckLoginFlow(); } }, beforeUnmount() { @@ -245,6 +242,16 @@ export default { } }, methods: { + async runCheckLoginFlow() { + const response = await axios.get(getServerURL() + "/auth/check", { + withCredentials: true, + }); + if (response.data.user) { + await this.$router.push(response.data.wizardCompleted === false ? "/wizard" : "/dashboard"); + } else if (response.data.needsSetup) { + await this.$router.push("/wizard"); + } + }, resetAppLoadState() { this.loaded = { users: false, diff --git a/frontend/src/auth/SetupWizard.vue b/frontend/src/auth/SetupWizard.vue new file mode 100644 index 000000000..952d98742 --- /dev/null +++ b/frontend/src/auth/SetupWizard.vue @@ -0,0 +1,687 @@ + + + + + diff --git a/frontend/src/basic/editor/Modal.vue b/frontend/src/basic/editor/Modal.vue index 764ec5fef..242bd8252 100644 --- a/frontend/src/basic/editor/Modal.vue +++ b/frontend/src/basic/editor/Modal.vue @@ -7,13 +7,8 @@ @@ -50,15 +45,15 @@ export default { } }, watch: { + modelValue: { + immediate: true, + handler() { + this.currentData = this.modelValue ?? ""; + }, + }, currentData() { this.$emit("update:modelValue", this.currentData); }, - modelValue() { - this.currentData = this.modelValue; - }, - }, - mounted() { - this.currentData = this.modelValue; }, methods: { open() { diff --git a/frontend/src/basic/form/Collapsible.vue b/frontend/src/basic/form/Collapsible.vue index 19e90f30b..9c1aee0bb 100644 --- a/frontend/src/basic/form/Collapsible.vue +++ b/frontend/src/basic/form/Collapsible.vue @@ -1,10 +1,10 @@ @@ -32,6 +33,14 @@ export default { required: false, default: null, }, + iconName: { + type: String, + default: "question-square-fill", + }, + buttonClass: { + type: [String, Object, Array], + default: null, + }, }, mounted() { //new Tooltip(this.$refs.tooltip, {trigger: "click"}); diff --git a/frontend/src/components/dashboard/Settings.vue b/frontend/src/components/dashboard/Settings.vue index aae12b85f..ed1f1b321 100644 --- a/frontend/src/components/dashboard/Settings.vue +++ b/frontend/src/components/dashboard/Settings.vue @@ -65,12 +65,44 @@
- + + +
@@ -104,28 +136,20 @@ @@ -142,13 +166,38 @@ import Card from "@/basic/dashboard/card/Card.vue"; import Loading from "@/basic/Loading.vue"; import LoadIcon from "@/basic/Icon.vue"; -import SettingItem from "@/components/dashboard/settings/SettingItem.vue"; +import SettingsSection from "@/components/dashboard/settings/SettingsSection.vue"; import Modal from "@/basic/Modal.vue"; import BasicButton from "@/basic/Button.vue"; import {downloadObjectsAs} from "@/assets/utils"; -import {onBeforeRouteUpdate} from 'vue-router' +import {onBeforeRouteUpdate} from "vue-router"; import ChangeUserSettingsModal from "@/components/dashboard/settings/ChangeUserSettingsModal.vue"; +/** + * Order of subsections within each section (for display). + */ +const SUBSECTION_ORDER = { + general: [ + "Copyright and consent", + "Login options", + "Study mode", + "Landing page links", + "ORCID login", + "LDAP login", + "SAML login", + "Two-factor authentication", + "Auth redirects", + ], + mail: ["Mail service", "Sendmail", "SMTP", "Base URL and verification", "Email templates"], + registration: ["Enable registration", "Information requested at registration", "Consent options", "Terms and conditions", "Email verification rate limit"], + moodle: ["Connection", "Course", "Show inputs"], + annotations: ["Comments", "Download", "NLP in annotations", "Sidebar"], + interface: ["Navigation and dashboard", "Branding", "Projects", "Statistics and tags"], + "text editor": ["Document buttons", "Edit history", "Toolbar"], + "ai & nlp": ["Modal NLP", "NLP service"], + system: ["Token expiry"], +}; + export default { name: "DashboardSettings", subscribeTable: ["template"], @@ -156,7 +205,7 @@ export default { Card, LoadIcon, Loading, - SettingItem, + SettingsSection, BasicButton, Modal, ChangeUserSettingsModal, @@ -164,21 +213,65 @@ export default { data() { return { settings: null, - collapseFirst: {}, uploadFile: null, uploading: false, originalSettingsSnapshot: null, + mailTestTo: "", + mailTestSending: false, + mailTestMessage: "", + mailTestError: false, }; }, computed: { - groupedSettings() { - if (!this.settings) return {}; - return this.settings.reduce((acc, setting) => { - const keys = setting.key.split("."); - if (!acc[keys[0]]) acc[keys[0]] = {}; - this.nestSettings(acc[keys[0]], keys.slice(1), setting); - return acc; - }, {}); + displaySettings() { + if (!this.settings) return []; + return this.settings.filter((s) => !s.key.startsWith("app.setup.")); + }, + sectionLayout() { + if (!this.displaySettings.length) return []; + const wizardSteps = ["general", "mail", "registration", "moodle"]; + const sections = []; + for (const step of wizardSteps) { + let keys = this.displaySettings + .filter((s) => s.wizardStep === step && s.showInWizard) + .sort((a, b) => (a.wizardOrder || 0) - (b.wizardOrder || 0)) + .map((s) => s.key); + const otherInStep = this.displaySettings.filter( + (s) => !s.showInWizard && (s.displayGroup || "").toLowerCase() === step + ); + if (otherInStep.length) { + keys = [...keys, ...otherInStep.map((s) => s.key)]; + } + + // Terms of Service should be visible in both "General" and "Registration". + if (step === "registration" && this.displaySettings.some((s) => s.key === "app.register.terms")) { + if (!keys.includes("app.register.terms")) keys.push("app.register.terms"); + } + + if (keys.length) { + const settingsInSection = keys.map((k) => this.displaySettings.find((s) => s.key === k)).filter(Boolean); + sections.push({ + title: step.charAt(0).toUpperCase() + step.slice(1), + subsections: this.buildSubsections(step, settingsInSection), + }); + } + } + const other = this.displaySettings.filter((s) => !s.showInWizard); + const mergedSteps = new Set(wizardSteps); + const otherGroups = {}; + for (const s of other) { + const group = s.displayGroup || "Other"; + if (mergedSteps.has((group || "").toLowerCase())) continue; + if (!otherGroups[group]) otherGroups[group] = []; + otherGroups[group].push(s); + } + for (const [groupTitle, settingsInGroup] of Object.entries(otherGroups).sort((a, b) => a[0].localeCompare(b[0]))) { + sections.push({ + title: groupTitle, + subsections: this.buildSubsections(groupTitle.toLowerCase(), settingsInGroup), + }); + } + return sections; }, hasUnsavedChanges() { if (!this.settings || this.originalSettingsSnapshot === null) return false; @@ -206,14 +299,35 @@ export default { }); }, methods: { - nestSettings(obj, keys, setting) { - if (keys.length === 1) { - if (!obj[keys[0]]) obj[keys[0]] = []; - obj[keys[0]].push(setting); - } else { - if (!obj[keys[0]]) obj[keys[0]] = {}; - this.nestSettings(obj[keys[0]], keys.slice(1), setting); + /** + * Build subsections from settings grouped by displaySubsection. + * @param {string} sectionKey - Section key (e.g. "general", "annotations") + * @param {Array} settingsInSection - Setting objects in this section + * @returns {Array<{title: string, keys: string[]}>} + */ + buildSubsections(sectionKey, settingsInSection) { + if (!settingsInSection.length) return []; + const order = SUBSECTION_ORDER[sectionKey]; + const bySubsection = {}; + for (const s of settingsInSection) { + const sub = s.displaySubsection || ""; + if (!bySubsection[sub]) bySubsection[sub] = []; + bySubsection[sub].push(s.key); + } + const result = []; + if (order) { + for (const title of order) { + if (bySubsection[title]?.length) { + result.push({ title, keys: bySubsection[title] }); + } + } + } + for (const [title, keys] of Object.entries(bySubsection)) { + if (!order || !order.includes(title)) { + result.push({ title: title || "", keys }); + } } + return result.length ? result : [{ title: "", keys: settingsInSection.map((s) => s.key) }]; }, setSettingsSnapshot() { if (!this.settings) { @@ -222,6 +336,23 @@ export default { } this.originalSettingsSnapshot = JSON.stringify(this.settings); }, + sendMailTest() { + const to = (this.mailTestTo || "").trim(); + if (!to || !this.$socket) return; + this.mailTestSending = true; + this.mailTestMessage = ""; + this.mailTestError = false; + this.$socket.emit("mailSendTest", { to }, (res) => { + this.mailTestSending = false; + if (res.success) { + this.mailTestError = false; + this.mailTestMessage = typeof res.data === "string" ? res.data : "Test email sent."; + } else { + this.mailTestError = true; + this.mailTestMessage = res.message || "Failed to send test email."; + } + }); + }, save() { this.$socket.emit("settingSave", this.settings, (res) => { if (res.success) { @@ -254,11 +385,6 @@ export default { }); } this.settings = res.data.sort((a, b) => (a.key > b.key ? 1 : -1)); - this.collapseFirst = this.settings.reduce((acc, setting) => { - const key = setting.key.split(".")[0]; - acc[key] = true; - return acc; - }, {}); this.setSettingsSnapshot(); } else { this.eventBus.emit("toast", { @@ -271,7 +397,7 @@ export default { }, exportSettings() { downloadObjectsAs( - this.settings.reduce( + this.displaySettings.reduce( (acc, {key, value}) => ({...acc, [key]: value}), {} ), @@ -338,10 +464,11 @@ export default { let updatedCount = 0; const flat = json; + const visibleKeys = new Set(this.displaySettings.map((setting) => setting.key)); // Overwrite only existing keys this.settings = this.settings.map((setting) => { - if (Object.prototype.hasOwnProperty.call(flat, setting.key)) { + if (visibleKeys.has(setting.key) && Object.prototype.hasOwnProperty.call(flat, setting.key)) { setting.value = flat[setting.key]; updatedCount++; } diff --git a/frontend/src/components/dashboard/settings/SettingItem.vue b/frontend/src/components/dashboard/settings/SettingItem.vue index de5b8bfcf..e29ce7737 100644 --- a/frontend/src/components/dashboard/settings/SettingItem.vue +++ b/frontend/src/components/dashboard/settings/SettingItem.vue @@ -1,206 +1,179 @@ - - diff --git a/frontend/src/components/dashboard/settings/SettingsSection.vue b/frontend/src/components/dashboard/settings/SettingsSection.vue new file mode 100644 index 000000000..6d88f687f --- /dev/null +++ b/frontend/src/components/dashboard/settings/SettingsSection.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/wizard/SetupWizardAdminStep.vue b/frontend/src/components/wizard/SetupWizardAdminStep.vue new file mode 100644 index 000000000..8189509da --- /dev/null +++ b/frontend/src/components/wizard/SetupWizardAdminStep.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/components/wizard/SetupWizardGeneralStep.vue b/frontend/src/components/wizard/SetupWizardGeneralStep.vue new file mode 100644 index 000000000..d64e32d59 --- /dev/null +++ b/frontend/src/components/wizard/SetupWizardGeneralStep.vue @@ -0,0 +1,337 @@ +