diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommand.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommand.cs new file mode 100644 index 0000000..ea980ef --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommand.cs @@ -0,0 +1,7 @@ +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +public record ResetPasswordCommand(ResetPasswordPayload Payload, IEnumerable CustomAttributes) : IRequest; diff --git a/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommandHandler.cs b/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommandHandler.cs new file mode 100644 index 0000000..5570b5a --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Commands/ResetPasswordCommandHandler.cs @@ -0,0 +1,104 @@ +using FluentValidation; +using Logitar.Master.Application.Accounts.Events; +using Logitar.Master.Application.Accounts.Validators; +using Logitar.Master.Application.Constants; +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts; +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Realms; +using Logitar.Portal.Contracts.Sessions; +using Logitar.Portal.Contracts.Tokens; +using Logitar.Portal.Contracts.Users; +using MediatR; + +namespace Logitar.Master.Application.Accounts.Commands; + +internal class ResetPasswordCommandHandler : IRequestHandler +{ + private readonly IMessageService _messageService; + private readonly IPublisher _publisher; + private readonly IRealmService _realmService; + private readonly ISessionService _sessionService; + private readonly ITokenService _tokenService; + private readonly IUserService _userService; + + public ResetPasswordCommandHandler(IMessageService messageService, IPublisher publisher, + IRealmService realmService, ISessionService sessionService, ITokenService tokenService, IUserService userService) + { + _messageService = messageService; + _publisher = publisher; + _realmService = realmService; + _sessionService = sessionService; + _tokenService = tokenService; + _userService = userService; + } + + public async Task Handle(ResetPasswordCommand command, CancellationToken cancellationToken) + { + Realm realm = await _realmService.FindAsync(cancellationToken); + + ResetPasswordPayload payload = command.Payload; + new ResetPasswordValidator(realm.PasswordSettings).ValidateAndThrow(payload); + + if (!string.IsNullOrWhiteSpace(payload.EmailAddress)) + { + return await HandleEmailAddressAsync(payload.EmailAddress, payload.Locale, cancellationToken); + } + else if (payload.Token != null && payload.NewPassword != null) + { + return await HandleNewPasswordAsync(payload.Token, payload.NewPassword, command.CustomAttributes, cancellationToken); + } + + throw new ArgumentException($"The '{nameof(command.Payload)}' is not valid.", nameof(command)); + } + + private async Task HandleEmailAddressAsync(string emailAddress, string locale, CancellationToken cancellationToken) + { + SentMessages sentMessages; + + User? user = await _userService.FindAsync(emailAddress, cancellationToken); + if (user != null) + { + CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), TokenTypes.PasswordRecovery, cancellationToken); + Dictionary variables = new() + { + ["Token"] = token.Token + }; + sentMessages = await _messageService.SendAsync(Templates.PasswordRecovery, user, ContactType.Email, locale, variables, cancellationToken); + } + else + { + sentMessages = new([Guid.NewGuid()]); + } + + SentMessage sentMessage = sentMessages.ToSentMessage(new Email(emailAddress)); + return ResetPasswordResult.RecoveryLinkSent(sentMessage); + } + + private async Task HandleNewPasswordAsync(string token, string newPassword, IEnumerable customAttributes, CancellationToken cancellationToken) + { + ValidatedToken validatedToken = await _tokenService.ValidateAsync(token, TokenTypes.PasswordRecovery, cancellationToken); + if (validatedToken.Subject == null) + { + throw new InvalidOperationException($"The claim '{validatedToken.Subject}' is required."); + } + Guid userId = Guid.Parse(validatedToken.Subject); + _ = await _userService.FindAsync(userId, cancellationToken) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); + User user = await _userService.ResetPasswordAsync(userId, newPassword, cancellationToken); + + return await EnsureProfileIsCompleted(user, customAttributes, cancellationToken); + } + + private async Task EnsureProfileIsCompleted(User user, IEnumerable customAttributes, CancellationToken cancellationToken) + { + if (!user.IsProfileCompleted()) + { + CreatedToken token = await _tokenService.CreateAsync(user.GetSubject(), TokenTypes.Profile, cancellationToken); + return ResetPasswordResult.RequireProfileCompletion(token); + } + + Session session = await _sessionService.CreateAsync(user, customAttributes, cancellationToken); + await _publisher.Publish(new UserSignedInEvent(session), cancellationToken); + return ResetPasswordResult.Succeed(session); + } +} diff --git a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs index d8186a8..14908ce 100644 --- a/backend/src/Logitar.Master.Application/Accounts/IUserService.cs +++ b/backend/src/Logitar.Master.Application/Accounts/IUserService.cs @@ -11,6 +11,7 @@ public interface IUserService Task CreateAsync(Email email, CancellationToken cancellationToken = default); Task FindAsync(string uniqueName, CancellationToken cancellationToken = default); Task FindAsync(Guid userId, CancellationToken cancellationToken = default); + Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken = default); Task SaveProfileAsync(Guid userId, SaveProfilePayload payload, CancellationToken cancellationToken = default); Task SignOutAsync(Guid userId, CancellationToken cancellationToken = default); Task UpdateEmailAsync(Guid userId, Email email, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Master.Application/Accounts/Validators/ResetPasswordValidator.cs b/backend/src/Logitar.Master.Application/Accounts/Validators/ResetPasswordValidator.cs new file mode 100644 index 0000000..fdb4a3f --- /dev/null +++ b/backend/src/Logitar.Master.Application/Accounts/Validators/ResetPasswordValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Passwords.Validators; +using Logitar.Identity.Domain.Shared; +using Logitar.Master.Contracts.Accounts; + +namespace Logitar.Master.Application.Accounts.Validators; + +internal class ResetPasswordValidator : AbstractValidator +{ + public ResetPasswordValidator(IPasswordSettings passwordSettings) + { + RuleFor(x => x.Locale).SetValidator(new LocaleValidator()); + + When(x => !string.IsNullOrWhiteSpace(x.EmailAddress), () => + { + RuleFor(x => x.EmailAddress).NotEmpty(); + RuleFor(x => x.Token).Empty(); + RuleFor(x => x.NewPassword).Empty(); + }).Otherwise(() => + { + RuleFor(x => x.EmailAddress).Empty(); + RuleFor(x => x.Token).NotEmpty(); + RuleFor(x => x.NewPassword).NotNull(); + When(x => x.NewPassword != null, () => RuleFor(x => x.NewPassword!).SetValidator(new PasswordValidator(passwordSettings))); + }); + } +} diff --git a/backend/src/Logitar.Master.Application/Constants/Templates.cs b/backend/src/Logitar.Master.Application/Constants/Templates.cs index d65f3ad..1103fe5 100644 --- a/backend/src/Logitar.Master.Application/Constants/Templates.cs +++ b/backend/src/Logitar.Master.Application/Constants/Templates.cs @@ -5,6 +5,7 @@ namespace Logitar.Master.Application.Constants; internal static class Templates { public const string AccountAuthentication = "AccountAuthentication"; + public const string PasswordRecovery = "PasswordRecovery"; private const string ContactVerification = "ContactVerification{ContactType}"; private const string MultiFactorAuthentication = "MultiFactorAuthentication{ContactType}"; diff --git a/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs b/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs index b71ed73..f184162 100644 --- a/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs +++ b/backend/src/Logitar.Master.Application/Constants/TokenTypes.cs @@ -3,5 +3,6 @@ internal static class TokenTypes { public const string Authentication = "auth+jwt"; + public const string PasswordRecovery = "reset_password+jwt"; public const string Profile = "profile+jwt"; } diff --git a/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordPayload.cs b/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordPayload.cs new file mode 100644 index 0000000..7a8ce81 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordPayload.cs @@ -0,0 +1,31 @@ +namespace Logitar.Master.Contracts.Accounts; + +public record ResetPasswordPayload +{ + public string Locale { get; set; } + + public string? EmailAddress { get; set; } + + public string? Token { get; set; } + public string? NewPassword { get; set; } + + public ResetPasswordPayload() : this(string.Empty) + { + } + + public ResetPasswordPayload(string locale) + { + Locale = locale; + } + + public ResetPasswordPayload(string locale, string emailAddress) : this(locale) + { + EmailAddress = emailAddress; + } + + public ResetPasswordPayload(string locale, string token, string newPassword) : this(locale) + { + Token = token; + NewPassword = newPassword; + } +} diff --git a/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordResult.cs b/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordResult.cs new file mode 100644 index 0000000..67bc828 --- /dev/null +++ b/backend/src/Logitar.Master.Contracts/Accounts/ResetPasswordResult.cs @@ -0,0 +1,30 @@ +using Logitar.Portal.Contracts.Sessions; +using Logitar.Portal.Contracts.Tokens; + +namespace Logitar.Master.Contracts.Accounts; + +public record ResetPasswordResult +{ + public SentMessage? RecoveryLinkSentTo { get; set; } + public string? ProfileCompletionToken { get; set; } + public Session? Session { get; set; } + + public ResetPasswordResult() + { + } + + public static ResetPasswordResult RecoveryLinkSent(SentMessage sentMessage) => new() + { + RecoveryLinkSentTo = sentMessage + }; + + public static ResetPasswordResult RequireProfileCompletion(CreatedToken createdToken) => new() + { + ProfileCompletionToken = createdToken.Token + }; + + public static ResetPasswordResult Succeed(Session session) => new() + { + Session = session + }; +} diff --git a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs index 9b6462b..e970d7b 100644 --- a/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs +++ b/backend/src/Logitar.Master.Infrastructure/IdentityServices/UserService.cs @@ -65,6 +65,13 @@ public async Task CreateAsync(Email email, CancellationToken cancellationT return await _userClient.ReadAsync(userId, uniqueName: null, identifier: null, context); } + public async Task ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken) + { + ResetUserPasswordPayload payload = new(newPassword); + RequestContext context = new(userId.ToString(), cancellationToken); + return await _userClient.ResetPasswordAsync(userId, payload, context) ?? throw new InvalidOperationException($"The user 'Id={userId}' could not be found."); + } + public async Task SaveProfileAsync(Guid userId, SaveProfilePayload profile, CancellationToken cancellationToken) { UpdateUserPayload payload = profile.ToUpdateUserPayload(); diff --git a/backend/src/Logitar.Master/Controllers/AccountController.cs b/backend/src/Logitar.Master/Controllers/AccountController.cs index 04edf2a..a4bb7d4 100644 --- a/backend/src/Logitar.Master/Controllers/AccountController.cs +++ b/backend/src/Logitar.Master/Controllers/AccountController.cs @@ -85,6 +85,18 @@ public async Task> TokenAsync([FromBody] GetToken return Ok(response); } + [HttpPost("password/reset")] + public async Task> ResetPasswordAsync([FromBody] ResetPasswordPayload payload, CancellationToken cancellationToken) + { + ResetPasswordResult result = await _requestPipeline.ExecuteAsync(new ResetPasswordCommand(payload, HttpContext.GetSessionCustomAttributes()), cancellationToken); + if (result.Session != null) + { + HttpContext.SignIn(result.Session); + } + + return Ok(new ResetPasswordResponse(result)); + } + [HttpPut("/phone/change")] [Authorize] public async Task> ChangePhoneAsync([FromBody] ChangePhonePayload payload, CancellationToken cancellationToken) diff --git a/backend/src/Logitar.Master/Models/Account/ResetPasswordResponse.cs b/backend/src/Logitar.Master/Models/Account/ResetPasswordResponse.cs new file mode 100644 index 0000000..0cdc2c0 --- /dev/null +++ b/backend/src/Logitar.Master/Models/Account/ResetPasswordResponse.cs @@ -0,0 +1,25 @@ +using Logitar.Master.Contracts.Accounts; + +namespace Logitar.Master.Models.Account; + +public record ResetPasswordResponse +{ + public SentMessage? RecoveryLinkSentTo { get; set; } + public string? ProfileCompletionToken { get; set; } + public CurrentUser? CurrentUser { get; set; } + + public ResetPasswordResponse() + { + } + + public ResetPasswordResponse(ResetPasswordResult result) + { + RecoveryLinkSentTo = result.RecoveryLinkSentTo; + ProfileCompletionToken = result.ProfileCompletionToken; + + if (result.Session != null) + { + CurrentUser = new CurrentUser(result.Session.User); + } + } +} diff --git a/backend/src/Logitar.Master/Models/Account/SignInResponse.cs b/backend/src/Logitar.Master/Models/Account/SignInResponse.cs index 556f5ad..e918fa7 100644 --- a/backend/src/Logitar.Master/Models/Account/SignInResponse.cs +++ b/backend/src/Logitar.Master/Models/Account/SignInResponse.cs @@ -6,6 +6,8 @@ public record SignInResponse { public SentMessage? AuthenticationLinkSentTo { get; set; } public bool IsPasswordRequired { get; set; } + public OneTimePasswordValidation? OneTimePasswordValidation { get; set; } + public string? ProfileCompletionToken { get; set; } public CurrentUser? CurrentUser { get; set; } public SignInResponse() @@ -16,6 +18,8 @@ public SignInResponse(SignInCommandResult result) { AuthenticationLinkSentTo = result.AuthenticationLinkSentTo; IsPasswordRequired = result.IsPasswordRequired; + OneTimePasswordValidation = result.OneTimePasswordValidation; + ProfileCompletionToken = result.ProfileCompletionToken; if (result.Session != null) { diff --git a/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/ResetPasswordCommandTests.cs b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/ResetPasswordCommandTests.cs new file mode 100644 index 0000000..01cc115 --- /dev/null +++ b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/ResetPasswordCommandTests.cs @@ -0,0 +1,150 @@ +using Logitar.Master.Contracts.Accounts; +using Logitar.Portal.Contracts; +using Logitar.Portal.Contracts.Messages; +using Logitar.Portal.Contracts.Sessions; +using Logitar.Portal.Contracts.Tokens; +using Logitar.Portal.Contracts.Users; +using Moq; + +namespace Logitar.Master.Application.Accounts.Commands; + +[Trait(Traits.Category, Categories.Integration)] +public class ResetPasswordCommandTests : IntegrationTests +{ + public ResetPasswordCommandTests() : base() + { + } + + [Fact(DisplayName = "It should fake an email sending when the user is not found.")] + public async Task It_should_fake_an_email_sending_when_the_user_is_not_found() + { + ResetPasswordPayload payload = new("fr", Faker.Person.Email); + ResetPasswordCommand command = new(payload, CustomAttributes: []); + ResetPasswordResult result = await Pipeline.ExecuteAsync(command, CancellationToken); + + Assert.NotNull(result.RecoveryLinkSentTo); + Assert.NotEqual(string.Empty, result.RecoveryLinkSentTo.ConfirmationNumber); + Assert.Equal(ContactType.Email, result.RecoveryLinkSentTo.ContactType); + Assert.Equal(payload.EmailAddress, result.RecoveryLinkSentTo.MaskedContact); + } + + [Fact(DisplayName = "It should issue a new session.")] + public async Task It_should_issue_a_new_session() + { + ResetPasswordPayload payload = new(Faker.Locale, "password_token", "Test123!"); + Assert.NotNull(payload.Token); + Assert.NotNull(payload.NewPassword); + + User user = new(Faker.Person.Email) + { + Id = Guid.NewGuid(), + Email = new Email(Faker.Person.Email) + { + IsVerified = true + } + }; + user.CustomAttributes.Add(new CustomAttribute("MultiFactorAuthenticationMode", MultiFactorAuthenticationMode.Email.ToString())); + user.CustomAttributes.Add(new CustomAttribute("ProfileCompletedOn", DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture))); + UserService.Setup(x => x.FindAsync(user.Id, CancellationToken)).ReturnsAsync(user); + UserService.Setup(x => x.ResetPasswordAsync(user.Id, payload.NewPassword, CancellationToken)).ReturnsAsync(user); + + ValidatedToken validatedToken = new() + { + Subject = user.GetSubject() + }; + TokenService.Setup(x => x.ValidateAsync(payload.Token, "reset_password+jwt", CancellationToken)).ReturnsAsync(validatedToken); + + CustomAttribute[] customAttributes = [new("IpAddress", Faker.Internet.Ip())]; + Session session = new(user) + { + Id = Guid.NewGuid() + }; + SessionService.Setup(x => x.CreateAsync(user, customAttributes, CancellationToken)).ReturnsAsync(session); + + ResetPasswordCommand command = new(payload, customAttributes); + ResetPasswordResult result = await Pipeline.ExecuteAsync(command, CancellationToken); + + Assert.Same(session, result.Session); + + UserService.Verify(x => x.ResetPasswordAsync(user.Id, payload.NewPassword, CancellationToken), Times.Once); + } + + [Fact(DisplayName = "It should require to complete the user profile.")] + public async Task It_should_require_to_complete_the_user_profile() + { + ResetPasswordPayload payload = new(Faker.Locale, "password_token", "Test123!"); + Assert.NotNull(payload.Token); + Assert.NotNull(payload.NewPassword); + + User user = new(Faker.Person.Email) + { + Id = Guid.NewGuid(), + Email = new Email(Faker.Person.Email) + { + IsVerified = true + } + }; + UserService.Setup(x => x.FindAsync(user.Id, CancellationToken)).ReturnsAsync(user); + UserService.Setup(x => x.ResetPasswordAsync(user.Id, payload.NewPassword, CancellationToken)).ReturnsAsync(user); + + ValidatedToken validatedToken = new() + { + Subject = user.GetSubject() + }; + TokenService.Setup(x => x.ValidateAsync(payload.Token, "reset_password+jwt", CancellationToken)).ReturnsAsync(validatedToken); + + CreatedToken createdToken = new("profile_token"); + TokenService.Setup(x => x.CreateAsync(user.Id.ToString(), "profile+jwt", CancellationToken)).ReturnsAsync(createdToken); + + ResetPasswordCommand command = new(payload, CustomAttributes: []); + ResetPasswordResult result = await Pipeline.ExecuteAsync(command, CancellationToken); + + Assert.Equal(createdToken.Token, result.ProfileCompletionToken); + + UserService.Verify(x => x.ResetPasswordAsync(user.Id, payload.NewPassword, CancellationToken), Times.Once); + } + + [Fact(DisplayName = "It should send an email to the found user.")] + public async Task It_should_send_an_email_to_the_found_user() + { + User user = new(Faker.Person.Email) + { + Id = Guid.NewGuid(), + Email = new Email(Faker.Person.Email) + { + IsVerified = true + } + }; + UserService.Setup(x => x.FindAsync(Faker.Person.Email, CancellationToken)).ReturnsAsync(user); + + CreatedToken createdToken = new("token"); + TokenService.Setup(x => x.CreateAsync(user.GetSubject(), "reset_password+jwt", CancellationToken)).ReturnsAsync(createdToken); + + ResetPasswordPayload payload = new("fr", Faker.Person.Email); + + SentMessages sentMessages = new([Guid.NewGuid()]); + MessageService.Setup(x => x.SendAsync("PasswordRecovery", user, ContactType.Email, payload.Locale, + It.Is>(v => v.Count == 1 && v["Token"] == createdToken.Token), CancellationToken + )).ReturnsAsync(sentMessages); + + ResetPasswordCommand command = new(payload, CustomAttributes: []); + ResetPasswordResult result = await Pipeline.ExecuteAsync(command, CancellationToken); + + Assert.NotNull(result.RecoveryLinkSentTo); + Assert.Equal(sentMessages.GenerateConfirmationNumber(), result.RecoveryLinkSentTo.ConfirmationNumber); + Assert.Equal(ContactType.Email, result.RecoveryLinkSentTo.ContactType); + Assert.Equal(payload.EmailAddress, result.RecoveryLinkSentTo.MaskedContact); + } + + [Fact(DisplayName = "It should throw ValidationException when the payload is not valid.")] + public async Task It_should_throw_ValidationException_when_the_payload_is_not_valid() + { + ResetPasswordPayload payload = new("fr"); + ResetPasswordCommand command = new(payload, CustomAttributes: []); + var exception = await Assert.ThrowsAsync(async () => await Pipeline.ExecuteAsync(command, CancellationToken)); + + Assert.Equal(2, exception.Errors.Count()); + Assert.Contains(exception.Errors, e => e.ErrorCode == "NotEmptyValidator" && e.PropertyName == "Token"); + Assert.Contains(exception.Errors, e => e.ErrorCode == "NotNullValidator" && e.PropertyName == "NewPassword"); + } +} diff --git a/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs index f9fe403..8e082a5 100644 --- a/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs +++ b/backend/tests/Logitar.Master.IntegrationTests/Application/Accounts/Commands/SignInCommandTests.cs @@ -198,7 +198,7 @@ public async Task It_should_require_to_complete_the_user_profile_Multi_Factor_Au Assert.Equal(createdToken.Token, result.ProfileCompletionToken); } - [Fact(DisplayName = "It should send a Multi Factor Authentication email message.")] + [Fact(DisplayName = "It should send a Multi-Factor Authentication email message.")] public async Task It_should_send_a_Multi_Factor_Authentication_email_message() { User user = new(Faker.Person.Email) @@ -236,7 +236,7 @@ public async Task It_should_send_a_Multi_Factor_Authentication_email_message() Assert.Equal(sentMessages.ToSentMessage(user.Email), result.OneTimePasswordValidation.SentMessage); } - [Fact(DisplayName = "It should send a Multi Factor Authentication SMS message.")] + [Fact(DisplayName = "It should send a Multi-Factor Authentication SMS message.")] public async Task It_should_send_a_Multi_Factor_Authentication_Sms_message() { User user = new(Faker.Person.Email) diff --git a/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/en.json b/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/en.json index 4df52ba..f185c9a 100644 --- a/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/en.json +++ b/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/en.json @@ -10,5 +10,8 @@ "MultiFactorAuthenticationEmail_Subject": "Access your account", "MultiFactorAuthenticationPhone_Content": "Your one-time code has arrived: {OneTimePassword}. Do not disclose it to anyone.", "MultiFactorAuthenticationPhone_Subject": "Access your account", + "PasswordRecovery_ClickLink": "If that someone is you, click the link below to reset your password.", + "PasswordRecovery_Reason": "You received this message since someone requested to reset your password.", + "PasswordRecovery_Subject": "Reset your password", "Team": "The Logitar Team" } \ No newline at end of file diff --git a/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/fr.json b/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/fr.json index 2ed4cf5..4415927 100644 --- a/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/fr.json +++ b/backend/tools/Logitar.Master.PortalSeeding.Worker/Dictionaries/fr.json @@ -10,5 +10,8 @@ "MultiFactorAuthenticationEmail_Subject": "Accédez à votre compte", "MultiFactorAuthenticationPhone_Content": "Votre code à usage unique est arrivé : {OneTimePassword}. Ne le divulguez à personne.", "MultiFactorAuthenticationPhone_Subject": "Accédez à votre compte", + "PasswordRecovery_ClickLink": "S’il s’agit bien de vous, cliquez sur le lien ci-dessous afin de réinitialiser votre mot de passe.", + "PasswordRecovery_Reason": "Vous avez reçu ce courriel puisque quelqu’un a demandé à réinitialiser votre mot de passe.", + "PasswordRecovery_Subject": "Réinitialisez votre mot de passe", "Team": "L’équipe Logitar" } \ No newline at end of file diff --git a/backend/tools/Logitar.Master.PortalSeeding.Worker/Logitar.Master.PortalSeeding.Worker.csproj b/backend/tools/Logitar.Master.PortalSeeding.Worker/Logitar.Master.PortalSeeding.Worker.csproj index 38dcb3e..8db6f34 100644 --- a/backend/tools/Logitar.Master.PortalSeeding.Worker/Logitar.Master.PortalSeeding.Worker.csproj +++ b/backend/tools/Logitar.Master.PortalSeeding.Worker/Logitar.Master.PortalSeeding.Worker.csproj @@ -22,6 +22,7 @@ + @@ -37,6 +38,9 @@ PreserveNewest + + PreserveNewest + diff --git a/backend/tools/Logitar.Master.PortalSeeding.Worker/Templates/PasswordRecovery.html b/backend/tools/Logitar.Master.PortalSeeding.Worker/Templates/PasswordRecovery.html new file mode 100644 index 0000000..5915f57 --- /dev/null +++ b/backend/tools/Logitar.Master.PortalSeeding.Worker/Templates/PasswordRecovery.html @@ -0,0 +1,20 @@ + + + + + + +

@(Model.Resource("Hello").Replace("{name}", Model.User?.FullName))

+

@(Model.Resource("PasswordRecovery_Reason"))

+

+ @(Model.Resource("PasswordRecovery_ClickLink")) +
+ @(Model.Variable("Token")) +

+

+ @(Model.Resource("Cordially")) +
+ @(Model.Resource("Team")) +

+ + \ No newline at end of file diff --git a/backend/tools/Logitar.Master.PortalSeeding.Worker/templates.json b/backend/tools/Logitar.Master.PortalSeeding.Worker/templates.json index 1fc1af2..f6cd0fd 100644 --- a/backend/tools/Logitar.Master.PortalSeeding.Worker/templates.json +++ b/backend/tools/Logitar.Master.PortalSeeding.Worker/templates.json @@ -18,5 +18,10 @@ "UniqueKey": "MultiFactorAuthenticationPhone", "DisplayName": "Multi-Factor Authentication (Phone)", "Description": "This is the phone multi-factor authentication template." + }, + { + "UniqueKey": "PasswordRecovery", + "DisplayName": "Password Recovery", + "Description": "This is the password recovery email template." } ]