foxes
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ const loginSchema = z.object({
|
||||
const DEFAULT_APP_NAME = 'Expense Control';
|
||||
|
||||
export const publicConfig = async (_req: Request, res: Response) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
@@ -7,6 +6,7 @@ import { Expense } from '../entities/Expense.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||
import { createSmtpTransport, formatFromAddress } from '../services/mail.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
@@ -28,7 +28,6 @@ const exportQuerySchema = z.object({
|
||||
});
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
|
||||
@@ -273,17 +272,11 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
range.bucket
|
||||
);
|
||||
|
||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
||||
const transport = nodemailer.createTransport({
|
||||
host: appSettings.smtpHost,
|
||||
port: appSettings.smtpPort,
|
||||
secure: appSettings.smtpSecure,
|
||||
auth: appSettings.smtpUser ? { user: appSettings.smtpUser, pass: appSettings.smtpPassword ?? '' } : undefined
|
||||
});
|
||||
const transport = createSmtpTransport(appSettings);
|
||||
|
||||
const to = prefs.sendToEmail || user.email;
|
||||
await transport.sendMail({
|
||||
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
|
||||
from: formatFromAddress(appSettings),
|
||||
to,
|
||||
subject: `${appSettings.appName} - ${range.label} report`,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createAdminUser,
|
||||
getSettings,
|
||||
getSystemInfo,
|
||||
listUsers,
|
||||
@@ -15,6 +16,7 @@ adminRouter.use(requireAuth, requireAdmin);
|
||||
adminRouter.get('/settings', getSettings);
|
||||
adminRouter.put('/settings', updateSettings);
|
||||
adminRouter.get('/users', listUsers);
|
||||
adminRouter.post('/users', createAdminUser);
|
||||
adminRouter.patch('/users/:id', updateUser);
|
||||
adminRouter.post('/test-smtp', testSmtp);
|
||||
adminRouter.get('/system-info', getSystemInfo);
|
||||
|
||||
42
api/src/services/mail.service.ts
Normal file
42
api/src/services/mail.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { AppSetting } from "../entities/AppSetting.js";
|
||||
|
||||
type MailSettings = Pick<AppSetting, 'smtpHost' | 'smtpPort' | 'smtpSecure' | 'smtpUser' | 'smtpPassword' | 'smtpFromName' | 'smtpFromEmail' | 'appName'>;
|
||||
|
||||
const normalizePort = (value: number | null | undefined) => {
|
||||
const port = Number(value ?? 0);
|
||||
return Number.isFinite(port) && port > 0 ? port : 587;
|
||||
};
|
||||
|
||||
export const resolveSmtpSecurity = (settings: Pick<AppSetting, 'smtpPort' | 'smtpSecure'>) => {
|
||||
const port = normalizePort(settings.smtpPort);
|
||||
const wantsSecure = Boolean(settings.smtpSecure);
|
||||
const secure = port === 465 ? true : wantsSecure && port !== 587;
|
||||
const requireTLS = port === 587 || (!secure && wantsSecure);
|
||||
return { port, secure, requireTLS };
|
||||
};
|
||||
|
||||
export const createSmtpTransport = (settings: MailSettings) => {
|
||||
const security = resolveSmtpSecurity(settings);
|
||||
return nodemailer.createTransport({
|
||||
host: settings.smtpHost ?? undefined,
|
||||
port: security.port,
|
||||
secure: security.secure,
|
||||
requireTLS: security.requireTLS,
|
||||
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
||||
});
|
||||
};
|
||||
|
||||
export const formatFromAddress = (settings: Pick<AppSetting, 'smtpFromName' | 'smtpFromEmail'>) => {
|
||||
if (!settings.smtpFromEmail) return '';
|
||||
return settings.smtpFromName
|
||||
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
||||
: settings.smtpFromEmail;
|
||||
};
|
||||
|
||||
export const describeSmtpMode = (settings: Pick<AppSetting, 'smtpPort' | 'smtpSecure'>) => {
|
||||
const { port, secure, requireTLS } = resolveSmtpSecurity(settings);
|
||||
if (secure) return `implicit TLS on port ${port}`;
|
||||
if (requireTLS) return `STARTTLS on port ${port}`;
|
||||
return `plain/upgrade connection on port ${port}`;
|
||||
};
|
||||
20
api/tests/mail.service.test.ts
Normal file
20
api/tests/mail.service.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveSmtpSecurity } from '../src/services/mail.service.js';
|
||||
|
||||
describe('mail.service', () => {
|
||||
it('uses STARTTLS for port 587 even when secure flag is enabled', () => {
|
||||
expect(resolveSmtpSecurity({ smtpPort: 587, smtpSecure: true })).toEqual({
|
||||
port: 587,
|
||||
secure: false,
|
||||
requireTLS: true
|
||||
});
|
||||
});
|
||||
|
||||
it('uses implicit TLS for port 465', () => {
|
||||
expect(resolveSmtpSecurity({ smtpPort: 465, smtpSecure: false })).toEqual({
|
||||
port: 465,
|
||||
secure: true,
|
||||
requireTLS: false
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user