feat(frontend): add responsive design, form validation, toast service and error handling
- Responsive navigation with mobile hamburger menu - Reactive forms with real-time validation on profile page - Toast service for success/error/warning messages - HTTP error interceptor for 401/403/500 handling - Loading states with spinner animations
This commit is contained in:
parent
05ec18d550
commit
838fdb2826
@ -4,13 +4,14 @@ import {routes} from './app.routes';
|
|||||||
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http';
|
||||||
import {BASE_PATH} from './api';
|
import {BASE_PATH} from './api';
|
||||||
import {jwtInterceptor} from './interceptors/jwt.interceptor';
|
import {jwtInterceptor} from './interceptors/jwt.interceptor';
|
||||||
|
import {errorInterceptor} from './interceptors/error.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(withFetch(),
|
provideHttpClient(withFetch(),
|
||||||
withInterceptors([jwtInterceptor])),
|
withInterceptors([jwtInterceptor, errorInterceptor])),
|
||||||
{ provide: BASE_PATH, useValue: 'http://localhost:8080' }
|
{ provide: BASE_PATH, useValue: 'http://localhost:8080' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
|
<main class="min-h-[calc(100vh-4rem)] bg-gray-50">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
<app-toast></app-toast>
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import {Component, signal} from '@angular/core';
|
import {Component, signal} from '@angular/core';
|
||||||
import {NavigationEnd, Router, RouterOutlet} from '@angular/router';
|
import {NavigationEnd, Router, RouterOutlet} from '@angular/router';
|
||||||
import {HeaderComponent} from './layout/header/header';
|
import {HeaderComponent} from './layout/header/header';
|
||||||
|
import {ToastComponent} from './components/toast/toast';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, HeaderComponent],
|
imports: [RouterOutlet, HeaderComponent, ToastComponent],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
})
|
})
|
||||||
|
|||||||
44
eeg_frontend/src/app/components/toast/toast.ts
Normal file
44
eeg_frontend/src/app/components/toast/toast.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ToastService } from '../../services/toast';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toast',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||||
|
@for (toast of toastService.toasts$(); track toast.id) {
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 rounded-lg shadow-lg text-white text-sm font-medium max-w-sm animate-slide-in"
|
||||||
|
[class]="getToastClass(toast.type)">
|
||||||
|
{{ toast.message }}
|
||||||
|
<button
|
||||||
|
(click)="toastService.dismiss(toast.id)"
|
||||||
|
class="ml-2 text-white opacity-70 hover:opacity-100">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
@keyframes slide-in {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class ToastComponent {
|
||||||
|
toastService = inject(ToastService);
|
||||||
|
|
||||||
|
getToastClass(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return 'bg-green-500';
|
||||||
|
case 'error': return 'bg-red-500';
|
||||||
|
case 'warning': return 'bg-yellow-500';
|
||||||
|
default: return 'bg-blue-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
eeg_frontend/src/app/interceptors/error.interceptor.ts
Normal file
31
eeg_frontend/src/app/interceptors/error.interceptor.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { ToastService } from '../services/toast';
|
||||||
|
|
||||||
|
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const toastService = inject(ToastService);
|
||||||
|
|
||||||
|
return next(req).pipe(
|
||||||
|
catchError(error => {
|
||||||
|
if (error.status === 401) {
|
||||||
|
// Unauthorized - auto logout
|
||||||
|
authService.logout();
|
||||||
|
router.navigate(['/login']);
|
||||||
|
toastService.error('Sitzung abgelaufen. Bitte erneut einloggen.');
|
||||||
|
} else if (error.status === 403) {
|
||||||
|
toastService.error('Keine Berechtigung für diese Aktion.');
|
||||||
|
} else if (error.status === 404) {
|
||||||
|
toastService.error('Ressource nicht gefunden.');
|
||||||
|
} else if (error.status >= 500) {
|
||||||
|
toastService.error('Ein Serverfehler ist aufgetreten. Bitte versuchen Sie es später erneut.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(() => error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,15 @@
|
|||||||
<div class="flex h-screen bg-slate-50">
|
<div class="flex h-screen bg-slate-50">
|
||||||
|
|
||||||
<aside class="w-64 bg-white border-r border-slate-200 flex flex-col z-10 shrink-0">
|
<!-- Mobile Overlay -->
|
||||||
|
@if (isMobileMenuOpen()) {
|
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-50 z-20 md:hidden"
|
||||||
|
(click)="closeMobileMenu()"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside [class]="isMobileMenuOpen()
|
||||||
|
? 'fixed inset-y-0 left-0 w-64 bg-white border-r border-slate-200 flex flex-col z-30 transform transition-transform duration-200 ease-in-out'
|
||||||
|
: 'fixed inset-y-0 left-0 w-64 bg-white border-r border-slate-200 flex flex-col z-30 transform transition-transform duration-200 ease-in-out -translate-x-full md:translate-x-0 md:static md:z-10'">
|
||||||
<div class="h-16 flex items-center px-6 border-b border-slate-200 shrink-0">
|
<div class="h-16 flex items-center px-6 border-b border-slate-200 shrink-0">
|
||||||
<a routerLink="/" class="flex items-center space-x-2 group">
|
<a routerLink="/" class="flex items-center space-x-2 group">
|
||||||
<svg class="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@ -17,6 +26,7 @@
|
|||||||
<a [routerLink]="item.path"
|
<a [routerLink]="item.path"
|
||||||
routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
routerLinkActive="bg-emerald-50 text-emerald-700 font-medium"
|
||||||
[routerLinkActiveOptions]="{exact: item.path === '/dashboard'}"
|
[routerLinkActiveOptions]="{exact: item.path === '/dashboard'}"
|
||||||
|
(click)="closeMobileMenu()"
|
||||||
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
class="block px-4 py-2.5 rounded-lg text-slate-600 hover:bg-slate-50 hover:text-slate-900 transition-colors">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</a>
|
</a>
|
||||||
@ -26,13 +36,21 @@
|
|||||||
|
|
||||||
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-8 justify-between shrink-0">
|
<header class="h-16 bg-white border-b border-slate-200 flex items-center px-4 md:px-8 justify-between shrink-0">
|
||||||
<h1 class="text-lg font-semibold text-slate-800">
|
<!-- Mobile Menu Button -->
|
||||||
|
<button (click)="toggleMobileMenu()"
|
||||||
|
class="md:hidden p-2 rounded-md text-slate-600 hover:text-slate-900 hover:bg-slate-100">
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 class="text-lg font-semibold text-slate-800 hidden md:block">
|
||||||
{{ authService.currentUserRole() === 'ADMIN' ? 'Verwaltung' : 'Mein Bereich' }}
|
{{ authService.currentUserRole() === 'ADMIN' ? 'Verwaltung' : 'Mein Bereich' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="text-sm text-gray-500">
|
<span class="text-sm text-gray-500 hidden sm:inline">
|
||||||
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
|
Rolle: <span class="font-semibold text-gray-700">{{ authService.currentUserRole() }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button (click)="onLogout()"
|
<button (click)="onLogout()"
|
||||||
@ -42,7 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto p-8">
|
<div class="flex-1 overflow-auto p-4 md:p-8">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {Component, computed, inject} from '@angular/core';
|
import {Component, computed, inject, signal} from '@angular/core';
|
||||||
import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
|
||||||
import {AuthService} from '../../services/auth';
|
import {AuthService} from '../../services/auth';
|
||||||
import {NAV_ITEMS} from './dashboard-nav-items';
|
import {NAV_ITEMS} from './dashboard-nav-items';
|
||||||
@ -14,11 +14,21 @@ export class DashboardLayout {
|
|||||||
authService = inject(AuthService);
|
authService = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
|
isMobileMenuOpen = signal(false);
|
||||||
|
|
||||||
visibleNavItems = computed(() => {
|
visibleNavItems = computed(() => {
|
||||||
const role = this.authService.currentUserRole();
|
const role = this.authService.currentUserRole();
|
||||||
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
|
return NAV_ITEMS.filter(item => role !== null && item.roles.includes(role));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toggleMobileMenu() {
|
||||||
|
this.isMobileMenuOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMobileMenu() {
|
||||||
|
this.isMobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.authService.logout();
|
this.authService.logout();
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
|
|||||||
@ -1,48 +1,46 @@
|
|||||||
<div class="max-w-2xl mx-auto p-6">
|
<div class="max-w-2xl mx-auto p-4 md:p-6">
|
||||||
<h1 class="text-2xl font-bold mb-6">Mein Profil</h1>
|
<h1 class="text-2xl font-bold mb-6">Mein Profil</h1>
|
||||||
|
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<div class="text-center py-8 text-gray-500">Lade Profil...</div>
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mb-2"></div>
|
||||||
|
<div>Lade Profil...</div>
|
||||||
|
</div>
|
||||||
} @else if (profile()) {
|
} @else if (profile()) {
|
||||||
<!-- Erfolg/Fehler-Meldungen -->
|
|
||||||
@if (successMessage()) {
|
|
||||||
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
|
||||||
{{ successMessage() }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (errorMessage()) {
|
|
||||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
|
||||||
{{ errorMessage() }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Profil-Informationen -->
|
<!-- Profil-Informationen -->
|
||||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2>
|
<h2 class="text-lg font-semibold mb-4">Persönliche Daten</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<form [formGroup]="profileForm" (ngSubmit)="updateProfile()">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Vorname</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Vorname *</label>
|
||||||
<input type="text" [(ngModel)]="editFirstName"
|
<input type="text" formControlName="firstName"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(profileForm, 'firstName') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
|
@if (isFieldInvalid(profileForm, 'firstName')) {
|
||||||
|
<p class="text-red-500 text-xs mt-1">Vorname ist erforderlich (min. 2 Zeichen)</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nachname</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Nachname *</label>
|
||||||
<input type="text" [(ngModel)]="editLastName"
|
<input type="text" formControlName="lastName"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(profileForm, 'lastName') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
|
@if (isFieldInvalid(profileForm, 'lastName')) {
|
||||||
|
<p class="text-red-500 text-xs mt-1">Nachname ist erforderlich (min. 2 Zeichen)</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Teilnehmertyp</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Teilnehmertyp</label>
|
||||||
<select [(ngModel)]="editParticipantType"
|
<select formControlName="participantType"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="PRIVATE">Privatperson</option>
|
<option value="PRIVATE">Privatperson</option>
|
||||||
<option value="COMPANY">Juristische Person</option>
|
<option value="COMPANY">Juristische Person</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@if (editParticipantType === 'COMPANY') {
|
@if (profileForm.get('participantType')?.value === 'COMPANY') {
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Organisationsname</label>
|
||||||
<input type="text" [(ngModel)]="editOrganizationName"
|
<input type="text" formControlName="organizationName"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -53,54 +51,65 @@
|
|||||||
<div class="text-gray-900">{{ profile()!.email }}</div>
|
<div class="text-gray-900">{{ profile()!.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button (click)="updateProfile()" [disabled]="isSaving()"
|
<button type="submit" [disabled]="isSaving() || profileForm.invalid"
|
||||||
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
{{ isSaving() ? 'Speichere...' : 'Profil speichern' }}
|
{{ isSaving() ? 'Speichere...' : 'Profil speichern' }}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort ändern -->
|
<!-- Passwort ändern -->
|
||||||
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
<div class="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
|
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="passwordForm" (ngSubmit)="changePassword()">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Aktuelles Passwort *</label>
|
||||||
<input type="password" [(ngModel)]="currentPassword"
|
<input type="password" formControlName="currentPassword"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(passwordForm, 'currentPassword') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort *</label>
|
||||||
<input type="password" [(ngModel)]="newPassword"
|
<input type="password" formControlName="newPassword"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(passwordForm, 'newPassword') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
|
@if (isFieldInvalid(passwordForm, 'newPassword')) {
|
||||||
|
<p class="text-red-500 text-xs mt-1">Passwort muss mindestens 8 Zeichen lang sein</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort bestätigen</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Neues Passwort bestätigen *</label>
|
||||||
<input type="password" [(ngModel)]="newPasswordConfirm"
|
<input type="password" formControlName="newPasswordConfirm"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(passwordForm, 'newPasswordConfirm') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button (click)="changePassword()" [disabled]="isSaving()"
|
<button type="submit" [disabled]="isSaving() || passwordForm.invalid"
|
||||||
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
{{ isSaving() ? 'Ändere...' : 'Passwort ändern' }}
|
{{ isSaving() ? 'Ändere...' : 'Passwort ändern' }}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- E-Mail ändern -->
|
<!-- E-Mail ändern -->
|
||||||
<div class="bg-white shadow rounded-lg p-6">
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2>
|
<h2 class="text-lg font-semibold mb-4">E-Mail-Adresse ändern</h2>
|
||||||
|
|
||||||
|
<form [formGroup]="emailForm" (ngSubmit)="changeEmail()">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Neue E-Mail-Adresse *</label>
|
||||||
<input type="email" [(ngModel)]="newEmail" placeholder="neu@example.com"
|
<input type="email" formControlName="newEmail" placeholder="neu@example.com"
|
||||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
[class]="isFieldInvalid(emailForm, 'newEmail') ? 'w-full border border-red-500 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-red-500' : 'w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500'" />
|
||||||
|
@if (isFieldInvalid(emailForm, 'newEmail')) {
|
||||||
|
<p class="text-red-500 text-xs mt-1">Bitte geben Sie eine gültige E-Mail-Adresse ein</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button (click)="changeEmail()" [disabled]="isSaving()"
|
<button type="submit" [disabled]="isSaving() || emailForm.invalid"
|
||||||
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||||
{{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }}
|
{{ isSaving() ? 'Ändere...' : 'E-Mail ändern' }}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,32 +1,39 @@
|
|||||||
import {Component, inject, OnInit, signal} from '@angular/core';
|
import {Component, inject, OnInit, signal} from '@angular/core';
|
||||||
import {FormsModule} from '@angular/forms';
|
import {ReactiveFormsModule, FormBuilder, FormGroup, Validators} from '@angular/forms';
|
||||||
import {UserService, UserProfile, UpdateProfileRequest} from '../../services/user';
|
import {UserService, UserProfile, UpdateProfileRequest} from '../../services/user';
|
||||||
|
import {ToastService} from '../../services/toast';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-profile',
|
selector: 'app-profile',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FormsModule],
|
imports: [ReactiveFormsModule],
|
||||||
templateUrl: './profile.html'
|
templateUrl: './profile.html'
|
||||||
})
|
})
|
||||||
export class ProfileComponent implements OnInit {
|
export class ProfileComponent implements OnInit {
|
||||||
private userService = inject(UserService);
|
private userService = inject(UserService);
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
profile = signal<UserProfile | null>(null);
|
profile = signal<UserProfile | null>(null);
|
||||||
isLoading = signal(true);
|
isLoading = signal(true);
|
||||||
isSaving = signal(false);
|
isSaving = signal(false);
|
||||||
successMessage = signal('');
|
|
||||||
errorMessage = signal('');
|
|
||||||
|
|
||||||
editFirstName = '';
|
profileForm: FormGroup = this.fb.group({
|
||||||
editLastName = '';
|
firstName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
editOrganizationName = '';
|
lastName: ['', [Validators.required, Validators.minLength(2)]],
|
||||||
editParticipantType = 'PRIVATE';
|
organizationName: [''],
|
||||||
|
participantType: ['PRIVATE']
|
||||||
|
});
|
||||||
|
|
||||||
currentPassword = '';
|
passwordForm: FormGroup = this.fb.group({
|
||||||
newPassword = '';
|
currentPassword: ['', [Validators.required]],
|
||||||
newPasswordConfirm = '';
|
newPassword: ['', [Validators.required, Validators.minLength(8)]],
|
||||||
|
newPasswordConfirm: ['', [Validators.required]]
|
||||||
|
});
|
||||||
|
|
||||||
newEmail = '';
|
emailForm: FormGroup = this.fb.group({
|
||||||
|
newEmail: ['', [Validators.required, Validators.email]]
|
||||||
|
});
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.loadProfile();
|
this.loadProfile();
|
||||||
@ -36,10 +43,12 @@ export class ProfileComponent implements OnInit {
|
|||||||
this.userService.getProfile().subscribe({
|
this.userService.getProfile().subscribe({
|
||||||
next: profile => {
|
next: profile => {
|
||||||
this.profile.set(profile);
|
this.profile.set(profile);
|
||||||
this.editFirstName = profile.firstName || '';
|
this.profileForm.patchValue({
|
||||||
this.editLastName = profile.lastName || '';
|
firstName: profile.firstName || '',
|
||||||
this.editOrganizationName = profile.organizationName || '';
|
lastName: profile.lastName || '',
|
||||||
this.editParticipantType = profile.participantType;
|
organizationName: profile.organizationName || '',
|
||||||
|
participantType: profile.participantType
|
||||||
|
});
|
||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
},
|
},
|
||||||
error: () => this.isLoading.set(false)
|
error: () => this.isLoading.set(false)
|
||||||
@ -47,76 +56,69 @@ export class ProfileComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateProfile() {
|
updateProfile() {
|
||||||
if (this.isSaving()) return;
|
if (this.isSaving() || this.profileForm.invalid) return;
|
||||||
this.isSaving.set(true);
|
this.isSaving.set(true);
|
||||||
this.successMessage.set('');
|
|
||||||
this.errorMessage.set('');
|
|
||||||
|
|
||||||
const request: UpdateProfileRequest = {
|
const request: UpdateProfileRequest = this.profileForm.value;
|
||||||
firstName: this.editFirstName,
|
|
||||||
lastName: this.editLastName,
|
|
||||||
organizationName: this.editOrganizationName || undefined,
|
|
||||||
participantType: this.editParticipantType as 'PRIVATE' | 'COMPANY'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.userService.updateProfile(request).subscribe({
|
this.userService.updateProfile(request).subscribe({
|
||||||
next: profile => {
|
next: profile => {
|
||||||
this.profile.set(profile);
|
this.profile.set(profile);
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.successMessage.set('Profil erfolgreich aktualisiert.');
|
this.toastService.success('Profil erfolgreich aktualisiert.');
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.errorMessage.set(err.error?.message || 'Fehler beim Aktualisieren.');
|
this.toastService.error(err.error?.message || 'Fehler beim Aktualisieren.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
changePassword() {
|
changePassword() {
|
||||||
if (this.isSaving() || !this.currentPassword || !this.newPassword) return;
|
if (this.isSaving() || this.passwordForm.invalid) return;
|
||||||
if (this.newPassword !== this.newPasswordConfirm) {
|
|
||||||
this.errorMessage.set('Passwörter stimmen nicht überein.');
|
if (this.passwordForm.value.newPassword !== this.passwordForm.value.newPasswordConfirm) {
|
||||||
|
this.toastService.warning('Passwörter stimmen nicht überein.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isSaving.set(true);
|
this.isSaving.set(true);
|
||||||
this.successMessage.set('');
|
|
||||||
this.errorMessage.set('');
|
|
||||||
|
|
||||||
this.userService.changePassword({
|
this.userService.changePassword({
|
||||||
currentPassword: this.currentPassword,
|
currentPassword: this.passwordForm.value.currentPassword,
|
||||||
newPassword: this.newPassword
|
newPassword: this.passwordForm.value.newPassword
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.successMessage.set('Passwort erfolgreich geändert.');
|
this.toastService.success('Passwort erfolgreich geändert.');
|
||||||
this.currentPassword = '';
|
this.passwordForm.reset();
|
||||||
this.newPassword = '';
|
|
||||||
this.newPasswordConfirm = '';
|
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.errorMessage.set(err.error?.message || 'Fehler beim Ändern des Passworts.');
|
this.toastService.error(err.error?.message || 'Fehler beim Ändern des Passworts.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
changeEmail() {
|
changeEmail() {
|
||||||
if (this.isSaving() || !this.newEmail) return;
|
if (this.isSaving() || this.emailForm.invalid) return;
|
||||||
this.isSaving.set(true);
|
this.isSaving.set(true);
|
||||||
this.successMessage.set('');
|
|
||||||
this.errorMessage.set('');
|
|
||||||
|
|
||||||
this.userService.changeEmail({newEmail: this.newEmail}).subscribe({
|
this.userService.changeEmail({newEmail: this.emailForm.value.newEmail}).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.successMessage.set('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.');
|
this.toastService.success('E-Mail-Adresse geändert. Bitte neue Adresse verifizieren.');
|
||||||
this.newEmail = '';
|
this.emailForm.reset();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.isSaving.set(false);
|
this.isSaving.set(false);
|
||||||
this.errorMessage.set(err.error?.message || 'Fehler beim Ändern der E-Mail.');
|
this.toastService.error(err.error?.message || 'Fehler beim Ändern der E-Mail.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isFieldInvalid(form: FormGroup, fieldName: string): boolean {
|
||||||
|
const field = form.get(fieldName);
|
||||||
|
return !!(field && field.invalid && (field.dirty || field.touched));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
eeg_frontend/src/app/services/toast.ts
Normal file
50
eeg_frontend/src/app/services/toast.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ToastService {
|
||||||
|
private toasts = signal<Toast[]>([]);
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
get toasts$() {
|
||||||
|
return this.toasts.asReadonly();
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message: string, type: Toast['type'] = 'info', duration = 3000) {
|
||||||
|
const id = this.nextId++;
|
||||||
|
const toast: Toast = { id, message, type };
|
||||||
|
|
||||||
|
this.toasts.update(toasts => [...toasts, toast]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => this.dismiss(id), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, duration = 3000) {
|
||||||
|
return this.show(message, 'success', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, duration = 5000) {
|
||||||
|
return this.show(message, 'error', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message: string, duration = 4000) {
|
||||||
|
return this.show(message, 'warning', duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number) {
|
||||||
|
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.toasts.set([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user