Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
}
};
113 changes: 113 additions & 0 deletions create-a-container/models/invite-token.js
Original file line number Diff line number Diff line change
@@ -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<InviteToken|null>}
*/
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;
};
7 changes: 7 additions & 0 deletions create-a-container/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,10 @@ main {
main {
padding-bottom: 3rem;
}

/* Readonly input styling */
input[readonly].input-locked {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
}
9 changes: 8 additions & 1 deletion create-a-container/routers/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,14 @@
// 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);
Comment thread Dismissed
});
});

module.exports = router;
74 changes: 68 additions & 6 deletions create-a-container/routers/register.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,91 @@
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,
sn: req.body.sn,
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);
Expand All @@ -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);
}
});

Expand Down
59 changes: 58 additions & 1 deletion create-a-container/routers/users.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading