refactor - login und auth

This commit is contained in:
Tizian.Breuch
2025-09-26 15:34:19 +02:00
parent 7d10ecf1cc
commit f7147f281a
14 changed files with 363 additions and 158 deletions

View File

@@ -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 }
]
};

View File

@@ -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: '',

View File

@@ -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 = [
];

View File

@@ -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('/') } });
};

View File

@@ -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<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
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);
};

View File

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

View File

@@ -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<boolean>(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<boolean>(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<AuthResponseDto> {
return this.http.post<AuthResponseDto>(`${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<AuthResponse | null> {
return this.http.post<AuthResponse>(`${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<AuthResponseDto> {
return this.http.post<AuthResponseDto>(`${this.apiUrl}/login/customer`, credentials).pipe(
tap(response => {
if (response.isAuthSuccessful && response.token) {
this.setSession(response);
}
loginCustomer(credentials: LoginRequest): Observable<AuthResponse | null> {
return this.http.post<AuthResponse>(`${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);
}
}

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
<!-- /src/app/features/components/auth/login/login.component.html -->
<div class="component-header">
<h2 class="auth-title">Anmelden</h2>
<p class="auth-subtitle">Bitte geben Sie Ihre Daten ein, um fortzufahren.</p>
</div>
<!-- novalidate verhindert die Standard-Browser-Validierung, sodass unser Angular-Feedback die volle Kontrolle hat -->
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" novalidate>
<app-form-field
label="E-Mail-Adresse"
@@ -20,11 +23,18 @@
<a routerLink="/auth/forgot-password" class="link">Passwort vergessen?</a>
</div>
<!-- +++ NEU: Container für Fehlermeldungen +++ -->
<div *ngIf="errorMessage" class="error-message-container">
<p class="error-message">{{ errorMessage }}</p>
</div>
<!-- +++ ENDE NEU +++ -->
<app-button
submitType="submit"
buttonType="primary"
[fullWidth]="true"
[disabled]="loginForm.invalid">
[disabled]="loginForm.invalid || isLoading"
[isLoading]="isLoading">
Anmelden
</app-button>
@@ -33,7 +43,8 @@
<app-button
buttonType="secondary"
[fullWidth]="true"
iconName="placeholder">
iconName="placeholder"
[disabled]="isLoading">
Mit Google anmelden
</app-button>
</form>

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<!-- /src/app/shared/components/ui/button/button.component.html -->
<button
[type]="submitType"
[disabled]="disabled"
[disabled]="disabled || isLoading"
class="btn"
[class.btn-primary]="buttonType === 'primary'"
[class.btn-secondary]="buttonType === 'secondary'"
@@ -9,7 +11,14 @@
[class.btn-icon]="buttonType === 'icon' || buttonType === 'icon-danger'"
[class.btn-icon-danger]="buttonType === 'icon-danger'"
[class.btn-full-width]="fullWidth"
[class.is-loading]="isLoading"
>
<app-icon *ngIf="iconName" [iconName]="iconName" [svgColor]="svgColor"></app-icon>
<ng-content></ng-content>
</button>
<div *ngIf="isLoading" class="spinner"></div>
<div class="btn-content" [class.is-hidden]="isLoading">
<app-icon *ngIf="iconName" [iconName]="iconName" [svgColor]="svgColor"></app-icon>
<span>
<ng-content></ng-content>
</span>
</div>
</button>

View File

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