diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 5df1e55..ad0bed6 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,34 +1,42 @@ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +// /src/app/app.config.ts + +import { ApplicationConfig, importProvidersFrom, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; - -import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { provideClientHydration } from '@angular/platform-browser'; - -// +++ HIER IST DIE DEFINITIVE KORREKTUR FÜR FORMS-PROVIDER +++ -import { - ReactiveFormsModule, // <-- Importieren des Moduls selbst - FormsModule // <-- Importieren des Moduls für Template-driven forms (falls benötigt) -} from '@angular/forms'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; import { routes } from './app.routes'; +// --- NEUE IMPORTE --- +import { environment } from './environment'; +import { API_URL } from './core/tokens/api-url.token'; +import { authInterceptor } from './core/interceptors/auth.interceptor'; + export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideAnimations(), - - provideHttpClient(withFetch()), - provideClientHydration(), + provideClientHydration(), - // +++ Korrigierte Verwendung der Forms-Provider +++ - // Dies macht die Provider für Reactive Forms global verfügbar - ReactiveFormsModule.withConfig({ - warnOnNgModelWithFormControl: 'never' // Optional: Schaltet eine NgModel Warnung aus - }).providers!, // <--- Korrigiert - - // Falls Sie Template-Driven Forms (mit NgModel) benötigen, würden Sie das hinzufügen: - // FormsModule.withConfig({}).providers! + // --- HttpClient Konfiguration aktualisieren --- + // withFetch() entfernen, da es mit Interceptors noch Probleme geben kann + // Stattdessen withInterceptors() verwenden + provideHttpClient( + withInterceptors([authInterceptor]) // Registriert unseren neuen Interceptor + ), + + // --- Forms Provider (sauberer Import) --- + // importProvidersFrom ist der empfohlene Weg für NgModule-basierte Bibliotheken + importProvidersFrom( + ReactiveFormsModule.withConfig({ warnOnNgModelWithFormControl: 'never' }), + FormsModule + ), + + // --- API_URL Provider hinzufügen --- + // Stellt den API_URL-Token mit dem Wert aus der passenden environment-Datei bereit + { provide: API_URL, useValue: environment.apiUrl } ] }; \ No newline at end of file diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index fc8c9a8..3adbaa1 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,8 +4,6 @@ import { AccessDeniedComponent } from './core/components/access-denied/access-de import { NotFoundComponent } from './core/components/not-found/not-found.component'; import { DefaultLayoutComponent } from './core/components/default-layout/default-layout.component'; - -import { DashboardPageComponent } from './features/components/dashboard/dashboard-page/dashboard-page.component'; import { authGuard } from './core/guards/auth.guard'; export const routes: Routes = [ @@ -18,8 +16,8 @@ export const routes: Routes = [ { path: 'dashboard', component: DefaultLayoutComponent, - // canActivate: [authGuard], - // data: { requiredRole: 'Admin' }, + canActivate: [authGuard], + data: { requiredRole: 'Admin' }, children: [ { path: '', diff --git a/src/app/core/components/core.routes.ts b/src/app/core/components/core.routes.ts new file mode 100644 index 0000000..036f95b --- /dev/null +++ b/src/app/core/components/core.routes.ts @@ -0,0 +1,8 @@ +import { Routes } from '@angular/router'; + +// Importiere dein spezielles Layout für Auth-Seiten und alle Komponenten + +export const CORE_ROUTES: Routes = [ + + +]; diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index 39678ca..1e0831a 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -1,48 +1,43 @@ +// /src/app/core/guards/auth.guard.ts + import { inject } from '@angular/core'; -import { CanActivateFn, Router, ActivatedRouteSnapshot } from '@angular/router'; +import { CanActivateFn, Router, ActivatedRouteSnapshot, UrlTree } from '@angular/router'; import { AuthService } from '../services/auth.service'; /** - * Ein funktionaler Guard, der Routen schützt. - * + * Ein funktionaler Guard, der Routen basierend auf Authentifizierung und Rollen schützt. + * * Verwendung in der Routing-Konfiguration: * { * path: 'admin', - * component: AdminDashboardComponent, * canActivate: [authGuard], * data: { requiredRole: 'Admin' } // Die erforderliche Rolle hier übergeben * } * { * path: 'profile', - * component: UserProfileComponent, * canActivate: [authGuard] // Schützt nur vor nicht angemeldeten Benutzern * } */ -export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { +export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot): boolean | UrlTree => { const authService = inject(AuthService); const router = inject(Router); - // 1. Prüfen, ob der Benutzer überhaupt angemeldet ist - if (!authService.getToken()) { - // Nicht angemeldet -> zum Login umleiten - router.navigate(['/login']); - return false; - } + const isLoggedIn = !!authService.getToken(); + const requiredRole = route.data['requiredRole'] as string | undefined; - // 2. Prüfen, ob die Route eine bestimmte Rolle erfordert - const requiredRole = route.data['requiredRole'] as string; - if (requiredRole) { - // Rolle ist erforderlich -> prüfen, ob der Benutzer sie hat - if (authService.hasRole(requiredRole)) { - // Benutzer hat die Rolle -> Zugriff erlaubt + // Fall 1: Route ist nicht geschützt oder der Benutzer ist angemeldet und hat die richtige Rolle + if (isLoggedIn) { + if (!requiredRole || authService.hasRole(requiredRole)) { + // Zugriff erlaubt return true; } else { - // Benutzer hat die Rolle nicht -> zu einer "Zugriff verweigert"-Seite umleiten - router.navigate(['/access-denied']); - return false; + // Angemeldet, aber falsche Rolle -> Zugriff verweigert + // Leite zu einer 'Forbidden'-Seite um oder zur Startseite + return router.createUrlTree(['/forbidden']); } } - // 3. Wenn die Route nur eine Anmeldung, aber keine spezielle Rolle erfordert - return true; + // Fall 2: Nicht angemeldet + // Leite zur Login-Seite um und speichere die Ziel-URL für eine spätere Weiterleitung + return router.createUrlTree(['/auth/login'], { queryParams: { returnUrl: route.url.join('/') } }); }; \ No newline at end of file diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..5ed9e5b --- /dev/null +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -0,0 +1,30 @@ +// /src/app/core/interceptors/auth.interceptor.ts + +import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { AuthService } from '../services/auth.service'; +import { API_URL } from '../tokens/api-url.token'; + +export const authInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn +): Observable> => { + + const authService = inject(AuthService); + const apiUrl = inject(API_URL); + const token = authService.getToken(); + + // Den Interceptor nur auf Anfragen an unsere eigene API anwenden + if (token && req.url.startsWith(apiUrl)) { + // Request klonen und den Authorization-Header hinzufügen + const clonedReq = req.clone({ + headers: req.headers.set('Authorization', `Bearer ${token}`), + }); + return next(clonedReq); + } + + // Für alle anderen Anfragen (oder wenn kein Token vorhanden ist), + // den ursprünglichen Request weiterleiten. + return next(req); +}; \ No newline at end of file diff --git a/src/app/core/models/auth.models.ts b/src/app/core/models/auth.models.ts index f49fba0..69d6080 100644 --- a/src/app/core/models/auth.models.ts +++ b/src/app/core/models/auth.models.ts @@ -1,15 +1,47 @@ -// src/app/core/models/auth.models.ts +// /src/app/core/models/auth.model.ts -export interface LoginRequestDto { +export interface LoginRequest { email: string; password: string; } -export interface AuthResponseDto { +export interface RegisterRequest extends LoginRequest { + firstName: string; + lastName: string; + confirmPassword: string; +} + +export interface AuthResponse { isAuthSuccessful: boolean; - errorMessage?: string; token?: string; userId?: string; email?: string; roles?: string[]; + errorMessage?: string; +} + +export interface ChangePasswordRequest { + oldPassword: string; + newPassword: string; + confirmNewPassword: string; +} + +export interface ChangeEmailRequest { + newEmail: string; + currentPassword: string; +} + +export interface ForgotPasswordRequest { + email: string; +} + +export interface ResetPassword { + email: string; + token: string; + newPassword: string; + confirmPassword: string; +} + +export interface ResendEmailConfirmationRequest { + email: string; } \ No newline at end of file diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 33bf0c8..a5b522f 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -1,121 +1,96 @@ -import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; +// /src/app/core/services/auth.service.ts + +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { isPlatformBrowser } from '@angular/common'; import { Router } from '@angular/router'; import { Observable, BehaviorSubject, of } from 'rxjs'; -import { tap, catchError } from 'rxjs/operators'; +import { tap, catchError, map } from 'rxjs/operators'; -import { LoginRequestDto } from '../models/auth.models'; -import { AuthResponseDto } from '../models/auth.models'; +import { LoginRequest, AuthResponse, RegisterRequest } from '../models/auth.models' +import { API_URL } from '../tokens/api-url.token'; @Injectable({ providedIn: 'root' }) export class AuthService { - // Basis-URL Ihrer API - private apiUrl = '/api/v1/Auth'; - - // Keys für die Speicherung im localStorage - private readonly tokenKey = 'auth-token'; - private readonly userRolesKey = 'auth-user-roles'; + // --- Injizierte Abhängigkeiten mit moderner Syntax --- + private http = inject(HttpClient); + private router = inject(Router); + private platformId = inject(PLATFORM_ID); + private apiUrl = inject(API_URL); // <-- SAUBERE INJEKTION! - // Ein BehaviorSubject, um den Login-Status reaktiv in der App zu teilen - private loggedInStatus = new BehaviorSubject(this.hasToken()); + private readonly endpoint = '/Auth'; + + // Keys für die Speicherung im localStorage + private readonly TOKEN_KEY = 'auth-token'; + private readonly ROLES_KEY = 'auth-user-roles'; + + private loggedInStatus = new BehaviorSubject(this.isBrowser() && !!this.getToken()); public isLoggedIn$ = this.loggedInStatus.asObservable(); - constructor( - private http: HttpClient, - private router: Router, - @Inject(PLATFORM_ID) private platformId: Object - ) {} - - /** - * Meldet einen Admin-Benutzer an. - */ - loginAdmin(credentials: LoginRequestDto): Observable { - return this.http.post(`${this.apiUrl}/login/admin`, credentials).pipe( - tap(response => { - if (response.isAuthSuccessful && response.token) { - this.setSession(response); - } - }), - catchError(error => { - // Fehlerbehandlung, z.B. das Token löschen, falls eines vorhanden war - this.logout(); - throw error; + loginAdmin(credentials: LoginRequest): Observable { + return this.http.post(`${this.apiUrl}${this.endpoint}/login/admin`, credentials).pipe( + tap(response => this.setSession(response)), + catchError(() => { + this.clearSession(); // Bei fehlgeschlagenem Login immer die Session aufräumen + return of(null); // Gib ein "leeres" Observable zurück statt einen Fehler zu werfen }) ); } - /** - * Meldet einen normalen Kunden an. - */ - loginCustomer(credentials: LoginRequestDto): Observable { - return this.http.post(`${this.apiUrl}/login/customer`, credentials).pipe( - tap(response => { - if (response.isAuthSuccessful && response.token) { - this.setSession(response); - } + loginCustomer(credentials: LoginRequest): Observable { + return this.http.post(`${this.apiUrl}${this.endpoint}/login/customer`, credentials).pipe( + tap(response => this.setSession(response)), + catchError(() => { + this.clearSession(); + return of(null); }) ); } - /** - * Meldet den Benutzer ab, entfernt die Daten aus dem Speicher und leitet um. - */ + register(data: RegisterRequest): Observable<{ message: string } | null> { + return this.http.post<{ message: string }>(`${this.apiUrl}${this.endpoint}/register`, data).pipe( + catchError(() => of(null)) + ); + } + logout(): void { - if (isPlatformBrowser(this.platformId)) { - localStorage.removeItem(this.tokenKey); - localStorage.removeItem(this.userRolesKey); - } - this.loggedInStatus.next(false); - this.router.navigate(['/login']); // oder wohin auch immer Sie umleiten möchten + this.clearSession(); + this.router.navigate(['/auth/login']); // Empfehlung: Routen gruppieren } - /** - * Gibt das gespeicherte JWT-Token zurück. - */ getToken(): string | null { - if (isPlatformBrowser(this.platformId)) { - return localStorage.getItem(this.tokenKey); - } - return null; + return this.isBrowser() ? localStorage.getItem(this.TOKEN_KEY) : null; } - /** - * Gibt die Rollen des angemeldeten Benutzers zurück. - */ getUserRoles(): string[] { - if (isPlatformBrowser(this.platformId)) { - const roles = localStorage.getItem(this.userRolesKey); - return roles ? JSON.parse(roles) : []; - } - return []; + if (!this.isBrowser()) return []; + const roles = localStorage.getItem(this.ROLES_KEY); + return roles ? JSON.parse(roles) : []; } - /** - * Überprüft, ob der Benutzer eine bestimmte Rolle hat. - */ hasRole(requiredRole: string): boolean { - const userRoles = this.getUserRoles(); - return userRoles.includes(requiredRole); + return this.getUserRoles().includes(requiredRole); } - /** - * Private Methode zum Speichern der Sitzungsdaten. - */ - private setSession(authResponse: AuthResponseDto): void { - if (isPlatformBrowser(this.platformId) && authResponse.token && authResponse.roles) { - localStorage.setItem(this.tokenKey, authResponse.token); - localStorage.setItem(this.userRolesKey, JSON.stringify(authResponse.roles)); + private setSession(authResponse: AuthResponse): void { + if (this.isBrowser() && authResponse?.token && authResponse?.roles) { + localStorage.setItem(this.TOKEN_KEY, authResponse.token); + localStorage.setItem(this.ROLES_KEY, JSON.stringify(authResponse.roles)); this.loggedInStatus.next(true); } } - /** - * Private Methode, die prüft, ob ein Token vorhanden ist (nützlich beim App-Start). - */ - private hasToken(): boolean { - return !!this.getToken(); + private clearSession(): void { + if (this.isBrowser()) { + localStorage.removeItem(this.TOKEN_KEY); + localStorage.removeItem(this.ROLES_KEY); + } + this.loggedInStatus.next(false); + } + + private isBrowser(): boolean { + return isPlatformBrowser(this.platformId); } } \ No newline at end of file diff --git a/src/app/core/services/logging.service.ts b/src/app/core/services/logging.service.ts new file mode 100644 index 0000000..e1d9769 --- /dev/null +++ b/src/app/core/services/logging.service.ts @@ -0,0 +1,48 @@ +// /src/app/core/services/logging.service.ts + +import { Injectable } from '@angular/core'; +import { environment } from '../../environment'; + +export enum LogLevel { + Info, + Warn, + Error +} + +@Injectable({ + providedIn: 'root' +}) +export class LoggingService { + + log(level: LogLevel, message: string, data?: any) { + // In der Produktion werden nur Warnungen und Fehler geloggt + if (!environment.production || level > LogLevel.Info) { + const logEntry = `[${LogLevel[level]}] ${new Date().toISOString()}: ${message}`; + + switch (level) { + case LogLevel.Info: + console.info(logEntry, data || ''); + break; + case LogLevel.Warn: + console.warn(logEntry, data || ''); + break; + case LogLevel.Error: + console.error(logEntry, data || ''); + break; + } + } + // HIER KÖNNTE SPÄTER EINE LOGIK ZUM SENDEN AN EINEN SERVER STEHEN + } + + info(message: string, data?: any) { + this.log(LogLevel.Info, message, data); + } + + warn(message: string, data?: any) { + this.log(LogLevel.Warn, message, data); + } + + error(message: string, data?: any) { + this.log(LogLevel.Error, message, data); + } +} \ No newline at end of file diff --git a/src/app/features/components/auth/login/login.component.css b/src/app/features/components/auth/login/login.component.css index e69de29..ced3778 100644 --- a/src/app/features/components/auth/login/login.component.css +++ b/src/app/features/components/auth/login/login.component.css @@ -0,0 +1,15 @@ +.error-message-container { + width: 100%; + background-color: #f8d7da; /* Helles Rot, anpassbar an dein Design */ + border: 1px solid #f5c6cb; /* Dunkleres Rot */ + border-radius: 4px; + padding: 1rem; + margin-bottom: 1.5rem; /* Abstand zum Button */ + text-align: center; +} + +.error-message { + color: #721c24; /* Dunkelroter Text */ + margin: 0; + font-size: 0.9rem; +} \ No newline at end of file diff --git a/src/app/features/components/auth/login/login.component.html b/src/app/features/components/auth/login/login.component.html index 64e98f2..ac14155 100644 --- a/src/app/features/components/auth/login/login.component.html +++ b/src/app/features/components/auth/login/login.component.html @@ -1,8 +1,11 @@ + +

