From 790e2d3b08d9676df1871b8aa3fbe03ebd5de792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 7 Apr 2026 15:30:49 +0200 Subject: [PATCH] foxes --- api/src/controllers/admin.controller.ts | 96 +++++++-- api/src/controllers/auth.controller.ts | 1 + api/src/controllers/report.controller.ts | 13 +- api/src/routes/admin.routes.ts | 2 + api/src/services/mail.service.ts | 42 ++++ api/tests/mail.service.test.ts | 20 ++ web/src/app/core/services/admin.service.ts | 18 +- .../app/core/services/app-settings.service.ts | 84 +++++++- web/src/app/core/services/ui.service.ts | 16 ++ web/src/app/features/admin/admin.component.ts | 184 +++++++++++++++++- web/src/app/features/auth/login.component.ts | 4 +- web/src/main.ts | 57 +++++- 12 files changed, 483 insertions(+), 54 deletions(-) create mode 100644 api/src/services/mail.service.ts create mode 100644 api/tests/mail.service.test.ts diff --git a/api/src/controllers/admin.controller.ts b/api/src/controllers/admin.controller.ts index 3634c07..916fb91 100644 --- a/api/src/controllers/admin.controller.ts +++ b/api/src/controllers/admin.controller.ts @@ -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 } }; - 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: `

SMTP connection works

This message was sent from the admin panel test action.

` + }); - await transport.sendMail({ - from: settings.smtpFromName - ? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>` - : settings.smtpFromEmail, - to: parsed.data.to, - subject: `${settings.appName} - SMTP test`, - html: `

SMTP connection works

This message was sent from the admin panel test action.

` - }); - - 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) => { diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index d561c71..23d71c7 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -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' } diff --git a/api/src/controllers/report.controller.ts b/api/src/controllers/report.controller.ts index ab56384..1355c1f 100644 --- a/api/src/controllers/report.controller.ts +++ b/api/src/controllers/report.controller.ts @@ -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 } }; - 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) diff --git a/api/src/routes/admin.routes.ts b/api/src/routes/admin.routes.ts index d13a446..a6da716 100644 --- a/api/src/routes/admin.routes.ts +++ b/api/src/routes/admin.routes.ts @@ -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); diff --git a/api/src/services/mail.service.ts b/api/src/services/mail.service.ts new file mode 100644 index 0000000..f8921ca --- /dev/null +++ b/api/src/services/mail.service.ts @@ -0,0 +1,42 @@ +import nodemailer from "nodemailer"; +import { AppSetting } from "../entities/AppSetting.js"; + +type MailSettings = Pick; + +const normalizePort = (value: number | null | undefined) => { + const port = Number(value ?? 0); + return Number.isFinite(port) && port > 0 ? port : 587; +}; + +export const resolveSmtpSecurity = (settings: Pick) => { + 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) => { + if (!settings.smtpFromEmail) return ''; + return settings.smtpFromName + ? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>` + : settings.smtpFromEmail; +}; + +export const describeSmtpMode = (settings: Pick) => { + 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}`; +}; diff --git a/api/tests/mail.service.test.ts b/api/tests/mail.service.test.ts new file mode 100644 index 0000000..4ab7f9a --- /dev/null +++ b/api/tests/mail.service.test.ts @@ -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 + }); + }); +}); diff --git a/web/src/app/core/services/admin.service.ts b/web/src/app/core/services/admin.service.ts index ce75fd9..5dbb8a6 100644 --- a/web/src/app/core/services/admin.service.ts +++ b/web/src/app/core/services/admin.service.ts @@ -3,6 +3,16 @@ import { HttpClient } from '@angular/common/http'; import { environment } from '../../../environments/environment'; import type { AdminSystemInfo, AppSettings, User } from '../../shared/models'; +export interface AdminUserPayload { + fullName?: string; + email?: string; + password?: string; + role?: 'ADMIN' | 'USER'; + defaultCurrency?: string; + isActive?: boolean; + integrationsEnabled?: boolean; +} + @Injectable({ providedIn: 'root' }) export class AdminService { private readonly http = inject(HttpClient); @@ -19,12 +29,16 @@ export class AdminService { return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`); } - updateUser(id: string, payload: Partial & { integrationsEnabled?: boolean }) { + createUser(payload: AdminUserPayload) { + return this.http.post<{ item: User }>(`${environment.apiBaseUrl}/admin/users`, payload); + } + + updateUser(id: string, payload: AdminUserPayload) { return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload); } testSmtp(to: string) { - return this.http.post<{ message: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to }); + return this.http.post<{ message: string; mode?: string }>(`${environment.apiBaseUrl}/admin/test-smtp`, { to }); } getSystemInfo() { diff --git a/web/src/app/core/services/app-settings.service.ts b/web/src/app/core/services/app-settings.service.ts index 32eac45..e5ec9fa 100644 --- a/web/src/app/core/services/app-settings.service.ts +++ b/web/src/app/core/services/app-settings.service.ts @@ -1,5 +1,7 @@ -import { Injectable, computed, inject, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; import { tap } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; import type { AppSettings } from '../../shared/models'; @@ -9,25 +11,86 @@ export interface PublicAppConfig { registrationEnabled: boolean; } +const PUBLIC_CONFIG_STORAGE_KEY = 'expense-control-public-config'; +const DEFAULT_PUBLIC_CONFIG: PublicAppConfig = { + appName: 'Expense Control', + registrationEnabled: true +}; + +type GlobalWithPublicConfig = typeof globalThis & { + __EXPENSE_CONTROL_PUBLIC_CONFIG__?: PublicAppConfig; +}; + +const isPublicConfig = (value: unknown): value is PublicAppConfig => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + return typeof candidate['appName'] === 'string' && typeof candidate['registrationEnabled'] === 'boolean'; +}; + +const readCachedPublicConfig = (): PublicAppConfig | null => { + try { + const raw = globalThis.localStorage?.getItem(PUBLIC_CONFIG_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + return isPublicConfig(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const initialState = (() => { + const fromBootstrap = (globalThis as GlobalWithPublicConfig).__EXPENSE_CONTROL_PUBLIC_CONFIG__; + if (isPublicConfig(fromBootstrap)) { + return { config: fromBootstrap, bootstrapped: true }; + } + const cached = readCachedPublicConfig(); + if (cached) { + return { config: cached, bootstrapped: false }; + } + return { config: DEFAULT_PUBLIC_CONFIG, bootstrapped: false }; +})(); + @Injectable({ providedIn: 'root' }) export class AppSettingsService { private readonly http = inject(HttpClient); + private readonly document = inject(DOCUMENT); + private loadedPublic = initialState.bootstrapped; - readonly publicConfig = signal({ - appName: 'Expense Control', - registrationEnabled: true - }); - + readonly publicConfig = signal(initialState.config); readonly settings = signal(null); - readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || 'Expense Control'); + readonly appName = computed(() => this.settings()?.appName || this.publicConfig().appName || DEFAULT_PUBLIC_CONFIG.appName); readonly registrationEnabled = computed( - () => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? true + () => this.settings()?.registrationEnabled ?? this.publicConfig().registrationEnabled ?? DEFAULT_PUBLIC_CONFIG.registrationEnabled ); - loadPublic() { + constructor() { + effect(() => { + const config = { + appName: this.appName(), + registrationEnabled: this.registrationEnabled() + }; + this.document.title = config.appName; + try { + globalThis.localStorage?.setItem(PUBLIC_CONFIG_STORAGE_KEY, JSON.stringify(config)); + } catch { + // noop + } + }); + } + + loadPublic(force = false): Observable { + if (this.loadedPublic && !force) { + return of(this.publicConfig()); + } + return this.http .get(`${environment.apiBaseUrl}/auth/config`) - .pipe(tap((config) => this.publicConfig.set(config))); + .pipe( + tap((config) => { + this.publicConfig.set(config); + this.loadedPublic = true; + }) + ); } applySettings(item: AppSettings) { @@ -36,5 +99,6 @@ export class AppSettingsService { appName: item.appName, registrationEnabled: item.registrationEnabled }); + this.loadedPublic = true; } } diff --git a/web/src/app/core/services/ui.service.ts b/web/src/app/core/services/ui.service.ts index 54c4f23..083d1ef 100644 --- a/web/src/app/core/services/ui.service.ts +++ b/web/src/app/core/services/ui.service.ts @@ -48,6 +48,7 @@ const translations: Record> = { 'action.setUser': 'Ustaw USER', 'action.setAdmin': 'Ustaw ADMIN', 'action.import': 'Importuj', + 'action.addUser': 'Dodaj użytkownika', 'action.enableIntegrations': 'Włącz integracje', 'action.disableIntegrations': 'Wyłącz integracje', @@ -243,7 +244,9 @@ const translations: Record> = { 'admin.fromName': 'Nazwa nadawcy', 'admin.fromEmail': 'E-mail nadawcy', 'admin.secureConnection': 'Bezpieczne połączenie', + 'admin.smtpHint': 'Port 587 działa przez STARTTLS, a port 465 przez implicit TLS.', 'admin.users': 'Użytkownicy', + 'admin.newUser': 'Nowy użytkownik', 'admin.userLabel': 'Użytkownik', 'admin.role': 'Rola', 'admin.status': 'Status', @@ -260,6 +263,11 @@ const translations: Record> = { 'admin.statusError': 'Nie udało się zmienić statusu.', 'admin.integrationsAccess': 'Integracje', 'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.', + 'admin.passwordHint': 'Pozostaw puste, jeśli hasło ma zostać bez zmian.', + 'admin.userCreated': 'Użytkownik został dodany.', + 'admin.userCreateError': 'Nie udało się dodać użytkownika.', + 'admin.userUpdated': 'Dane użytkownika zostały zaktualizowane.', + 'admin.userUpdateError': 'Nie udało się zaktualizować użytkownika.', 'nav.cashflow': 'Cashflow', @@ -503,6 +511,7 @@ const translations: Record> = { 'action.setUser': 'Set USER', 'action.setAdmin': 'Set ADMIN', 'action.import': 'Import', + 'action.addUser': 'Add user', 'action.enableIntegrations': 'Enable integrations', 'action.disableIntegrations': 'Disable integrations', @@ -696,7 +705,9 @@ const translations: Record> = { 'admin.fromName': 'Sender name', 'admin.fromEmail': 'Sender email', 'admin.secureConnection': 'Secure connection', + 'admin.smtpHint': 'Port 587 uses STARTTLS, while port 465 uses implicit TLS.', 'admin.users': 'Users', + 'admin.newUser': 'New user', 'admin.userLabel': 'User', 'admin.role': 'Role', 'admin.status': 'Status', @@ -713,6 +724,11 @@ const translations: Record> = { 'admin.statusError': 'Failed to change the account status.', 'admin.integrationsAccess': 'Integrations', 'admin.integrationsUpdated': 'Integrations access has been updated.', + 'admin.passwordHint': 'Leave empty to keep the current password.', + 'admin.userCreated': 'User has been created.', + 'admin.userCreateError': 'Failed to create the user.', + 'admin.userUpdated': 'User details have been updated.', + 'admin.userUpdateError': 'Failed to update the user.', 'nav.cashflow': 'Cashflow', diff --git a/web/src/app/features/admin/admin.component.ts b/web/src/app/features/admin/admin.component.ts index 9ff7470..4a84521 100644 --- a/web/src/app/features/admin/admin.component.ts +++ b/web/src/app/features/admin/admin.component.ts @@ -119,6 +119,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models'; {{ ui.t('admin.secureConnection') }} +
{{ ui.t('admin.smtpHint') }}
@@ -170,6 +172,28 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
+ @if (editingUserId() === user.id) { + + +
+
+
+
+
+
+
{{ ui.t('admin.passwordHint') }}
+
+ + +
+
+ + +
+
+ + + } } @empty { {{ ui.t('admin.noUsers') }} } @@ -177,6 +201,26 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models'; + +
+

{{ ui.t('admin.newUser') }}

+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
` @@ -192,6 +236,8 @@ export class AdminComponent implements OnInit { readonly settings = signal(null); readonly systemInfo = signal(null); readonly saving = signal(false); + readonly userSaving = signal(false); + readonly editingUserId = signal(null); readonly form = this.fb.nonNullable.group({ appName: ['', [Validators.required, Validators.minLength(2)]], @@ -208,7 +254,29 @@ export class AdminComponent implements OnInit { smtpFromEmail: [''] }); - ngOnInit() { this.load(); } + readonly createUserForm = this.fb.nonNullable.group({ + fullName: ['', [Validators.required, Validators.minLength(2)]], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + role: ['USER' as 'ADMIN' | 'USER', Validators.required], + defaultCurrency: ['PLN', [Validators.required, Validators.minLength(3)]], + isActive: [true], + integrationsEnabled: [false] + }); + + readonly editUserForm = this.fb.nonNullable.group({ + fullName: ['', [Validators.required, Validators.minLength(2)]], + email: ['', [Validators.required, Validators.email]], + password: [''], + role: ['USER' as 'ADMIN' | 'USER', Validators.required], + defaultCurrency: ['PLN', [Validators.required, Validators.minLength(3)]], + isActive: [true], + integrationsEnabled: [false] + }); + + ngOnInit() { + this.load(); + } load() { this.admin.getSettings().subscribe({ @@ -232,7 +300,15 @@ export class AdminComponent implements OnInit { } }); + this.loadUsers(); + this.refreshSystemInfo(); + } + + loadUsers() { this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) }); + } + + refreshSystemInfo() { this.admin.getSystemInfo().subscribe({ next: (response) => this.systemInfo.set(response.item) }); } @@ -260,7 +336,7 @@ export class AdminComponent implements OnInit { this.settings.set(response.item); this.appSettings.applySettings(response.item); this.toast.success(this.ui.t('admin.settingsSaved')); - this.admin.getSystemInfo().subscribe({ next: (systemResponse) => this.systemInfo.set(systemResponse.item) }); + this.refreshSystemInfo(); }, error: (error) => { this.saving.set(false); @@ -276,15 +352,107 @@ export class AdminComponent implements OnInit { return; } this.admin.testSmtp(to).subscribe({ - next: () => this.toast.success(this.ui.t('admin.testSent')), + next: (response) => this.toast.success(response.mode ? `${this.ui.t('admin.testSent')} (${response.mode})` : this.ui.t('admin.testSent')), error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.testError')) }); } + createUser() { + if (this.createUserForm.invalid) return; + this.userSaving.set(true); + const raw = this.createUserForm.getRawValue(); + this.admin.createUser({ + fullName: raw.fullName, + email: raw.email, + password: raw.password, + role: raw.role, + defaultCurrency: raw.defaultCurrency, + isActive: raw.isActive, + integrationsEnabled: raw.integrationsEnabled + }).subscribe({ + next: (response) => { + this.userSaving.set(false); + this.users.update((items) => [response.item, ...items]); + this.createUserForm.reset({ + fullName: '', + email: '', + password: '', + role: 'USER', + defaultCurrency: this.form.getRawValue().defaultCurrency || 'PLN', + isActive: true, + integrationsEnabled: false + }); + this.refreshSystemInfo(); + this.toast.success(this.ui.t('admin.userCreated')); + }, + error: (error) => { + this.userSaving.set(false); + this.toast.error(error.error?.message ?? this.ui.t('admin.userCreateError')); + } + }); + } + + startEdit(user: User) { + this.editingUserId.set(user.id); + this.editUserForm.reset({ + fullName: user.fullName, + email: user.email, + password: '', + role: user.role, + defaultCurrency: user.defaultCurrency, + isActive: user.isActive, + integrationsEnabled: Boolean(user.integrationsEnabled) + }); + } + + cancelEdit() { + this.editingUserId.set(null); + this.editUserForm.reset({ + fullName: '', + email: '', + password: '', + role: 'USER', + defaultCurrency: 'PLN', + isActive: true, + integrationsEnabled: false + }); + } + + saveUser() { + const userId = this.editingUserId(); + if (!userId || this.editUserForm.invalid) return; + this.userSaving.set(true); + const raw = this.editUserForm.getRawValue(); + this.admin.updateUser(userId, { + fullName: raw.fullName, + email: raw.email, + password: raw.password.trim() || undefined, + role: raw.role, + defaultCurrency: raw.defaultCurrency, + isActive: raw.isActive, + integrationsEnabled: raw.integrationsEnabled + }).subscribe({ + next: (response) => { + this.userSaving.set(false); + this.replaceUser(response.item); + this.cancelEdit(); + this.toast.success(this.ui.t('admin.userUpdated')); + }, + error: (error) => { + this.userSaving.set(false); + this.toast.error(error.error?.message ?? this.ui.t('admin.userUpdateError')); + } + }); + } + + replaceUser(user: User) { + this.users.update((items) => items.map((item) => (item.id === user.id ? user : item))); + } + toggleRole(user: User) { this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({ next: (response) => { - this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item))); + this.replaceUser(response.item); this.toast.success(this.ui.t('admin.roleUpdated')); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError')) @@ -294,18 +462,18 @@ export class AdminComponent implements OnInit { toggleActive(user: User) { this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({ next: (response) => { - this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item))); + this.replaceUser(response.item); + this.refreshSystemInfo(); this.toast.success(this.ui.t('admin.statusUpdated')); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError')) }); } - toggleIntegrations(user: User) { this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({ next: (response) => { - this.users.update((items) => items.map((item) => (item.id === user.id ? response.item : item))); + this.replaceUser(response.item); this.toast.success(this.ui.t('admin.integrationsUpdated')); }, error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError')) diff --git a/web/src/app/features/auth/login.component.ts b/web/src/app/features/auth/login.component.ts index 932f555..72d5d03 100644 --- a/web/src/app/features/auth/login.component.ts +++ b/web/src/app/features/auth/login.component.ts @@ -114,9 +114,7 @@ export class LoginComponent { fullName: [''] }); - constructor() { - this.appSettings.loadPublic().subscribe({ error: () => undefined }); - } + constructor() {} submit() { if (this.form.invalid) return; diff --git a/web/src/main.ts b/web/src/main.ts index 2c60270..df6c012 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,5 +2,60 @@ import '@tabler/core/dist/js/tabler.min.js'; import { bootstrapApplication } from '@angular/platform-browser'; import { App } from './app/app'; import { appConfig } from './app/app.config'; +import { environment } from './environments/environment'; +import type { PublicAppConfig } from './app/core/services/app-settings.service'; -bootstrapApplication(App, appConfig).catch((err) => console.error(err)); +const PUBLIC_CONFIG_STORAGE_KEY = 'expense-control-public-config'; + +type GlobalWithPublicConfig = typeof globalThis & { + __EXPENSE_CONTROL_PUBLIC_CONFIG__?: PublicAppConfig; +}; + +const isPublicConfig = (value: unknown): value is PublicAppConfig => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + return typeof candidate['appName'] === 'string' && typeof candidate['registrationEnabled'] === 'boolean'; +}; + +const persistPublicConfig = (config: PublicAppConfig) => { + document.title = config.appName; + (globalThis as GlobalWithPublicConfig).__EXPENSE_CONTROL_PUBLIC_CONFIG__ = config; + try { + globalThis.localStorage?.setItem(PUBLIC_CONFIG_STORAGE_KEY, JSON.stringify(config)); + } catch { + // noop + } +}; + +const readCachedPublicConfig = () => { + try { + const raw = globalThis.localStorage?.getItem(PUBLIC_CONFIG_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + return isPublicConfig(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const loadBootstrapPublicConfig = async () => { + const cached = readCachedPublicConfig(); + if (cached) { + persistPublicConfig(cached); + } + + try { + const response = await fetch(`${environment.apiBaseUrl}/auth/config`, { cache: 'no-store' }); + if (!response.ok) return; + const config = (await response.json()) as unknown; + if (isPublicConfig(config)) { + persistPublicConfig(config); + } + } catch { + // keep cached/default title + } +}; + +loadBootstrapPublicConfig() + .finally(() => bootstrapApplication(App, appConfig)) + .catch((err) => console.error(err));