configured invitation emails for created users
This commit is contained in:
parent
ad27624607
commit
25c8905d76
@ -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);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user