diff --git a/create-a-container/migrations/20260206150000-create-invite-tokens.js b/create-a-container/migrations/20260206150000-create-invite-tokens.js new file mode 100644 index 00000000..0955d0f7 --- /dev/null +++ b/create-a-container/migrations/20260206150000-create-invite-tokens.js @@ -0,0 +1,48 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('InviteTokens', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false + }, + email: { + type: Sequelize.STRING(255), + allowNull: false + }, + token: { + type: Sequelize.STRING(64), + allowNull: false, + unique: true + }, + expiresAt: { + type: Sequelize.DATE, + allowNull: false + }, + used: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + + await queryInterface.addIndex('InviteTokens', ['token']); + await queryInterface.addIndex('InviteTokens', ['email']); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('InviteTokens'); + } +}; diff --git a/create-a-container/models/invite-token.js b/create-a-container/models/invite-token.js new file mode 100644 index 00000000..f1b25486 --- /dev/null +++ b/create-a-container/models/invite-token.js @@ -0,0 +1,113 @@ +'use strict'; +const { Model } = require('sequelize'); +const crypto = require('crypto'); + +module.exports = (sequelize, DataTypes) => { + class InviteToken extends Model { + static associate(models) { + // No user association - invite is sent before user exists + } + + /** + * Generate a new invite token for an email address + * @param {string} email - Email address to invite + * @param {number} expirationHours - Hours until token expires (default: 24) + * @returns {Promise<{token: string, inviteToken: InviteToken}>} + */ + static async generateToken(email, expirationHours = 24) { + const token = crypto.randomBytes(32).toString('hex'); + + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + expirationHours); + + const inviteToken = await InviteToken.create({ + email: email.toLowerCase().trim(), + token, + expiresAt, + used: false + }); + + return { token, inviteToken }; + } + + /** + * Validate and retrieve a token + * @param {string} token - The token string + * @returns {Promise} + */ + static async validateToken(token) { + const inviteToken = await InviteToken.findOne({ + where: { + token, + used: false + } + }); + + if (!inviteToken) { + return null; + } + + if (new Date() > inviteToken.expiresAt) { + return null; + } + + return inviteToken; + } + + /** + * Mark token as used + */ + async markAsUsed() { + this.used = true; + await this.save(); + } + + /** + * Clean up expired and used tokens + */ + static async cleanup() { + const now = new Date(); + await InviteToken.destroy({ + where: { + [sequelize.Sequelize.Op.or]: [ + { expiresAt: { [sequelize.Sequelize.Op.lt]: now } }, + { used: true } + ] + } + }); + } + } + + InviteToken.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + allowNull: false + }, + email: { + type: DataTypes.STRING(255), + allowNull: false + }, + token: { + type: DataTypes.STRING(64), + allowNull: false, + unique: true + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + used: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + } + }, { + sequelize, + modelName: 'InviteToken', + tableName: 'InviteTokens' + }); + + return InviteToken; +}; diff --git a/create-a-container/public/style.css b/create-a-container/public/style.css index 0c53c9d5..c89a70a8 100644 --- a/create-a-container/public/style.css +++ b/create-a-container/public/style.css @@ -481,3 +481,10 @@ main { main { padding-bottom: 3rem; } + +/* Readonly input styling */ +input[readonly].input-locked { + background-color: #e9ecef; + color: #6c757d; + cursor: not-allowed; +} diff --git a/create-a-container/routers/login.js b/create-a-container/routers/login.js index 0a06e7f0..25bb592a 100644 --- a/create-a-container/routers/login.js +++ b/create-a-container/routers/login.js @@ -102,7 +102,14 @@ router.post('/', async (req, res) => { // ensure redirect is a relative path redirectUrl = '/'; } - return res.redirect(redirectUrl); + + // Save session before redirect to ensure it's persisted + req.session.save((err) => { + if (err) { + console.error('Session save error:', err); + } + return res.redirect(redirectUrl); + }); }); module.exports = router; diff --git a/create-a-container/routers/register.js b/create-a-container/routers/register.js index bebc0eec..6eb88cdc 100644 --- a/create-a-container/routers/register.js +++ b/create-a-container/routers/register.js @@ -1,17 +1,66 @@ const express = require('express'); const router = express.Router(); -const { User } = require('../models'); +const { User, InviteToken } = require('../models'); // GET / - Display registration form -router.get('/', (req, res) => { +router.get('/', async (req, res) => { + const { token } = req.query; + let inviteEmail = null; + let validToken = null; + + // If token provided, validate it and extract email + if (token) { + const inviteToken = await InviteToken.validateToken(token); + if (inviteToken) { + inviteEmail = inviteToken.email; + validToken = token; + } else { + await req.flash('error', 'Invalid or expired invitation link. Please request a new invitation.'); + } + } + res.render('register', { successMessages: req.flash('success'), - errorMessages: req.flash('error') + errorMessages: req.flash('error'), + inviteEmail, + inviteToken: validToken }); }); // POST / - Handle registration submission router.post('/', async (req, res) => { + const { inviteToken } = req.body; + let isInvitedUser = false; + let validatedInvite = null; + + // If invite token provided, validate it matches the email + if (inviteToken) { + validatedInvite = await InviteToken.validateToken(inviteToken); + if (!validatedInvite) { + await req.flash('error', 'Invalid or expired invitation link. Please request a new invitation.'); + return res.redirect('/register'); + } + + // Ensure email matches the invite + const submittedEmail = req.body.mail.toLowerCase().trim(); + if (submittedEmail !== validatedInvite.email) { + await req.flash('error', 'Email address does not match the invitation.'); + return res.redirect(`/register?token=${inviteToken}`); + } + + isInvitedUser = true; + } + + // Determine user status + let status; + if (await User.count() === 0) { + status = 'active'; // First user is always active + } else if (isInvitedUser) { + status = 'active'; // Invited users are auto-activated + } else { + status = 'pending'; // Regular registrations are pending + } + const userParams = { uidNumber: await User.nextUidNumber(), uid: req.body.uid, @@ -19,14 +68,24 @@ router.post('/', async (req, res) => { givenName: req.body.givenName, mail: req.body.mail, userPassword: req.body.userPassword, - status: await User.count() === 0 ? 'active' : 'pending', // first user is active + status, cn: `${req.body.givenName} ${req.body.sn}`, homeDirectory: `/home/${req.body.uid}`, }; try { await User.create(userParams); - await req.flash('success', 'Account registered successfully. You will be notified via email once approved.'); + + // Mark invite token as used + if (validatedInvite) { + await validatedInvite.markAsUsed(); + } + + if (isInvitedUser) { + await req.flash('success', 'Account created successfully! You can now log in.'); + } else { + await req.flash('success', 'Account registered successfully. You will be notified via email once approved.'); + } return res.redirect('/login'); } catch (err) { console.error('Registration error:', err); @@ -44,7 +103,10 @@ router.post('/', async (req, res) => { } else { await req.flash('error', 'Registration failed: ' + err.message); } - return res.redirect('/register'); + + // Preserve invite token in redirect if present + const redirectUrl = inviteToken ? `/register?token=${inviteToken}` : '/register'; + return res.redirect(redirectUrl); } }); diff --git a/create-a-container/routers/users.js b/create-a-container/routers/users.js index 7a91cf4d..a6afe0b0 100644 --- a/create-a-container/routers/users.js +++ b/create-a-container/routers/users.js @@ -1,7 +1,8 @@ const express = require('express'); const router = express.Router(); -const { User, Group } = require('../models'); +const { User, Group, InviteToken, Setting } = require('../models'); const { requireAuth, requireAdmin } = require('../middlewares'); +const { sendInviteEmail } = require('../utils/email'); // Apply auth and admin check to all routes router.use(requireAuth); @@ -47,6 +48,62 @@ router.get('/new', async (req, res) => { }); }); +// GET /users/invite - Display form for inviting a user via email +router.get('/invite', async (req, res) => { + res.render('users/invite', { + req + }); +}); + +// POST /users/invite - Send invitation email +router.post('/invite', async (req, res) => { + const { email } = req.body; + + if (!email || email.trim() === '') { + await req.flash('error', 'Please enter an email address'); + return res.redirect('/users/invite'); + } + + const normalizedEmail = email.toLowerCase().trim(); + + try { + // Check if SMTP is configured + const settings = await Setting.getMultiple(['smtp_url']); + if (!settings.smtp_url || settings.smtp_url.trim() === '') { + await req.flash('error', 'SMTP is not configured. Please configure SMTP settings before sending invitations.'); + return res.redirect('/users/invite'); + } + + // Check if email is already registered + const existingUser = await User.findOne({ where: { mail: normalizedEmail } }); + if (existingUser) { + await req.flash('error', 'A user with this email address is already registered'); + return res.redirect('/users/invite'); + } + + // Generate invite token (24-hour expiry) + const { token } = await InviteToken.generateToken(normalizedEmail, 24); + + // Build invite URL + const inviteUrl = `${req.protocol}://${req.get('host')}/register?token=${token}`; + + // Send invite email + try { + await sendInviteEmail(normalizedEmail, inviteUrl); + await req.flash('success', `Invitation sent to ${normalizedEmail}`); + return res.redirect('/users'); + } catch (emailError) { + console.error('Failed to send invite email:', emailError); + await req.flash('error', 'Failed to send invitation email. Please check SMTP settings.'); + return res.redirect('/users/invite'); + } + } catch (error) { + console.error('Invite error:', error); + await req.flash('error', 'Failed to send invitation: ' + error.message); + return res.redirect('/users/invite'); + } +}); + // GET /users/:id/edit - Display form for editing an existing user router.get('/:id/edit', async (req, res) => { const uidNumber = parseInt(req.params.id, 10); diff --git a/create-a-container/utils/email.js b/create-a-container/utils/email.js index 6f5a8561..ce8be880 100644 --- a/create-a-container/utils/email.js +++ b/create-a-container/utils/email.js @@ -95,7 +95,67 @@ Medical Informatics Engineering`, await transporter.sendMail(mailOptions); } +/** + * Send invite email to a new user + * @param {string} to - Recipient email address + * @param {string} inviteUrl - Full URL for registration with invite token + */ +async function sendInviteEmail(to, inviteUrl) { + const settings = await Setting.getMultiple(['smtp_noreply_address']); + const from = settings.smtp_noreply_address || 'noreply@localhost'; + + const transporter = await createTransport(); + + const mailOptions = { + from, + to, + subject: 'You\'re Invited to Join MIE Container Creation', + text: `Hello, + +You have been invited to create an account on the MIE Container Creation system. + +Please click the following link to register your account: +${inviteUrl} + +This link will expire in 24 hours. + +If you did not expect this invitation, please ignore this email. + +--- +Medical Informatics Engineering`, + html: ` +
+

You're Invited!

+

Hello,

+

You have been invited to create an account on the MIE Container Creation system.

+

Please click the button below to register your account:

+
+ + Create Your Account + +
+

Or copy and paste this link into your browser:

+

+ ${inviteUrl} +

+

This link will expire in 24 hours.

+
+

+ If you did not expect this invitation, please ignore this email. +

+

+ Medical Informatics Engineering +

+
+ ` + }; + + await transporter.sendMail(mailOptions); +} + module.exports = { createTransport, - sendPasswordResetEmail + sendPasswordResetEmail, + sendInviteEmail }; diff --git a/create-a-container/views/register.ejs b/create-a-container/views/register.ejs index 10171e9f..e0947f80 100644 --- a/create-a-container/views/register.ejs +++ b/create-a-container/views/register.ejs @@ -11,6 +11,12 @@

Register for an Opensource Account

+ <% if (typeof inviteEmail !== 'undefined' && inviteEmail) { %> +
+ You've been invited! Complete the form below to create your account. +
+ <% } %> + <% if (successMessages && successMessages.length > 0) { %> <% successMessages.forEach(function(message) { %>
@@ -28,6 +34,9 @@ <% } %>
+ <% if (typeof inviteToken !== 'undefined' && inviteToken) { %> + + <% } %> @@ -39,7 +48,11 @@ - + <% if (typeof inviteEmail !== 'undefined' && inviteEmail) { %> + + <% } else { %> + + <% } %> diff --git a/create-a-container/views/users/index.ejs b/create-a-container/views/users/index.ejs index 5dce85f9..ded466ea 100644 --- a/create-a-container/views/users/index.ejs +++ b/create-a-container/views/users/index.ejs @@ -9,7 +9,10 @@

Users

- New User +
diff --git a/create-a-container/views/users/invite.ejs b/create-a-container/views/users/invite.ejs new file mode 100644 index 00000000..6c9d002f --- /dev/null +++ b/create-a-container/views/users/invite.ejs @@ -0,0 +1,46 @@ +<%- include('../layouts/header', { + title: 'Invite User - MIE', + breadcrumbs: [ + { label: 'Users', url: '/users' }, + { label: 'Invite', url: '#' } + ], + colWidth: 'col-lg-6', + req +}) %> + +
+
+

Invite User

+

+ Send an invitation email to a new user. They will receive a link to register their account, + and their account will be automatically activated upon registration. +

+ + +
+ + +
+ The invitation link will be sent to this email address and will expire in 24 hours. +
+
+ +
+ + Cancel +
+ +
+
+ +<%- include('../layouts/footer') %> diff --git a/mie-opensource-landing/docs/admins/users-and-groups.md b/mie-opensource-landing/docs/admins/users-and-groups.md index 86232185..597284e3 100644 --- a/mie-opensource-landing/docs/admins/users-and-groups.md +++ b/mie-opensource-landing/docs/admins/users-and-groups.md @@ -60,6 +60,33 @@ Newly registered users are automatically: Administrators can also create user accounts manually through the admin interface, skipping the registration process. ::: +### Inviting Users + +Administrators can invite new users via email, which streamlines onboarding by automatically activating accounts upon registration. + +**To invite a user:** + +1. Navigate to **Users** in the administration interface +2. Click **Invite User** (next to the "New User" button) +3. Enter the email address of the person you want to invite +4. Click **Send Invitation** + +**How it works:** + +- The system sends an email with a secure registration link +- The link expires after **24 hours** +- When the recipient registers using the link, their email is pre-filled and locked +- Their account is **automatically activated** (no admin approval needed) +- Each invitation link can only be used once + +:::important SMTP Configuration Required +You must configure SMTP settings before sending invitations. If SMTP is not configured, you'll receive an error message prompting you to set it up in **Settings**. +::: + +:::note Duplicate Emails +You cannot invite an email address that is already registered to an existing user. +::: + ### User Statuses Users can have one of three statuses: diff --git a/mie-opensource-landing/docs/developers/database-schema.md b/mie-opensource-landing/docs/developers/database-schema.md index a0a9fb49..45931f8b 100644 --- a/mie-opensource-landing/docs/developers/database-schema.md +++ b/mie-opensource-landing/docs/developers/database-schema.md @@ -23,6 +23,8 @@ erDiagram Users }o--o{ Groups : "member of" UserGroups }|--|| Users : joins UserGroups }|--|| Groups : joins + PasswordResetTokens }o--|| Users : "for" + InviteTokens ||--o| Users : "creates" Sites { int id PK @@ -144,6 +146,22 @@ erDiagram string key PK,UK string value } + + PasswordResetTokens { + uuid id PK + int uidNumber FK + string token UK + datetime expiresAt + boolean used + } + + InviteTokens { + uuid id PK + string email + string token UK + datetime expiresAt + boolean used + } ``` ## Core Models @@ -338,6 +356,52 @@ The **UserGroup** model is a join table for the many-to-many User-Group relation **Constraints:** - Composite primary key on `(uidNumber, gidNumber)` +### PasswordResetToken + +The **PasswordResetToken** model stores password reset tokens for users. + +**Key Fields:** +- `id`: UUID primary key +- `uidNumber`: Foreign key to Users +- `token`: Unique 64-character hex token +- `expiresAt`: Token expiration timestamp +- `used`: Whether the token has been used + +**Relationships:** +- Belongs to User + +**Static Methods:** +- `generateToken(uidNumber, expirationHours)`: Creates a new token (default 1 hour expiry) +- `validateToken(token)`: Validates and returns token if valid and unused +- `cleanup()`: Removes expired and used tokens + +**Instance Methods:** +- `markAsUsed()`: Marks the token as used + +### InviteToken + +The **InviteToken** model stores user invitation tokens sent by administrators. + +**Key Fields:** +- `id`: UUID primary key +- `email`: Email address the invitation was sent to +- `token`: Unique 64-character hex token +- `expiresAt`: Token expiration timestamp (default 24 hours) +- `used`: Whether the token has been used + +**Static Methods:** +- `generateToken(email, expirationHours)`: Creates a new invite token (default 24 hour expiry) +- `validateToken(token)`: Validates and returns token if valid, unused, and not expired +- `cleanup()`: Removes expired and used tokens + +**Instance Methods:** +- `markAsUsed()`: Marks the token as used after successful registration + +**Usage:** +- Created when an admin invites a user via email +- Validated during registration to auto-activate the user's account +- Email is tied to the token and cannot be changed during registration + ## Job Management Models ### Job