diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs new file mode 100644 index 0000000..d68c0ff --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -0,0 +1,231 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AuthenticationControllerTests +{ + private static string Json(object? value) => + JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); + + private static AuthenticationController NewController(Mock service, string? userId = null) + { + var controller = new AuthenticationController(service.Object); + if (userId != null) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "Test")) + } + }; + } + return controller; + } + + [Fact] + public async Task Login_WithSuccessfulResult_MapsLowercaseTokenPayload() + { + var service = new Mock(); + service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny())) + .ReturnsAsync(new LoginResultDTO + { + Token = "tok", + Expiration = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Email = "alice@example.com", + UserRole = "Admin", + PhoneNumber = "555", + Fullname = "Alice Q", + Id = "user-1" + }); + + var controller = NewController(service); + + var result = await controller.Login(new LoginModel { Username = "alice", Password = "pw" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var json = Json(ok.Value); + json.Should().Contain("\"token\":\"tok\""); + json.Should().Contain("\"expiration\":\""); + json.Should().Contain("\"email\":\"alice@example.com\""); + json.Should().Contain("\"userRoles\":\"Admin\""); + json.Should().Contain("\"phoneNumber\":\"555\""); + json.Should().Contain("\"fullname\":\"Alice Q\""); + json.Should().Contain("\"id\":\"user-1\""); + } + + [Fact] + public async Task Login_WithNullResult_ReturnsUnauthorized() + { + var service = new Mock(); + service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((LoginResultDTO?)null); + + var controller = NewController(service); + + var result = await controller.Login(new LoginModel(), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("Password successfully changed"); + } + + [Fact] + public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Old Password is incorrect"); + } + + [Fact] + public async Task UpdateProfile_MapsProfileDataIntoDataResponse() + { + var service = new Mock(); + service.Setup(s => s.UpdateProfileAsync("42", It.IsAny(), It.IsAny())) + .ReturnsAsync(new UserProfileDTO { FirstName = "Bob", Email = "bob@x.com", Contact = "111" }); + + var controller = NewController(service, "42"); + + var form = new User_DTO { Name = "Bob", Email = "bob@x.com", Contact = "111" }; + var result = await controller.UserProfileUpdate(form, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Introduction Updated Successfully"); + var json = Json((object?)response.Data); + json.Should().Contain("\"FirstName\":\"Bob\""); + json.Should().Contain("\"Email\":\"bob@x.com\""); + json.Should().Contain("\"Contact\":\"111\""); + } + + [Fact] + public async Task ForgetPassword_Found_ReturnsCheckEmailMessage() + { + var service = new Mock(); + service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("a@b.com", CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("Please check your email for code"); + } + + [Fact] + public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage() + { + var service = new Mock(); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("x@y.com", CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().Be("No such email is registered"); + } + + [Theory] + [InlineData(true, "Success ", "Code Matched")] + [InlineData(false, "Error", "Code Not Matched")] + public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage) + { + var service = new Mock(); + service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + + var controller = NewController(service); + + var result = await controller.VerificationCode("123456", CancellationToken.None); + + if (matched) + { + var ok = result.Should().BeOfType().Subject; + var r = ok.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + else + { + var bad = result.Should().BeOfType().Subject; + var r = bad.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + } + + [Fact] + public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com", Code = "123", Password = "new" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("password changed"); + } + + [Fact] + public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your email or code not found please check"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs new file mode 100644 index 0000000..f233055 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -0,0 +1,172 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class AuthenticationServiceTests +{ + private static AuthenticationService NewService( + Mock userData, + Mock forget, + Mock email, + out Mock> store, + out Mock> hasher) + { + var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); + store = s; + hasher = h; + return new AuthenticationService(manager, Config(), userData.Object, forget.Object, email.Object); + } + + private static IConfiguration Config() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["JWT:Secret"] = new string('x', 64), + ["JWT:ValidIssuer"] = "issuer", + ["JWT:ValidAudience"] = "audience" + }) + .Build(); + + [Fact] + public async Task Login_UnknownUser_ReturnsNull() + { + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out _); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var result = await service.LoginAsync("nobody", "pw", CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task Login_DeletedUser_RejectedBeforePasswordCheck() + { + var deletedUser = IdentityTestHelpers.User(isDeleted: true); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); + store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); + + var result = await service.LoginAsync("alice", "pw", CancellationToken.None); + + result.Should().BeNull(); + hasher.Verify(h => h.VerifyHashedPassword(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() + { + var user = IdentityTestHelpers.User(); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); + hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success); + + var result = await service.LoginAsync("alice", "pw", CancellationToken.None); + + result.Should().NotBeNull(); + result!.Id.Should().Be(user.Id); + result.Email.Should().Be(user.Email); + result.PhoneNumber.Should().Be(user.PhoneNumber); + result.Fullname.Should().Be("Alice Q"); + result.UserRole.Should().Be("Admin"); + result.Token.Should().NotBeNullOrEmpty(); + result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9)); + } + + [Fact] + public async Task Login_BadPassword_ReturnsNull() + { + var user = IdentityTestHelpers.User(); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).ReturnsAsync("hash"); + hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed); + + var result = await service.LoginAsync("alice", "wrong", CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None); + + found.Should().BeTrue(); + forget.Verify( + f => f.ReplaceCodeAsync( + user.Email!, + user.Id, + It.Is(code => code.Length == 6 && code.All(char.IsDigit)), + It.IsAny()), + Times.Once); + email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); + } + + [Fact] + public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var forget = new Mock(); + var email = new Mock(); + + var service = NewService(userData, forget, email, out _, out _); + + var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None); + + found.Should().BeFalse(); + forget.Verify(f => f.ReplaceCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyCode_ForwardsDataServiceResult(bool exists) + { + var forget = new Mock(); + forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + + var service = NewService(new Mock(), forget, new Mock(), out _, out _); + + var result = await service.VerifyCodeAsync("abc", CancellationToken.None); + + result.Should().Be(exists); + } + + [Fact] + public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset() + { + var forget = new Mock(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), out var store, out _); + + var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None); + + result.Should().BeFalse(); + store.Verify(s => s.FindByIdAsync(It.IsAny(), It.IsAny()), Times.Never); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs new file mode 100644 index 0000000..469bedc --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs @@ -0,0 +1,42 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Api.SeaHavenIndustries.Tests; + +internal static class IdentityTestHelpers +{ + public static (UserManager manager, Mock> store, Mock> hasher) CreateUserManager() + { + var store = new Mock>(); + var hasher = new Mock>(); + + store.As>(); + + var manager = new UserManager( + store.Object, + Options.Create(new IdentityOptions()), + hasher.Object, + new List>(), + new List>(), + new Mock().Object, + new IdentityErrorDescriber(), + new Mock().Object, + new Mock>>().Object); + + return (manager, store, hasher); + } + + public static ApplicationUser User(string id = "u1", string? userName = "alice", bool? isDeleted = false) => new() + { + Id = id, + UserName = userName, + Email = userName + "@example.com", + FirstName = "Alice", + LastName = "Q", + PhoneNumber = "555", + IsDeleted = isDeleted + }; +} diff --git a/Api.SeaHavenIndustries.Tests/UserControllerTests.cs b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs new file mode 100644 index 0000000..1754479 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs @@ -0,0 +1,190 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using System.Text.Json; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UserControllerTests +{ + private static string Json(object? value) => + JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object)); + + private static UserController NewController(Mock service, string? userId = null) + { + var controller = new UserController(service.Object); + if (userId != null) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "Test")) + } + }; + } + return controller; + } + + [Fact] + public async Task GetUsers_ReturnsServiceList() + { + var service = new Mock(); + service.Setup(s => s.GetUsersAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", Date = "Jan 01 2026" } + }); + + var controller = NewController(service); + + var result = await controller.GetUsers(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeAssignableTo>(); + } + + [Fact] + public async Task AddUser_Success_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = true }); + + var controller = NewController(service); + + var result = await controller.AddUser(new User_DTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Updated Successfully"); + } + + [Fact] + public async Task AddUser_Failure_ReturnsBadRequestWithServiceError() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" }); + + var controller = NewController(service); + + var result = await controller.AddUser(new User_DTO(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().Be("boom"); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsUserNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("User not found"); + } + + [Fact] + public async Task EditUser_Found_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + } + + [Fact] + public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("ID not matched!"); + } + + [Fact] + public async Task DeleteUser_Found_DelegatesAndReturnsOk() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync("5", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeleteUserAsync("5", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_UsesClaimUserId() + { + var service = new Mock(); + service.Setup(s => s.DeleteCurrentUserAsync("77", It.IsAny())).Returns(Task.CompletedTask); + + var controller = NewController(service, "77"); + + var result = await controller.DeleteCurrentUser(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + service.Verify(s => s.DeleteCurrentUserAsync("77", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserProfile_PreservesLowercaseAddedDateKey() + { + var service = new Mock(); + service.Setup(s => s.GetUserProfileAsync("77", It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "77", Name = "Me", AddedDate = new DateTime(2026, 1, 1), Email = "me@x.com", Contact = "321" } + }); + + var controller = NewController(service, "77"); + + var result = await controller.UserProfile(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var json = Json(ok.Value); + json.Should().Contain("\"Id\":\"77\""); + json.Should().Contain("\"Name\":\"Me\""); + json.Should().Contain("\"addeddate\":\""); + json.Should().Contain("\"Email\":\"me@x.com\""); + json.Should().Contain("\"Contact\":\"321\""); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs new file mode 100644 index 0000000..8047ad7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -0,0 +1,195 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UserServiceTests +{ + private static UserService NewService( + Mock userData, + Mock email, + out Mock> store) + { + var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); + store = s; + return new UserService(manager, userData.Object, email.Object); + } + + [Fact] + public async Task GetUsers_FormatsCreatedDate_AndHandlesNullDate() + { + var userData = new Mock(); + userData.Setup(u => u.GetAllForListAsync(It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", CreatedDate = new DateTime(2026, 1, 5) }, + new() { Id = "2", Name = "B", Email = "b@x.com", RoleName = "Tech", Status = "Active", CreatedDate = null } + }); + + var service = NewService(userData, new Mock(), out _); + + var users = (await service.GetUsersAsync(CancellationToken.None)).ToList(); + + users.Should().HaveCount(2); + users[0].Date.Should().Be("Jan 05 2026"); + users[0].RoleName.Should().Be("Admin"); + users[0].Status.Should().Be("Active"); + users[1].Date.Should().Be(""); + users[1].Name.Should().Be("B"); + } + + [Fact] + public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade() + { + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("missing", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("missing", CancellationToken.None); + + found.Should().BeFalse(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteUser_Found_PerformsCascade() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("u1", CancellationToken.None); + + found.Should().BeTrue(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_SetsIsDeletedFlag() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + await service.DeleteCurrentUserAsync("u1", CancellationToken.None); + + user.IsDeleted.Should().BeTrue(); + userData.Verify(u => u.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsFalse() + { + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("x", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out _); + + var succeeded = await service.EditUserAsync(new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, CancellationToken.None); + + succeeded.Should().BeFalse(); + } + + [Fact] + public async Task GetUserProfile_MapsCreatedDateToAddedDate() + { + var user = new UserProfileData + { + Id = "u1", + FirstName = "Alice", + CreatedDate = new DateTime(2026, 3, 4) + }; + var userData = new Mock(); + userData.Setup(u => u.GetProfileAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var rows = await service.GetUserProfileAsync("u1", CancellationToken.None); + + rows.Should().HaveCount(1); + rows[0].AddedDate.Should().Be(new DateTime(2026, 3, 4)); + rows[0].Name.Should().Be("Alice"); + } + + [Fact] + public async Task AddUser_CreateSuccess_PersistsRoleAndEmailsLoginInformation() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var email = new Mock(); + + var service = NewService(userData, email, out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + outcome.Success.Should().BeTrue(); + store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + store.Verify(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify( + e => e.SendEmailAsync( + "n@x.com", + "Login Information", + It.Is(body => ContainsStrongGeneratedPassword(body))), + Times.Once); + } + + [Fact] + public async Task AddUser_CreateFails_ReturnsFirstError() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" })); + + var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None); + + outcome.Success.Should().BeFalse(); + outcome.Error.Should().Be("duplicate"); + } + + private static bool ContainsStrongGeneratedPassword(string body) + { + const string marker = "Your password is: "; + var start = body.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + return false; + + var password = body[(start + marker.Length)..] + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + return password is + { + Length: 8 + } + && password.Any(char.IsUpper) + && password.Any(char.IsLower) + && password.Any(char.IsDigit) + && password.Any(character => !char.IsLetterOrDigit(character)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 013cbfe..67a5d33 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -1,14 +1,10 @@ -using Data.SeaHavenIndustries; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Text; -using Api.SeaHavenIndustries.DTOs; -using Api.SeaHavenIndustries.Helper; -using Microsoft.EntityFrameworkCore; namespace Api.SeaHavenIndustries.Controllers { @@ -16,55 +12,35 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Authentication")] public class AuthenticationController : Controller { - private readonly UserManager _userManager; - private readonly IConfiguration _configuration; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendMessage; + private readonly IAuthenticationService _authenticationService; - public AuthenticationController(UserManager userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage) + public AuthenticationController(IAuthenticationService authenticationService) { - _userManager = userManager; - _configuration = configuration; - _db = db; - _sendMessage = sendMessage; + _authenticationService = authenticationService; } [AllowAnonymous] [HttpPost] [Route("login")] - public async Task Login([FromBody] LoginModel model) + public async Task Login([FromBody] LoginModel model, CancellationToken cancellationToken) { try { - var user = await _userManager.FindByNameAsync(model.Username ?? ""); - if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? "")) + var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); + if (result != null) { - // Standard login without 2FA - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? ""), - new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - foreach (var userRole in userRoles) - { - authClaims.Add(new Claim(ClaimTypes.Role, userRole)); - } - var token = GetToken(authClaims); return Ok(new { - token = new JwtSecurityTokenHandler().WriteToken(token), - expiration = token.ValidTo, - email = user.Email, - userRoles = userRoles.FirstOrDefault(), - phoneNumber = user.PhoneNumber, - fullname = user.FirstName + " " + user.LastName, - id = user.Id + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id }); } - // Invalid credentials return Unauthorized(); } catch (Exception ex) @@ -72,17 +48,16 @@ namespace Api.SeaHavenIndustries.Controllers return StatusCode(500, ex.Message); } - + } [Route("ChangePassword")] [HttpPost] - public async Task ChangePassword(ChangePasswords usermodel) + public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await _userManager.FindByIdAsync(userid); - var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? ""); - if (result.Succeeded) + var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); + if (succeeded) { return Ok(new Response { Status = "Success ", Message = "Password successfully changed" }); } @@ -90,44 +65,31 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Old Password is incorrect" }); } - private JwtSecurityToken GetToken(List authClaims) - { - var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? "")); - var token = new JwtSecurityToken( - issuer: _configuration["JWT:ValidIssuer"], - audience: _configuration["JWT:ValidAudience"], - expires: DateTime.Now.AddDays(10), - claims: authClaims, - signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) - ); - return token; - } - [HttpPost] [Route("UpdateProfile")] - public async Task UserProfileUpdate([FromForm] User_DTO model) + public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - ApplicationUser appuser = _db.Users.AsNoTracking().Where(u => u.Id == userid).FirstOrDefault() ?? new ApplicationUser(); - appuser.FirstName = model.Name; - appuser.Email = model.Email; - appuser.Contact = model.Contact; - _db.Users.Update(appuser); - await _db.SaveChangesAsync(); + var dto = new UpdateProfileRequestDTO + { + Name = model.Name, + Email = model.Email, + Contact = model.Contact + }; + var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken); return Ok(new DataResponse { Message = "Introduction Updated Successfully", Status = "200", - Data = _db.Users.Where(u => u.Id == userid).Select(s => new + Data = data == null ? null : new { - s.FirstName, - //s.Location, - s.Email, - s.Contact - }).FirstOrDefault() + data.FirstName, + data.Email, + data.Contact + } }); } catch (Exception ex) @@ -141,25 +103,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ForgetPassword")] - public async Task ForgetPassword(string Email) + public async Task ForgetPassword(string Email, CancellationToken cancellationToken) { - var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync(); - if (user != null) + var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken); + if (found) { - if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).Any()) - { - _db.ForgetPasswordCodes.Remove(_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).FirstOrDefault()); - _db.SaveChanges(); - } - ForgetPasswordCode forgetPasswordCode = new ForgetPasswordCode(); - forgetPasswordCode.Email = user.Email ?? ""; - forgetPasswordCode.UserId = user.Id; - forgetPasswordCode.Code = GenerateRandomNo(); - - _db.ForgetPasswordCodes.Add(forgetPasswordCode); - _db.SaveChanges(); - string body = $"Your Password Reset Code is: " + forgetPasswordCode.Code; - await _sendMessage.SendEMail(user.Email, "Forget Password Request.", body); return Ok(new Response { Status = "Success ", Message = "Please check your email for code" }); } else @@ -171,11 +119,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("VerificationCode")] - public async Task VerificationCode(string code) + public async Task VerificationCode(string code, CancellationToken cancellationToken) { try { - if (await _db.ForgetPasswordCodes.Where(u => u.Code == code).AnyAsync()) + if (await _authenticationService.VerifyCodeAsync(code, cancellationToken)) { return Ok(new Response { Status = "Success ", Message = "Code Matched" }); @@ -195,16 +143,12 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ResetPassword")] - public async Task ResetPassword(ForgetPassword_Dto fpdto) + public async Task ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { try { - if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim() && u.Code == fpdto.Code.Trim()).Any()) + if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken)) { - var GetUser = _db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim()).FirstOrDefault(); - var user = await _userManager.FindByIdAsync(GetUser.UserId); - var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var result = await _userManager.ResetPasswordAsync(user, token, fpdto.Password); return Ok(new Response { Status = "Success ", Message = "password changed" }); } else @@ -219,12 +163,6 @@ namespace Api.SeaHavenIndustries.Controllers } } - - private Random _random = new Random(); - private string GenerateRandomNo() - { - return _random.Next(0, 999999).ToString("D6"); - } #endregion } } diff --git a/Api.SeaHavenIndustries/Controllers/UserController.cs b/Api.SeaHavenIndustries/Controllers/UserController.cs index 375649c..39aa1bf 100644 --- a/Api.SeaHavenIndustries/Controllers/UserController.cs +++ b/Api.SeaHavenIndustries/Controllers/UserController.cs @@ -1,15 +1,10 @@ using Api.SeaHavenIndustries.DTOs; -using Api.SeaHavenIndustries.Helper; -using Api.SeaHavenIndustries.Models; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using PasswordGenerator; -using SendGrid.Helpers.Mail; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Security.Cryptography; namespace Api.SeaHavenIndustries.Controllers { @@ -18,107 +13,36 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/User")] public class UserController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendemail; + private readonly IUserService _userService; - public UserController(UserManager userManager, ApplicationDbContext db, SendMessage sendemail) + public UserController(IUserService userService) { - _userManager = userManager; - _db = db; - _sendemail = sendemail; + _userService = userService; } [Route("")] [HttpGet] - public async Task GetUsers() + public async Task GetUsers(CancellationToken cancellationToken) { - _db.ChangeTracker.Clear(); - - var allUsers = _db.Users.Select(s => new UserRoleViewModel - { - RoleName = s.PhoneNumber, - Email = s.Email, - Name = s.FirstName, - Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "", - Status = s.UniqueName, - Id = s.Id - }).AsQueryable(); - return Ok(allUsers); + var users = await _userService.GetUsersAsync(cancellationToken); + return Ok(users); } [HttpPost] [Route("AddUser")] - public async Task AddUser(User_DTO user) + public async Task AddUser(User_DTO user, CancellationToken cancellationToken) { - - var model = new ApplicationUser - { - UserName = user.Email, - FirstName = user.Name, - Email = user.Email, - }; - try { - _db.ChangeTracker.Clear(); - var exist = _db.Set().Find(model.Id); - if (exist == null) + var dto = new AddUserRequestDTO { - model.EmailConfirmed = true; - model.UserName = model.Email; - model.CreatedDate = DateTime.Now; - model.UniqueName = "Active"; - model.PhoneNumber = user.Role; - - var pwd = new Password(8).IncludeLowercase().IncludeUppercase().IncludeSpecial().IncludeNumeric(); - var password = pwd.Next(); - - var result = await _userManager.CreateAsync(model, password); - if (result.Succeeded) - { - await _userManager.AddToRoleAsync(model, user.Role ?? ""); - } - else - { - return BadRequest(new Response { Status = "Error", Message = result.Errors.FirstOrDefault()?.Description ?? "error" }); - } - var request = HttpContext?.Request; - //var domain = $"{request?.Scheme}://{request?.Host}"; - var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#"; - string subject = "Login Information"; - string greeting = $"Dear {model.FirstName},\n\n"; - string loginLink = $"Click here to login:Login\n"; - string passwordInfo = $"Your password is: {password}\n\n"; - string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}"; - await _sendemail.SendEMail(model?.Email ?? "", subject, body); - } - else + Name = user.Name, + Email = user.Email, + Contact = user.Contact, + Role = user.Role + }; + var outcome = await _userService.AddUserAsync(dto, cancellationToken); + if (!outcome.Success) { - - var exist1 = await _userManager.Users.FirstOrDefaultAsync(w => w.Id == model.Id); - if (exist1 == null) - return BadRequest(new Response { Status = "Error", Message = "User not found" }); - - var existingRole = await _userManager.GetRolesAsync(exist1); - - // Remove from roles if any exist - if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber) - { - await _userManager.RemoveFromRolesAsync(exist1, existingRole); - } - - // Update user details - exist1.FirstName = model.FirstName; - exist1.LastName = model.LastName; - exist1.Contact = model.Contact; - exist1.PhoneNumber = model.PhoneNumber; - - // Optionally, detach the entity if you're about to attach it again - _db.Entry(exist1).State = EntityState.Detached; - - // Add to role after setting properties - await _userManager.AddToRoleAsync(exist1, user.Role ?? ""); - var result = await _userManager.UpdateAsync(exist1); - //await _context.SaveChangesAsync(); + return BadRequest(new Response { Status = "Error", Message = outcome.Error }); } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); @@ -131,31 +55,20 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPut] [Route("EditUser")] - public async Task EditUser(EditUser_DTO user) + public async Task EditUser(EditUser_DTO user, CancellationToken cancellationToken) { try { - var exist = _db.Users.Where(w => w.Id == user.Id).FirstOrDefault(); - if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "User not found" }); - exist.FirstName = user.Name; - exist.EmailConfirmed = true; - exist.UserName = user.Email; - exist.Email = user.Email; - exist.NormalizedEmail = user.Email.ToUpper(); - exist.NormalizedUserName = user.Email.ToUpper(); - exist.CreatedDate = DateTime.Now; - exist.UniqueName = "Active"; - exist.PhoneNumber = user.Role; - var existingRole = await _userManager.GetRolesAsync(exist); - - if (existingRole != null && existingRole.Any()) + var dto = new EditUserRequestDTO { - await _userManager.RemoveFromRolesAsync(exist, existingRole); - } - await _userManager.AddToRoleAsync(exist, user.Role ?? ""); - - await _db.SaveChangesAsync(); + Id = user.Id, + Name = user.Name, + Email = user.Email, + Role = user.Role + }; + var succeeded = await _userService.EditUserAsync(dto, cancellationToken); + if (!succeeded) + return BadRequest(new Response { Status = "Error", Message = "User not found" }); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -163,53 +76,19 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - public class DeleteUserRequest - { - public string Id { get; set; } - } - [HttpDelete] [Route("DeleteUser")] - public async Task DeleteUser(EditUser_DTO req) + public async Task DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) { - string id = req.Id; + string id = req.Id ?? ""; try { - var data = await _db.Users.Where(w => w.Id == id).FirstOrDefaultAsync(); - - if (data != null) - { - var existworkorder = _db.workOrders.Where(w => w.AssignTo.Contains(data.Id)).ToList(); - foreach (var workorder in existworkorder) - { - var existdata = _db.workOrders.Where(w => w.Id == workorder.Id).FirstOrDefault(); - existdata.AssignTo = null; - } - var existworkordercomments = _db.Comments.Where(w => w.UserId.Contains(data.Id)).ToList(); - foreach (var workorderc in existworkordercomments) - { - var existdatacomm = _db.Comments.Where(w => w.Id == workorderc.Id).FirstOrDefault(); - existdatacomm.UserId = null; - } - var existtemplate = _db.Templates.Where(w => w.AssignTo.Contains(data.Id)).ToList(); - foreach (var workordert in existtemplate) - { - var existdattem = _db.Templates.Where(w => w.Id == workordert.Id).FirstOrDefault(); - existdattem.AssignTo = null; - } - var existtempexistuserrolelate = _db.UserRoles.Where(w => w.UserId.Contains(data.Id)).ToList(); - if (existtempexistuserrolelate.Count() > 0) - { - _db.UserRoles.RemoveRange(existtempexistuserrolelate); - } - _db.Users.Remove(data); - } - else + var found = await _userService.DeleteUserAsync(id, cancellationToken); + if (!found) { return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); } - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -220,17 +99,12 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] [Route("DeleteCurrentUser")] - public async Task DeleteCurrentUser() + public async Task DeleteCurrentUser(CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault(); - if(exist != null) - { - exist.IsDeleted = true; - } - await _db.SaveChangesAsync(); + await _userService.DeleteCurrentUserAsync(userid, cancellationToken); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -241,64 +115,19 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("UserProfile")] - public async Task UserProfile() + public async Task UserProfile(CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var data = await _db.Users.Where(w => w.Id == userid).Select(s => new + var data = await _userService.GetUserProfileAsync(userid, cancellationToken); + return Ok(data.Select(d => new { - s.Id, - Name = s.FirstName, - addeddate = s.CreatedDate, - s.Email, - s.Contact - }).ToListAsync(); - return Ok(data); - } - public static string GenerateRandomPassword(int length = 8) - { - const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; - const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const string digits = "1234567890"; - const string symbols = "!@#$%^&*()_-+=<>?"; - - using (var rng = RandomNumberGenerator.Create()) - { - var passwordChars = new char[length]; - - // Ensure at least one capital letter - passwordChars[0] = capitalLetters[GetNextNonNegativeIndex(rng, capitalLetters.Length)]; - - // Ensure at least one digit - passwordChars[1] = digits[GetNextNonNegativeIndex(rng, digits.Length)]; - - // Ensure at least one symbol - passwordChars[2] = symbols[GetNextNonNegativeIndex(rng, symbols.Length)]; - - // Fill the rest of the password with random characters - for (int i = 3; i < length; i++) - { - passwordChars[i] = validChars[GetNextNonNegativeIndex(rng, validChars.Length)]; - } - - // Shuffle the password characters, excluding the initial characters - Random random = new Random(); - for (int i = length - 1; i > 2; i--) - { - int j = random.Next(2, i + 1); - (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); - } - - return new string(passwordChars); - } - } - - private static int GetNextNonNegativeIndex(RandomNumberGenerator rng, int maxValue) - { - byte[] randomBytes = new byte[4]; - rng.GetBytes(randomBytes); - int nextIndex = BitConverter.ToInt32(randomBytes, 0) % maxValue; - return (nextIndex + maxValue) % maxValue; // Ensure the index is non-negative + d.Id, + Name = d.Name, + addeddate = d.AddedDate, + d.Email, + d.Contact + })); } } } diff --git a/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs new file mode 100644 index 0000000..b59bb14 --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs @@ -0,0 +1,20 @@ +using Api.SeaHavenIndustries.Helper; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Helper +{ + public class SendGridEmailSender : IEmailSender + { + private readonly SendMessage _sendMessage; + + public SendGridEmailSender(SendMessage sendMessage) + { + _sendMessage = sendMessage; + } + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + return _sendMessage.SendEMail(emailTo, subject, body); + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..1e6de6a 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -38,6 +38,8 @@ builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(sp => { diff --git a/SeaHaven.DataServices/Dto/UserReadModels.cs b/SeaHaven.DataServices/Dto/UserReadModels.cs new file mode 100644 index 0000000..c3cb153 --- /dev/null +++ b/SeaHaven.DataServices/Dto/UserReadModels.cs @@ -0,0 +1,21 @@ +namespace SeaHaven.DataServices.Dto +{ + public sealed class UserListRowData + { + public required string Id { get; init; } + public string? RoleName { get; init; } + public string? Email { get; init; } + public string? Name { get; init; } + public DateTime? CreatedDate { get; init; } + public string? Status { get; init; } + } + + public sealed class UserProfileData + { + public required string Id { get; init; } + public string? FirstName { get; init; } + public string? Email { get; init; } + public string? Contact { get; init; } + public DateTime? CreatedDate { get; init; } + } +} diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs new file mode 100644 index 0000000..c1781a2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -0,0 +1,83 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class ForgetPasswordDataService : IForgetPasswordDataService + { + private readonly ApplicationDbContext _context; + + public ForgetPasswordDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + + var existing = await _context.ForgetPasswordCodes + .Where(u => u.Email.ToLower().Trim() == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + _context.ForgetPasswordCodes.Remove(existing); + await _context.SaveChangesAsync(cancellationToken); + } + + var forgetPasswordCode = new ForgetPasswordCode + { + Email = email, + UserId = userId, + Code = code + }; + + _context.ForgetPasswordCodes.Add(forgetPasswordCode); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task CodeExistsAsync(string code, CancellationToken cancellationToken) + { + return await _context.ForgetPasswordCodes + .AsNoTracking() + .Where(u => u.Code == code) + .AnyAsync(cancellationToken); + } + + public async Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + var trimmedCode = code.Trim(); + + return await _context.ForgetPasswordCodes + .AsNoTracking() + .Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode) + .AnyAsync(cancellationToken); + } + + public async Task GetByEmailAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + + return await _context.ForgetPasswordCodes + .Where(u => u.Email.ToLower().Trim() == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.ToLower().Trim(); + var records = await _context.ForgetPasswordCodes + .Where(code => code.Email.ToLower().Trim() == normalizedEmail) + .ToListAsync(cancellationToken); + + if (records.Count == 0) + return; + + _context.ForgetPasswordCodes.RemoveRange(records); + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 365ceca..fbf0dd0 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -33,5 +34,112 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Users.AnyAsync(u => u.Id == id); } + + public async Task> GetAllForListAsync(CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Select(user => new UserListRowData + { + Id = user.Id, + RoleName = user.PhoneNumber, + Email = user.Email, + Name = user.FirstName, + CreatedDate = user.CreatedDate, + Status = user.UniqueName + }) + .ToListAsync(cancellationToken); + } + + public async Task GetProfileAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(u => u.Id == id) + .Select(user => new UserProfileData + { + Id = user.Id, + FirstName = user.FirstName, + Email = user.Email, + Contact = user.Contact, + CreatedDate = user.CreatedDate + }) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task UpdateProfileAsync( + string id, + string? name, + string? email, + string? contact, + CancellationToken cancellationToken) + { + var updated = await _context.Users + .Where(user => user.Id == id) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(user => user.FirstName, name) + .SetProperty(user => user.Email, email) + .SetProperty(user => user.Contact, contact), + cancellationToken); + + return updated == 1; + } + + public async Task GetForEditAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users + .Where(w => w.Id == id) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken) + { + var normalizedEmail = email.Trim().ToUpperInvariant(); + return await _context.Users + .AsNoTracking() + .Where(user => user.NormalizedEmail == normalizedEmail) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken) + { + _context.Users.Update(user); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var id = user.Id; + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + + await _context.workOrders + .Where(workOrder => workOrder.AssignTo == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(workOrder => workOrder.AssignTo, (string?)null), + cancellationToken); + + await _context.Comments + .Where(comment => comment.UserId == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(comment => comment.UserId, (string?)null), + cancellationToken); + + await _context.Templates + .Where(template => template.AssignTo == id) + .ExecuteUpdateAsync( + setters => setters.SetProperty(template => template.AssignTo, (string?)null), + cancellationToken); + + await _context.UserRoles + .Where(role => role.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + + await _context.Users + .Where(existingUser => existingUser.Id == id) + .ExecuteDeleteAsync(cancellationToken); + + await transaction.CommitAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs new file mode 100644 index 0000000..720e665 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IForgetPasswordDataService + { + Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken); + Task CodeExistsAsync(string code, CancellationToken cancellationToken); + Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken); + Task GetByEmailAsync(string email, CancellationToken cancellationToken); + Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6524cc4..8597002 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; namespace SeaHaven.DataServices.Interfaces { @@ -8,5 +9,13 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + + Task> GetAllForListAsync(CancellationToken cancellationToken); + Task GetProfileAsync(string id, CancellationToken cancellationToken); + Task UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken); + Task GetForEditAsync(string id, CancellationToken cancellationToken); + Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); + Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); + Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs new file mode 100644 index 0000000..94df6dc --- /dev/null +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -0,0 +1,68 @@ +namespace SeaHaven.Services.DTOs +{ + public class LoginResultDTO + { + public string Token { get; set; } = string.Empty; + public DateTime Expiration { get; set; } + public string? Email { get; set; } + public string? UserRole { get; set; } + public string? PhoneNumber { get; set; } + public string Fullname { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + } + + public class UpdateProfileRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class UserProfileDTO + { + public string? FirstName { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class AddUserRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + public string? Role { get; set; } + } + + public class AddUserOutcomeDTO + { + public bool Success { get; set; } + public string? Error { get; set; } + } + + public class EditUserRequestDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Role { get; set; } + } + + public class UserListRowDTO + { + public string? RoleName { get; set; } + public string? Email { get; set; } + public string? Name { get; set; } + public string Date { get; set; } = string.Empty; + public string? Status { get; set; } + public string? Id { get; set; } + } + + public class UserProfileRowDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public DateTime? AddedDate { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs new file mode 100644 index 0000000..5f4a59e --- /dev/null +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -0,0 +1,159 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Implementation +{ + public class AuthenticationService : IAuthenticationService + { + private readonly UserManager _userManager; + private readonly IConfiguration _configuration; + private readonly IUserDataService _userDataService; + private readonly IForgetPasswordDataService _forgetPasswordDataService; + private readonly IEmailSender _emailSender; + public AuthenticationService( + UserManager userManager, + IConfiguration configuration, + IUserDataService userDataService, + IForgetPasswordDataService forgetPasswordDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _configuration = configuration; + _userDataService = userDataService; + _forgetPasswordDataService = forgetPasswordDataService; + _emailSender = emailSender; + } + + public async Task LoginAsync(string? username, string? password, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(username ?? ""); + if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) + { + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List + { + new Claim(ClaimTypes.Name, user.UserName ?? ""), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + foreach (var userRole in userRoles) + { + authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + } + var token = GetToken(authClaims); + return new LoginResultDTO + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo, + Email = user.Email, + UserRole = userRoles.FirstOrDefault(), + PhoneNumber = user.PhoneNumber, + Fullname = user.FirstName + " " + user.LastName, + Id = user.Id + }; + } + + return null; + } + + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return false; + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); + return result.Succeeded; + } + + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) + { + var exists = await _userDataService.UpdateProfileAsync( + userId, + dto.Name, + dto.Email, + dto.Contact, + cancellationToken); + if (!exists) + return null; + + var updated = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (updated == null) return null; + return new UserProfileDTO + { + FirstName = updated.FirstName, + Email = updated.Email, + Contact = updated.Contact + }; + } + + public async Task ForgetPasswordAsync(string email, CancellationToken cancellationToken) + { + var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken); + if (user == null) return false; + + var code = GenerateRandomNo(); + await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken); + var body = $"Your Password Reset Code is: " + code; + await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body); + return true; + } + + public async Task VerifyCodeAsync(string code, CancellationToken cancellationToken) + { + return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken); + } + + public async Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password)) + return false; + + var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken); + if (!matched) return false; + + var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); + if (record == null) + return false; + + var user = await _userManager.FindByIdAsync(record.UserId); + if (user == null) + return false; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, password); + if (!result.Succeeded) + return false; + + await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken); + return true; + } + + private JwtSecurityToken GetToken(List authClaims) + { + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? "")); + var token = new JwtSecurityToken( + issuer: _configuration["JWT:ValidIssuer"], + audience: _configuration["JWT:ValidAudience"], + expires: DateTime.Now.AddDays(10), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + return token; + } + + private static string GenerateRandomNo() + { + return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); + } + } +} diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs new file mode 100644 index 0000000..454e619 --- /dev/null +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -0,0 +1,201 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class UserService : IUserService + { + private readonly UserManager _userManager; + private readonly IUserDataService _userDataService; + private readonly IEmailSender _emailSender; + + public UserService( + UserManager userManager, + IUserDataService userDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _userDataService = userDataService; + _emailSender = emailSender; + } + + public async Task> GetUsersAsync(CancellationToken cancellationToken) + { + var users = await _userDataService.GetAllForListAsync(cancellationToken); + return users.Select(s => new UserListRowDTO + { + RoleName = s.RoleName, + Email = s.Email, + Name = s.Name, + Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "", + Status = s.Status, + Id = s.Id + }); + } + + public async Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken) + { + var model = new ApplicationUser + { + UserName = dto.Email, + FirstName = dto.Name, + Email = dto.Email, + }; + + var exist = await _userDataService.GetByIdAsync(model.Id); + if (exist == null) + { + model.EmailConfirmed = true; + model.UserName = model.Email; + model.CreatedDate = DateTime.Now; + model.UniqueName = "Active"; + model.PhoneNumber = dto.Role; + + var password = GenerateRandomPassword(); + + var result = await _userManager.CreateAsync(model, password); + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(model, dto.Role ?? ""); + } + else + { + return new AddUserOutcomeDTO { Success = false, Error = result.Errors.FirstOrDefault()?.Description ?? "error" }; + } + var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#"; + string subject = "Login Information"; + string greeting = $"Dear {model.FirstName},\n\n"; + string loginLink = $"Click here to login:Login\n"; + string passwordInfo = $"Your password is: {password}\n\n"; + string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}"; + await _emailSender.SendEmailAsync(model?.Email ?? "", subject, body); + return new AddUserOutcomeDTO { Success = true }; + } + else + { + var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken); + if (exist1 == null) + return new AddUserOutcomeDTO { Success = false, Error = "User not found" }; + + var existingRole = await _userManager.GetRolesAsync(exist1); + + if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber) + { + await _userManager.RemoveFromRolesAsync(exist1, existingRole); + } + + exist1.FirstName = model.FirstName; + exist1.LastName = model.LastName; + exist1.Contact = model.Contact; + exist1.PhoneNumber = model.PhoneNumber; + + await _userDataService.UpdateUserAsync(exist1, cancellationToken); + + await _userManager.AddToRoleAsync(exist1, dto.Role ?? ""); + await _userManager.UpdateAsync(exist1); + return new AddUserOutcomeDTO { Success = true }; + } + } + + public async Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken); + if (exist == null) + return false; + + exist.FirstName = dto.Name; + if (string.IsNullOrWhiteSpace(dto.Email)) + throw new ArgumentException("Email is required.", nameof(dto)); + + exist.EmailConfirmed = true; + exist.UserName = dto.Email; + exist.Email = dto.Email; + exist.NormalizedEmail = dto.Email.ToUpperInvariant(); + exist.NormalizedUserName = dto.Email.ToUpperInvariant(); + exist.CreatedDate = DateTime.Now; + exist.UniqueName = "Active"; + exist.PhoneNumber = dto.Role; + + var existingRole = await _userManager.GetRolesAsync(exist); + if (existingRole != null && existingRole.Any()) + { + await _userManager.RemoveFromRolesAsync(exist, existingRole); + } + await _userManager.AddToRoleAsync(exist, dto.Role ?? ""); + + await _userDataService.UpdateUserAsync(exist, cancellationToken); + return true; + } + + public async Task DeleteUserAsync(string id, CancellationToken cancellationToken) + { + var data = await _userDataService.GetForEditAsync(id, cancellationToken); + if (data == null) + return false; + + await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken); + return true; + } + + public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(userId, cancellationToken); + if (exist != null) + { + exist.IsDeleted = true; + await _userDataService.UpdateUserAsync(exist, cancellationToken); + } + } + + public async Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken) + { + var user = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (user == null) + return Array.Empty(); + + return new List + { + new UserProfileRowDTO + { + Id = user.Id, + Name = user.FirstName, + AddedDate = user.CreatedDate, + Email = user.Email, + Contact = user.Contact + } + }; + } + + private static string GenerateRandomPassword(int length = 8) + { + const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; + const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const string digits = "1234567890"; + const string symbols = "!@#$%^&*()_-+=<>?"; + + var passwordChars = new char[length]; + + passwordChars[0] = capitalLetters[RandomNumberGenerator.GetInt32(capitalLetters.Length)]; + passwordChars[1] = digits[RandomNumberGenerator.GetInt32(digits.Length)]; + passwordChars[2] = symbols[RandomNumberGenerator.GetInt32(symbols.Length)]; + passwordChars[3] = "abcdefghijklmnopqrstuvwxyz"[RandomNumberGenerator.GetInt32(26)]; + + for (int i = 4; i < length; i++) + { + passwordChars[i] = validChars[RandomNumberGenerator.GetInt32(validChars.Length)]; + } + + for (int i = passwordChars.Length - 1; i > 0; i--) + { + int j = RandomNumberGenerator.GetInt32(i + 1); + (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); + } + + return new string(passwordChars); + } + } +} diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs new file mode 100644 index 0000000..426eb5f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IAuthenticationService + { + Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); + Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); + Task VerifyCodeAsync(string code, CancellationToken cancellationToken); + Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IEmailSender.cs b/SeaHaven.Services/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..3d2f68f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IEmailSender.cs @@ -0,0 +1,11 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Outbound email port. Implemented in the API layer so the service layer + /// can orchestrate email without depending on the transport (SendGrid). + /// + public interface IEmailSender + { + Task SendEmailAsync(string emailTo, string subject, string body); + } +} diff --git a/SeaHaven.Services/Interfaces/IUserService.cs b/SeaHaven.Services/Interfaces/IUserService.cs new file mode 100644 index 0000000..9f47410 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUserService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUserService + { + Task> GetUsersAsync(CancellationToken cancellationToken); + Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken); + Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken); + Task DeleteUserAsync(string id, CancellationToken cancellationToken); + Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken); + Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken); + } +}