diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 293e8aa..33dc782 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -332,7 +332,7 @@ NavMenu.razor: | ✓ | A7 | Foundation | DI Registration | 2 DI-Dateien ändern | | ✓ | B1 | User/Account | UserService erweitern | 1 Service, 1 Interface, 1 DTO | | ✓ | B2 | User/Account | Register Page | 1 Razor | -| ☐ | B3 | User/Account | Password Reset Pages | 2 Razor | +| ✓ | B3 | User/Account | Password Reset Pages | 2 Razor | | ☐ | B4 | User/Account | Profile Page | 1 Razor | | ☐ | B5 | User/Account | Admin Users Page | 1 Razor | | ☐ | **C1** | **Clubs** | **ClubState Fluxor** | **4 State-Dateien** | diff --git a/src/Koogle.Application/DTOs/AuthDto.cs b/src/Koogle.Application/DTOs/AuthDto.cs index 6cee846..88f1bda 100644 --- a/src/Koogle.Application/DTOs/AuthDto.cs +++ b/src/Koogle.Application/DTOs/AuthDto.cs @@ -1,18 +1,101 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Koogle.Application.DTOs { + /// + /// Form DTO for user login. + /// public record LoginDto { + /// + /// Email of the user requesting login. + /// + [Required] + [EmailAddress] public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; public string ClubName { get; set; } = string.Empty; public bool RememberMe { get; set; } public string? ReturnUrl { get; set; } } + + /// + /// Form DTO for password reset with confirmation field. + /// + public class ResetPasswordFormDto + { + /// + /// Email of the user requesting password reset. + /// + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + public string Token { get; set; } = ""; + public string NewPassword { get; set; } = ""; + public string ConfirmPassword { get; set; } = ""; + } + + /// + /// DTO for user registration. + /// + public class RegisterUserDto + { + /// + /// Email address (also used as username). + /// + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + + /// + /// Password for the new account. + /// + [Required] + [MinLength(6)] + public string Password { get; set; } = ""; + + /// + /// Display name for the user profile. + /// + [Required] + public string DisplayName { get; set; } = ""; + + /// + /// Optional club name to assign user to during registration. + /// + public string? ClubName { get; set; } + } + + /// + /// DTO for password reset request. + /// + public class ResetPasswordDto + { + /// + /// Email of the user requesting password reset. + /// + [Required] + [EmailAddress] + public string Email { get; set; } = ""; + + /// + /// Password reset token received via email. + /// + [Required] + public string Token { get; set; } = ""; + + /// + /// New password. + /// + [Required] + [MinLength(6)] + public string NewPassword { get; set; } = ""; + } } diff --git a/src/Koogle.Application/DTOs/UserDto.cs b/src/Koogle.Application/DTOs/UserDto.cs index ce41e0d..7a2c55c 100644 --- a/src/Koogle.Application/DTOs/UserDto.cs +++ b/src/Koogle.Application/DTOs/UserDto.cs @@ -70,62 +70,6 @@ namespace Koogle.Application.DTOs Koogle.Domain.Entities.UserProfile Profile, Koogle.Infrastructure.Identity.ApplicationUser Identity); - /// - /// DTO for user registration. - /// - public class RegisterUserDto - { - /// - /// Email address (also used as username). - /// - [Required] - [EmailAddress] - public string Email { get; set; } = ""; - - /// - /// Password for the new account. - /// - [Required] - [MinLength(6)] - public string Password { get; set; } = ""; - - /// - /// Display name for the user profile. - /// - [Required] - public string DisplayName { get; set; } = ""; - - /// - /// Optional club name to assign user to during registration. - /// - public string? ClubName { get; set; } - } - - /// - /// DTO for password reset request. - /// - public class ResetPasswordDto - { - /// - /// Email of the user requesting password reset. - /// - [Required] - [EmailAddress] - public string Email { get; set; } = ""; - - /// - /// Password reset token received via email. - /// - [Required] - public string Token { get; set; } = ""; - - /// - /// New password. - /// - [Required] - [MinLength(6)] - public string NewPassword { get; set; } = ""; - } /// /// DTO for updating user profile. diff --git a/src/Koogle.Web/Components/Pages/Account/ForgotPassword.razor b/src/Koogle.Web/Components/Pages/Account/ForgotPassword.razor new file mode 100644 index 0000000..2a871e1 --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Account/ForgotPassword.razor @@ -0,0 +1,85 @@ +@page "/account/forgot-password" + +@using Microsoft.AspNetCore.WebUtilities + +@inject NavigationManager NavigationManager +@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery +@inject IHttpContextAccessor HttpContextAccessor + +
+ + + + + @if (_success) + { + + Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. + + } + + @if (!string.IsNullOrWhiteSpace(_error)) + { + @_error + } + + + Passwort vergessen + + + + Gib deine E-Mail-Adresse ein. Wenn ein Konto existiert, senden wir dir einen Link zum Zuruecksetzen. + + + + + + Reset-Link anfordern + + + + Zurueck zur Anmeldung + + + +
+ + +@code { + private string? _error; + private bool _success; + private string _antiToken = ""; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("error", out var err)) + { + _error = err.ToString(); + } + + if (query.TryGetValue("success", out _)) + { + _success = true; + } + + // Antiforgery Token generieren + var http = HttpContextAccessor.HttpContext!; + var tokens = Antiforgery.GetAndStoreTokens(http); + _antiToken = tokens.RequestToken!; + } +} diff --git a/src/Koogle.Web/Components/Pages/Account/Login.razor b/src/Koogle.Web/Components/Pages/Account/Login.razor index 21d0aa8..da4780a 100644 --- a/src/Koogle.Web/Components/Pages/Account/Login.razor +++ b/src/Koogle.Web/Components/Pages/Account/Login.razor @@ -68,9 +68,14 @@ Anmelden - - Noch kein Konto? Registrieren - + + + Noch kein Konto? Registrieren + + + Passwort vergessen? + + diff --git a/src/Koogle.Web/Components/Pages/Account/ResetPassword.razor b/src/Koogle.Web/Components/Pages/Account/ResetPassword.razor new file mode 100644 index 0000000..5c1ef9b --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Account/ResetPassword.razor @@ -0,0 +1,173 @@ +@page "/account/reset-password" + +@using Microsoft.AspNetCore.WebUtilities + +@inject NavigationManager NavigationManager +@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery +@inject IHttpContextAccessor HttpContextAccessor + +@if (_success) +{ + + + Passwort wurde erfolgreich zurueckgesetzt. + + + Zur Anmeldung + + +} +else if (_invalidToken) +{ + + + Ungueltiger oder abgelaufener Reset-Link. + + + Neuen Link anfordern + + +} +else +{ +
+ + + + + + + @if (!string.IsNullOrWhiteSpace(_error)) + { + @_error + } + + + Neues Passwort setzen + + + + E-Mail: @_email + + + + + + + + Passwort zuruecksetzen + + + + Zurueck zur Anmeldung + + + +
+} + + +@code { + private string? _error; + private bool _success; + private bool _invalidToken; + private string _email = ""; + private string _token = ""; + private string _antiToken = ""; + + protected override void OnInitialized() + { + var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("email", out var email)) + { + _email = email.ToString(); + } + + if (query.TryGetValue("token", out var token)) + { + _token = token.ToString(); + } + + if (query.TryGetValue("success", out _)) + { + _success = true; + } + + if (query.TryGetValue("error", out var err)) + { + var errorStr = err.ToString(); + if (errorStr == "InvalidToken") + { + _invalidToken = true; + } + else + { + _error = MapErrorCodes(errorStr); + } + } + + // Missing required params + if (string.IsNullOrWhiteSpace(_email) || string.IsNullOrWhiteSpace(_token)) + { + if (!_success) + { + _invalidToken = true; + } + } + + // Antiforgery Token generieren + var http = HttpContextAccessor.HttpContext!; + var tokens = Antiforgery.GetAndStoreTokens(http); + _antiToken = tokens.RequestToken!; + } + + private string MapErrorCodes(string errorCodes) + { + if (string.IsNullOrWhiteSpace(errorCodes)) + return "Passwort-Reset fehlgeschlagen"; + + var codes = errorCodes.Split(','); + var messages = new List(); + + foreach (var code in codes) + { + var msg = code.Trim() switch + { + "InvalidToken" => "Ungueltiger oder abgelaufener Token", + "PasswordTooShort" => "Passwort zu kurz", + "PasswordRequiresNonAlphanumeric" => "Passwort erfordert Sonderzeichen", + "PasswordRequiresDigit" => "Passwort erfordert Zahl", + "PasswordRequiresUpper" => "Passwort erfordert Grossbuchstaben", + "PasswordRequiresLower" => "Passwort erfordert Kleinbuchstaben", + "PasswordMismatch" => "Passwoerter stimmen nicht ueberein", + "UserNotFound" => "Benutzer nicht gefunden", + _ => code + }; + messages.Add(msg); + } + + return string.Join(", ", messages); + } +} diff --git a/src/Koogle.Web/Controllers/AuthController.cs b/src/Koogle.Web/Controllers/AuthController.cs index 506293c..829e055 100644 --- a/src/Koogle.Web/Controllers/AuthController.cs +++ b/src/Koogle.Web/Controllers/AuthController.cs @@ -70,5 +70,57 @@ namespace Koogle.Web.Controllers return LocalRedirect($"/account/register?error={errors}"); } + /// + /// Handles password reset request (forgot password). + /// + [HttpPost("forgot-password")] + [ValidateAntiForgeryToken] + public async Task ForgotPassword([FromForm] string email) + { + // Always show success to prevent email enumeration + var token = await _userService.RequestPasswordResetAsync(email); + + if (token != null) + { + // TODO: Send email with reset link + // For now, log the token for development purposes + var resetUrl = $"/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}"; + Console.WriteLine($"[DEV] Password reset link: {resetUrl}"); + } + + return LocalRedirect("/account/forgot-password?success=true"); + } + + /// + /// Handles password reset with token. + /// + [HttpPost("reset-password")] + [ValidateAntiForgeryToken] + public async Task ResetPassword([FromForm] ResetPasswordFormDto input) + { + // Validate password confirmation + if (input.NewPassword != input.ConfirmPassword) + { + return LocalRedirect($"/account/reset-password?email={Uri.EscapeDataString(input.Email)}&token={Uri.EscapeDataString(input.Token)}&error=PasswordMismatch"); + } + + var dto = new ResetPasswordDto + { + Email = input.Email, + Token = input.Token, + NewPassword = input.NewPassword + }; + + var result = await _userService.ResetPasswordAsync(dto); + if (result.Succeeded) + { + return LocalRedirect("/account/reset-password?success=true"); + } + + var errors = string.Join(",", result.Errors.Select(e => e.Code)); + return LocalRedirect($"/account/reset-password?email={Uri.EscapeDataString(input.Email)}&token={Uri.EscapeDataString(input.Token)}&error={errors}"); + } } + + }