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

View File

@@ -3,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<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);
}
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() {

View File

@@ -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<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' })
export class AppSettingsService {
private readonly http = inject(HttpClient);
private readonly document = inject(DOCUMENT);
private loadedPublic = initialState.bootstrapped;
readonly publicConfig = signal<PublicAppConfig>({
appName: 'Expense Control',
registrationEnabled: true
});
readonly publicConfig = signal<PublicAppConfig>(initialState.config);
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(
() => 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
.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) {
@@ -36,5 +99,6 @@ export class AppSettingsService {
appName: item.appName,
registrationEnabled: item.registrationEnabled
});
this.loadedPublic = true;
}
}

View File

@@ -48,6 +48,7 @@ const translations: Record<UiLanguage, Record<string, string>> = {
'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<UiLanguage, Record<string, string>> = {
'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<UiLanguage, Record<string, string>> = {
'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<UiLanguage, Record<string, string>> = {
'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<UiLanguage, Record<string, string>> = {
'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<UiLanguage, Record<string, string>> = {
'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',

View File

@@ -119,6 +119,7 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
<input class="form-check-input" type="checkbox" formControlName="smtpSecure" />
<span class="form-check-label">{{ ui.t('admin.secureConnection') }}</span>
</label>
<div class="small text-secondary">{{ ui.t('admin.smtpHint') }}</div>
<div class="btn-list flex-wrap">
<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>{{ user.createdAt | date:'short' }}</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)">
{{ user.role === 'ADMIN' ? ui.t('action.setUser') : ui.t('action.setAdmin') }}
</button>
@@ -170,6 +172,28 @@ import type { AdminSystemInfo, AppSettings, User } from '../../shared/models';
</div>
</td>
</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 {
<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>
</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>
`
@@ -192,6 +236,8 @@ export class AdminComponent implements OnInit {
readonly settings = signal<AppSettings | null>(null);
readonly systemInfo = signal<AdminSystemInfo | null>(null);
readonly saving = signal(false);
readonly userSaving = signal(false);
readonly editingUserId = signal<string | null>(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'))

View File

@@ -114,9 +114,7 @@ export class LoginComponent {
fullName: ['']
});
constructor() {
this.appSettings.loadPublic().subscribe({ error: () => undefined });
}
constructor() {}
submit() {
if (this.form.invalid) return;

View File

@@ -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<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));