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..6b77210f3 --- /dev/null +++ b/backend/db/migrations/20260119185850-extend-setting-wizard.js @@ -0,0 +1,30 @@ +'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', 'wizardStep', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('setting', 'showInWizard'); + await queryInterface.removeColumn('setting', 'wizardOrder'); + await queryInterface.removeColumn('setting', 'requiredInWizard'); + await queryInterface.removeColumn('setting', 'wizardStep'); + }, +}; 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..bcbaa28e7 --- /dev/null +++ b/backend/db/migrations/20260119192230-basic-wizard_steps.js @@ -0,0 +1,21 @@ +'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: '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..a13b87a14 --- /dev/null +++ b/backend/db/migrations/20260119192356-transform-user-admin.js @@ -0,0 +1,94 @@ +'use strict'; + +const { genSalt, genPwdHash } = require('../../utils/auth'); + +/** + * Names of the 5 Exposé configurations created by basic-configuration (20250919125851). + * They are reassigned to Bot (userId 2) before deleting the admin to satisfy the + * configuration.userId FK to user. POST /auth/setup-admin then reassigns them to the new admin. + */ +const EXPOSE_CONFIG_NAMES = [ + 'Exposé assessment configuration', + 'Exposé feedback configuration', + 'UKP Exposé Submission Validator', + 'Exposé assessment configuration (German)', + 'Exposé feedback configuration (German)', +]; + +/** + * Removes the default admin user created by basic-users so the first admin + * must be created via the setup wizard. Runs after basic-configuration; the 5 + * Exposé configs are first reassigned to Bot (userId 2) to satisfy the FK, then + * the admin is deleted. POST /auth/setup-admin reassigns them 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; + + for (const name of EXPOSE_CONFIG_NAMES) { + await queryInterface.sequelize.query( + `UPDATE configuration SET "userId" = :botId, "updatedAt" = :now WHERE name = :name AND "userId" = :adminId`, + { + replacements: { botId: BOT_USER_ID, adminId, name, 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..475ea50c0 --- /dev/null +++ b/backend/db/migrations/20260119194252-transform-setting-wizard.js @@ -0,0 +1,75 @@ +'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, "wizardStep" = :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, "wizardStep" = 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-create-app_state.js b/backend/db/migrations/20260223130010-create-app_state.js new file mode 100644 index 000000000..389af1507 --- /dev/null +++ b/backend/db/migrations/20260223130010-create-app_state.js @@ -0,0 +1,35 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('app_state', { + key: { + allowNull: false, + type: Sequelize.STRING, + primaryKey: true, + }, + value: { + type: Sequelize.TEXT, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + const now = new Date(); + await queryInterface.bulkInsert('app_state', [ + { key: 'setup.wizardCompleted', value: 'false', createdAt: now, updatedAt: now }, + { key: 'setup.wizardCurrentStep', value: '0', createdAt: now, updatedAt: now }, + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('app_state'); + }, +}; diff --git a/backend/db/migrations/20260225234559-extend-setting-displayName.js b/backend/db/migrations/20260225234559-extend-setting-displayName.js new file mode 100644 index 000000000..d2cb7e015 --- /dev/null +++ b/backend/db/migrations/20260225234559-extend-setting-displayName.js @@ -0,0 +1,352 @@ +'use strict'; + +/** + * Add 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 (takes precedence 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) { + 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, + }); + + 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) { + await queryInterface.removeColumn('setting', 'displayName'); + await queryInterface.removeColumn('setting', 'displayGroup'); + await queryInterface.removeColumn('setting', 'displaySubsection'); + }, +}; \ 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/models/app_state.js b/backend/db/models/app_state.js new file mode 100644 index 000000000..8e6e7c897 --- /dev/null +++ b/backend/db/models/app_state.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * AppState model — key-value store for internal runtime state (e.g. wizard progress). + * Not user-configurable; separate from the setting table. + * + */ +module.exports = (sequelize, DataTypes) => { + const { Model } = require('sequelize'); + + class AppState extends Model { + static associate(models) { + // no associations + } + + /** + * Get value by key + * @param {string} key + * @returns {Promise} + */ + static async get(key) { + try { + const row = await AppState.findOne({ where: { key }, raw: true }); + return row ? row.value : null; + } catch (e) { + console.log(e); + return null; + } + } + + /** + * Set value by key (upsert) + * @param {string} key + * @param {string} value + * @param {object} options + * @returns {Promise} + */ + static async set(key, value, options = {}) { + try { + const [instance] = await AppState.upsert( + { key, value }, + { conflictFields: ['key'], ...options } + ); + return instance?.dataValues ?? null; + } catch (e) { + console.log(e); + return null; + } + } + } + + AppState.init( + { + key: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + value: { + type: DataTypes.TEXT, + }, + }, + { + sequelize, + modelName: 'app_state', + tableName: 'app_state', + } + ); + + AppState.removeAttribute('id'); + + return AppState; +}; diff --git a/backend/db/models/setting.js b/backend/db/models/setting.js index 45ec7e6af..f769bd1da 100644 --- a/backend/db/models/setting.js +++ b/backend/db/models/setting.js @@ -48,6 +48,46 @@ 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', 'wizardStep'], + 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: [] }; + } + } + /** * Set setting value by key * @param {string} key setting key @@ -77,7 +117,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, + wizardStep: DataTypes.STRING, 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..8ce7149fb --- /dev/null +++ b/backend/db/models/wizard_step.js @@ -0,0 +1,59 @@ +'use strict'; + +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class WizardStep extends MetaModel { + static autoTable = false; + + static associate(models) { + // no associations + } + } + + 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, + }, + type: { + type: DataTypes.STRING, + 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/webserver/Server.js b/backend/webserver/Server.js index dff1a5491..36527c746 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,14 @@ module.exports = class Server { }); this.httpServer = http.createServer(this.app); - Promise.resolve(this.#initMailServer()).then(() => { + Promise.resolve(this.refreshMailServer()).then(() => { if (this.mailer) { this.logger.info("Mail server initialized"); } else { this.logger.warn("Mail server not available!"); } }); + Promise.resolve(setupDevAdmin(this)); this.#initWebsocketServer(); this.#discoverComponents("./rpcs", RPC, this.addRPC.bind(this)); this.#discoverComponents("./sockets", Socket, this.addSocket.bind(this)); @@ -137,10 +140,25 @@ module.exports = class Server { } /** - * Initialize the mail server + * Re-read mail settings from the database and rebuild the nodemailer transport. + * Used at startup and after admin saves mail-related settings (see SettingSocket). + * @returns {Promise} + */ + async refreshMailServer() { + try { + await this.#initMailServer(); + } catch (err) { + this.logger.error("refreshMailServer failed: " + err); + } + } + + /** + * 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() { + 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..e3615f5e2 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['app_state'].get('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..56bd5db15 --- /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 the Exposé configurations from Bot 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..7084c5fcb --- /dev/null +++ b/backend/webserver/routes/setup.js @@ -0,0 +1,123 @@ +/** + * 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"); + /** + * 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_state.setup.wizardCompleted is true. + * 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["app_state"].get("setup.wizardCompleted")) === "true"; + + if (!needsSetup && wizardCompleted) { + return res.status(200).json({ needsSetup: false, steps: [], wizardSettings: [] }); + } + + const WizardStep = server.db.models["wizard_step"]; + const steps = await WizardStep.findAll({ + where: { deleted: false }, + order: [["order", "ASC"]], + attributes: ["key", "title", "description", "type", "order"], + 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, 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 to = req.body && req.body.to != null ? String(req.body.to).trim() : ""; + if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { + return res.status(400).json({ success: false, message: "A valid recipient email address is required." }); + } + + const rows = await server.db.models["setting"].getAll(false); + 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"] || ""; + 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 app_state. + * 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 AppState = server.db.models["app_state"]; + + if (wizardCompleted !== undefined) { + await AppState.set("setup.wizardCompleted", String(wizardCompleted)); + } + if (wizardCurrentStep !== undefined) { + await AppState.set("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." }); + } + }); +}; diff --git a/backend/webserver/sockets/setting.js b/backend/webserver/sockets/setting.js index 096712e1b..385d55685 100644 --- a/backend/webserver/sockets/setting.js +++ b/backend/webserver/sockets/setting.js @@ -1,4 +1,26 @@ const Socket = require("../Socket.js"); +const mailTest = require("../utils/mailTest.js"); + +const MAIL_SERVICE_KEY_PREFIX = "system.mailService."; + +/** + * @param {Array<{ key: string }>|undefined} data Settings payload from settingSave + * @returns {boolean} True if any saved key is under system.mailService.* + */ +function payloadTouchesMailService(data) { + if (!Array.isArray(data)) { + return false; + } + for (const setting of data) { + if (!setting || typeof setting.key !== "string") { + continue; + } + if (setting.key.startsWith(MAIL_SERVICE_KEY_PREFIX)) { + return true; + } + } + return false; +} /** * Handle settings through websocket @@ -42,6 +64,8 @@ class SettingSocket extends Socket { throw new Error("You do not have permission to save settings."); } + const shouldRefreshMail = payloadTouchesMailService(data); + for (const setting of data) { let value = setting.value; if (typeof value === "object") { @@ -54,6 +78,9 @@ class SettingSocket extends Socket { } options.transaction.afterCommit(async () => { + if (shouldRefreshMail) { + await this.server.refreshMailServer(); + } 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 }); @@ -61,9 +88,34 @@ class SettingSocket extends Socket { return "Settings saved successfully."; } + /** + * Sends a fixed test email using current DB mail settings. + * + * @socketEvent mailSendTest + * @param {{ to: string }} data Recipient address. + * @returns {Promise} Success message. + */ + async mailSendTest(data) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to send test mail."); + } + const to = data && data.to != null ? String(data.to).trim() : ""; + if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { + throw new Error("A valid recipient email address is required."); + } + + const rows = await this.models["setting"].getAll(false); + const map = mailTest.buildMailMapFromSettingsRows(rows); + const transport = mailTest.buildTransportFromMailSettings(map); + const from = map["system.mailService.senderAddress"] || ""; + 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..f9c5f2087 --- /dev/null +++ b/backend/webserver/utils/devAdmin.js @@ -0,0 +1,44 @@ +"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 + * and NODE_ENV !== "production". No-op if an admin already exists. + * + * @param {Server} server + * @returns {Promise} + */ +async function setupDevAdmin(server) { + if (process.env.DEV_SKIP_WIZARD !== "true") { + return; + } + if (process.env.NODE_ENV === "production") { + server.logger.warn("DEV_SKIP_WIZARD ignored: NODE_ENV=production"); + 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["app_state"].set("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..ea2bc1c87 --- /dev/null +++ b/backend/webserver/utils/mailTest.js @@ -0,0 +1,90 @@ +"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."; + +function get(map, key) { + const v = map[key]; + if (v === null || v === undefined) { + return null; + } + return String(v); +} + +function buildTransportFromMailSettings(map) { + if (get(map, "system.mailService.enabled") !== "true") { + throw new Error("Email service is not enabled."); + } + if (get(map, "system.mailService.sendMail.enabled") === "true") { + const sendmailPath = get(map, "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(map, "system.mailService.smtp.enabled") === "true") { + const host = get(map, "system.mailService.smtp.host"); + const portStr = get(map, "system.mailService.smtp.port"); + const secure = get(map, "system.mailService.smtp.secure") === "true"; + const authEnabled = get(map, "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(map, "system.mailService.smtp.auth.user"); + const pass = get(map, "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."); +} + +async function sendFixedTestMail(transport, { from, to }) { + if (!from || !to) { + throw new Error("From and to addresses are required."); + } + await transport.sendMail({ + from, + to, + subject: TEST_MAIL_SUBJECT, + text: TEST_MAIL_TEXT, + }); +} + +function buildMailMapFromSettingsRows(rows) { + const map = {}; + for (const row of rows || []) { + if (row.key && String(row.key).startsWith("system.mailService.")) { + map[row.key] = + row.value != null && row.value !== undefined ? String(row.value) : ""; + } + } + return map; +} + +module.exports = { + TEST_MAIL_SUBJECT, + TEST_MAIL_TEXT, + buildTransportFromMailSettings, + sendFixedTestMail, + buildMailMapFromSettingsRows, +}; diff --git a/backend/webserver/utils/setupAdmin.js b/backend/webserver/utils/setupAdmin.js new file mode 100644 index 000000000..67cf00bac --- /dev/null +++ b/backend/webserver/utils/setupAdmin.js @@ -0,0 +1,101 @@ +'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 the 5 Exposé configurations from + * Bot (userId=2) to the new admin in a single transaction. + * + * @param {Server} server main server instance + * @param {{userName: string, email: string, password: string}} input + * @returns {Promise} created user + */ +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.'); + } + + if (!password || (typeof password === 'string' && password.length < 8)) { + throw validationError('Password does not meet requirements (min 8 characters).'); + } + + const User = server.db.models['user']; + const Configuration = server.db.models['configuration']; + const { Op } = server.db.Sequelize; + + const EXPOSE_CONFIG_NAMES = [ + 'Exposé assessment configuration', + 'Exposé feedback configuration', + 'UKP Exposé Submission Validator', + 'Exposé assessment configuration (German)', + 'Exposé feedback configuration (German)', + ]; + 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: { + name: { [Op.in]: EXPOSE_CONFIG_NAMES }, + 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 4fd7d30fc..8ac11bbda 100644 --- a/docs/source/for_developers/basics/user_stories.rst +++ b/docs/source/for_developers/basics/user_stories.rst @@ -8,6 +8,58 @@ Landing and Authentication ----- +First-Time Setup Wizard +----------------------- + +.. container:: user-story + + :Description: + 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 + + :Description: + 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 + + :Description: + 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 + + :Description: + 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 ------------ @@ -525,6 +577,19 @@ Admin Settings ----- +Wizard-Style Settings Configuration +----------------------------------- + +.. container:: user-story + + :Description: + 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 ---------- 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..4ed4824b6 --- /dev/null +++ b/docs/source/for_developers/frontend/setup_wizard.rst @@ -0,0 +1,84 @@ +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, 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. +- ``PATCH /setup/state``: 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. + +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 logic in ``SetupWizard.vue`` (validation, dependency toggles, and summary rendering). +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 6f3f16dc7..4ae8f54c5 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..885e8d74c 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: +The frontend essentially consists of the following 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) + 2. the first-time setup wizard (shown when no admin account exists) + 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..64d99df82 --- /dev/null +++ b/frontend/src/auth/SetupWizard.vue @@ -0,0 +1,1328 @@ + - - 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/router.js b/frontend/src/router.js index 674a25ec9..0563588fe 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -9,6 +9,7 @@ * @author: Dennis Zyska, Nils Dycke **/ import * as VueRouter from 'vue-router' +import getServerURL from "@/assets/serverUrl"; const routes = [ { @@ -69,6 +70,12 @@ const routes = [ component: () => import("@/auth/ResetPassword.vue"), meta: {requireAuth: false, hideTopbar: true, checkLogin: true} }, + { + path: "/wizard", + name: "wizard", + component: () => import("@/auth/SetupWizard.vue"), + meta: {requireAuth: false, hideTopbar: true} + }, { path: "/document/:documentHash", component: () => import('@/components/Document.vue'), @@ -112,9 +119,37 @@ const router = VueRouter.createRouter({ history: VueRouter.createWebHistory(), hashbang: false, routes: routes, - mode: 'html5', - root: "/" -}) + mode: "html5", + root: "/", +}); + +/** + * If the user is logged in and the setup wizard is not completed, redirect to /wizard + * so they cannot bypass it by typing /dashboard, /login, etc. + */ +router.beforeEach(async (to, from, next) => { + if (to.path === "/wizard") { + try { + const r = await fetch(getServerURL() + "/auth/check", { credentials: "include" }); + if (!r.ok) return next({ path: "/" }); + const d = await r.json(); + if (d.needsSetup === true) return next(); + if (d.user && d.wizardCompleted === false) return next(); + return next({ path: "/" }); + } catch (_) {} + return next(); + } + if (!to.meta.requireAuth && !to.meta.checkLogin) return next(); + try { + const r = await fetch(getServerURL() + "/auth/check", { credentials: "include" }); + if (!r.ok) return next(); + const d = await r.json(); + if (d.user && d.wizardCompleted === false) { + return next({ path: "/wizard" }); + } + } catch (_) {} + next(); +}); // Navigation guard to check if self-registration is enabled router.beforeEach((to, from, next) => {