SendPasswordResetEmailAsync(string toEmail, string resetUrl, CancellationToken ct = default)
+ {
+ if (!_settings.Enabled)
+ {
+ _logger.LogInformation("[EMAIL DISABLED] Password reset - To: {Email}, URL: {Url}", toEmail, resetUrl);
+ return false;
+ }
+
+ try
+ {
+ var senderEmail = _settings.DefaultSenderEmail;
+ var senderName = "Koogle";
+
+ var subject = "Passwort zurücksetzen - Koogle";
+ var body = $@"
+
+
+Passwort zurücksetzen
+Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.
+Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
+{resetUrl}
+Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.
+Der Link ist aus Sicherheitsgründen nur für eine begrenzte Zeit gültig.
+Viele Grüße,
Koogle
+
+";
+
+ await SendEmailAsync(senderEmail, senderName, toEmail, subject, body, ct);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to send password reset email to {Email}", toEmail);
+ return false;
+ }
+ }
+
private string GetSenderEmail(Club club)
{
var parts = _settings.DefaultSenderEmail.Split('@');
diff --git a/src/Koogle.Web/Controllers/AuthController.cs b/src/Koogle.Web/Controllers/AuthController.cs
index 087f8d9..f8d670a 100644
--- a/src/Koogle.Web/Controllers/AuthController.cs
+++ b/src/Koogle.Web/Controllers/AuthController.cs
@@ -1,6 +1,7 @@
using Fluxor;
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
+using Koogle.Domain.Interfaces;
using Koogle.Infrastructure.Identity;
using Koogle.Web.Store.AuthState;
using Microsoft.AspNetCore.Components;
@@ -17,10 +18,11 @@ namespace Koogle.Web.Controllers
/// Aus dem Grund werden Login/Logout Aktionen hier im MVC Controller mit einem "normalen" Post-Request abgewickelt.
///
[Microsoft.AspNetCore.Mvc.Route("auth")]
- public class AuthController(IDispatcher dispatcher, IUserService userService) : Controller
+ public class AuthController(IDispatcher dispatcher, IUserService userService, IEmailService emailService) : Controller
{
private readonly IDispatcher _dispatcher = dispatcher;
private readonly IUserService _userService = userService;
+ private readonly IEmailService _emailService = emailService;
[HttpPost("login")]
@@ -90,10 +92,9 @@ namespace Koogle.Web.Controllers
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}");
+ var baseUrl = $"{Request.Scheme}://{Request.Host}";
+ var resetUrl = $"{baseUrl}/account/reset-password?email={Uri.EscapeDataString(email)}&token={Uri.EscapeDataString(token)}";
+ await _emailService.SendPasswordResetEmailAsync(email, resetUrl);
}
return LocalRedirect("/account/forgot-password?success=true");
diff --git a/test/Koogle.Tests/Integration/AuthControllerTests.cs b/test/Koogle.Tests/Integration/AuthControllerTests.cs
index 9f7199f..918438c 100644
--- a/test/Koogle.Tests/Integration/AuthControllerTests.cs
+++ b/test/Koogle.Tests/Integration/AuthControllerTests.cs
@@ -1,6 +1,7 @@
using Fluxor;
using Koogle.Application.DTOs;
using Koogle.Application.Interfaces;
+using Koogle.Domain.Interfaces;
using Koogle.Web.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
@@ -16,18 +17,23 @@ public class AuthControllerTests
{
private readonly Mock _dispatcherMock;
private readonly Mock _userServiceMock;
+ private readonly Mock _emailServiceMock;
private readonly AuthController _sut;
public AuthControllerTests()
{
_dispatcherMock = new Mock();
_userServiceMock = new Mock();
- _sut = new AuthController(_dispatcherMock.Object, _userServiceMock.Object);
+ _emailServiceMock = new Mock();
+ _sut = new AuthController(_dispatcherMock.Object, _userServiceMock.Object, _emailServiceMock.Object);
- // Setup controller context
+ // Setup controller context with request info for password reset URL building
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("localhost");
_sut.ControllerContext = new ControllerContext
{
- HttpContext = new DefaultHttpContext()
+ HttpContext = httpContext
};
}
@@ -241,6 +247,8 @@ public class AuthControllerTests
// Arrange
_userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync("reset-token");
+ _emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
// Act
await _sut.ForgotPassword("test@example.com");
@@ -249,6 +257,42 @@ public class AuthControllerTests
_userServiceMock.Verify(s => s.RequestPasswordResetAsync("test@example.com", It.IsAny()), Times.Once);
}
+ [Fact]
+ public async Task ForgotPassword_SendsEmail_WhenTokenGenerated()
+ {
+ // Arrange
+ _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync("reset-token");
+ _emailServiceMock.Setup(s => s.SendPasswordResetEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ // Act
+ await _sut.ForgotPassword("test@example.com");
+
+ // Assert
+ _emailServiceMock.Verify(s => s.SendPasswordResetEmailAsync(
+ "test@example.com",
+ It.Is(url => url.Contains("reset-password") && url.Contains("token=")),
+ It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task ForgotPassword_DoesNotSendEmail_WhenNoToken()
+ {
+ // Arrange - No user found, no token generated
+ _userServiceMock.Setup(s => s.RequestPasswordResetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((string?)null);
+
+ // Act
+ await _sut.ForgotPassword("nonexistent@example.com");
+
+ // Assert - Email should not be sent for non-existent users
+ _emailServiceMock.Verify(s => s.SendPasswordResetEmailAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
#endregion
#region ResetPassword Tests