Skip to content

Commit 7d50e56

Browse files
committed
feat: invite users via email
1 parent 3421e6e commit 7d50e56

8 files changed

Lines changed: 414 additions & 9 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.createTable('InviteTokens', {
7+
id: {
8+
type: Sequelize.UUID,
9+
defaultValue: Sequelize.UUIDV4,
10+
primaryKey: true,
11+
allowNull: false
12+
},
13+
email: {
14+
type: Sequelize.STRING(255),
15+
allowNull: false
16+
},
17+
token: {
18+
type: Sequelize.STRING(64),
19+
allowNull: false,
20+
unique: true
21+
},
22+
expiresAt: {
23+
type: Sequelize.DATE,
24+
allowNull: false
25+
},
26+
used: {
27+
type: Sequelize.BOOLEAN,
28+
allowNull: false,
29+
defaultValue: false
30+
},
31+
createdAt: {
32+
allowNull: false,
33+
type: Sequelize.DATE
34+
},
35+
updatedAt: {
36+
allowNull: false,
37+
type: Sequelize.DATE
38+
}
39+
});
40+
41+
await queryInterface.addIndex('InviteTokens', ['token']);
42+
await queryInterface.addIndex('InviteTokens', ['email']);
43+
},
44+
45+
async down(queryInterface, Sequelize) {
46+
await queryInterface.dropTable('InviteTokens');
47+
}
48+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use strict';
2+
const { Model } = require('sequelize');
3+
const crypto = require('crypto');
4+
5+
module.exports = (sequelize, DataTypes) => {
6+
class InviteToken extends Model {
7+
static associate(models) {
8+
// No user association - invite is sent before user exists
9+
}
10+
11+
/**
12+
* Generate a new invite token for an email address
13+
* @param {string} email - Email address to invite
14+
* @param {number} expirationHours - Hours until token expires (default: 24)
15+
* @returns {Promise<{token: string, inviteToken: InviteToken}>}
16+
*/
17+
static async generateToken(email, expirationHours = 24) {
18+
const token = crypto.randomBytes(32).toString('hex');
19+
20+
const expiresAt = new Date();
21+
expiresAt.setHours(expiresAt.getHours() + expirationHours);
22+
23+
const inviteToken = await InviteToken.create({
24+
email: email.toLowerCase().trim(),
25+
token,
26+
expiresAt,
27+
used: false
28+
});
29+
30+
return { token, inviteToken };
31+
}
32+
33+
/**
34+
* Validate and retrieve a token
35+
* @param {string} token - The token string
36+
* @returns {Promise<InviteToken|null>}
37+
*/
38+
static async validateToken(token) {
39+
const inviteToken = await InviteToken.findOne({
40+
where: {
41+
token,
42+
used: false
43+
}
44+
});
45+
46+
if (!inviteToken) {
47+
return null;
48+
}
49+
50+
if (new Date() > inviteToken.expiresAt) {
51+
return null;
52+
}
53+
54+
return inviteToken;
55+
}
56+
57+
/**
58+
* Mark token as used
59+
*/
60+
async markAsUsed() {
61+
this.used = true;
62+
await this.save();
63+
}
64+
65+
/**
66+
* Clean up expired and used tokens
67+
*/
68+
static async cleanup() {
69+
const now = new Date();
70+
await InviteToken.destroy({
71+
where: {
72+
[sequelize.Sequelize.Op.or]: [
73+
{ expiresAt: { [sequelize.Sequelize.Op.lt]: now } },
74+
{ used: true }
75+
]
76+
}
77+
});
78+
}
79+
}
80+
81+
InviteToken.init({
82+
id: {
83+
type: DataTypes.UUID,
84+
defaultValue: DataTypes.UUIDV4,
85+
primaryKey: true,
86+
allowNull: false
87+
},
88+
email: {
89+
type: DataTypes.STRING(255),
90+
allowNull: false
91+
},
92+
token: {
93+
type: DataTypes.STRING(64),
94+
allowNull: false,
95+
unique: true
96+
},
97+
expiresAt: {
98+
type: DataTypes.DATE,
99+
allowNull: false
100+
},
101+
used: {
102+
type: DataTypes.BOOLEAN,
103+
allowNull: false,
104+
defaultValue: false
105+
}
106+
}, {
107+
sequelize,
108+
modelName: 'InviteToken',
109+
tableName: 'InviteTokens'
110+
});
111+
112+
return InviteToken;
113+
};

create-a-container/routers/register.js

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,91 @@
11
const express = require('express');
22
const router = express.Router();
3-
const { User } = require('../models');
3+
const { User, InviteToken } = require('../models');
44

