From 25c8905d7657f044736ba2f3b3122f29c0963867 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 25 Jun 2026 14:35:46 +0000 Subject: [PATCH] configured invitation emails for created users --- .../src/api/controllers/auth.controller.ts | 6 +- .../src/api/controllers/users.controller.ts | 2 +- backend/src/services/auth.ts | 33 ++++- .../invitation/invitationTemplate.html | 29 ++++- backend/src/services/email/list/invitation.ts | 30 ++++- backend/src/services/users.test.ts | 121 ++++++++++++++++++ backend/src/services/users.ts | 75 +++++++++-- 7 files changed, 263 insertions(+), 33 deletions(-) diff --git a/backend/src/api/controllers/auth.controller.ts b/backend/src/api/controllers/auth.controller.ts index 60b2b21..b8ff649 100644 --- a/backend/src/api/controllers/auth.controller.ts +++ b/backend/src/api/controllers/auth.controller.ts @@ -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 { 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 { req.body.password, req.body.organizationId, req, - link.host, + link.origin, ); const session = await AuthService.createSession(result.user, req); cookies.setSessionCookies(res, session); diff --git a/backend/src/api/controllers/users.controller.ts b/backend/src/api/controllers/users.controller.ts index 7ead168..af218c1 100644 --- a/backend/src/api/controllers/users.controller.ts +++ b/backend/src/api/controllers/users.controller.ts @@ -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 { diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index b6675a8..0ce414f 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -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); diff --git a/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html index 064c88d..0f803c9 100644 --- a/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html +++ b/backend/src/services/email/htmlTemplates/invitation/invitationTemplate.html @@ -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 @@ - \ No newline at end of file + diff --git a/backend/src/services/email/list/invitation.ts b/backend/src/services/email/list/invitation.ts index e8ac0d7..cb083f8 100644 --- a/backend/src/services/email/list/invitation.ts +++ b/backend/src/services/email/list/invitation.ts @@ -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, '''); +} + 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; diff --git a/backend/src/services/users.test.ts b/backend/src/services/users.test.ts index ffe0eaa..c5bd00c 100644 --- a/backend/src/services/users.test.ts +++ b/backend/src/services/users.test.ts @@ -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'; diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts index b6ea137..6b6a1dc 100644 --- a/backend/src/services/users.ts +++ b/backend/src/services/users.ts @@ -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[1]; type ListFilter = Parameters[0]; type BulkRow = Parameters[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 { + return bcrypt.hash(password, config.bcrypt.saltRounds); +} + +async function setGeneratedTemporaryPassword( + data: CreateData | BulkRow, +): Promise { + const email = String(data.email ?? ''); + const temporaryPassword = generateTemporaryPassword(); + data.password = await hashPassword(temporaryPassword); + return { email, temporaryPassword }; +} + +async function hashIncomingPassword(data: UpdateData): Promise { + 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 { 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,