foxes
This commit is contained in:
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
@@ -14,7 +13,8 @@ import { Expense } from '../entities/Expense.js';
|
|||||||
import { Merchant } from '../entities/Merchant.js';
|
import { Merchant } from '../entities/Merchant.js';
|
||||||
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
||||||
import { User } from '../entities/User.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';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -33,14 +33,26 @@ const settingsSchema = z.object({
|
|||||||
smtpFromEmail: z.string().max(160).nullable().optional()
|
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({
|
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(),
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
defaultCurrency: z.string().min(3).max(8).optional(),
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
||||||
integrationsEnabled: z.boolean().optional()
|
integrationsEnabled: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -160,6 +172,33 @@ export const listUsers = async (_req: AuthenticatedRequest, res: Response) => {
|
|||||||
return res.json({ items: items.map(sanitizeUser) });
|
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) => {
|
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const parsed = userUpdateSchema.safeParse(req.body);
|
const parsed = userUpdateSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -170,6 +209,23 @@ export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
const item = await userRepo().findOne({ where: { id: itemId } });
|
const item = await userRepo().findOne({ where: { id: itemId } });
|
||||||
if (!item) return res.status(404).json({ message: 'User not found' });
|
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 (parsed.data.role) item.role = parsed.data.role;
|
||||||
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
||||||
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
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' });
|
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
try {
|
||||||
const transport = nodemailer.createTransport({
|
const transport = createSmtpTransport(settings);
|
||||||
host: settings.smtpHost,
|
await transport.verify();
|
||||||
port: settings.smtpPort,
|
await transport.sendMail({
|
||||||
secure: settings.smtpSecure,
|
from: formatFromAddress(settings),
|
||||||
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
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({
|
return res.json({ message: 'SMTP test message was sent', mode: describeSmtpMode(settings) });
|
||||||
from: settings.smtpFromName
|
} catch (error) {
|
||||||
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
const rawMessage = error instanceof Error ? error.message : 'SMTP connection failed';
|
||||||
: settings.smtpFromEmail,
|
const message = rawMessage.includes('wrong version number')
|
||||||
to: parsed.data.to,
|
? `SMTP handshake failed. For port ${settings.smtpPort} the application now uses ${describeSmtpMode(settings)}.`
|
||||||
subject: `${settings.appName} - SMTP test`,
|
: rawMessage;
|
||||||
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.status(500).json({ message, mode: describeSmtpMode(settings) });
|
||||||
});
|
}
|
||||||
|
|
||||||
return res.json({ message: 'SMTP test message was sent' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {
|
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const loginSchema = z.object({
|
|||||||
const DEFAULT_APP_NAME = 'Expense Control';
|
const DEFAULT_APP_NAME = 'Expense Control';
|
||||||
|
|
||||||
export const publicConfig = async (_req: Request, res: Response) => {
|
export const publicConfig = async (_req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||||
take: 1,
|
take: 1,
|
||||||
order: { createdAt: 'ASC' }
|
order: { createdAt: 'ASC' }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { createRequire } from 'node:module';
|
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { AppDataSource } from '../config/data-source.js';
|
import { AppDataSource } from '../config/data-source.js';
|
||||||
@@ -7,6 +6,7 @@ import { Expense } from '../entities/Expense.js';
|
|||||||
import { User } from '../entities/User.js';
|
import { User } from '../entities/User.js';
|
||||||
import { getStatistics } from '../services/statistics.service.js';
|
import { getStatistics } from '../services/statistics.service.js';
|
||||||
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
import { processDueRecurringExpenses } from '../services/recurring.service.js';
|
||||||
|
import { createSmtpTransport, formatFromAddress } from '../services/mail.service.js';
|
||||||
import type { AuthenticatedRequest } from '../types/express.js';
|
import type { AuthenticatedRequest } from '../types/express.js';
|
||||||
|
|
||||||
const preferencesSchema = z.object({
|
const preferencesSchema = z.object({
|
||||||
@@ -28,7 +28,6 @@ const exportQuerySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userRepo = () => AppDataSource.getRepository(User);
|
const userRepo = () => AppDataSource.getRepository(User);
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||||
|
|
||||||
@@ -273,17 +272,11 @@ export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
|||||||
range.bucket
|
range.bucket
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
const transport = createSmtpTransport(appSettings);
|
||||||
const transport = nodemailer.createTransport({
|
|
||||||
host: appSettings.smtpHost,
|
|
||||||
port: appSettings.smtpPort,
|
|
||||||
secure: appSettings.smtpSecure,
|
|
||||||
auth: appSettings.smtpUser ? { user: appSettings.smtpUser, pass: appSettings.smtpPassword ?? '' } : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const to = prefs.sendToEmail || user.email;
|
const to = prefs.sendToEmail || user.email;
|
||||||
await transport.sendMail({
|
await transport.sendMail({
|
||||||
from: appSettings.smtpFromName ? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>` : appSettings.smtpFromEmail,
|
from: formatFromAddress(appSettings),
|
||||||
to,
|
to,
|
||||||
subject: `${appSettings.appName} - ${range.label} report`,
|
subject: `${appSettings.appName} - ${range.label} report`,
|
||||||
html: buildReportHtml(`${range.label} report`, summary)
|
html: buildReportHtml(`${range.label} report`, summary)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
|
createAdminUser,
|
||||||
getSettings,
|
getSettings,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
listUsers,
|
listUsers,
|
||||||
@@ -15,6 +16,7 @@ adminRouter.use(requireAuth, requireAdmin);
|
|||||||
adminRouter.get('/settings', getSettings);
|
adminRouter.get('/settings', getSettings);
|
||||||
adminRouter.put('/settings', updateSettings);
|
adminRouter.put('/settings', updateSettings);
|
||||||
adminRouter.get('/users', listUsers);
|
adminRouter.get('/users', listUsers);
|
||||||
|
adminRouter.post('/users', createAdminUser);
|
||||||
adminRouter.patch('/users/:id', updateUser);
|
adminRouter.patch('/users/:id', updateUser);
|
||||||
adminRouter.post('/test-smtp', testSmtp);
|
adminRouter.post('/test-smtp', testSmtp);
|
||||||
adminRouter.get('/system-info', getSystemInfo);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,16 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
@@ -19,12 +29,16 @@ export class AdminService {
|
|||||||
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
return this.http.get<{ items: User[] }>(`${environment.apiBaseUrl}/admin/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateUser(id: string, payload: Partial<User> & { 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);
|
return this.http.patch<{ item: User }>(`${environment.apiBaseUrl}/admin/users/${id}`, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
testSmtp(to: string) {
|
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() {
|
getSystemInfo() {
|
||||||
|
|||||||
@@ -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 { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import type { AppSettings } from '../../shared/models';
|
import type { AppSettings } from '../../shared/models';
|
||||||
@@ -9,25 +11,86 @@ export interface PublicAppConfig {
|
|||||||
registrationEnabled: boolean;
|
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<string, unknown>;
|
||||||
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AppSettingsService {
|
export class AppSettingsService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private loadedPublic = initialState.bootstrapped;
|
||||||
|
|
||||||
readonly publicConfig = signal<PublicAppConfig>({
|
readonly publicConfig = signal<PublicAppConfig>(initialState.config);
|
||||||
appName: 'Expense Control',
|
|
||||||
registrationEnabled: true
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly settings = signal<AppSettings | null>(null);
|
readonly settings = signal<AppSettings | null>(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(
|
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<PublicAppConfig> {
|
||||||
|
if (this.loadedPublic && !force) {
|
||||||
|
return of(this.publicConfig());
|
||||||
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
.get<PublicAppConfig>(`${environment.apiBaseUrl}/auth/config`)
|
||||||
.pipe(tap((config) => this.publicConfig.set(config)));
|
.pipe(
|
||||||
|
tap((config) => {
|
||||||
|
this.publicConfig.set(config);
|
||||||
|
this.loadedPublic = true;
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applySettings(item: AppSettings) {
|
applySettings(item: AppSettings) {
|
||||||
@@ -36,5 +99,6 @@ export class AppSettingsService {
|
|||||||
appName: item.appName,
|
appName: item.appName,
|
||||||
registrationEnabled: item.registrationEnabled
|
registrationEnabled: item.registrationEnabled
|
||||||
});
|
});
|
||||||
|
this.loadedPublic = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.setUser': 'Ustaw USER',
|
'action.setUser': 'Ustaw USER',
|
||||||
'action.setAdmin': 'Ustaw ADMIN',
|
'action.setAdmin': 'Ustaw ADMIN',
|
||||||
'action.import': 'Importuj',
|
'action.import': 'Importuj',
|
||||||
|
'action.addUser': 'Dodaj użytkownika',
|
||||||
'action.enableIntegrations': 'Włącz integracje',
|
'action.enableIntegrations': 'Włącz integracje',
|
||||||
'action.disableIntegrations': 'Wyłącz integracje',
|
'action.disableIntegrations': 'Wyłącz integracje',
|
||||||
|
|
||||||
@@ -243,7 +244,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.fromName': 'Nazwa nadawcy',
|
'admin.fromName': 'Nazwa nadawcy',
|
||||||
'admin.fromEmail': 'E-mail nadawcy',
|
'admin.fromEmail': 'E-mail nadawcy',
|
||||||
'admin.secureConnection': 'Bezpieczne połączenie',
|
'admin.secureConnection': 'Bezpieczne połączenie',
|
||||||
|
'admin.smtpHint': 'Port 587 działa przez STARTTLS, a port 465 przez implicit TLS.',
|
||||||
'admin.users': 'Użytkownicy',
|
'admin.users': 'Użytkownicy',
|
||||||
|
'admin.newUser': 'Nowy użytkownik',
|
||||||
'admin.userLabel': 'Użytkownik',
|
'admin.userLabel': 'Użytkownik',
|
||||||
'admin.role': 'Rola',
|
'admin.role': 'Rola',
|
||||||
'admin.status': 'Status',
|
'admin.status': 'Status',
|
||||||
@@ -260,6 +263,11 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.statusError': 'Nie udało się zmienić statusu.',
|
'admin.statusError': 'Nie udało się zmienić statusu.',
|
||||||
'admin.integrationsAccess': 'Integracje',
|
'admin.integrationsAccess': 'Integracje',
|
||||||
'admin.integrationsUpdated': 'Dostęp do integracji został zaktualizowany.',
|
'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',
|
'nav.cashflow': 'Cashflow',
|
||||||
@@ -503,6 +511,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'action.setUser': 'Set USER',
|
'action.setUser': 'Set USER',
|
||||||
'action.setAdmin': 'Set ADMIN',
|
'action.setAdmin': 'Set ADMIN',
|
||||||
'action.import': 'Import',
|
'action.import': 'Import',
|
||||||
|
'action.addUser': 'Add user',
|
||||||
'action.enableIntegrations': 'Enable integrations',
|
'action.enableIntegrations': 'Enable integrations',
|
||||||
'action.disableIntegrations': 'Disable integrations',
|
'action.disableIntegrations': 'Disable integrations',
|
||||||
|
|
||||||
@@ -696,7 +705,9 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.fromName': 'Sender name',
|
'admin.fromName': 'Sender name',
|
||||||
'admin.fromEmail': 'Sender email',
|
'admin.fromEmail': 'Sender email',
|
||||||
'admin.secureConnection': 'Secure connection',
|
'admin.secureConnection': 'Secure connection',
|
||||||
|
'admin.smtpHint': 'Port 587 uses STARTTLS, while port 465 uses implicit TLS.',
|
||||||
'admin.users': 'Users',
|
'admin.users': 'Users',
|
||||||
|
'admin.newUser': 'New user',
|
||||||
'admin.userLabel': 'User',
|
'admin.userLabel': 'User',
|
||||||
'admin.role': 'Role',
|
'admin.role': 'Role',
|
||||||
'admin.status': 'Status',
|
'admin.status': 'Status',
|
||||||
@@ -713,6 +724,11 @@ const translations: Record<UiLanguage, Record<string, string>> = {
|
|||||||
'admin.statusError': 'Failed to change the account status.',
|
'admin.statusError': 'Failed to change the account status.',
|
||||||
'admin.integrationsAccess': 'Integrations',
|
'admin.integrationsAccess': 'Integrations',
|
||||||
'admin.integrationsUpdated': 'Integrations access has been updated.',
|
'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',
|
'nav.cashflow': 'Cashflow',
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
|
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
|
||||||
<span class="form-check-label">{{ ui.t('admin.secureConnection') }}</span>
|
<span class="form-check-label">{{ ui.t('admin.secureConnection') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="small text-secondary">{{ ui.t('admin.smtpHint') }}</div>
|
||||||
|
|
||||||
<div class="btn-list flex-wrap">
|
<div class="btn-list flex-wrap">
|
||||||
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid || saving()">
|
<button class="btn btn-success d-inline-flex align-items-center gap-2" [disabled]="form.invalid || saving()">
|
||||||
@@ -157,7 +158,8 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
|
<td><span class="badge" [class.bg-success]="user.integrationsEnabled" [class.bg-secondary]="!user.integrationsEnabled">{{ user.integrationsEnabled ? ui.t('common.active') : ui.t('common.blocked') }}</span></td>
|
||||||
<td>{{ user.createdAt | date:'short' }}</td>
|
<td>{{ user.createdAt | date:'short' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-list flex-wrap">
|
<div class="btn-list flex-wrap justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="startEdit(user)">{{ ui.t('action.edit') }}</button>
|
||||||
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
<button class="btn btn-outline-warning btn-sm" type="button" (click)="toggleRole(user)">
|
||||||
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
|
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -170,6 +172,28 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (editingUserId() === user.id) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="bg-body-secondary">
|
||||||
|
<form [formGroup]="editUserForm" (ngSubmit)="saveUser()" class="row g-3 p-2">
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
|
||||||
|
<div class="col-md-4"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
|
||||||
|
<div class="col-12 small text-secondary">{{ ui.t('admin.passwordHint') }}</div>
|
||||||
|
<div class="col-12 d-flex gap-3 flex-wrap">
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-2 flex-wrap">
|
||||||
|
<button class="btn btn-primary btn-sm" [disabled]="editUserForm.invalid || userSaving()">{{ ui.t('action.saveChanges') }}</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" (click)="cancelEdit()">{{ ui.t('action.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
<tr><td colspan="6" class="text-secondary">{{ ui.t('admin.noUsers') }}</td></tr>
|
||||||
}
|
}
|
||||||
@@ -177,6 +201,26 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card pv-card overflow-hidden mt-3">
|
||||||
|
<div class="card-header"><h3 class="card-title">{{ ui.t('admin.newUser') }}</h3></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form [formGroup]="createUserForm" (ngSubmit)="createUser()" class="row g-3">
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('login.fullName') }}</label><input class="form-control" formControlName="fullName" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('login.email') }}</label><input class="form-control" formControlName="email" /></div>
|
||||||
|
<div class="col-12"><label class="form-label">{{ ui.t('admin.password') }}</label><input class="form-control" type="password" formControlName="password" /></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.role') }}</label><select class="form-select" formControlName="role"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select></div>
|
||||||
|
<div class="col-md-6"><label class="form-label">{{ ui.t('admin.defaultCurrency') }}</label><input class="form-control" formControlName="defaultCurrency" /></div>
|
||||||
|
<div class="col-12 d-flex gap-3 flex-wrap">
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="isActive" /><span class="form-check-label">{{ ui.t('common.active') }}</span></label>
|
||||||
|
<label class="form-check mb-0"><input class="form-check-input" type="checkbox" formControlName="integrationsEnabled" /><span class="form-check-label">{{ ui.t('admin.integrationsAccess') }}</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button class="btn btn-primary" [disabled]="createUserForm.invalid || userSaving()">{{ ui.t('action.addUser') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -192,6 +236,8 @@ export class AdminComponent implements OnInit {
|
|||||||
readonly settings = signal<AppSettings | null>(null);
|
readonly settings = signal<AppSettings | null>(null);
|
||||||
readonly systemInfo = signal<AdminSystemInfo | null>(null);
|
readonly systemInfo = signal<AdminSystemInfo | null>(null);
|
||||||
readonly saving = signal(false);
|
readonly saving = signal(false);
|
||||||
|
readonly userSaving = signal(false);
|
||||||
|
readonly editingUserId = signal<string | null>(null);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group({
|
readonly form = this.fb.nonNullable.group({
|
||||||
appName: ['', [Validators.required, Validators.minLength(2)]],
|
appName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
@@ -208,7 +254,29 @@ export class AdminComponent implements OnInit {
|
|||||||
smtpFromEmail: ['']
|
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() {
|
load() {
|
||||||
this.admin.getSettings().subscribe({
|
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) });
|
this.admin.listUsers().subscribe({ next: (response) => this.users.set(response.items) });
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSystemInfo() {
|
||||||
this.admin.getSystemInfo().subscribe({ next: (response) => this.systemInfo.set(response.item) });
|
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.settings.set(response.item);
|
||||||
this.appSettings.applySettings(response.item);
|
this.appSettings.applySettings(response.item);
|
||||||
this.toast.success(this.ui.t('admin.settingsSaved'));
|
this.toast.success(this.ui.t('admin.settingsSaved'));
|
||||||
this.admin.getSystemInfo().subscribe({ next: (systemResponse) => this.systemInfo.set(systemResponse.item) });
|
this.refreshSystemInfo();
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
@@ -276,15 +352,107 @@ export class AdminComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.admin.testSmtp(to).subscribe({
|
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'))
|
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) {
|
toggleRole(user: User) {
|
||||||
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
this.admin.updateUser(user.id, { role: user.role === 'ADMIN' ? 'USER' : 'ADMIN' }).subscribe({
|
||||||
next: (response) => {
|
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'));
|
this.toast.success(this.ui.t('admin.roleUpdated'));
|
||||||
},
|
},
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
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) {
|
toggleActive(user: User) {
|
||||||
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
this.admin.updateUser(user.id, { isActive: !user.isActive }).subscribe({
|
||||||
next: (response) => {
|
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'));
|
this.toast.success(this.ui.t('admin.statusUpdated'));
|
||||||
},
|
},
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.statusError'))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
toggleIntegrations(user: User) {
|
toggleIntegrations(user: User) {
|
||||||
this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({
|
this.admin.updateUser(user.id, { integrationsEnabled: !user.integrationsEnabled }).subscribe({
|
||||||
next: (response) => {
|
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'));
|
this.toast.success(this.ui.t('admin.integrationsUpdated'));
|
||||||
},
|
},
|
||||||
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
error: (error) => this.toast.error(error.error?.message ?? this.ui.t('admin.roleError'))
|
||||||
|
|||||||
@@ -114,9 +114,7 @@ export class LoginComponent {
|
|||||||
fullName: ['']
|
fullName: ['']
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {}
|
||||||
this.appSettings.loadPublic().subscribe({ error: () => undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
submit() {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
|
|||||||
@@ -2,5 +2,60 @@ import '@tabler/core/dist/js/tabler.min.js';
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
import { appConfig } from './app/app.config';
|
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<string, unknown>;
|
||||||
|
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));
|
||||||
|
|||||||
Reference in New Issue
Block a user