55
// GET / - Display registration form
6-
router.get('/', (req, res) => {
6+
router.get('/', async (req, res) => {
7+
const { token } = req.query;
8+
let inviteEmail = null;
9+
let validToken = null;
10+
11+
// If token provided, validate it and extract email
12+
if (token) {
13+
const inviteToken = await InviteToken.validateToken(token);
14+
if (inviteToken) {
15+
inviteEmail = inviteToken.email;
16+
validToken = token;
17+
} else {
18+
await req.flash('error', 'Invalid or expired invitation link. Please request a new invitation.');
19+
}
20+
}
21+
722
res.render('register', {
823
successMessages: req.flash('success'),
9-
errorMessages: req.flash('error')
24+
errorMessages: req.flash('error'),
25+
inviteEmail,
26+
inviteToken: validToken
1027
});
1128
});
1229

1330
// POST / - Handle registration submission
1431
router.post('/', async (req, res) => {
32+
const { inviteToken } = req.body;
33+
let isInvitedUser = false;
34+
let validatedInvite = null;
35+
36+
// If invite token provided, validate it matches the email
37+
if (inviteToken) {
38+
validatedInvite = await InviteToken.validateToken(inviteToken);
39+
if (!validatedInvite) {
40+
await req.flash('error', 'Invalid or expired invitation link. Please request a new invitation.');
41+
return res.redirect('/register');
42+
}
43+
44+
// Ensure email matches the invite
45+
const submittedEmail = req.body.mail.toLowerCase().trim();
46+
if (submittedEmail !== validatedInvite.email) {
47+
await req.flash('error', 'Email address does not match the invitation.');
48+
return res.redirect(`/register?token=${inviteToken}`);
49+
}
50+
51+
isInvitedUser = true;
52+
}
53+
54+
// Determine user status
55+
let status;
56+
if (await User.count() === 0) {
57+
status = 'active'; // First user is always active
58+
} else if (isInvitedUser) {
59+
status = 'active'; // Invited users are auto-activated
60+
} else {
61+
status = 'pending'; // Regular registrations are pending
62+
}
63+
1564
const userParams = {
1665
uidNumber: await User.nextUidNumber(),
1766
uid: req.body.uid,
1867
sn: req.body.sn,
1968
givenName: req.body.givenName,
2069
mail: req.body.mail,
2170
userPassword: req.body.userPassword,
22-
status: await User.count() === 0 ? 'active' : 'pending', // first user is active
71+
status,
2372
cn: `${req.body.givenName} ${req.body.sn}`,
2473
homeDirectory: `/home/${req.body.uid}`,
2574
};
2675

2776
try {
2877
await User.create(userParams);
29-
await req.flash('success', 'Account registered successfully. You will be notified via email once approved.');
78+
79+
// Mark invite token as used
80+
if (validatedInvite) {
81+
await validatedInvite.markAsUsed();
82+
}
83+
84+
if (isInvitedUser) {
85+
await req.flash('success', 'Account created successfully! You can now log in.');
86+
} else {
87+
await req.flash('success', 'Account registered successfully. You will be notified via email once approved.');
88+
}
3089
return res.redirect('/login');
3190
} catch (err) {
3291
console.error('Registration error:', err);
@@ -44,7 +103,10 @@ router.post('/', async (req, res) => {
44103
} else {
45104
await req.flash('error', 'Registration failed: ' + err.message);
46105
}
47-
return res.redirect('/register');
106+
107+
// Preserve invite token in redirect if present
108+
const redirectUrl = inviteToken ? `/register?token=${inviteToken}` : '/register';
109+
return res.redirect(redirectUrl);
48110
}
49111
});
50112

create-a-container/routers/users.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
const express = require('express');
22
const router = express.Router();
3-
const { User, Group } = require('../models');
3+
const { User, Group, InviteToken, Setting } = require('../models');
44
const { requireAuth, requireAdmin } = require('../middlewares');
5+
const { sendInviteEmail } = require('../utils/email');
56

67
// Apply auth and admin check to all routes
78
router.use(requireAuth);
@@ -47,6 +48,64 @@ router.get('/new', async (req, res) => {
4748
});
4849
});
4950

51+
// GET /users/invite - Display form for inviting a user via email
52+
router.get('/invite', async (req, res) => {
53+
res.render('users/invite', {
54+
req,
55+
successMessages: req.flash('success'),
56+
errorMessages: req.flash('error')
57+
});
58+
});
59+
60+
// POST /users/invite - Send invitation email
61+
router.post('/invite', async (req, res) => {
62+
const { email } = req.body;
63+
64+
if (!email || email.trim() === '') {
65+
await req.flash('error', 'Please enter an email address');
66+
return res.redirect('/users/invite');
67+
}
68+
69+
const normalizedEmail = email.toLowerCase().trim();
70+
71+
try {
72+
// Check if SMTP is configured
73+
const settings = await Setting.getMultiple(['smtp_url']);
74+
if (!settings.smtp_url || settings.smtp_url.trim() === '') {
75+
await req.flash('error', 'SMTP is not configured. Please configure SMTP settings before sending invitations.');
76+
return res.redirect('/users/invite');
77+
}
78+
79+
// Check if email is already registered
80+
const existingUser = await User.findOne({ where: { mail: normalizedEmail } });
81+
if (existingUser) {
82+
await req.flash('error', 'A user with this email address is already registered');
83+
return res.redirect('/users/invite');
84+
}
85+
86+
// Generate invite token (24-hour expiry)
87+
const { token } = await InviteToken.generateToken(normalizedEmail, 24);
88+
89+
// Build invite URL
90+
const inviteUrl = `${req.protocol}://${req.get('host')}/register?token=${token}`;
91+
92+
// Send invite email
93+
try {
94+
await sendInviteEmail(normalizedEmail, inviteUrl);
95+
await req.flash('success', `Invitation sent to ${normalizedEmail}`);
96+
return res.redirect('/users');
97+
} catch (emailError) {
98+
console.error('Failed to send invite email:', emailError);
99+
await req.flash('error', 'Failed to send invitation email. Please check SMTP settings.');
100+
return res.redirect('/users/invite');
101+
}
102+
} catch (error) {
103+
console.error('Invite error:', error);
104+
await req.flash('error', 'Failed to send invitation: ' + error.message);
105+
return res.redirect('/users/invite');
106+
}
107+
});
108+
50109
// GET /users/:id/edit - Display form for editing an existing user
51110
router.get('/:id/edit', async (req, res) => {
52111
const uidNumber = parseInt(req.params.id, 10);

0 commit comments

Comments
 (0)