configured invitation emails for created users

This commit is contained in:
Flatlogic Bot 2026-06-25 14:35:46 +00:00
parent ad27624607
commit 25c8905d76
7 changed files with 263 additions and 33 deletions

View File

@ -118,8 +118,10 @@ export async function sendEmailVerification(
if (!req.currentUser) {
throw new ForbiddenError();
}
const link = refererUrl(req);
await AuthService.sendEmailAddressVerificationEmail(
req.currentUser.email ?? '',
link.origin,
);
res.status(200).send(true);
}
@ -129,7 +131,7 @@ export async function sendPasswordResetEmail(
res: Response,
): Promise<void> {
const link = refererUrl(req);
await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.host);
await AuthService.sendPasswordResetEmail(req.body.email, 'register', link.origin);
res.status(200).send(true);
}
@ -140,7 +142,7 @@ export async function signup(req: Request, res: Response): Promise<void> {
req.body.password,
req.body.organizationId,
req,
link.host,
link.origin,
);
const session = await AuthService.createSession(result.user, req);
cookies.setSessionCookies(res, session);

View File

@ -16,7 +16,7 @@ function hostFromReferer(req: Request): string {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
return new URL(referer).host;
return new URL(referer).origin;
}
export async function create(req: Request, res: Response): Promise<void> {

View File

@ -47,6 +47,15 @@ function asId(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function frontendUrl(host: string | undefined, path: string): string {
const fallbackBase = config.backUrl || config.uiUrl || 'http://localhost:3000';
const base = host
? (/^https?:\/\//i.test(host) ? host : `https://${host}`)
: fallbackBase;
return new URL(path, base.endsWith('/') ? base : `${base}/`).toString();
}
/** Returns the plain attributes of a Sequelize instance, or the record itself. */
function toPlainRecord(record: unknown): PlainRecord | null {
if (!isRecord(record)) {
@ -405,7 +414,7 @@ class Auth {
let link: string;
try {
const token = await UsersDBApi.generateEmailVerificationToken(email);
link = `${host}/verify-email?token=${token}`;
link = frontendUrl(host, `/verify-email?token=${token}`);
} catch (error) {
logger.error('Failed to generate email verification token', error);
throw new ValidationError('auth.emailAddressVerificationEmail.error');
@ -421,26 +430,36 @@ class Auth {
static async sendPasswordResetEmail(
email: string,
type: 'register' | 'invitation' = 'register',
_type: 'register' | 'invitation' = 'register',
host?: string,
) {
let link: string;
try {
const token = await UsersDBApi.generatePasswordResetToken(email);
link = `${host}/password-reset?token=${token}`;
link = frontendUrl(host, `/password-reset?token=${token}`);
} catch (error) {
logger.error('Failed to generate password reset token', error);
throw new ValidationError('auth.passwordReset.error');
}
const passwordResetEmail =
type === 'invitation'
? new InvitationEmail(email, link)
: new PasswordResetEmail(email, link);
const passwordResetEmail = new PasswordResetEmail(email, link);
return new EmailSender(passwordResetEmail).send();
}
static async sendInvitationEmail(
email: string,
temporaryPassword: string,
host?: string,
) {
const invitationEmail = new InvitationEmail(email, {
loginUrl: frontendUrl(host, '/login'),
temporaryPassword,
});
return new EmailSender(invitationEmail).send();
}
static async verifyEmail(token: string, options: DbApiOptions = {}) {
const user = await UsersDBApi.findByEmailVerificationToken(token, options);

View File

@ -9,6 +9,7 @@
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
font-family: Arial, sans-serif;
}
.email-header {
background-color: #3498db;
@ -18,6 +19,23 @@
}
.email-body {
padding: 16px;
color: #1f2937;
line-height: 1.5;
}
.credentials {
background-color: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 4px;
margin: 16px 0;
padding: 12px;
}
.credential-label {
color: #4b5563;
font-weight: bold;
}
.credential-value {
font-family: monospace;
word-break: break-all;
}
.email-footer {
padding: 16px;
@ -43,8 +61,13 @@
</div>
<div class="email-body">
<p>Hello,</p>
<p>You've been invited to join {appTitle}. Please click the button below to set up your account.</p>
<a href="{signupUrl}" class="btn-primary">Set up account</a>
<p>You've been invited to join {appTitle}. Use the credentials below to log in to the platform.</p>
<div class="credentials">
<p><span class="credential-label">Login email:</span><br/><span class="credential-value">{email}</span></p>
<p><span class="credential-label">Temporary password:</span><br/><span class="credential-value">{temporaryPassword}</span></p>
</div>
<p>For your security, please change this temporary password after your first login from your profile page.</p>
<a href="{loginUrl}" class="btn-primary">Log in to {appTitle}</a>
</div>
<div class="email-footer">
Thanks,<br/>
@ -52,4 +75,4 @@
</div>
</div>
</body>
</html>
</html>

View File

@ -5,13 +5,29 @@ import { getNotification } from '@/shared/notifications/helpers';
const __dirname = import.meta.dirname;
interface InvitationEmailOptions {
loginUrl: string;
temporaryPassword: string;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
class InvitationEmail {
to: string;
host: string;
loginUrl: string;
temporaryPassword: string;
constructor(to: string, host: string) {
constructor(to: string, options: InvitationEmailOptions) {
this.to = to;
this.host = host;
this.loginUrl = options.loginUrl;
this.temporaryPassword = options.temporaryPassword;
}
get subject(): string {
@ -31,12 +47,12 @@ class InvitationEmail {
const template = await fs.readFile(templatePath, 'utf8');
const appTitle = getNotification('app.title');
const signupUrl = `${this.host}&invitation=true`;
return template
.replace(/{appTitle}/g, appTitle)
.replace(/{signupUrl}/g, signupUrl)
.replace(/{to}/g, this.to);
.replace(/{appTitle}/g, escapeHtml(appTitle))
.replace(/{loginUrl}/g, escapeHtml(this.loginUrl))
.replace(/{email}/g, escapeHtml(this.to))
.replace(/{temporaryPassword}/g, escapeHtml(this.temporaryPassword));
} catch (error) {
logger.error('Error generating invitation email HTML:', error);
throw error;

View File

@ -1,9 +1,11 @@
import { afterEach, describe, mock, test } from 'node:test';
import assert from 'node:assert/strict';
import bcrypt from 'bcrypt';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users';
import UsersService from '@/services/users';
import AuthService from '@/services/auth';
import ValidationError from '@/shared/errors/validation';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import { createTestUser } from '@/test-utils';
@ -16,6 +18,125 @@ afterEach(() => {
mock.restoreAll();
});
describe('UsersService password provisioning', () => {
test('creates a user with a hashed generated password and emails the plaintext temporary password', async () => {
let passwordStoredInCreate: string | null = null;
const invitations: Array<{ email: string; temporaryPassword: string; host?: string }> = [];
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => null);
mock.method(UsersDBApi, 'create', async (payload: { data: { password?: unknown } }) => {
passwordStoredInCreate =
typeof payload.data.password === 'string' ? payload.data.password : null;
return { id: 'created-user-id' };
});
mock.method(
AuthService,
'sendInvitationEmail',
async (email: string, temporaryPassword: string, host?: string) => {
invitations.push({ email, temporaryPassword, host });
},
);
await UsersService.create(
{ email: 'invitee@example.com', firstName: 'Invitee' },
undefined,
true,
'https://app.example.test',
);
const invitation = invitations[0];
assert.ok(invitation);
assert.equal(invitation.email, 'invitee@example.com');
assert.equal(invitation.host, 'https://app.example.test');
assert.ok(invitation.temporaryPassword.length >= 20);
assert.ok(passwordStoredInCreate);
assert.notEqual(passwordStoredInCreate, invitation.temporaryPassword);
assert.equal(
await bcrypt.compare(invitation.temporaryPassword, passwordStoredInCreate),
true,
);
});
test('bulk import hashes generated passwords and sends invitations when enabled', async () => {
const csv = Buffer.from(
'email,firstName\none@example.com,One\ntwo@example.com,Two\n',
);
const invitations: Array<{ email: string; temporaryPassword: string }> = [];
let importedRows: Array<{ email?: string; password?: string | null }> = [];
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'bulkImport', async (rows: typeof importedRows) => {
importedRows = rows;
return rows.map((row, index) => ({ id: `created-${index}`, email: row.email }));
});
mock.method(
AuthService,
'sendInvitationEmail',
async (email: string, temporaryPassword: string) => {
invitations.push({ email, temporaryPassword });
},
);
await UsersService.bulkImport(csv, undefined, true, 'https://app.example.test');
assert.equal(importedRows.length, 2);
assert.equal(invitations.length, 2);
for (const row of importedRows) {
const invitation = invitations.find((item) => item.email === row.email);
assert.ok(invitation);
assert.ok(row.password);
assert.notEqual(row.password, invitation.temporaryPassword);
assert.equal(await bcrypt.compare(invitation.temporaryPassword, row.password), true);
}
});
test('admin user update hashes a supplied plaintext password before saving', async () => {
const admin = createTestUser({
id: '11111111-1111-4111-8111-111111111111',
app_role: {
name: ROLE_NAMES.SUPER_ADMIN,
scope: ROLE_SCOPES.SYSTEM,
globalAccess: true,
permissions: [permission('UPDATE_USERS')],
},
});
let updatedPassword: string | null = null;
mock.method(db.sequelize, 'transaction', async () => ({
commit: async () => undefined,
rollback: async () => undefined,
}));
mock.method(UsersDBApi, 'findBy', async () => ({
id: 'target-user-id',
organizationId: null,
classId: null,
app_role: { name: ROLE_NAMES.STUDENT },
}));
mock.method(UsersDBApi, 'update', async (_id: string, data: { password?: unknown }) => {
updatedPassword = typeof data.password === 'string' ? data.password : null;
return null;
});
await UsersService.update(
{ password: 'new-plaintext-password' },
'target-user-id',
admin,
);
assert.ok(updatedPassword);
assert.notEqual(updatedPassword, 'new-plaintext-password');
assert.equal(await bcrypt.compare('new-plaintext-password', updatedPassword), true);
});
});
describe('UsersService teacher class roster management', () => {
test('creates a student inside the teacher class scope', async () => {
const organizationId = '22222222-2222-4222-8222-222222222222';

View File

@ -1,5 +1,7 @@
import crypto from 'crypto';
import { PassThrough } from 'stream';
import csv from 'csv-parser';
import bcrypt from 'bcrypt';
import type { Transaction } from 'sequelize';
import db from '@/db/models';
import UsersDBApi from '@/db/api/users';
@ -14,6 +16,7 @@ import {
} from '@/services/shared/role-policy';
import { getOrganizationId, hasGlobalAccess } from '@/services/shared/access';
import { ROLE_NAMES, ROLE_SCOPES } from '@/shared/constants/roles';
import config from '@/shared/config';
import type { AuthenticatedUser, CurrentUser, FileInput } from '@/db/api/types';
/**
@ -36,6 +39,36 @@ type UpdateData = Parameters<typeof UsersDBApi.update>[1];
type ListFilter = Parameters<typeof UsersDBApi.findAll>[0];
type BulkRow = Parameters<typeof UsersDBApi.bulkImport>[0][number];
type InvitationCredential = {
email: string;
temporaryPassword: string;
};
const TEMPORARY_PASSWORD_BYTES = 18;
function generateTemporaryPassword(): string {
return crypto.randomBytes(TEMPORARY_PASSWORD_BYTES).toString('base64url');
}
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, config.bcrypt.saltRounds);
}
async function setGeneratedTemporaryPassword(
data: CreateData | BulkRow,
): Promise<InvitationCredential> {
const email = String(data.email ?? '');
const temporaryPassword = generateTemporaryPassword();
data.password = await hashPassword(temporaryPassword);
return { email, temporaryPassword };
}
async function hashIncomingPassword(data: UpdateData): Promise<void> {
if (typeof data.password === 'string') {
data.password = await hashPassword(data.password);
}
}
/** Parses an uploaded CSV buffer into bulk-import rows. */
function parseCsvRows(fileBuffer: Buffer): Promise<BulkRow[]> {
const bufferStream = new PassThrough();
@ -258,7 +291,7 @@ class UsersService {
const globalAccess = currentUser?.app_role?.globalAccess ?? false;
const email = data.email;
const emailsToInvite: string[] = [];
const invitationsToSend: InvitationCredential[] = [];
let createdId: string;
let createdOrganizationId: string | null = null;
@ -299,13 +332,14 @@ class UsersService {
await normalizeTenantAssignment(data, transaction);
normalizeAvatarInput(data);
const invitation = await setGeneratedTemporaryPassword(data);
const created = await UsersDBApi.create({ data }, globalAccess, {
currentUser,
transaction,
});
createdId = created.id;
emailsToInvite.push(email);
invitationsToSend.push(invitation);
} else {
throw new ValidationError('iam.errors.emailRequired');
}
@ -315,8 +349,16 @@ class UsersService {
throw error;
}
if (emailsToInvite.length && sendInvitationEmails) {
AuthService.sendPasswordResetEmail(emailsToInvite[0], 'invitation', host);
if (invitationsToSend.length && sendInvitationEmails) {
await Promise.all(
invitationsToSend.map((invitation) =>
AuthService.sendInvitationEmail(
invitation.email,
invitation.temporaryPassword,
host,
),
),
);
}
return { id: createdId, organizationId: createdOrganizationId };
@ -354,7 +396,7 @@ class UsersService {
host?: string,
) {
const rows = await parseCsvRows(fileBuffer);
const emailsToInvite: string[] = [];
const invitationsToSend: InvitationCredential[] = [];
const transaction = await db.sequelize.transaction();
try {
@ -370,6 +412,10 @@ class UsersService {
assertCanCreateUserWithRole(currentUser, role?.name ?? null);
}
for (const row of rows) {
invitationsToSend.push(await setGeneratedTemporaryPassword(row));
}
await UsersDBApi.bulkImport(rows, {
transaction,
ignoreDuplicates: true,
@ -377,20 +423,22 @@ class UsersService {
currentUser,
});
for (const row of rows) {
if (row.email) emailsToInvite.push(row.email);
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
if (emailsToInvite.length && !sendInvitationEmails) {
emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host);
});
if (invitationsToSend.length && sendInvitationEmails) {
await Promise.all(
invitationsToSend.map((invitation) =>
AuthService.sendInvitationEmail(
invitation.email,
invitation.temporaryPassword,
host,
),
),
);
}
}
@ -429,6 +477,7 @@ class UsersService {
transaction,
);
normalizeAvatarInput(data);
await hashIncomingPassword(data);
const updatedUser = await UsersDBApi.update(id, data, globalAccess, {
currentUser,