diff --git a/.env.example b/.env.example
index 5a32404..de6af12 100644
--- a/.env.example
+++ b/.env.example
@@ -14,3 +14,6 @@ MONGO_DB=fantasyrealm_logs
# pgAdmin Configuration
PGADMIN_EMAIL=contact@fantasy-realm.com
PGADMIN_PASSWORD=__PGADMIN_PASSWORD__
+
+# Frontend URL (used for email links)
+FRONTEND_URL=https://fantasy-realm.com
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 78d88a3..fc22cbd 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -63,6 +63,7 @@ services:
ConnectionStrings__PostgreSQL: "Host=postgres;Port=5432;Database=${POSTGRES_DB:-fantasyrealm};Username=${POSTGRES_USER:-fantasyrealm};Password=${POSTGRES_PASSWORD}"
ConnectionStrings__MongoDB: "mongodb://${MONGO_USER:-fantasyrealm}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB:-fantasyrealm_logs}?authSource=admin"
Email__Password: ${EMAIL_PASSWORD}
+ Email__BaseUrl: ${FRONTEND_URL:-http://localhost:5173}
depends_on:
postgres:
condition: service_healthy
diff --git a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
index d9c5b66..f1ac787 100644
--- a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
+++ b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs
@@ -1,5 +1,7 @@
+using System.IdentityModel.Tokens.Jwt;
using FantasyRealm.Application.DTOs;
using FantasyRealm.Application.Interfaces;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FantasyRealm.Api.Controllers
@@ -9,15 +11,8 @@ namespace FantasyRealm.Api.Controllers
///
[ApiController]
[Route("api/[controller]")]
- public sealed class AuthController : ControllerBase
+ public sealed class AuthController(IAuthService authService) : ControllerBase
{
- private readonly IAuthService _authService;
-
- public AuthController(IAuthService authService)
- {
- _authService = authService;
- }
-
///
/// Registers a new user account.
///
@@ -33,7 +28,7 @@ public AuthController(IAuthService authService)
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{
- var result = await _authService.RegisterAsync(request, cancellationToken);
+ var result = await authService.RegisterAsync(request, cancellationToken);
if (result.IsFailure)
{
@@ -58,7 +53,7 @@ public async Task Register([FromBody] RegisterRequest request, Ca
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
- var result = await _authService.LoginAsync(request, cancellationToken);
+ var result = await authService.LoginAsync(request, cancellationToken);
if (result.IsFailure)
{
@@ -85,7 +80,7 @@ public async Task Login([FromBody] LoginRequest request, Cancella
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken cancellationToken)
{
- var result = await _authService.ForgotPasswordAsync(request, cancellationToken);
+ var result = await authService.ForgotPasswordAsync(request, cancellationToken);
if (result.IsFailure)
{
@@ -94,5 +89,40 @@ public async Task ForgotPassword([FromBody] ForgotPasswordRequest
return Ok(new { message = "Un nouveau mot de passe a été envoyé à votre adresse email." });
}
+
+ ///
+ /// Changes the password for the authenticated user.
+ ///
+ /// The current and new password details.
+ /// Cancellation token.
+ /// A new JWT token upon successful password change.
+ /// Password changed successfully.
+ /// Invalid request data or password validation failed.
+ /// Not authenticated or current password is incorrect.
+ /// Account suspended.
+ [HttpPost("change-password")]
+ [Authorize]
+ [ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
+ {
+ var userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
+
+ if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId))
+ {
+ return Unauthorized(new { message = "Token invalide." });
+ }
+
+ var result = await authService.ChangePasswordAsync(userId, request, cancellationToken);
+
+ if (result.IsFailure)
+ {
+ return StatusCode(result.ErrorCode ?? 400, new { message = result.Error });
+ }
+
+ return Ok(result.Value);
+ }
}
}
diff --git a/src/backend/src/FantasyRealm.Api/Program.cs b/src/backend/src/FantasyRealm.Api/Program.cs
index 108fdbd..ff66c59 100644
--- a/src/backend/src/FantasyRealm.Api/Program.cs
+++ b/src/backend/src/FantasyRealm.Api/Program.cs
@@ -44,6 +44,7 @@ private static void Main(string[] args)
})
.AddJwtBearer(options =>
{
+ options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
diff --git a/src/backend/src/FantasyRealm.Api/appsettings.json b/src/backend/src/FantasyRealm.Api/appsettings.json
index 8318bb8..c365572 100644
--- a/src/backend/src/FantasyRealm.Api/appsettings.json
+++ b/src/backend/src/FantasyRealm.Api/appsettings.json
@@ -20,7 +20,8 @@
"Username": "noreply@fantasy-realm.com",
"Password": "",
"FromAddress": "noreply@fantasy-realm.com",
- "FromName": "FantasyRealm"
+ "FromName": "FantasyRealm",
+ "BaseUrl": "http://localhost:5173"
},
"Jwt": {
"Secret": "CHANGE_ME_IN_PRODUCTION_THIS_IS_A_DEV_SECRET_KEY_MIN_32_CHARS",
diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs
new file mode 100644
index 0000000..c093096
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace FantasyRealm.Application.DTOs
+{
+ ///
+ /// Request payload for changing user password.
+ ///
+ public sealed record ChangePasswordRequest(
+ [Required(ErrorMessage = "Le mot de passe actuel est requis.")]
+ string CurrentPassword,
+
+ [Required(ErrorMessage = "Le nouveau mot de passe est requis.")]
+ string NewPassword,
+
+ [Required(ErrorMessage = "La confirmation du mot de passe est requise.")]
+ string ConfirmNewPassword
+ );
+}
diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs
new file mode 100644
index 0000000..4e7f0b3
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs
@@ -0,0 +1,11 @@
+namespace FantasyRealm.Application.DTOs
+{
+ ///
+ /// Response payload for successful password change.
+ ///
+ public sealed record ChangePasswordResponse(
+ string Token,
+ DateTime ExpiresAt,
+ UserInfo User
+ );
+}
diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs
index 1dd20c1..abb94b3 100644
--- a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs
+++ b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs
@@ -31,5 +31,14 @@ public interface IAuthService
/// Cancellation token.
/// A result indicating success or an error.
Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Changes the password for an authenticated user.
+ ///
+ /// The ID of the authenticated user.
+ /// The change password request containing current and new passwords.
+ /// Cancellation token.
+ /// A result containing the new token or an error.
+ Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default);
}
}
diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs
index 7ef2976..81ca503 100644
--- a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs
+++ b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs
@@ -36,6 +36,14 @@ public interface IUserRepository
/// The user with their role, or null if not found.
Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default);
+ ///
+ /// Retrieves a user by ID, including their role.
+ ///
+ /// The user ID.
+ /// Cancellation token.
+ /// The user with their role, or null if not found.
+ Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default);
+
///
/// Retrieves a user by email and pseudo combination, including their role.
/// Used for password reset verification.
diff --git a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs
index 0c8cd42..e83881a 100644
--- a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs
+++ b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs
@@ -10,36 +10,19 @@ namespace FantasyRealm.Application.Services
///
/// Service implementation for authentication operations.
///
- public sealed class AuthService : IAuthService
+ public sealed class AuthService(
+ IUserRepository userRepository,
+ IPasswordHasher passwordHasher,
+ IPasswordGenerator passwordGenerator,
+ IEmailService emailService,
+ IJwtService jwtService,
+ ILogger logger) : IAuthService
{
private const string DefaultRole = "User";
private const string InvalidCredentialsMessage = "Identifiants incorrects.";
private const string AccountSuspendedMessage = "Votre compte a été suspendu.";
private const string UserNotFoundMessage = "Aucun compte ne correspond à ces informations.";
- private readonly IUserRepository _userRepository;
- private readonly IPasswordHasher _passwordHasher;
- private readonly IPasswordGenerator _passwordGenerator;
- private readonly IEmailService _emailService;
- private readonly IJwtService _jwtService;
- private readonly ILogger _logger;
-
- public AuthService(
- IUserRepository userRepository,
- IPasswordHasher passwordHasher,
- IPasswordGenerator passwordGenerator,
- IEmailService emailService,
- IJwtService jwtService,
- ILogger logger)
- {
- _userRepository = userRepository;
- _passwordHasher = passwordHasher;
- _passwordGenerator = passwordGenerator;
- _emailService = emailService;
- _jwtService = jwtService;
- _logger = logger;
- }
-
///
public async Task> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default)
{
@@ -55,19 +38,19 @@ public async Task> RegisterAsync(RegisterRequest reques
return Result.Failure(errorMessage, 400);
}
- var emailExists = await _userRepository.ExistsByEmailAsync(request.Email, cancellationToken);
+ var emailExists = await userRepository.ExistsByEmailAsync(request.Email, cancellationToken);
if (emailExists)
{
return Result.Failure("Cette adresse email est déjà utilisée.", 409);
}
- var pseudoExists = await _userRepository.ExistsByPseudoAsync(request.Pseudo, cancellationToken);
+ var pseudoExists = await userRepository.ExistsByPseudoAsync(request.Pseudo, cancellationToken);
if (pseudoExists)
{
return Result.Failure("Ce pseudo est déjà utilisé.", 409);
}
- var role = await _userRepository.GetRoleByLabelAsync(DefaultRole, cancellationToken);
+ var role = await userRepository.GetRoleByLabelAsync(DefaultRole, cancellationToken);
if (role is null)
{
return Result.Failure("Configuration error: default role not found.", 500);
@@ -77,24 +60,24 @@ public async Task> RegisterAsync(RegisterRequest reques
{
Email = request.Email.ToLowerInvariant().Trim(),
Pseudo = request.Pseudo.Trim(),
- PasswordHash = _passwordHasher.Hash(request.Password),
+ PasswordHash = passwordHasher.Hash(request.Password),
RoleId = role.Id,
IsSuspended = false,
MustChangePassword = false
};
- var createdUser = await _userRepository.CreateAsync(user, cancellationToken);
+ var createdUser = await userRepository.CreateAsync(user, cancellationToken);
_ = Task.Run(async () =>
{
try
{
- await _emailService.SendWelcomeEmailAsync(createdUser.Email, createdUser.Pseudo, CancellationToken.None);
- _logger.LogInformation("Welcome email sent to {Email}", createdUser.Email);
+ await emailService.SendWelcomeEmailAsync(createdUser.Email, createdUser.Pseudo, CancellationToken.None);
+ logger.LogInformation("Welcome email sent to {Email}", createdUser.Email);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to send welcome email to {Email}", createdUser.Email);
+ logger.LogWarning(ex, "Failed to send welcome email to {Email}", createdUser.Email);
}
}, CancellationToken.None);
@@ -112,30 +95,30 @@ public async Task> LoginAsync(LoginRequest request, Cancel
{
var normalizedEmail = request.Email.ToLowerInvariant().Trim();
- var user = await _userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken);
+ var user = await userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken);
if (user is null)
{
- _logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail);
+ logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail);
return Result.Failure(InvalidCredentialsMessage, 401);
}
- if (!_passwordHasher.Verify(request.Password, user.PasswordHash))
+ if (!passwordHasher.Verify(request.Password, user.PasswordHash))
{
- _logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id);
+ logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id);
return Result.Failure(InvalidCredentialsMessage, 401);
}
if (user.IsSuspended)
{
- _logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id);
+ logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id);
return Result.Failure(AccountSuspendedMessage, 403);
}
- var token = _jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label);
- var expiresAt = _jwtService.GetExpirationDate();
+ var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label);
+ var expiresAt = jwtService.GetExpirationDate();
- _logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email);
+ logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email);
var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label);
@@ -153,42 +136,101 @@ public async Task> ForgotPasswordAsync(ForgotPasswordRequest reques
var normalizedEmail = request.Email.ToLowerInvariant().Trim();
var normalizedPseudo = request.Pseudo.Trim();
- var user = await _userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken);
+ var user = await userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken);
if (user is null)
{
- _logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo);
+ logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo);
return Result.Failure(UserNotFoundMessage, 404);
}
if (user.IsSuspended)
{
- _logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id);
+ logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id);
return Result.Failure(AccountSuspendedMessage, 403);
}
- var temporaryPassword = _passwordGenerator.GenerateSecurePassword();
- user.PasswordHash = _passwordHasher.Hash(temporaryPassword);
+ var temporaryPassword = passwordGenerator.GenerateSecurePassword();
+ user.PasswordHash = passwordHasher.Hash(temporaryPassword);
user.MustChangePassword = true;
- await _userRepository.UpdateAsync(user, cancellationToken);
+ await userRepository.UpdateAsync(user, cancellationToken);
_ = Task.Run(async () =>
{
try
{
- await _emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None);
- _logger.LogInformation("Temporary password email sent to {Email}", user.Email);
+ await emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None);
+ logger.LogInformation("Temporary password email sent to {Email}", user.Email);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email);
+ logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email);
}
}, CancellationToken.None);
- _logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email);
+ logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email);
return Result.Success(Unit.Value);
}
+
+ ///
+ public async Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default)
+ {
+ var user = await userRepository.GetByIdWithRoleAsync(userId, cancellationToken);
+
+ if (user is null)
+ {
+ logger.LogWarning("Change password failed: user not found for ID {UserId}", userId);
+ return Result.Failure(InvalidCredentialsMessage, 401);
+ }
+
+ if (user.IsSuspended)
+ {
+ logger.LogWarning("Change password failed: account suspended for user {UserId}", userId);
+ return Result.Failure(AccountSuspendedMessage, 403);
+ }
+
+ if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash))
+ {
+ logger.LogWarning("Change password failed: invalid current password for user {UserId}", userId);
+ return Result.Failure("Le mot de passe actuel est incorrect.", 401);
+ }
+
+ if (request.NewPassword != request.ConfirmNewPassword)
+ {
+ return Result.Failure("Les nouveaux mots de passe ne correspondent pas.", 400);
+ }
+
+ var passwordValidation = PasswordValidator.Validate(request.NewPassword);
+ if (!passwordValidation.IsValid)
+ {
+ var errorMessage = string.Join(" ", passwordValidation.Errors);
+ return Result.Failure(errorMessage, 400);
+ }
+
+ if (passwordHasher.Verify(request.NewPassword, user.PasswordHash))
+ {
+ return Result.Failure("Le nouveau mot de passe doit être différent de l'ancien.", 400);
+ }
+
+ user.PasswordHash = passwordHasher.Hash(request.NewPassword);
+ user.MustChangePassword = false;
+
+ await userRepository.UpdateAsync(user, cancellationToken);
+
+ var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label);
+ var expiresAt = jwtService.GetExpirationDate();
+
+ logger.LogInformation("Password changed successfully for user {UserId} ({Email})", user.Id, user.Email);
+
+ var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label);
+
+ return Result.Success(new ChangePasswordResponse(
+ token,
+ expiresAt,
+ userInfo
+ ));
+ }
}
}
diff --git a/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep b/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep b/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
index 72dce71..3a84836 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
@@ -44,5 +44,10 @@ public class EmailSettings
/// Gets or sets the sender display name.
///
public string FromName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the base URL for links in email templates.
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
index 66aae67..dd5b275 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
@@ -8,9 +8,6 @@ namespace FantasyRealm.Infrastructure.Email
///
public static class EmailTemplates
{
- private const string BaseUrl = "https://fantasy-realm.com";
-
- // Dark fantasy color palette - aligned with official style guide v3.0
private const string DarkBg = "#0D0D0F"; // --dark-900
private const string CardBg = "#121110"; // --dark-800
private const string CardBorder = "#18181B"; // --dark-700
@@ -24,8 +21,9 @@ public static class EmailTemplates
/// Generates a welcome email template for new users.
///
/// The user's display name.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetWelcomeTemplate(string pseudo)
+ public static string GetWelcomeTemplate(string pseudo, string baseUrl)
{
return WrapInLayout(
"Bienvenue dans FantasyRealm !",
@@ -74,7 +72,7 @@ Partager vos créations avec la communauté
- {GetPrimaryButton("Créer mon premier personnage", $"{BaseUrl}/characters/create", "🛡️")}
+ {GetPrimaryButton("Créer mon premier personnage", $"{baseUrl}/characters/create", "🛡️")}
@@ -88,10 +86,11 @@ Partager vos créations avec la communauté
///
/// The user's display name.
/// The password reset token.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetPasswordResetTemplate(string pseudo, string resetToken)
+ public static string GetPasswordResetTemplate(string pseudo, string resetToken, string baseUrl)
{
- var resetUrl = $"{BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
+ var resetUrl = $"{baseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
return WrapInLayout(
"Réinitialisation de mot de passe",
$@"
@@ -130,8 +129,9 @@ Nous avons reçu une demande de réinitialisation de votre mot de passe.
///
/// The user's display name.
/// The generated temporary password.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword)
+ public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword, string baseUrl)
{
return WrapInLayout(
"Nouveau mot de passe temporaire",
@@ -172,7 +172,7 @@ Ce mot de passe est temporaire et ne devrait pas être réutilisé.
- {GetPrimaryButton("Se connecter", $"{BaseUrl}/login", "🔑")}
+ {GetPrimaryButton("Se connecter", $"{baseUrl}/login", "🔑")}
@@ -188,8 +188,9 @@ Ce mot de passe est temporaire et ne devrait pas être réutilisé.
///
///
The user's display name.
///
The approved character's name.
+ ///
The base URL for links in the email.
///
The HTML email body.
- public static string GetCharacterApprovedTemplate(string pseudo, string characterName)
+ public static string GetCharacterApprovedTemplate(string pseudo, string characterName, string baseUrl)
{
return WrapInLayout(
"Personnage approuvé !",
@@ -218,7 +219,7 @@ et approuvé par notre équipe de modération.
- {GetPrimaryButton("Voir mes personnages", $"{BaseUrl}/characters", "👥")}
+ {GetPrimaryButton("Voir mes personnages", $"{baseUrl}/characters", "👥")}
");
}
@@ -229,8 +230,9 @@ et approuvé par notre équipe de modération.
/// The user's display name.
/// The rejected character's name.
/// The rejection reason.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason)
+ public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason, string baseUrl)
{
return WrapInLayout(
"Personnage non approuvé",
@@ -266,7 +268,7 @@ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen.
- {GetPrimaryButton("Modifier mon personnage", $"{BaseUrl}/characters", "✏️")}
+ {GetPrimaryButton("Modifier mon personnage", $"{baseUrl}/characters", "✏️")}
");
}
@@ -276,8 +278,9 @@ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen.
///
/// The user's display name.
/// The character's name that was commented on.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetCommentApprovedTemplate(string pseudo, string characterName)
+ public static string GetCommentApprovedTemplate(string pseudo, string characterName, string baseUrl)
{
return WrapInLayout(
"Commentaire publié !",
@@ -304,7 +307,7 @@ Commentaire publié !
- {GetPrimaryButton("Explorer la galerie", $"{BaseUrl}/gallery", "🖼️")}
+ {GetPrimaryButton("Explorer la galerie", $"{baseUrl}/gallery", "🖼️")}
");
}
@@ -425,94 +428,94 @@ private static string GetPrimaryButton(string text, string url, string icon = ""
private static string WrapInLayout(string title, string content)
{
return $@"
-
-
-
-
-
- {Encode(title)} - FantasyRealm
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | 🏰 |
-
-
- FANTASYREALM
-
-
-
- Character Manager
-
- |
-
-
- |
-
-
- |
-
-
-
-
- |
- {content}
- |
-
-
-
-
-
-
-
- |
-
-
-
-
- © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios
-
-
- Ceci est un message automatique. Merci de ne pas répondre à cet email.
-
- |
-
-
- |
-
-
-
-
- |
-
-
-
-
-";
+
+
+
+
+
+ {Encode(title)} - FantasyRealm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 🏰 |
+
+
+ FANTASYREALM
+
+
+
+ Character Manager
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+ {content}
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+ © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios
+
+
+ Ceci est un message automatique. Merci de ne pas répondre à cet email.
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ ";
}
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
index 07928be..f92aeb5 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
@@ -1,5 +1,4 @@
using FantasyRealm.Application.Interfaces;
-using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -10,33 +9,24 @@ namespace FantasyRealm.Infrastructure.Email
///
/// SMTP-based implementation of the email service using MailKit.
///
- public class SmtpEmailService : IEmailService
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The email configuration settings.
+ /// The factory for creating SMTP clients.
+ /// The logger instance.
+ public class SmtpEmailService(
+ IOptions settings,
+ ISmtpClientFactory smtpClientFactory,
+ ILogger logger) : IEmailService
{
- private readonly EmailSettings _settings;
- private readonly ISmtpClientFactory _smtpClientFactory;
- private readonly ILogger _logger;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The email configuration settings.
- /// The factory for creating SMTP clients.
- /// The logger instance.
- public SmtpEmailService(
- IOptions settings,
- ISmtpClientFactory smtpClientFactory,
- ILogger logger)
- {
- _settings = settings.Value;
- _smtpClientFactory = smtpClientFactory;
- _logger = logger;
- }
+ private readonly EmailSettings _settings = settings.Value;
///
public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, CancellationToken cancellationToken = default)
{
var subject = "Welcome to FantasyRealm!";
- var body = EmailTemplates.GetWelcomeTemplate(pseudo);
+ var body = EmailTemplates.GetWelcomeTemplate(pseudo, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -44,7 +34,7 @@ public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, Cancellat
public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default)
{
var subject = "Reset your FantasyRealm password";
- var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken);
+ var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -52,7 +42,7 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str
public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default)
{
var subject = "Votre nouveau mot de passe FantasyRealm";
- var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword);
+ var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -60,7 +50,7 @@ public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo,
public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default)
{
var subject = $"Your character {characterName} has been approved!";
- var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName);
+ var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -68,7 +58,7 @@ public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo,
public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, string characterName, string reason, CancellationToken cancellationToken = default)
{
var subject = $"Your character {characterName} was not approved";
- var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason);
+ var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -76,7 +66,7 @@ public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo,
public async Task SendCommentApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default)
{
var subject = "Your comment has been approved!";
- var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName);
+ var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -111,7 +101,7 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod
try
{
- using var client = _smtpClientFactory.Create();
+ using var client = smtpClientFactory.Create();
var secureSocketOptions = _settings.UseSsl
? SecureSocketOptions.StartTls
@@ -122,11 +112,11 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod
await client.SendAsync(message, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
- _logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject);
+ logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject);
+ logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject);
throw;
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs
index 8834183..d90b85a 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs
@@ -8,20 +8,13 @@ namespace FantasyRealm.Infrastructure.Repositories
///
/// Repository implementation for User entity data access operations.
///
- public sealed class UserRepository : IUserRepository
+ public sealed class UserRepository(FantasyRealmDbContext context) : IUserRepository
{
- private readonly FantasyRealmDbContext _context;
-
- public UserRepository(FantasyRealmDbContext context)
- {
- _context = context;
- }
-
///
public async Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default)
{
var normalizedEmail = email.ToLowerInvariant().Trim();
- return await _context.Users
+ return await context.Users
.AnyAsync(u => u.Email == normalizedEmail, cancellationToken);
}
@@ -29,22 +22,22 @@ public async Task ExistsByEmailAsync(string email, CancellationToken cance
public async Task ExistsByPseudoAsync(string pseudo, CancellationToken cancellationToken = default)
{
var normalizedPseudo = pseudo.Trim();
- return await _context.Users
+ return await context.Users
.AnyAsync(u => u.Pseudo == normalizedPseudo, cancellationToken);
}
///
public async Task CreateAsync(User user, CancellationToken cancellationToken = default)
{
- _context.Users.Add(user);
- await _context.SaveChangesAsync(cancellationToken);
+ context.Users.Add(user);
+ await context.SaveChangesAsync(cancellationToken);
return user;
}
///
public async Task GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default)
{
- return await _context.Roles
+ return await context.Roles
.FirstOrDefaultAsync(r => r.Label == label, cancellationToken);
}
@@ -52,17 +45,25 @@ public async Task CreateAsync(User user, CancellationToken cancellationTok
public async Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default)
{
var normalizedEmail = email.ToLowerInvariant().Trim();
- return await _context.Users
+ return await context.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken);
}
+ ///
+ public async Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await context.Users
+ .Include(u => u.Role)
+ .FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
+ }
+
///
public async Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default)
{
var normalizedEmail = email.ToLowerInvariant().Trim();
var normalizedPseudo = pseudo.Trim();
- return await _context.Users
+ return await context.Users
.Include(u => u.Role)
.FirstOrDefaultAsync(u => u.Email == normalizedEmail && u.Pseudo == normalizedPseudo, cancellationToken);
}
@@ -71,8 +72,8 @@ public async Task CreateAsync(User user, CancellationToken cancellationTok
public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
{
user.UpdatedAt = DateTime.UtcNow;
- _context.Users.Update(user);
- await _context.SaveChangesAsync(cancellationToken);
+ context.Users.Update(user);
+ await context.SaveChangesAsync(cancellationToken);
return user;
}
}
diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs
index b2c2083..cd75ccb 100644
--- a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs
+++ b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs
@@ -408,6 +408,383 @@ public async Task Login_WithEmptyFields_ReturnsBadRequest(string email, string p
#endregion
+ #region ChangePassword Tests
+
+ [Fact]
+ public async Task ChangePassword_WithValidData_ReturnsOkAndNewToken()
+ {
+ // Arrange - Register and login
+ var email = $"changepwd_{Guid.NewGuid():N}@example.com";
+ var oldPassword = "MySecure@Pass123";
+ var newPassword = "NewSecure@Pass456";
+ var pseudo = $"ChgPwd{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = oldPassword,
+ ConfirmPassword = oldPassword
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = oldPassword,
+ NewPassword = newPassword,
+ ConfirmNewPassword = newPassword
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ result.Should().NotBeNull();
+ result!.Token.Should().NotBeNullOrEmpty();
+ result.Token.Should().NotBe(loginResult.Token);
+ result.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
+ result.User.Email.Should().Be(email.ToLowerInvariant());
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithNewToken_CanLoginWithNewPassword()
+ {
+ // Arrange - Register, login, and change password
+ var email = $"newlogin_{Guid.NewGuid():N}@example.com";
+ var oldPassword = "MySecure@Pass123";
+ var newPassword = "NewSecure@Pass456";
+ var pseudo = $"NewLog{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = oldPassword,
+ ConfirmPassword = oldPassword
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ using var changeRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ changeRequest.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ changeRequest.Content = JsonContent.Create(new
+ {
+ CurrentPassword = oldPassword,
+ NewPassword = newPassword,
+ ConfirmNewPassword = newPassword
+ });
+
+ await _client.SendAsync(changeRequest);
+
+ // Act - Try to login with new password
+ var newLoginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = newPassword });
+
+ // Assert
+ newLoginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithoutAuthentication_ReturnsUnauthorized()
+ {
+ // Arrange
+ var request = new
+ {
+ CurrentPassword = "OldPassword@123",
+ NewPassword = "NewPassword@456",
+ ConfirmNewPassword = "NewPassword@456"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/change-password", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithInvalidToken_ReturnsUnauthorized()
+ {
+ // Arrange
+ var request = new
+ {
+ CurrentPassword = "OldPassword@123",
+ NewPassword = "NewPassword@456",
+ ConfirmNewPassword = "NewPassword@456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", "Bearer invalid.token.here");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithWrongCurrentPassword_ReturnsUnauthorized()
+ {
+ // Arrange
+ var email = $"wrongcur_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"WrngCur{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = "WrongPassword@123",
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("mot de passe actuel");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithMismatchedNewPasswords_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"mismatch_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"MisMat{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "DifferentPass@789"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("correspondent");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithWeakNewPassword_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"weaknew_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"WeakNw{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "weak",
+ ConfirmNewPassword = "weak"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithSameAsOldPassword_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"sameold_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"SameOl{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = password,
+ ConfirmNewPassword = password
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("différent");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithSuspendedAccount_ReturnsForbidden()
+ {
+ // Arrange
+ var email = $"suspchg_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"SusChg{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ // Suspend the user directly in DB
+ using var scope = _factory.Services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ user.IsSuspended = true;
+ await context.SaveChangesAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("suspendu");
+ }
+
+ [Fact]
+ public async Task ChangePassword_SetsMustChangePasswordToFalse()
+ {
+ // Arrange
+ var email = $"mustchg_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"MustCh{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ // Set MustChangePassword to true in DB
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ user.MustChangePassword = true;
+ await context.SaveChangesAsync();
+ }
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+ loginResult!.MustChangePassword.Should().BeTrue();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ await _client.SendAsync(requestMessage);
+
+ // Assert - Check DB
+ using var verifyScope = _factory.Services.CreateScope();
+ var verifyContext = verifyScope.ServiceProvider.GetRequiredService();
+ var updatedUser = await verifyContext.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ updatedUser.MustChangePassword.Should().BeFalse();
+ }
+
+ #endregion
+
private record ErrorResponse(string Message);
}
}
diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
index 209da21..7ddb3a1 100644
--- a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
+++ b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
@@ -4,6 +4,8 @@ namespace FantasyRealm.Tests.Unit.Email
{
public class EmailTemplatesTests
{
+ private const string TestBaseUrl = "https://test.fantasy-realm.com";
+
[Fact]
public void GetWelcomeTemplate_ContainsPseudo()
{
@@ -11,7 +13,7 @@ public void GetWelcomeTemplate_ContainsPseudo()
var pseudo = "TestPlayer";
// Act
- var result = EmailTemplates.GetWelcomeTemplate(pseudo);
+ var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl);
// Assert
Assert.Contains(pseudo, result);
@@ -24,19 +26,30 @@ public void GetWelcomeTemplate_EscapesHtmlCharacters()
{
var pseudo = "";
- var result = EmailTemplates.GetWelcomeTemplate(pseudo);
+ var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl);
Assert.DoesNotContain("";
var temporaryPassword = "TempPass@123!";
- var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword);
+ var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, TestBaseUrl);
Assert.DoesNotContain("