From ba249914ef797d32002bcfefacd924fc71e2c603 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:35:05 +0100 Subject: [PATCH 01/73] feat: add wizard_step table and model for setup wizard --- .../20260119185828-create-wizard_step.js | 56 ++++++++++++++++++ backend/db/models/wizard_step.js | 59 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 backend/db/migrations/20260119185828-create-wizard_step.js create mode 100644 backend/db/models/wizard_step.js 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/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; +}; From 9d5e6cfe3cfd7d7413c2065d0a8b68be6647f487 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:38:33 +0100 Subject: [PATCH 02/73] feat: add wizard columns to setting table (showInWizard, wizardOrder, requiredInWizard, wizardStep) --- .../20260119185850-extend-setting-wizard.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 backend/db/migrations/20260119185850-extend-setting-wizard.js 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'); + }, +}; From 3130b7a7ec8f99f5250132fe33eb466ea47618fc Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:41:28 +0100 Subject: [PATCH 03/73] feat: add wizard steps and wizard completion settings --- .../20260119190004-basic-setting-setup.js | 32 +++++++++++++++++++ .../20260119192230-basic-wizard_steps.js | 22 +++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 backend/db/migrations/20260119190004-basic-setting-setup.js create mode 100644 backend/db/migrations/20260119192230-basic-wizard_steps.js diff --git a/backend/db/migrations/20260119190004-basic-setting-setup.js b/backend/db/migrations/20260119190004-basic-setting-setup.js new file mode 100644 index 000000000..6a5cd5239 --- /dev/null +++ b/backend/db/migrations/20260119190004-basic-setting-setup.js @@ -0,0 +1,32 @@ +'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: 'Whether the setup wizard has been completed', + createdAt: now, + updatedAt: now, + }, + { + key: 'app.setup.wizardCurrentStep', + value: '0', + type: 'string', + description: 'Current step index in the setup wizard', + createdAt: now, + updatedAt: now, + }, + ], {}); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete('setting', { + key: ['app.setup.wizardCompleted', 'app.setup.wizardCurrentStep'], + }, {}); + }, +}; 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..5511d95ee --- /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, study mode, 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 Integration', description: 'Optional: API URL, key, course ID', 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'], + }, {}); + }, +}; From a3796673bf946bfffe90e455883d70fe83983c94 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:42:00 +0100 Subject: [PATCH 04/73] feat: assign wizard settings to steps (general, mail, registration, moodle) --- ...20260119194252-transform-setting-wizard.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 backend/db/migrations/20260119194252-transform-setting-wizard.js 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..d4754e988 --- /dev/null +++ b/backend/db/migrations/20260119194252-transform-setting-wizard.js @@ -0,0 +1,72 @@ +'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.study.enabled', wizardStep: 'general', wizardOrder: 5, 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.requestName', wizardStep: 'registration', wizardOrder: 25, requiredInWizard: false }, + { key: 'app.register.requestStats', wizardStep: 'registration', wizardOrder: 26, requiredInWizard: false }, + { key: 'app.register.requestData', wizardStep: 'registration', wizardOrder: 27, requiredInWizard: false }, + { key: 'app.register.acceptStats.default', wizardStep: 'registration', wizardOrder: 28, requiredInWizard: false }, + { key: 'app.register.acceptDataSharing.default', wizardStep: 'registration', wizardOrder: 29, requiredInWizard: false }, + { key: 'app.register.terms', wizardStep: 'registration', wizardOrder: 30, requiredInWizard: false }, + // moodle + { 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 }, +]; + +/** @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, + } + ); + } + }, +}; From bcaaad38fb3c87a88638939f25a14a4a386e1cb5 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:42:36 +0100 Subject: [PATCH 05/73] refactor: remove default admin to require setup wizard --- .../20260119192356-transform-user-admin.js | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 backend/db/migrations/20260119192356-transform-user-admin.js 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, + }], {}); + } + }, +}; From 59a6fc58c8db47c3f72e8b470bf22d04c3f0a7c9 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:43:53 +0100 Subject: [PATCH 06/73] feat: add getWizardSettingsByStep and setup config route --- backend/db/models/setting.js | 44 +++++++++++++++++++++ backend/webserver/Server.js | 1 + backend/webserver/routes/setup.js | 64 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 backend/webserver/routes/setup.js diff --git a/backend/db/models/setting.js b/backend/db/models/setting.js index 45ec7e6af..8841cbd79 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', '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 @@ -78,6 +118,10 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, description: 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/webserver/Server.js b/backend/webserver/Server.js index 7fd46df6f..3199f5cbe 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -80,6 +80,7 @@ module.exports = class Server { this.logger.debug("Initializing Routes for config..."); require("./routes/config")(this); require('./routes/auth')(this); + require("./routes/setup")(this); // all further urls reference to frontend this.app.use("/*", express.static(`${__dirname}/../../dist/index.html`)); diff --git a/backend/webserver/routes/setup.js b/backend/webserver/routes/setup.js new file mode 100644 index 000000000..918bb39d5 --- /dev/null +++ b/backend/webserver/routes/setup.js @@ -0,0 +1,64 @@ +/** + * Setup wizard routes. GET /setup/config returns needsSetup, steps, wizardSettings, and + * wizardSettingsByStep when no admin exists (first-time setup) or when ?reRun=true with an + * authenticated admin (re-run wizard). When setup is not needed, returns empty steps and settings. + * + * @author Mohammad Elwan + */ + +/** + * Register setup routes + * @param {import("../Server").Server} server + */ +module.exports = function (server) { + /** + * GET /setup/config + * Returns wizard config for first-time setup or re-run. needsSetup is true when no admin exists. + * When setup is needed (or reRun=true with an admin): steps, wizardSettings, + * and wizardSettingsByStep (grouped by general, mail, registration, moodle). + * When setup is not needed: 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; + + // reRun=true: admin only; return steps, wizardSettings, wizardSettingsByStep for re-run. + if (req.query.reRun === "true" && req.user) { + const isAdmin = admins.some((a) => a.id === req.user.id); + if (isAdmin) { + 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(); + return res.status(200).json({ needsSetup: false, steps, wizardSettings, wizardSettingsByStep }); + } + } + + if (!needsSetup) { + 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(); + + return res.status(200).json({ needsSetup: true, steps, wizardSettings, wizardSettingsByStep }); + } catch (err) { + server.logger.error("GET /setup/config error: " + err); + return res.status(500).json({ message: "Internal server error." }); + } + }); +}; From b3eb522c63f67d43827aeeca7d6031a6717b0386 Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:45:06 +0100 Subject: [PATCH 07/73] feat: add setup wizard support to auth routes (check, setup-admin) --- backend/webserver/routes/auth.js | 121 +++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/backend/webserver/routes/auth.js b/backend/webserver/routes/auth.js index 143d0a3fc..8f5529f26 100644 --- a/backend/webserver/routes/auth.js +++ b/backend/webserver/routes/auth.js @@ -7,7 +7,7 @@ * @author Nils Dycke, Dennis Zyska */ const passport = require('passport'); -const { generateToken, decodeToken } = require('../../utils/auth'); +const { generateToken, decodeToken, relevantFields } = require('../../utils/auth'); /** * Route for user management @@ -136,16 +136,121 @@ module.exports = function (server) { }) /** - * Check whether user is logged in + * Check whether user is logged in. If not logged in and no admin exists, returns needsSetup for the setup wizard. */ - server.app.get('/auth/check', function (req, res) { - if (req.user) { - res.status(200).send({user: req.user}); - } else { - res.status(401); - } + server.app.get('/auth/check', async function (req, res) { server.logger.debug(`req.session.passport: ${JSON.stringify(req.session.passport)}`); server.logger.debug(`req.user: ${JSON.stringify(req.user)}`); + if (req.user) { + const wizardCompleted = (await server.db.models['setting'].get('app.setup.wizardCompleted')) === 'true'; + return res.status(200).send({ user: req.user, wizardCompleted }); + } + try { + const admins = await server.db.models['user'].getUsersByRole('admin'); + if (admins.length === 0) { + return res.status(200).send({ needsSetup: true }); + } + } catch (err) { + server.logger.error('Error checking admin in /auth/check: ' + err); + } + res.status(401).send(); + }); + + /** + * Create the first admin account (setup wizard step 1). Allowed only when no admin exists. + * Reassigns the 5 Exposé configurations from Bot (userId=2) 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.' }); + } + + if (!userName || (typeof userName === 'string' && !userName.trim())) { + return res.status(400).json({ message: 'Please provide a user name.' }); + } + const existingByName = await server.db.models['user'].getUserIdByName(userName); + if (existingByName !== 0) { + return res.status(400).json({ message: 'Username already taken.' }); + } + + if (!email || (typeof email === 'string' && !email.trim())) { + return res.status(400).json({ message: 'Please provide an email.' }); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ message: 'Please provide a valid email.' }); + } + const existingByEmail = await server.db.models['user'].getUserIdByEmail(email); + if (existingByEmail !== 0) { + return res.status(400).json({ message: 'E-Mail already taken.' }); + } + + if (!password || (typeof password === 'string' && password.length < 8)) { + return res.status(400).json({ message: '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(); + + 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) { + await transaction.rollback(); + server.logger.error('Cannot create setup admin: ' + err); + return res.status(400).json({ message: 'Failed to create admin.', error: err.message }); + } + } catch (err) { + server.logger.error('setup-admin error: ' + err); + return res.status(500).json({ message: 'Internal server error.' }); + } }); /** From e427b62b7255f819474c4f1ff16bc31b972dfe8d Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:45:28 +0100 Subject: [PATCH 08/73] feat: add Collapsible form component for sections --- frontend/src/basic/form/Collapsible.vue | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 frontend/src/basic/form/Collapsible.vue diff --git a/frontend/src/basic/form/Collapsible.vue b/frontend/src/basic/form/Collapsible.vue new file mode 100644 index 000000000..9c1aee0bb --- /dev/null +++ b/frontend/src/basic/form/Collapsible.vue @@ -0,0 +1,85 @@ + + + + + From 0736ee8b54af218a702c3d135427d05e1f18746f Mon Sep 17 00:00:00 2001 From: Mohammad Elwan Date: Mon, 23 Feb 2026 13:46:40 +0100 Subject: [PATCH 09/73] feat: add SetupWizard with 6 steps, collapsibles, and JSON import modal --- frontend/src/auth/SetupWizard.vue | 1179 +++++++++++++++++++++++++++++ 1 file changed, 1179 insertions(+) create mode 100644 frontend/src/auth/SetupWizard.vue diff --git a/frontend/src/auth/SetupWizard.vue b/frontend/src/auth/SetupWizard.vue new file mode 100644 index 000000000..a578e490a --- /dev/null +++ b/frontend/src/auth/SetupWizard.vue @@ -0,0 +1,1179 @@ +