Anmelden

Bitte geben Sie Ihre Daten ein, um fortzufahren.

+
Passwort vergessen? + +
+

{{ errorMessage }}

+
+ + + [disabled]="loginForm.invalid || isLoading" + [isLoading]="isLoading"> Anmelden @@ -33,7 +43,8 @@ + iconName="placeholder" + [disabled]="isLoading"> Mit Google anmelden diff --git a/src/app/features/components/auth/login/login.component.ts b/src/app/features/components/auth/login/login.component.ts index 1354bf3..c58d130 100644 --- a/src/app/features/components/auth/login/login.component.ts +++ b/src/app/features/components/auth/login/login.component.ts @@ -1,16 +1,19 @@ -import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { - ReactiveFormsModule, - FormBuilder, - Validators, - FormGroup, -} from '@angular/forms'; -import { RouterLink } from '@angular/router'; +// /src/app/features/components/auth/login/login.component.ts -// Importieren der wiederverwendbaren Komponenten +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { finalize } from 'rxjs'; + +// Services +import { AuthService } from '../../../../core/services/auth.service'; +import { LoggingService } from '../../../../core/services/logging.service'; + +// UI Komponenten import { ButtonComponent } from '../../../../shared/components/ui/button/button.component'; import { FormFieldComponent } from '../../../../shared/components/form/form-field/form-field.component'; +import { LoginRequest } from '../../../../core/models/auth.models'; @Component({ selector: 'app-login', @@ -27,22 +30,55 @@ import { FormFieldComponent } from '../../../../shared/components/form/form-fiel }) export class LoginComponent { loginForm: FormGroup; + isLoading = false; + errorMessage: string | null = null; - + // Moderne Dependency Injection mit inject() + private fb = inject(FormBuilder); + private authService = inject(AuthService); + private router = inject(Router); + private logger = inject(LoggingService); - constructor(private fb: FormBuilder) { + constructor() { this.loginForm = this.fb.group({ - email: ['', [Validators.required, Validators.email]], - password: ['', [Validators.required]], + email: ['admin@yourwebshop.com', [Validators.required, Validators.email]], + password: ['SecureAdminPass123!', [Validators.required]], }); } onSubmit() { - if (this.loginForm.valid) { - console.log('Formular abgeschickt:', this.loginForm.value); - } else { - console.log('Formular ist ungültig.'); + // Formular erneut prüfen und mehrfaches Absenden verhindern + if (this.loginForm.invalid || this.isLoading) { this.loginForm.markAllAsTouched(); + return; } + + this.isLoading = true; + this.errorMessage = null; + + const credentials: LoginRequest = this.loginForm.value; + + // Wir rufen den zentralen AuthService auf + this.authService.loginAdmin(credentials).pipe( + // finalize wird immer ausgeführt, egal ob erfolgreich oder nicht + finalize(() => this.isLoading = false) + ).subscribe({ + next: (response) => { + if (response && response.isAuthSuccessful) { + this.logger.info('Admin login successful', { email: credentials.email }); + // Erfolgreich eingeloggt -> Weiterleiten zum Admin-Dashboard + this.router.navigate(['/dashboard']); // Passe die Route ggf. an + } else { + // Login fehlgeschlagen (falsches Passwort etc.), vom Backend kontrolliert + this.errorMessage = 'E-Mail oder Passwort ist ungültig.'; + this.logger.warn('Admin login failed: Invalid credentials', { email: credentials.email }); + } + }, + error: (err) => { + // Unerwarteter Fehler (z.B. Server nicht erreichbar) + this.errorMessage = 'Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.'; + this.logger.error('An unexpected error occurred during admin login', err); + } + }); } -} +} \ No newline at end of file diff --git a/src/app/shared/components/ui/button/button.component.css b/src/app/shared/components/ui/button/button.component.css index 5317d80..d29295a 100644 --- a/src/app/shared/components/ui/button/button.component.css +++ b/src/app/shared/components/ui/button/button.component.css @@ -134,3 +134,42 @@ visibility: visible; transform: translateX(-50%) translateY(-12px); } + + +.btn.is-loading { + cursor: wait; +} + +.btn-content.is-hidden { + visibility: hidden; + opacity: 0; +} + +/* Der Lade-Spinner */ +.spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 1.2rem; + height: 1.2rem; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; /* Farbe des sich drehenden Teils */ + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Spinner-Farbe für nicht-primäre Buttons anpassen */ +.btn-secondary .spinner, +.btn-stroked .spinner, +.btn-flat .spinner, +.btn-icon .spinner { + border: 2px solid rgba(0, 0, 0, 0.1); + border-top-color: var(--color-primary); +} + +@keyframes spin { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} diff --git a/src/app/shared/components/ui/button/button.component.html b/src/app/shared/components/ui/button/button.component.html index 2c034a2..4c205f9 100644 --- a/src/app/shared/components/ui/button/button.component.html +++ b/src/app/shared/components/ui/button/button.component.html @@ -1,6 +1,8 @@ + + +
+ +
+ + + + +
+ \ No newline at end of file diff --git a/src/app/shared/components/ui/button/button.component.ts b/src/app/shared/components/ui/button/button.component.ts index 5718f60..0528ab8 100644 --- a/src/app/shared/components/ui/button/button.component.ts +++ b/src/app/shared/components/ui/button/button.component.ts @@ -19,6 +19,7 @@ export class ButtonComponent { @Input() fullWidth = false; @Input() iconName: string | null = null; @Input() svgColor: string | null = null; + @Input() isLoading = false; // Der Tooltip-Input @Input() tooltip: string | null = null;