From f3a8535dcb2f2cb90a6408e403b12549092660b2 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Fri, 6 Feb 2026 10:26:46 -0500 Subject: [PATCH 1/3] feat: invite users via email --- .../20260206150000-create-invite-tokens.js | 48 ++++++++ create-a-container/models/invite-token.js | 113 ++++++++++++++++++ create-a-container/public/style.css | 7 ++ create-a-container/routers/register.js | 74 +++++++++++- create-a-container/routers/users.js | 59 ++++++++- create-a-container/utils/email.js | 62 +++++++++- create-a-container/views/register.ejs | 15 ++- create-a-container/views/users/index.ejs | 5 +- create-a-container/views/users/invite.ejs | 46 +++++++ 9 files changed, 419 insertions(+), 10 deletions(-) create mode 100644 create-a-container/migrations/20260206150000-create-invite-tokens.js create mode 100644 create-a-container/models/invite-token.js create mode 100644 create-a-container/views/users/invite.ejs 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/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 @@