This commit is contained in:
Mateusz Gruszczyński
2026-04-07 15:30:49 +02:00
parent e4e2758416
commit 790e2d3b08
12 changed files with 483 additions and 54 deletions

View File

@@ -3,7 +3,6 @@ import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { z } from 'zod';
import { AppDataSource } from '../config/data-source.js';
import { env } from '../config/env.js';
@@ -14,7 +13,8 @@ import { Expense } from '../entities/Expense.js';
import { Merchant } from '../entities/Merchant.js';
import { RecurringExpense } from '../entities/RecurringExpense.js';
import { User } from '../entities/User.js';
import { sanitizeUser } from '../services/auth.service.js';
import { createUser, hashPassword, sanitizeUser } from '../services/auth.service.js';
import { createSmtpTransport, describeSmtpMode, formatFromAddress } from '../services/mail.service.js';
import type { AuthenticatedRequest } from '../types/express.js';
const settingsSchema = z.object({
@@ -33,14 +33,26 @@ const settingsSchema = z.object({
smtpFromEmail: z.string().max(160).nullable().optional()
});
const userCreateSchema = z.object({
fullName: z.string().min(2).max(120),
email: z.email(),
password: z.string().min(8).max(100),
role: z.enum(['ADMIN', 'USER']).optional(),
defaultCurrency: z.string().min(3).max(8).optional(),
isActive: z.boolean().optional(),
integrationsEnabled: z.boolean().optional()
});
const userUpdateSchema = z.object({
fullName: z.string().min(2).max(120).optional(),
email: z.email().optional(),
password: z.string().min(8).max(100).optional(),
role: z.enum(['ADMIN', 'USER']).optional(),
isActive: z.boolean().optional(),
defaultCurrency: z.string().min(3).max(8).optional(),
integrationsEnabled: z.boolean().optional()
});
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -160,6 +172,33 @@ export const listUsers = async (_req: AuthenticatedRequest, res: Response) => {
return res.json({ items: items.map(sanitizeUser) });
};
export const createAdminUser = async (req: AuthenticatedRequest, res: Response) => {
const parsed = userCreateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ message: 'Invalid user payload', issues: parsed.error.issues });
}
try {
const user = await createUser({
fullName: parsed.data.fullName,
email: parsed.data.email,
password: parsed.data.password,
role: parsed.data.role,
defaultCurrency: parsed.data.defaultCurrency
});
if (typeof parsed.data.isActive === 'boolean') user.isActive = parsed.data.isActive;
if (typeof parsed.data.integrationsEnabled === 'boolean') user.integrationsEnabled = parsed.data.integrationsEnabled;
await userRepo().save(user);
return res.status(201).json({ item: sanitizeUser(user) });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create user';
const status = message === 'Email address is already in use' ? 409 : 500;
return res.status(status).json({ message });
}
};
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
const parsed = userUpdateSchema.safeParse(req.body);
if (!parsed.success) {
@@ -170,6 +209,23 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
const item = await userRepo().findOne({ where: { id: itemId } });
if (!item) return res.status(404).json({ message: 'User not found' });
if (parsed.data.email) {
const normalizedEmail = parsed.data.email.toLowerCase();
if (normalizedEmail !== item.email) {
const existing = await userRepo().findOne({ where: { email: normalizedEmail } });
if (existing && existing.id !== item.id) {
return res.status(409).json({ message: 'Email address is already in use' });
}
const previousEmail = item.email;
item.email = normalizedEmail;
if (item.reportPreferences?.sendToEmail === previousEmail) {
item.reportPreferences = { ...item.reportPreferences, sendToEmail: normalizedEmail };
}
}
}
if (parsed.data.fullName) item.fullName = parsed.data.fullName;
if (parsed.data.password) item.passwordHash = await hashPassword(parsed.data.password);
if (parsed.data.role) item.role = parsed.data.role;
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
@@ -190,24 +246,24 @@ export const testSmtp = async (req: AuthenticatedRequest, res: Response) => {
return res.status(400).json({ message: 'SMTP is not configured' });
}
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
const transport = nodemailer.createTransport({
host: settings.smtpHost,
port: settings.smtpPort,
secure: settings.smtpSecure,
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
});
try {
const transport = createSmtpTransport(settings);
await transport.verify();
await transport.sendMail({
from: formatFromAddress(settings),
to: parsed.data.to,
subject: `${settings.appName} - SMTP test`,
html: `<div style="font-family:Arial,sans-serif"><h2>SMTP connection works</h2><p>This message was sent from the admin panel test action.</p></div>`
});
await transport.sendMail({
from: settings.smtpFromName
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
: settings.smtpFromEmail,
to: parsed.data.to,
subject: `${settings.appName} - SMTP test`,
html: `<div style="font-family:Arial,sans-serif"><h2>SMTP connection works</h2><p>This message was sent from the admin panel test action.</p></div>`
});
return res.json({ message: 'SMTP test message was sent' });
return res.json({ message: 'SMTP test message was sent', mode: describeSmtpMode(settings) });
} catch (error) {
const rawMessage = error instanceof Error ? error.message : 'SMTP connection failed';
const message = rawMessage.includes('wrong version number')
? `SMTP handshake failed. For port ${settings.smtpPort} the application now uses ${describeSmtpMode(settings)}.`
: rawMessage;
return res.status(500).json({ message, mode: describeSmtpMode(settings) });
}
};
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {