diff --git a/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs index 2c86f0fa7..a0469ff14 100644 --- a/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ApiAuthenticationService.cs @@ -47,10 +47,16 @@ public virtual async Task GetAuthenticatedCustomer() if (!authenticateResult.Succeeded) return null; - //try to get customer by email - var emailClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "Email"); - if (emailClaim != null) - customer = await _customerService.GetCustomerByEmail(emailClaim.Value); + //prefer the stable customer id (unambiguous with per-store identity), fall back to e-mail for old tokens + var customerIdClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "CustomerId"); + if (customerIdClaim != null) + customer = await _customerService.GetCustomerById(customerIdClaim.Value); + else + { + var emailClaim = authenticateResult.Principal.Claims.FirstOrDefault(claim => claim.Type == "Email"); + if (emailClaim != null) + customer = await _customerService.GetCustomerByEmail(emailClaim.Value); + } //whether the found customer is available if (customer is not { Active: true } || customer.Deleted || !await _groupService.IsRegistered(customer)) @@ -75,8 +81,14 @@ private async Task ApiCustomer() if (!authResult.Succeeded) return await _customerService.GetCustomerBySystemName(SystemCustomerNames.Anonymous); + var customerId = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "CustomerId")?.Value; var email = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "Email")?.Value; - if (email is null) + if (!string.IsNullOrEmpty(customerId)) + { + //prefer the stable customer id - unambiguous with per-store identity + customer = await _customerService.GetCustomerById(customerId); + } + else if (email is null) { //guest var id = authResult.Principal.Claims.FirstOrDefault(x => x.Type == "Guid")?.Value; diff --git a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs index 416bd682e..508e1e1b6 100644 --- a/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/CookieAuthenticationService.cs @@ -48,6 +48,10 @@ public CookieAuthenticationService( private string CustomerCookieName => $"{_securityConfig.CookiePrefix}Customer"; + //claim carrying the customer's stable id, so the authenticated session can be re-resolved unambiguously + //(works regardless of per-store customer identity - the id is globally unique, unlike e-mail/username) + private const string CustomerIdClaimType = "grand:customerId"; + #endregion #region Fields @@ -84,6 +88,12 @@ public virtual async Task SignIn(Customer customer, bool isPersistent) claims.Add(new Claim(ClaimTypes.Email, customer.Email, ClaimValueTypes.Email, _securityConfig.CookieClaimsIssuer)); + //store the customer's stable id so the session is re-resolved by id (GetCustomerById) rather than by + //e-mail/username, which is not unique when per-store customer identity is enabled + if (!string.IsNullOrEmpty(customer.Id)) + claims.Add(new Claim(CustomerIdClaimType, customer.Id, ClaimValueTypes.String, + _securityConfig.CookieClaimsIssuer)); + //add token var passwordToken = customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PasswordToken); if (string.IsNullOrEmpty(passwordToken)) @@ -154,6 +164,16 @@ public virtual async Task GetAuthenticatedCustomer() private async Task RetrieveCustomer(ClaimsPrincipal principal) { + //prefer the stable customer id - unambiguous even with per-store customer identity + var customerId = principal.FindFirst(claim => + claim.Type == CustomerIdClaimType && + claim.Issuer.Equals(_securityConfig.CookieClaimsIssuer, StringComparison.InvariantCultureIgnoreCase)) + ?.Value; + + if (!string.IsNullOrEmpty(customerId)) + return await _customerService.GetCustomerById(customerId); + + //fallback for sessions issued before the id claim existed (resolved globally, as before) if (_customerSettings.UsernamesEnabled) { var username = principal.FindFirst(claim => diff --git a/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs index aa3519d23..482b48fde 100644 --- a/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/JwtBearerCustomerAuthenticationService.cs @@ -29,11 +29,17 @@ public JwtBearerCustomerAuthenticationService(ICustomerService customerService, public async Task Valid(TokenValidatedContext context) { if (context.Principal == null) return false; + var customerId = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "CustomerId")?.Value; var email = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Email")?.Value; var passwordToken = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Token")?.Value; var refreshId = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "RefreshId")?.Value; Customer customer = null; - if (email is null) + if (!string.IsNullOrEmpty(customerId)) + { + //prefer the stable customer id - unambiguous with per-store identity + customer = await _customerService.GetCustomerById(customerId); + } + else if (email is null) { //guest var id = context.Principal.Claims.ToList().FirstOrDefault(x => x.Type == "Guid")?.Value; @@ -41,6 +47,7 @@ public async Task Valid(TokenValidatedContext context) } else { + //legacy fallback for tokens issued before the id claim existed customer = await _customerService.GetCustomerByEmail(email); } diff --git a/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs b/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs index fcfef781b..250d926aa 100644 --- a/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs +++ b/src/Business/Grand.Business.Common/Services/Security/PermissionProvider.cs @@ -209,6 +209,7 @@ public virtual IEnumerable GetDefaultPermissions() StandardPermission.ManagePaymentTransactions, StandardPermission.ManageShipments, StandardPermission.ManageMerchandiseReturns, + StandardPermission.ManageCustomers, StandardPermission.ManageReports ] }, diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs index a0bf37ed6..9536c9197 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs @@ -13,8 +13,9 @@ public interface ICustomerManagerService /// /// Username or email /// Password + /// Store identifier - used to resolve the customer when per-store customer identity is enabled /// Result - Task LoginCustomer(string usernameOrEmail, string password); + Task LoginCustomer(string usernameOrEmail, string password, string storeId = ""); /// /// Register customer @@ -36,5 +37,6 @@ public interface ICustomerManagerService /// Change password /// /// Request - Task ChangePassword(ChangePasswordRequest request); + /// Store identifier - used to resolve the customer when per-store customer identity is enabled + Task ChangePassword(ChangePasswordRequest request, string storeId = ""); } \ No newline at end of file diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs index 407308a40..685404e62 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs @@ -104,11 +104,14 @@ Task GetCountOnlineShoppingCart(DateTime lastActivityFromUtc, string storeI Task GetCustomerByGuid(Guid customerGuid); /// - /// Get customer by email + /// Get customer by email. + /// When is supplied (per-store customer identity) the lookup is scoped to + /// that store; when it is empty the customer is resolved globally (default behaviour). /// /// Email + /// Store identifier (optional) /// Customer - Task GetCustomerByEmail(string email); + Task GetCustomerByEmail(string email, string storeId = ""); /// /// Get customer by system group @@ -118,11 +121,14 @@ Task GetCountOnlineShoppingCart(DateTime lastActivityFromUtc, string storeI Task GetCustomerBySystemName(string systemName); /// - /// Get customer by username + /// Get customer by username. + /// When is supplied (per-store customer identity) the lookup is scoped to + /// that store; when it is empty the customer is resolved globally (default behaviour). /// /// Username + /// Store identifier (optional) /// Customer - Task GetCustomerByUsername(string username); + Task GetCustomerByUsername(string username, string storeId = ""); /// /// Insert a guest customer diff --git a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs index b0c5bbc2e..b32096d71 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs @@ -78,11 +78,11 @@ public virtual bool PasswordMatch(PasswordFormat passwordFormat, string oldPassw /// Username or email /// Password /// Result - public virtual async Task LoginCustomer(string usernameOrEmail, string password) + public virtual async Task LoginCustomer(string usernameOrEmail, string password, string storeId = "") { var customer = _customerSettings.UsernamesEnabled - ? await _customerService.GetCustomerByUsername(usernameOrEmail) - : await _customerService.GetCustomerByEmail(usernameOrEmail); + ? await _customerService.GetCustomerByUsername(usernameOrEmail, storeId) + : await _customerService.GetCustomerByEmail(usernameOrEmail, storeId); var pwd = customer.PasswordFormatId switch { PasswordFormat.Clear => password, @@ -168,11 +168,11 @@ public virtual async Task RegisterCustomer(RegistrationRequest request) /// Change password /// /// Request - public virtual async Task ChangePassword(ChangePasswordRequest request) + public virtual async Task ChangePassword(ChangePasswordRequest request, string storeId = "") { ArgumentNullException.ThrowIfNull(request); - var customer = await _customerService.GetCustomerByEmail(request.Email); + var customer = await _customerService.GetCustomerByEmail(request.Email, storeId); ArgumentNullException.ThrowIfNull(customer); switch (request.PasswordFormat) diff --git a/src/Business/Grand.Business.Customers/Services/CustomerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerService.cs index ea0ad56a8..802d65d62 100644 --- a/src/Business/Grand.Business.Customers/Services/CustomerService.cs +++ b/src/Business/Grand.Business.Customers/Services/CustomerService.cs @@ -8,6 +8,7 @@ using Grand.Domain.Shipping; using Grand.Infrastructure.Caching; using Grand.Infrastructure.Caching.Constants; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Extensions; using Grand.SharedKernel; using Grand.SharedKernel.Extensions; @@ -26,11 +27,13 @@ public class CustomerService : ICustomerService public CustomerService( IRepository customerRepository, IMediator mediator, - ICacheBase cacheBase) + ICacheBase cacheBase, + CustomerConfig customerConfig) { _customerRepository = customerRepository; _mediator = mediator; _cacheBase = cacheBase; + _customerConfig = customerConfig; } #endregion @@ -40,6 +43,7 @@ public CustomerService( private readonly IRepository _customerRepository; private readonly IMediator _mediator; private readonly ICacheBase _cacheBase; + private readonly CustomerConfig _customerConfig; #endregion @@ -224,9 +228,36 @@ public virtual async Task GetCustomerByGuid(Guid customerGuid) /// /// Email /// Customer - public virtual Task GetCustomerByEmail(string email) + public virtual async Task GetCustomerByEmail(string email, string storeId = "") { - return string.IsNullOrWhiteSpace(email) ? Task.FromResult(null) : _customerRepository.GetOneAsync(x => x.Email == email.ToLowerInvariant()); + if (string.IsNullOrWhiteSpace(email)) + return null; + + var loweredEmail = email.ToLowerInvariant(); + if (!string.IsNullOrEmpty(storeId)) + { + var inStore = await _customerRepository.GetOneAsync(x => x.Email == loweredEmail && x.StoreId == storeId); + if (inStore != null || !_customerConfig.RegisterCustomersPerStore) + return inStore; + + //a store-scoped lookup (e.g. storefront login) must still reach the store-independent + //system/back-office account (administrator, created without a store) so it can sign in too + return await _customerRepository.GetOneAsync(x => + x.Email == loweredEmail && (x.StoreId == null || x.StoreId == "")); + } + + //global (store-independent) lookup. With per-store identity the same email may exist in several + //stores, so prefer the store-independent account (system/admin/back-office created without a store) + //to make sure e.g. admin panel login is not shadowed by a store customer that reused the email. + if (_customerConfig.RegisterCustomersPerStore) + { + var storeless = await _customerRepository.GetOneAsync(x => + x.Email == loweredEmail && (x.StoreId == null || x.StoreId == "")); + if (storeless != null) + return storeless; + } + + return await _customerRepository.GetOneAsync(x => x.Email == loweredEmail); } /// @@ -249,12 +280,33 @@ public virtual Task GetCustomerBySystemName(string systemName) /// /// Username /// Customer - public virtual Task GetCustomerByUsername(string username) + public virtual async Task GetCustomerByUsername(string username, string storeId = "") { if (string.IsNullOrWhiteSpace(username)) - return Task.FromResult(null); + return null; + + var loweredUsername = username.ToLowerInvariant(); + if (!string.IsNullOrEmpty(storeId)) + { + var inStore = await _customerRepository.GetOneAsync(x => x.Username == loweredUsername && x.StoreId == storeId); + if (inStore != null || !_customerConfig.RegisterCustomersPerStore) + return inStore; + + //a store-scoped lookup must still reach the store-independent system/back-office account + return await _customerRepository.GetOneAsync(x => + x.Username == loweredUsername && (x.StoreId == null || x.StoreId == "")); + } + + //global (store-independent) lookup - prefer the store-independent account (see GetCustomerByEmail) + if (_customerConfig.RegisterCustomersPerStore) + { + var storeless = await _customerRepository.GetOneAsync(x => + x.Username == loweredUsername && (x.StoreId == null || x.StoreId == "")); + if (storeless != null) + return storeless; + } - return _customerRepository.GetOneAsync(x => x.Username == username.ToLowerInvariant()); + return await _customerRepository.GetOneAsync(x => x.Username == loweredUsername); } /// diff --git a/src/Core/Grand.Infrastructure/Configuration/CustomerConfig.cs b/src/Core/Grand.Infrastructure/Configuration/CustomerConfig.cs new file mode 100644 index 000000000..1fa860ae5 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/CustomerConfig.cs @@ -0,0 +1,30 @@ +namespace Grand.Infrastructure.Configuration; + +/// +/// Represents a Customer Config (bound from the "Customer" section of appsettings.json) +/// +public class CustomerConfig +{ + /// + /// Gets or sets a value indicating whether customer accounts are scoped per store. + /// + /// When false (default), the customer uniqueness key is the e-mail address (and username) + /// alone — an e-mail can only ever belong to a single customer across the whole installation. + /// + /// + /// When true, the uniqueness key becomes the pair (e-mail / username + StoreId). + /// The same e-mail address may therefore be registered independently in two different stores as + /// two separate, unrelated accounts. This affects: customer registration, storefront login, + /// the re-resolution of the authenticated customer from the auth cookie, password recovery and + /// the duplicate-checks performed by the Admin / Store-manager customer editors. + /// + /// + /// NOTE: customers are still stored in a single collection (they are discriminated by the + /// StoreId field — there is no separate collection per store). Uniqueness is enforced at + /// the application layer; the customer lookup index is the compound (Email + StoreId) index. + /// Changing this value on a live installation that already contains duplicate e-mails across + /// stores can make some accounts unreachable, so set it before going live. + /// + /// + public bool RegisterCustomersPerStore { get; set; } +} diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs index 34b6dc411..539bfc929 100644 --- a/src/Core/Grand.Infrastructure/StartupBase.cs +++ b/src/Core/Grand.Infrastructure/StartupBase.cs @@ -214,6 +214,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu }); services.StartupConfig(configuration.GetSection("Application")); + services.StartupConfig(configuration.GetSection("Customer")); services.StartupConfig(configuration.GetSection("Performance")); services.StartupConfig(configuration.GetSection("Security")); services.StartupConfig(configuration.GetSection("Extensions")); diff --git a/src/Modules/Grand.Module.Api/Commands/Handlers/Customers/DeleteCustomerCommandHandler.cs b/src/Modules/Grand.Module.Api/Commands/Handlers/Customers/DeleteCustomerCommandHandler.cs index a77d92170..4e4f2db57 100644 --- a/src/Modules/Grand.Module.Api/Commands/Handlers/Customers/DeleteCustomerCommandHandler.cs +++ b/src/Modules/Grand.Module.Api/Commands/Handlers/Customers/DeleteCustomerCommandHandler.cs @@ -1,5 +1,7 @@ using Grand.Business.Core.Interfaces.Customers; +using Grand.Infrastructure.Configuration; using Grand.Module.Api.Commands.Models.Customers; +using Grand.SharedKernel; using MediatR; namespace Grand.Module.Api.Commands.Handlers.Customers; @@ -7,16 +9,27 @@ namespace Grand.Module.Api.Commands.Handlers.Customers; public class DeleteCustomerCommandHandler : IRequestHandler { private readonly ICustomerService _customerService; + private readonly CustomerConfig _customerConfig; - public DeleteCustomerCommandHandler(ICustomerService customerService) + public DeleteCustomerCommandHandler(ICustomerService customerService, CustomerConfig customerConfig) { _customerService = customerService; + _customerConfig = customerConfig; } public async Task Handle(DeleteCustomerCommand request, CancellationToken cancellationToken) { var customer = await _customerService.GetCustomerByEmail(request.Email); - if (customer != null) await _customerService.DeleteCustomer(customer); + if (customer == null) return true; + + //Under per-store customer identity the e-mail is not a unique key, and the global lookup prefers + //the store-independent (system/back-office/admin) account. Refuse to delete such an account through + //this by-email API so an attempt to remove a store customer can never destroy the administrator. + if (_customerConfig.RegisterCustomersPerStore && string.IsNullOrEmpty(customer.StoreId)) + throw new GrandException( + "Refusing to delete a store-independent (system/back-office) account by e-mail while per-store customer identity is enabled"); + + await _customerService.DeleteCustomer(customer); return true; } diff --git a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs index 326a0a2df..5748ddb57 100644 --- a/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs +++ b/src/Modules/Grand.Module.Api/Controllers/TokenWebController.cs @@ -35,7 +35,8 @@ public TokenWebController( IContextAccessor contextAccessor, IRefreshTokenService refreshTokenService, IAntiforgery antiforgery, - FrontendAPIConfig apiConfig) + FrontendAPIConfig apiConfig, + CustomerConfig customerConfig) { _customerService = customerService; _mediator = mediator; @@ -43,8 +44,17 @@ public TokenWebController( _refreshTokenService = refreshTokenService; _antiforgery = antiforgery; _apiConfig = apiConfig; + _customerConfig = customerConfig; } + private readonly CustomerConfig _customerConfig; + + /// + /// The current store id when per-store customer identity is enabled, otherwise empty (global lookup). + /// + private string CustomerStoreId => + _customerConfig.RegisterCustomersPerStore ? _contextAccessor.StoreContext.CurrentStore.Id : ""; + [AllowAnonymous] [IgnoreAntiforgeryToken] [HttpPost] @@ -80,9 +90,10 @@ public async Task Login([FromBody] LoginWebModel model) try { - var customer = await _customerService.GetCustomerByEmail(model.Email); + var customer = await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); var claims = new Dictionary { - { "Email", model.Email }, + { "CustomerId", customer.Id }, + { "Email", model.Email }, { "Token", customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PasswordToken) } }; var tokenDto = await GetToken(claims, customer); @@ -102,34 +113,46 @@ public async Task Refresh([FromBody] TokenDto tokenDto) if (!_apiConfig.Enabled) return BadRequest("API is disabled"); - string email; Customer customer = null; var claims = new Dictionary(); ClaimsPrincipal principal; + string customerId; + string email; + string guid; try { principal = _refreshTokenService.GetPrincipalFromToken(tokenDto.AccessToken); + customerId = principal.Claims.ToList().FirstOrDefault(x => x.Type == "CustomerId")?.Value; email = principal.Claims.ToList().FirstOrDefault(x => x.Type == "Email")?.Value; + guid = principal.Claims.ToList().FirstOrDefault(x => x.Type == "Guid")?.Value; } catch (Exception) { return BadRequest("Invalid access token"); } - if (!string.IsNullOrEmpty(email)) - { + //prefer the stable customer id (unambiguous with per-store identity); fall back to e-mail/guid for + //tokens issued before the id claim existed + if (!string.IsNullOrEmpty(customerId)) + customer = await _customerService.GetCustomerById(customerId); + else if (!string.IsNullOrEmpty(email)) customer = await _customerService.GetCustomerByEmail(email); - claims.Add("Email", email); + else if (!string.IsNullOrEmpty(guid)) + customer = await _customerService.GetCustomerByGuid(Guid.Parse(guid)); + + if (customer == null) + return BadRequest("Invalid access token"); + + //rebuild the claims from the resolved customer (registered carry id/email/token, guests carry the guid) + if (!string.IsNullOrEmpty(customer.Email)) + { + claims.Add("CustomerId", customer.Id); + claims.Add("Email", customer.Email); claims.Add("Token", customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PasswordToken)); } else { - var guid = principal.Claims.ToList().FirstOrDefault(x => x.Type == "Guid")?.Value; - if (guid != null) - { - customer = await _customerService.GetCustomerByGuid(Guid.Parse(guid)); - claims.Add("Guid", guid); - } + claims.Add("Guid", customer.CustomerGuid.ToString()); } var customerRefreshToken = await _refreshTokenService.GetCustomerRefreshToken(customer); diff --git a/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs b/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs index ebc17d0ca..29349e51d 100644 --- a/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs +++ b/src/Modules/Grand.Module.Api/Validators/Common/LoginWebValidator.cs @@ -2,6 +2,7 @@ using Grand.Module.Api.Models.Common; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; @@ -13,9 +14,15 @@ public LoginWebValidator( IEnumerable> validators, FrontendAPIConfig apiConfig, ICustomerService customerService, - ICustomerManagerService customerManagerService) + ICustomerManagerService customerManagerService, + IContextAccessor contextAccessor, + CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + if (!apiConfig.Enabled) { RuleFor(x => x).Must(_ => false).WithMessage("API is disabled"); @@ -28,12 +35,12 @@ public LoginWebValidator( { if (!string.IsNullOrEmpty(x.Email)) { - var customer = await customerService.GetCustomerByEmail(x.Email.ToLowerInvariant()); + var customer = await customerService.GetCustomerByEmail(x.Email.ToLowerInvariant(), storeId); if (customer is { Active: true } && !customer.IsSystemAccount()) { var base64EncodedBytes = Convert.FromBase64String(x.Password); var password = Encoding.UTF8.GetString(base64EncodedBytes); - var result = await customerManagerService.LoginCustomer(x.Email, password); + var result = await customerManagerService.LoginCustomer(x.Email, password, storeId); return result == CustomerLoginResults.Successful; } } diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs index 4c8ec00b3..f2f281001 100644 --- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs +++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs @@ -494,6 +494,12 @@ await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create() "CustomerGuid_1"); await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create().Ascending(x => x.Email), "Email_1"); + //compound lookup indexes supporting per-store customer identity (uniqueness of Email/Username + StoreId + //is enforced at the application layer, gated by the "Customer:RegisterCustomersPerStore" setting) + await dbContext.CreateIndex(_customerRepository, + OrderBuilder.Create().Ascending(x => x.Email).Ascending(x => x.StoreId), "Email_StoreId"); + await dbContext.CreateIndex(_customerRepository, + OrderBuilder.Create().Ascending(x => x.Username).Ascending(x => x.StoreId), "Username_StoreId"); await dbContext.CreateIndex(_customerRepository, OrderBuilder.Create().Descending(x => x.CreatedOnUtc), "CreatedOnUtc"); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs index 1654a0d89..2599bcbd9 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/CookieAuthenticationServiceTests.cs @@ -113,6 +113,27 @@ public async Task GetAuthenticatedCustomer_UsernameEnableRegisterd_ReturnCustome Assert.AreEqual(customer.Username, expectedCustomer.Username); } + [TestMethod] + public async Task GetAuthenticatedCustomer_WithCustomerIdClaim_ResolvesById_ReturnCustomer() + { + var expectedCustomer = new Customer { Id = "cust-1", Email = "john@grand.com", Active = true }; + var claims = new List { + new("grand:customerId", "cust-1", ClaimValueTypes.String, "grandnode") + }; + var principals = + new ClaimsPrincipal(new ClaimsIdentity(claims, GrandCookieAuthenticationDefaults.AuthenticationScheme)); + _authServiceMock.Setup(c => c.AuthenticateAsync(It.IsAny(), It.IsAny())) + .Returns(() => Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principals, "")))); + _customerServiceMock.Setup(c => c.GetCustomerById("cust-1")).ReturnsAsync(expectedCustomer); + _groupServiceMock.Setup(c => c.IsRegistered(It.IsAny())).Returns(() => Task.FromResult(true)); + + var customer = await _cookieAuthService.GetAuthenticatedCustomer(); + + Assert.IsNotNull(customer); + Assert.AreEqual("cust-1", customer.Id); + _customerServiceMock.Verify(c => c.GetCustomerById("cust-1"), Times.Once); + } + [TestMethod] public async Task GetAuthenticatedCustomer_UsernameEnableGuests_ReturnNull() { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs index ec45adec1..5d42643a6 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/JwtBearerCustomerAuthenticationServiceTests.cs @@ -127,6 +127,36 @@ public async Task Valid_NoPermissions_Customer_ReturnFalse() await _jwtBearerCustomerAuthenticationService.ErrorMessage()); } + [TestMethod] + public async Task Valid_WithCustomerIdClaim_ResolvesById_ReturnTrue() + { + var customer = new Customer { Id = "cust-1", Username = "John", Active = true }; + customer.UserFields.Add(new UserField + { Key = SystemCustomerFieldNames.PasswordToken, Value = "123", StoreId = "" }); + _customerServiceMock.Setup(c => c.GetCustomerById("cust-1")).ReturnsAsync(customer); + _refreshTokenServiceMock.Setup(c => c.GetCustomerRefreshToken(customer)).Returns(() => + Task.FromResult(new RefreshToken { IsActive = true, RefreshId = "567", Token = "123" })); + _permissionServiceMock.Setup(c => c.Authorize(StandardPermission.AllowUseApi, customer)) + .ReturnsAsync(true); + var httpContext = new Mock(); + var context = new TokenValidatedContext(httpContext.Object, + new AuthenticationScheme("", "", typeof(AuthSchemaMock)), new JwtBearerOptions()); + IList claims = new List { + new("CustomerId", "cust-1"), + new("Email", "johny@gmail.com"), + new("Token", "123"), + new("RefreshId", "567") + }; + context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "")); + + var result = await _jwtBearerCustomerAuthenticationService.Valid(context); + + Assert.IsTrue(result); + //resolved by the stable id, not by e-mail + _customerServiceMock.Verify(c => c.GetCustomerById("cust-1"), Times.Once); + _customerServiceMock.Verify(c => c.GetCustomerByEmail(It.IsAny(), It.IsAny()), Times.Never); + } + [TestMethod] public async Task Valid_Customer_ReturnTrue() { diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs new file mode 100644 index 000000000..5eb36f462 --- /dev/null +++ b/src/Tests/Grand.Business.Common.Tests/Services/Security/PermissionProviderTests.cs @@ -0,0 +1,23 @@ +using Grand.Business.Common.Services.Security; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Linq; + +namespace Grand.Business.Common.Tests.Services.Security; + +[TestClass] +public class PermissionProviderTests +{ + private readonly PermissionProvider _provider = new(); + + [TestMethod] + public void StoreManager_DefaultPermissions_IncludeManageCustomers() + { + var storeManager = _provider.GetDefaultPermissions() + .FirstOrDefault(p => p.CustomerGroupSystemName == SystemCustomerGroupNames.StoreManager); + + Assert.IsNotNull(storeManager); + CollectionAssert.Contains(storeManager.Permissions.ToList(), StandardPermission.ManageCustomers); + } +} diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs index 07348f69f..3314e2eab 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs @@ -66,6 +66,35 @@ public async Task LoginCustomerTest_Successful() Assert.AreEqual(CustomerLoginResults.Successful, result); } + [TestMethod] + public async Task LoginCustomer_PassesStoreIdToLookup() + { + //Arrange + var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; + _customerServiceMock.Setup(c => c.GetCustomerByEmail("admin@admin.com", "store-1")) + .ReturnsAsync(customer); + _groupServiceMock.Setup(c => c.IsRegistered(It.IsAny())).ReturnsAsync(true); + //Act + var result = await _customerManagerService.LoginCustomer("admin@admin.com", "123456", "store-1"); + //Assert + Assert.AreEqual(CustomerLoginResults.Successful, result); + _customerServiceMock.Verify(c => c.GetCustomerByEmail("admin@admin.com", "store-1"), Times.Once); + } + + [TestMethod] + public async Task ChangePassword_PassesStoreIdToLookup() + { + //Arrange + var customer = new Customer { Active = true, PasswordFormatId = PasswordFormat.Clear, Password = "123456" }; + _customerServiceMock.Setup(c => c.GetCustomerByEmail("admin@admin.com", "store-1")) + .ReturnsAsync(customer); + var changepassword = new ChangePasswordRequest("admin@admin.com", PasswordFormat.Clear, "zxcvbn", "123456"); + //Act + await _customerManagerService.ChangePassword(changepassword, "store-1"); + //Assert + _customerServiceMock.Verify(c => c.GetCustomerByEmail("admin@admin.com", "store-1"), Times.Once); + } + [TestMethod] public async Task ChangePasswordTest_Success() { diff --git a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs index 8b6ba3239..11bb8e2f9 100644 --- a/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs +++ b/src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs @@ -30,9 +30,17 @@ public void TestInitialize() _cacheBase = new MemoryCacheBase(MemoryCacheTest.Get(), _mediatorMock.Object, new CacheConfig { DefaultCacheTimeMinutes = 1 }); - _customerService = new CustomerService(_repository, _mediatorMock.Object, _cacheBase); + _customerService = new CustomerService(_repository, _mediatorMock.Object, _cacheBase, + _customerConfig); } + private readonly CustomerConfig _customerConfig = new() { RegisterCustomersPerStore = true }; + + //builds a service over the same in-memory repository with the per-store flag explicitly set + private CustomerService CreateService(bool registerCustomersPerStore) => + new(_repository, _mediatorMock.Object, _cacheBase, + new CustomerConfig { RegisterCustomersPerStore = registerCustomersPerStore }); + [TestMethod] public async Task GetOnlineCustomersTest() @@ -135,6 +143,174 @@ public async Task GetCustomerByUsernameTest() Assert.IsNotNull(result); } + [TestMethod] + public async Task GetCustomerByEmail_WithStoreId_ReturnsOnlyMatchingStore() + { + //Arrange - same e-mail in two stores (per-store customer identity) + const string email = "shared@email.com"; + await _repository.InsertAsync(new Customer { Email = email, StoreId = "store-1" }); + await _repository.InsertAsync(new Customer { Email = email, StoreId = "store-2" }); + //Act + var result = await _customerService.GetCustomerByEmail(email, "store-2"); + //Assert + Assert.IsNotNull(result); + Assert.AreEqual("store-2", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_WithStoreId_NoMatch_ReturnsNull() + { + //Arrange + await _repository.InsertAsync(new Customer { Email = "shared@email.com", StoreId = "store-1" }); + //Act + var result = await _customerService.GetCustomerByEmail("shared@email.com", "other-store"); + //Assert + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetCustomerByUsername_WithStoreId_ReturnsOnlyMatchingStore() + { + //Arrange - same username in two stores + await _repository.InsertAsync(new Customer { Username = "user", StoreId = "store-1" }); + await _repository.InsertAsync(new Customer { Username = "user", StoreId = "store-2" }); + //Act + var result = await _customerService.GetCustomerByUsername("user", "store-1"); + //Assert + Assert.IsNotNull(result); + Assert.AreEqual("store-1", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_StoreScoped_FallsBackToStorelessAccount() + { + //Arrange - only the store-independent admin account exists (no customer for this store) + const string email = "admin@email.com"; + await _repository.InsertAsync(new Customer { Email = email, StoreId = "" }); + //Act - storefront login scoped to a store must still find the storeless admin + var result = await _customerService.GetCustomerByEmail(email, "store-1"); + //Assert + Assert.IsNotNull(result); + Assert.AreEqual("", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_StoreScoped_PrefersStoreCustomerOverStoreless() + { + //Arrange - both a store customer and the storeless admin share the email + const string email = "admin@email.com"; + await _repository.InsertAsync(new Customer { Email = email, StoreId = "" }); + await _repository.InsertAsync(new Customer { Email = email, StoreId = "store-1" }); + //Act + var result = await _customerService.GetCustomerByEmail(email, "store-1"); + //Assert - the store's own customer wins within that store + Assert.IsNotNull(result); + Assert.AreEqual("store-1", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_GlobalLookup_PrefersStorelessAccount() + { + //Arrange - a store customer reused the same email as the store-independent admin account + const string email = "admin@email.com"; + await _repository.InsertAsync(new Customer { Email = email, StoreId = "store-2" }); + await _repository.InsertAsync(new Customer { Email = email, StoreId = "" }); //admin / back-office + //Act - global lookup (e.g. back-office login) must not be shadowed by the store customer + var result = await _customerService.GetCustomerByEmail(email); + //Assert + Assert.IsNotNull(result); + Assert.AreEqual("", result.StoreId); + } + + #region GetCustomerByEmail - full branch coverage + + [TestMethod] + public async Task GetCustomerByEmail_NullEmail_ReturnsNull() + { + await _repository.InsertAsync(new Customer { Email = "user@email.com" }); + var result = await _customerService.GetCustomerByEmail(null); + Assert.IsNull(result); + } + + [DataTestMethod] + [DataRow("")] + [DataRow(" ")] + public async Task GetCustomerByEmail_EmptyOrWhitespaceEmail_ReturnsNull(string email) + { + await _repository.InsertAsync(new Customer { Email = "user@email.com" }); + var result = await _customerService.GetCustomerByEmail(email); + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetCustomerByEmail_IsCaseInsensitive() + { + //stored lowercased; the lookup must lowercase the input + await _repository.InsertAsync(new Customer { Email = "user@email.com", StoreId = "" }); + var result = await _customerService.GetCustomerByEmail("USER@Email.COM"); + Assert.IsNotNull(result); + } + + [TestMethod] + public async Task GetCustomerByEmail_StoreScoped_FallsBackToAccountWithNullStoreId() + { + //admin created without a store leaves StoreId null (not just "") - the fallback must match it too + await _repository.InsertAsync(new Customer { Email = "admin@email.com" }); + var result = await _customerService.GetCustomerByEmail("admin@email.com", "store-1"); + Assert.IsNotNull(result); + Assert.IsTrue(string.IsNullOrEmpty(result.StoreId)); + } + + [TestMethod] + public async Task GetCustomerByEmail_PerStoreOn_Global_NoStoreless_FallsBackToAnyMatch() + { + //only a store customer exists (no store-independent account) - global lookup still returns it + await _repository.InsertAsync(new Customer { Email = "user@email.com", StoreId = "store-1" }); + var result = await _customerService.GetCustomerByEmail("user@email.com"); + Assert.IsNotNull(result); + Assert.AreEqual("store-1", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_PerStoreOn_StoreScoped_NoMatchAndNoStoreless_ReturnsNull() + { + await _repository.InsertAsync(new Customer { Email = "user@email.com", StoreId = "store-1" }); + var result = await _customerService.GetCustomerByEmail("user@email.com", "store-2"); + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetCustomerByEmail_PerStoreOff_StoreScoped_ReturnsExactStoreMatch() + { + var service = CreateService(registerCustomersPerStore: false); + await _repository.InsertAsync(new Customer { Email = "user@email.com", StoreId = "store-1" }); + var result = await service.GetCustomerByEmail("user@email.com", "store-1"); + Assert.IsNotNull(result); + Assert.AreEqual("store-1", result.StoreId); + } + + [TestMethod] + public async Task GetCustomerByEmail_PerStoreOff_StoreScoped_DoesNotFallBackToStoreless() + { + var service = CreateService(registerCustomersPerStore: false); + //a store-independent account exists, but with the flag off there is no fallback + await _repository.InsertAsync(new Customer { Email = "admin@email.com", StoreId = "" }); + var result = await service.GetCustomerByEmail("admin@email.com", "store-1"); + Assert.IsNull(result); + } + + [TestMethod] + public async Task GetCustomerByEmail_PerStoreOff_Global_ReturnsMatch() + { + var service = CreateService(registerCustomersPerStore: false); + await _repository.InsertAsync(new Customer { Email = "user@email.com", StoreId = "store-1" }); + var result = await service.GetCustomerByEmail("user@email.com"); + Assert.IsNotNull(result); + Assert.AreEqual("store-1", result.StoreId); + } + + #endregion + [TestMethod] public async Task InsertGuestCustomerTest() { diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs index e99f19bde..b53883d55 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs @@ -5,6 +5,7 @@ using Grand.Domain.Customers; using Grand.Domain.Stores; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.Web.AdminShared.Models.Customers; using Grand.Web.AdminShared.Validators.Customers; @@ -50,7 +51,8 @@ public void Setup() contextAccessorMock.Object, new Mock().Object, _groupServiceMock.Object, - new CustomerSettings()); + new CustomerSettings(), + new CustomerConfig()); } private static CustomerModel BuildModel(string storeId) => diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs new file mode 100644 index 000000000..2a91c0b62 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs @@ -0,0 +1,191 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Addresses; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.Common.Models; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class CustomerControllerTests +{ + private const string StoreId = "store-current"; + + private Mock _customerServiceMock; + private Mock _customerViewModelServiceMock; + private Mock _customerManagerServiceMock; + private Mock _customerAttributeServiceMock; + private Mock _groupServiceMock; + private Mock _translationServiceMock; + private Mock _contextAccessorMock; + + [TestInitialize] + public void Setup() + { + _customerServiceMock = new Mock(); + _customerViewModelServiceMock = new Mock(); + _customerManagerServiceMock = new Mock(); + _customerAttributeServiceMock = new Mock(); + _groupServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + var workContextMock = new Mock(); + workContextMock.Setup(w => w.CurrentCustomer).Returns(new Customer { StaffStoreId = StoreId }); + _contextAccessorMock = new Mock(); + _contextAccessorMock.Setup(c => c.WorkContext).Returns(workContextMock.Object); + } + + private CustomerController BuildController(bool perStoreEnabled) + { + var controller = new CustomerController( + _customerServiceMock.Object, + _customerViewModelServiceMock.Object, + _customerManagerServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _customerAttributeServiceMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _groupServiceMock.Object, + _translationServiceMock.Object, + _contextAccessorMock.Object, + new CustomerSettings(), + new CustomerConfig { RegisterCustomersPerStore = perStoreEnabled }); + + var httpContext = new DefaultHttpContext(); + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + return controller; + } + + private static (ActionExecutingContext context, Func wasNextCalled) BuildGate(CustomerController controller, + string actionName) + { + var actionContext = new ActionContext(controller.ControllerContext.HttpContext, new RouteData(), + new ControllerActionDescriptor { ActionName = actionName }); + var context = new ActionExecutingContext(actionContext, new List(), + new Dictionary(), controller); + return (context, () => false); + } + + [TestMethod] + public async Task Gate_PerStoreDisabled_RedirectsToPerStoreDisabled() + { + var controller = BuildController(perStoreEnabled: false); + var (context, _) = BuildGate(controller, nameof(CustomerController.List)); + var nextCalled = false; + + await controller.OnActionExecutionAsync(context, () => + { + nextCalled = true; + return Task.FromResult(new ActionExecutedContext(context, new List(), controller)); + }); + + Assert.IsFalse(nextCalled); + var redirect = context.Result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual(nameof(CustomerController.PerStoreDisabled), redirect.ActionName); + } + + [TestMethod] + public async Task Gate_PerStoreDisabled_AllowsPerStoreDisabledAction() + { + var controller = BuildController(perStoreEnabled: false); + var (context, _) = BuildGate(controller, nameof(CustomerController.PerStoreDisabled)); + var nextCalled = false; + + await controller.OnActionExecutionAsync(context, () => + { + nextCalled = true; + return Task.FromResult(new ActionExecutedContext(context, new List(), controller)); + }); + + Assert.IsTrue(nextCalled); + Assert.IsNull(context.Result); + } + + [TestMethod] + public async Task Gate_PerStoreEnabled_CallsNext() + { + var controller = BuildController(perStoreEnabled: true); + var (context, _) = BuildGate(controller, nameof(CustomerController.List)); + var nextCalled = false; + + await controller.OnActionExecutionAsync(context, () => + { + nextCalled = true; + return Task.FromResult(new ActionExecutedContext(context, new List(), controller)); + }); + + Assert.IsTrue(nextCalled); + Assert.IsNull(context.Result); + } + + [TestMethod] + public void PerStoreDisabled_ReturnsView() + { + var controller = BuildController(perStoreEnabled: false); + var result = controller.PerStoreDisabled(); + Assert.IsInstanceOfType(result, typeof(ViewResult)); + } + + [TestMethod] + public async Task Create_ForcesStoreScopedRegisteredOnlyConstraints() + { + var controller = BuildController(perStoreEnabled: true); + + _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered)) + .ReturnsAsync(new CustomerGroup { Id = "registered-group" }); + _customerAttributeServiceMock.Setup(a => a.GetAllCustomerAttributes()) + .ReturnsAsync(new List()); + + CustomerModel captured = null; + _customerViewModelServiceMock.Setup(s => s.InsertCustomerModel(It.IsAny())) + .Callback(m => captured = m) + .ReturnsAsync(new Customer { Id = "c1", StoreId = StoreId }); + + //a malicious payload trying to assign a foreign store/role/ownership + var model = new CustomerModel { + Email = "new@customer.com", + StoreId = "foreign-store", + Owner = "owner@x.com", + VendorId = "vendor-1", + StaffStoreId = "staff-store", + SeId = "sales-1", + CustomerGroups = ["administrators-group"], + SelectedAttributes = new List() + }; + + var result = await controller.Create(model, false); + + Assert.IsInstanceOfType(result, typeof(RedirectToActionResult)); + Assert.IsNotNull(captured); + Assert.AreEqual(StoreId, captured.StoreId); + Assert.AreEqual("", captured.Owner); + Assert.AreEqual("", captured.VendorId); + Assert.AreEqual("", captured.StaffStoreId); + Assert.AreEqual("", captured.SeId); + CollectionAssert.AreEqual(new[] { "registered-group" }, captured.CustomerGroups); + } +} diff --git a/src/Web/Grand.Web.Admin/App_Data/appsettings.json b/src/Web/Grand.Web.Admin/App_Data/appsettings.json index e32f347f7..ffb1a975d 100644 --- a/src/Web/Grand.Web.Admin/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Admin/App_Data/appsettings.json @@ -21,6 +21,12 @@ //Gets or sets the value to enable a middleware for logging additional information about CurrentCustomer and CurrentStore "EnableContextLoggingMiddleware": true }, + "Customer": { + //Scope customer accounts per store. Must match the value used by the storefront (Grand.Web). + //false (default): the e-mail/username is a GLOBAL unique key across the whole installation. + //true: the uniqueness key becomes (e-mail/username + StoreId) - the same e-mail can be registered independently per store. + "RegisterCustomersPerStore": false + }, //only for advanced users, allow to set ConnectionString for MongoDb "ConnectionStrings": { "Mongodb": "" diff --git a/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs b/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs index ebb2254ec..7dfb21515 100644 --- a/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/CustomerController.cs @@ -260,7 +260,7 @@ public async Task Create(CustomerModel model, bool continueEditin { var changePassRequest = new ChangePasswordRequest(model.Email, _customerSettings.DefaultPasswordFormat, model.Password); - await _customerManagerService.ChangePassword(changePassRequest); + await _customerManagerService.ChangePassword(changePassRequest, customer.StoreId); } Success(_translationService.GetResource("Admin.Customers.Customers.Added")); @@ -308,7 +308,7 @@ public async Task Edit(CustomerModel model, bool continueEditing) { var changePassRequest = new ChangePasswordRequest(model.Email, _customerSettings.DefaultPasswordFormat, model.Password); - await _customerManagerService.ChangePassword(changePassRequest); + await _customerManagerService.ChangePassword(changePassRequest, customer.StoreId); } Success(_translationService.GetResource("Admin.Customers.Customers.Updated")); diff --git a/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerViewModelService.cs index f49b7419f..fce6cb99e 100644 --- a/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Interfaces/ICustomerViewModelService.cs @@ -14,7 +14,8 @@ public interface ICustomerViewModelService Task PrepareCustomerListModel(); Task<(IEnumerable customerModelList, int totalCount)> PrepareCustomerList(CustomerListModel model, - string[] searchCustomerGroupIds, string[] searchCustomerTagIds, int pageIndex, int pageSize); + string[] searchCustomerGroupIds, string[] searchCustomerTagIds, int pageIndex, int pageSize, + string storeId = ""); Task PrepareCustomerModel(CustomerModel model, Customer customer, bool excludeProperties); Task InsertCustomerModel(CustomerModel model); diff --git a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs index a9d339eb5..e3cce196a 100644 --- a/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs +++ b/src/Web/Grand.Web.AdminShared/Services/CustomerViewModelService.cs @@ -153,11 +153,13 @@ public virtual async Task PrepareCustomerListModel() public virtual async Task<(IEnumerable customerModelList, int totalCount)> PrepareCustomerList( CustomerListModel model, - string[] searchCustomerGroupIds, string[] searchCustomerTagIds, int pageIndex, int pageSize) + string[] searchCustomerGroupIds, string[] searchCustomerTagIds, int pageIndex, int pageSize, + string storeId = "") { var salesEmployeeId = _contextAccessor.WorkContext.CurrentCustomer.SeId; var customers = await _customerService.GetAllCustomers( + storeId: storeId, customerGroupIds: searchCustomerGroupIds, customerTagIds: searchCustomerTagIds, email: model.SearchEmail, diff --git a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs index 2b321ea24..9d4aa13c4 100644 --- a/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs +++ b/src/Web/Grand.Web.AdminShared/Validators/Customers/CustomerValidator.cs @@ -4,6 +4,7 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Extensions; using Grand.Web.AdminShared.Models.Customers; @@ -20,9 +21,13 @@ public CustomerValidator( IContextAccessor contextAccessor, ICustomerService customerService, IGroupService groupService, - CustomerSettings customerSettings) + CustomerSettings customerSettings, + CustomerConfig customerConfig) : base(validators) { + //when per-store customer identity is enabled, uniqueness is scoped to the customer's store + string StoreScope(CustomerModel m) => customerConfig.RegisterCustomersPerStore ? m.StoreId : ""; + CustomerCreateValidator(); CustomerEditValidator(); @@ -182,21 +187,21 @@ void CustomerCreateValidator() context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailTooLong")); - var customerByEmail = await customerService.GetCustomerByEmail(x.Email); + var customerByEmail = await customerService.GetCustomerByEmail(x.Email, StoreScope(x)); if (customerByEmail != null) context.AddFailure("Email is already registered"); } if (!string.IsNullOrWhiteSpace(x.Owner)) { - var customerOwner = await customerService.GetCustomerByEmail(x.Owner); + var customerOwner = await customerService.GetCustomerByEmail(x.Owner, StoreScope(x)); if (customerOwner == null) context.AddFailure("Owner email is not exists"); } if (!string.IsNullOrWhiteSpace(x.Username) && customerSettings.UsernamesEnabled) { - var customerByUsername = await customerService.GetCustomerByUsername(x.Username); + var customerByUsername = await customerService.GetCustomerByUsername(x.Username, StoreScope(x)); if (customerByUsername != null) context.AddFailure("Username is already registered"); @@ -230,7 +235,7 @@ void CustomerEditValidator() if (!string.IsNullOrWhiteSpace(x.Owner)) { - var customerByOwner = await customerService.GetCustomerByEmail(x.Owner); + var customerByOwner = await customerService.GetCustomerByEmail(x.Owner, StoreScope(x)); if (customerByOwner == null) context.AddFailure("Owner email is not exists"); @@ -253,7 +258,7 @@ void CustomerEditValidator() context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailTooLong")); - var customer2 = await customerService.GetCustomerByEmail(x.Email); + var customer2 = await customerService.GetCustomerByEmail(x.Email, StoreScope(x)); if (customer2 != null && customer.Id != customer2.Id) context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailAlreadyExists")); @@ -264,7 +269,7 @@ void CustomerEditValidator() if (x.Username.Length > 100) context.AddFailure("Username is too long"); - var user2 = await customerService.GetCustomerByUsername(x.Username); + var user2 = await customerService.GetCustomerByUsername(x.Username, StoreScope(x)); if (user2 != null && customer.Id != user2.Id) context.AddFailure("The username is already in use"); } diff --git a/src/Web/Grand.Web.Store/App_Data/appsettings.json b/src/Web/Grand.Web.Store/App_Data/appsettings.json index d0d8805b5..8de0a2c9c 100644 --- a/src/Web/Grand.Web.Store/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Store/App_Data/appsettings.json @@ -21,6 +21,12 @@ //Gets or sets the value to enable a middleware for logging additional information about CurrentCustomer and CurrentStore "EnableContextLoggingMiddleware": true }, + "Customer": { + //Scope customer accounts per store. Must match the value used by the storefront (Grand.Web). + //false (default): the e-mail/username is a GLOBAL unique key across the whole installation. + //true: the uniqueness key becomes (e-mail/username + StoreId) - the same e-mail can be registered independently per store. + "RegisterCustomersPerStore": false + }, //only for advanced users, allow to set ConnectionString for MongoDb "ConnectionStrings": { "Mongodb": "" diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressCreate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressCreate.cshtml new file mode 100644 index 000000000..941b61c6f --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressCreate.cshtml @@ -0,0 +1,28 @@ +@model CustomerAddressModel +@{ + ViewBag.Title = Loc["Admin.Customers.Customers.Addresses.AddNew"]; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Customers.Customers.Addresses.AddNew"] + @Html.ActionLink("(" + Loc["Admin.Customers.Customers.Addresses.BackToCustomer"] + ")", "Edit", new { id = Model.CustomerId }) +
+
+ +
+
+
+ +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressEdit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressEdit.cshtml new file mode 100644 index 000000000..44640ad46 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/AddressEdit.cshtml @@ -0,0 +1,28 @@ +@model CustomerAddressModel +@{ + ViewBag.Title = Loc["Admin.Customers.Customers.Addresses.EditAddress"]; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Customers.Customers.Addresses.EditAddress"] + @Html.ActionLink("(" + Loc["Admin.Customers.Customers.Addresses.BackToCustomer"] + ")", "Edit", new { id = Model.CustomerId }) +
+
+ +
+
+
+ +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Create.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Create.cshtml new file mode 100644 index 000000000..9eff4594a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Create.cshtml @@ -0,0 +1,35 @@ +@model CustomerModel +@{ + ViewBag.Title = Loc["Admin.Customers.Customers.AddNew"]; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Customers.Customers.AddNew"] + + @Html.ActionLink(Loc["Admin.Customers.Customers.BackToList"], "List") + +
+
+
+ + +
+
+
+
+ +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Edit.cshtml new file mode 100644 index 000000000..433784d2e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Edit.cshtml @@ -0,0 +1,50 @@ +@model CustomerModel +@{ + ViewBag.Title = Loc["Admin.Customers.Customers.EditCustomerDetails"]; +} +
+ +
+
+
+
+
+ + @Loc["Admin.Customers.Customers.EditCustomerDetails"] - @Model.LastName @Model.FirstName + + @Html.ActionLink(Loc["Admin.Customers.Customers.BackToList"], "List") + +
+
+
+ + + + @if (Model.AllowSendingOfWelcomeMessage) + { + + } + @if (Model.AllowReSendingOfActivationMessage) + { + + } + + @Loc["Admin.Common.Delete"] + +
+
+
+
+ +
+
+
+
+
+ + + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/List.cshtml new file mode 100644 index 000000000..f4a35ede7 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/List.cshtml @@ -0,0 +1,236 @@ +@model CustomerListModel +@inject AdminAreaSettings adminAreaSettings +@{ + ViewBag.Title = Loc["Admin.Customers.Customers"]; +} + +
+ +
+
+ +
+
+ + +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabAddresses.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabAddresses.cshtml new file mode 100644 index 000000000..c7832a8de --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabAddresses.cshtml @@ -0,0 +1,83 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +
+
+
+
+ +
+ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentShoppingCart.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentShoppingCart.cshtml new file mode 100644 index 000000000..d17d04056 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentShoppingCart.cshtml @@ -0,0 +1,143 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings + +
+
+ @Loc["Admin.Customers.Customers.CurrentShoppingCart"] +
+
+
+
+
+ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentWishlist.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentWishlist.cshtml new file mode 100644 index 000000000..765a52864 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabCurrentWishlist.cshtml @@ -0,0 +1,100 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings + +
+
+ @Loc["Admin.Customers.Customers.CurrentWishlist"] +
+
+
+
+
+ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml new file mode 100644 index 000000000..ae49760cf --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabInfo.cshtml @@ -0,0 +1,406 @@ +@model CustomerModel + +@if (Model.CountryEnabled && Model.StateProvinceEnabled) +{ + +} +
+
+@if (Model.UsernamesEnabled) +{ + if (string.IsNullOrEmpty(Model.Id) || Model.AllowUsersToChangeUsernames) + { +
+ +
+ + +
+
+ } + else + { +
+ +
+ + +
+
+ } +} +
+ +
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+@if (!string.IsNullOrEmpty(Model.Id) && Model.AssociatedExternalAuthRecords.Count > 0) +{ +
+ +
+
+ +
+
+} +@if (Model.GenderEnabled) +{ +
+ +
+
+ @Html.RadioButton("Gender", "M", Model.Gender == "M", new { id = "Gender_Male" }) + +
+
+ @Html.RadioButton("Gender", "F", Model.Gender == "F", new { id = "Gender_Female" }) + +
+
+
+} +
+ +
+ + +
+
+
+ +
+ + +
+
+@if (Model.DateOfBirthEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.CompanyEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.StreetAddressEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.StreetAddress2Enabled) +{ +
+ +
+ + +
+
+} +@if (Model.ZipPostalCodeEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.CityEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.CountryEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.CountryEnabled && Model.StateProvinceEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.PhoneEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.FaxEnabled) +{ +
+ +
+ + +
+
+} +@if (Model.CustomerAttributes.Count > 0) +{ +
+ +
+} +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+@if (Model.DisplayVatNumber) +{ +
+ +
+
+ + + + +
+ +
+
+} +@if (Model.AvailableNewsletterSubscriptionStores is { Count: > 0 }) +{ +
+ +
+ @foreach (var store in Model.AvailableNewsletterSubscriptionStores) + { +
+ +
+ } +
+
+} +
+ +
+ + +
+
+@if (!string.IsNullOrEmpty(Model.Id)) +{ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+} +@if (!string.IsNullOrEmpty(Model.Id) && !string.IsNullOrEmpty(Model.LastVisitedPage)) +{ +
+ +
+ +
+
+} +@if (!string.IsNullOrEmpty(Model.Id) && Model.LastPurchaseDate.HasValue) +{ +
+ +
+ +
+
+} +
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabLoyaltyPoints.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabLoyaltyPoints.cshtml new file mode 100644 index 000000000..9bac57f94 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabLoyaltyPoints.cshtml @@ -0,0 +1,124 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings + +
+
+
+
+
+ + +

+ @Loc["Admin.Customers.Customers.LoyaltyPoints.AddTitle"] +

+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ +
+
+
+ diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOrders.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOrders.cshtml new file mode 100644 index 000000000..795c57425 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOrders.cshtml @@ -0,0 +1,141 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +@{ +
+
+
+
+
+ } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOutOfStockSubscriptions.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOutOfStockSubscriptions.cshtml new file mode 100644 index 000000000..d26585a1a --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabOutOfStockSubscriptions.cshtml @@ -0,0 +1,60 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +@{ +
+
+
+
+
+ } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProduct.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProduct.cshtml new file mode 100644 index 000000000..fd5dfd5f0 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProduct.cshtml @@ -0,0 +1,117 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +@{ + @Html.Raw(Loc["Admin.Customers.Customers.PersonalizedProduct"]) + + + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProductPrice.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProductPrice.cshtml new file mode 100644 index 000000000..4b73fd8c2 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabProductPrice.cshtml @@ -0,0 +1,121 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +@{ + @Loc["Admin.Customers.Customers.ProductPrice"] + +
+ @Loc["Admin.Customers.Customers.ProductPrice.Price.Warning"] +
+ + + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabReviews.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabReviews.cshtml new file mode 100644 index 000000000..ce52b7ee5 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.TabReviews.cshtml @@ -0,0 +1,125 @@ +@model CustomerModel +@inject AdminAreaSettings adminAreaSettings +@{ +
+
+
+
+
+ + +} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.cshtml new file mode 100644 index 000000000..dbb056350 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdate.cshtml @@ -0,0 +1,80 @@ +@model CustomerModel +
+ + +@foreach (var item in Model.CustomerGroups.Select((value, index) => new { value, index })) +{ + +} + + + + + + +
+ +
+
+
+ @if (!string.IsNullOrEmpty(Model.Id)) + { + + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ +
+
+
+ @if (Model.DisplayLoyaltyPointsHistory) + { + + +
+ +
+
+
+ } + } +
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdateAddress.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdateAddress.cshtml new file mode 100644 index 000000000..ef794153e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CreateOrUpdateAddress.cshtml @@ -0,0 +1,4 @@ +@model CustomerAddressModel +
+ + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CustomerAttributes.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CustomerAttributes.cshtml new file mode 100644 index 000000000..046159e42 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/CustomerAttributes.cshtml @@ -0,0 +1,78 @@ +@model IList +@foreach (var attribute in Model) +{ + var controlId = $"attributes[{attribute.Id}]"; + var textPrompt = attribute.Name; +
+
+ +
+ + @switch (attribute.AttributeControlType) + { + case AttributeControlType.DropdownList: + { + + } + break; + case AttributeControlType.RadioList: + { +
+ @foreach (var attributeValue in attribute.Values) + { +
+ + +
+ } +
+ } + break; + case AttributeControlType.Checkboxes: + case AttributeControlType.ReadonlyCheckboxes: + { +
+ @foreach (var attributeValue in attribute.Values) + { +
+ + +
+ } +
+ } + break; + case AttributeControlType.TextBox: + { + + } + break; + case AttributeControlType.MultilineTextbox: + { + + } + break; + case AttributeControlType.Datepicker: + case AttributeControlType.FileUpload: + case AttributeControlType.ColorSquares: + case AttributeControlType.ImageSquares: + { + //not support attribute type + } + break; + } +
+
+
+} diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/SendEmail.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/SendEmail.cshtml new file mode 100644 index 000000000..b6fb9b055 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/Partials/SendEmail.cshtml @@ -0,0 +1,68 @@ +@model CustomerModel.SendEmailModel + + + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/PerStoreDisabled.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/PerStoreDisabled.cshtml new file mode 100644 index 000000000..76e513256 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/PerStoreDisabled.cshtml @@ -0,0 +1,23 @@ +@{ + ViewBag.Title = Loc["Admin.Customers.Customers"]; +} + +
+
+
+
+
+ + @Loc["Admin.Customers.Customers"] +
+
+
+
+ Customer management from the store panel is disabled. It requires per-store customer + identity. Ask an administrator to set Customer:RegisterCustomersPerStore + to true in appsettings.json. +
+
+
+
+
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/ProductAddPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/ProductAddPopup.cshtml new file mode 100644 index 000000000..bfe08490f --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Customer/ProductAddPopup.cshtml @@ -0,0 +1,175 @@ +@model CustomerModel.AddProductModel +@inject AdminAreaSettings adminAreaSettings +@{ + Layout = ""; + ViewBag.Title = Loc["Admin.Customers.Products.AddNew"]; +} + +
+ +
+
+ +
+
+ + +
diff --git a/src/Web/Grand.Web.Store/Controllers/CustomerController.cs b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs new file mode 100644 index 000000000..5a460178a --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/CustomerController.cs @@ -0,0 +1,881 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Addresses; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Domain.Tax; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.SharedKernel; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Customers; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Models; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Grand.Web.Store.Controllers; + +/// +/// Lets a store manager manage the registered customers that belong to his own store. +/// The customer is always forced into the 'Registered' group and tied to the current store; the manager +/// never sees or sets the role, owner, vendor, staff-store or store fields, and the customer-notes / +/// documents tabs are not exposed. Export and impersonation are intentionally not available here. +/// +[PermissionAuthorize(PermissionSystemName.Customers)] +public class CustomerController : BaseStoreController +{ + #region Constructors + + public CustomerController( + ICustomerService customerService, + ICustomerViewModelService customerViewModelService, + ICustomerManagerService customerManagerService, + IProductReviewService productReviewService, + IProductReviewViewModelService productReviewViewModelService, + IProductViewModelService productViewModelService, + ICustomerAttributeParser customerAttributeParser, + ICustomerAttributeService customerAttributeService, + IAddressAttributeParser addressAttributeParser, + IAddressAttributeService addressAttributeService, + IMessageProviderService messageProviderService, + IGroupService groupService, + ITranslationService translationService, + IContextAccessor contextAccessor, + CustomerSettings customerSettings, + CustomerConfig customerConfig) + { + _customerService = customerService; + _customerViewModelService = customerViewModelService; + _customerManagerService = customerManagerService; + _productReviewService = productReviewService; + _productReviewViewModelService = productReviewViewModelService; + _productViewModelService = productViewModelService; + _customerAttributeParser = customerAttributeParser; + _customerAttributeService = customerAttributeService; + _addressAttributeParser = addressAttributeParser; + _addressAttributeService = addressAttributeService; + _messageProviderService = messageProviderService; + _groupService = groupService; + _translationService = translationService; + _contextAccessor = contextAccessor; + _customerSettings = customerSettings; + _customerConfig = customerConfig; + } + + #endregion + + #region Fields + + private readonly ICustomerService _customerService; + private readonly ICustomerViewModelService _customerViewModelService; + private readonly ICustomerManagerService _customerManagerService; + private readonly IProductReviewService _productReviewService; + private readonly IProductReviewViewModelService _productReviewViewModelService; + private readonly IProductViewModelService _productViewModelService; + private readonly ICustomerAttributeParser _customerAttributeParser; + private readonly ICustomerAttributeService _customerAttributeService; + private readonly IAddressAttributeParser _addressAttributeParser; + private readonly IAddressAttributeService _addressAttributeService; + private readonly IMessageProviderService _messageProviderService; + private readonly IGroupService _groupService; + private readonly ITranslationService _translationService; + private readonly IContextAccessor _contextAccessor; + private readonly CustomerSettings _customerSettings; + private readonly CustomerConfig _customerConfig; + + #endregion + + #region Per-store gate + + //managing customers from the store panel only makes sense when customer identity is scoped per store + //(Customer:RegisterCustomersPerStore). When it's off the whole controller is disabled and every action + //is routed to the PerStoreDisabled page that explains how to enable the setting. + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!_customerConfig.RegisterCustomersPerStore && + context.ActionDescriptor is ControllerActionDescriptor { ActionName: not nameof(PerStoreDisabled) }) + { + context.Result = RedirectToAction(nameof(PerStoreDisabled)); + return; + } + + await next(); + } + + #endregion + + #region Utilities + + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + /// + /// Loads a customer only when it is a non-deleted, registered customer of the current store. + /// Returns null otherwise so callers can deny access. + /// + private async Task GetStoreCustomer(string id) + { + var customer = await _customerService.GetCustomerById(id); + if (customer == null || customer.Deleted || customer.StoreId != CurrentStoreId) + return null; + if (!await _groupService.IsRegistered(customer)) + return null; + return customer; + } + + /// + /// Forces the store-scoped, registered-only constraints on the posted model regardless of what was sent, + /// so the reused AdminShared insert/update path can never assign a foreign store, role or ownership. + /// + private async Task ApplyStoreConstraints(CustomerModel model) + { + model.StoreId = CurrentStoreId; + model.Owner = ""; + model.VendorId = ""; + model.StaffStoreId = ""; + model.SeId = ""; + var registered = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered); + model.CustomerGroups = registered != null ? new[] { registered.Id } : Array.Empty(); + } + + private async Task> ParseCustomCustomerAttributes(IList model) + { + ArgumentNullException.ThrowIfNull(model); + + var customAttributes = new List(); + var customerAttributes = await _customerAttributeService.GetAllCustomerAttributes(); + foreach (var attribute in customerAttributes) + { + switch (attribute.AttributeControlTypeId) + { + case AttributeControlType.DropdownList: + case AttributeControlType.RadioList: + { + var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + customAttributes = _customerAttributeParser + .AddCustomerAttribute(customAttributes, attribute, ctrlAttributes).ToList(); + } + break; + case AttributeControlType.Checkboxes: + { + var cblAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(cblAttributes)) + foreach (var item in cblAttributes.Split(',').Where(x => !string.IsNullOrEmpty(x))) + customAttributes = _customerAttributeParser + .AddCustomerAttribute(customAttributes, attribute, item).ToList(); + } + break; + case AttributeControlType.ReadonlyCheckboxes: + { + foreach (var selectedAttributeId in attribute.CustomerAttributeValues + .Where(v => v.IsPreSelected).Select(v => v.Id).ToList()) + customAttributes = _customerAttributeParser + .AddCustomerAttribute(customAttributes, attribute, selectedAttributeId).ToList(); + } + break; + case AttributeControlType.TextBox: + case AttributeControlType.MultilineTextbox: + { + var ctrlAttributes = model.FirstOrDefault(x => x.Key == attribute.Id)?.Value; + if (!string.IsNullOrEmpty(ctrlAttributes)) + customAttributes = _customerAttributeParser + .AddCustomerAttribute(customAttributes, attribute, ctrlAttributes.Trim()).ToList(); + } + break; + default: + break; + } + } + + return customAttributes; + } + + #endregion + + #region Customers + + public IActionResult Index() + { + return RedirectToAction("List"); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task List() + { + var model = await _customerViewModelService.PrepareCustomerListModel(); + return View(model); + } + + /// + /// Shown instead of the panel when per-store customer identity is disabled (see the gate below). + /// + public IActionResult PerStoreDisabled() + { + return View(); + } + + [PermissionAuthorizeAction(PermissionActionName.List)] + [HttpPost] + public async Task CustomerList(DataSourceRequest command, CustomerListModel model) + { + //store managers only ever see the registered customers of their own store + var registered = await _groupService.GetCustomerGroupBySystemName(SystemCustomerGroupNames.Registered); + var (customerModelList, totalCount) = await _customerViewModelService.PrepareCustomerList(model, + registered != null ? [registered.Id] : [], null, command.Page, command.PageSize, CurrentStoreId); + var gridModel = new DataSourceResult { + Data = customerModelList.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, null, false); + await ApplyStoreConstraints(model); + model.Active = true; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(CustomerModel model, bool continueEditing) + { + await ApplyStoreConstraints(model); + + if (ModelState.IsValid) + { + model.Attributes = await ParseCustomCustomerAttributes(model.SelectedAttributes); + var customer = await _customerViewModelService.InsertCustomerModel(model); + + //password + if (!string.IsNullOrWhiteSpace(model.Password)) + { + var changePassRequest = new ChangePasswordRequest(model.Email, _customerSettings.DefaultPasswordFormat, + model.Password); + await _customerManagerService.ChangePassword(changePassRequest, customer.StoreId); + } + + Success(_translationService.GetResource("Admin.Customers.Customers.Added")); + return continueEditing ? RedirectToAction("Edit", new { id = customer.Id }) : RedirectToAction("List"); + } + + //If we got this far, something failed, redisplay form + await _customerViewModelService.PrepareCustomerModel(model, null, true); + await ApplyStoreConstraints(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + var model = new CustomerModel(); + await _customerViewModelService.PrepareCustomerModel(model, customer, false); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(CustomerModel model, bool continueEditing) + { + var customer = await GetStoreCustomer(model.Id); + if (customer == null) + return RedirectToAction("List"); + + await ApplyStoreConstraints(model); + + if (ModelState.IsValid) + try + { + model.Attributes = await ParseCustomCustomerAttributes(model.SelectedAttributes); + customer = await _customerViewModelService.UpdateCustomerModel(customer, model); + //change password + if (!string.IsNullOrWhiteSpace(model.Password)) + { + var changePassRequest = new ChangePasswordRequest(model.Email, + _customerSettings.DefaultPasswordFormat, model.Password); + await _customerManagerService.ChangePassword(changePassRequest, customer.StoreId); + } + + Success(_translationService.GetResource("Admin.Customers.Customers.Updated")); + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = customer.Id }); + } + + return RedirectToAction("List"); + } + catch (GrandException exc) + { + Error(exc.Message); + } + + //If we got this far, something failed, redisplay form + await _customerViewModelService.PrepareCustomerModel(model, customer, true); + await ApplyStoreConstraints(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + if (customer.Id == _contextAccessor.WorkContext.CurrentCustomer.Id) + { + Error(_translationService.GetResource("Admin.Customers.Customers.NoSelfDelete")); + return RedirectToAction("List"); + } + + try + { + if (ModelState.IsValid) + { + await _customerViewModelService.DeleteCustomer(customer); + Success(_translationService.GetResource("Admin.Customers.Customers.Deleted")); + return RedirectToAction("List"); + } + + Error(ModelState); + return RedirectToAction("Edit", new { id = customer.Id }); + } + catch (GrandException exc) + { + Error(exc.Message); + return RedirectToAction("Edit", new { id = customer.Id }); + } + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task MarkVatNumberAsValid(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.VatNumberStatusId, + (int)VatNumberStatus.Valid); + + return RedirectToAction("Edit", new { id = customer.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task MarkVatNumberAsInvalid(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.VatNumberStatusId, + (int)VatNumberStatus.Invalid); + + return RedirectToAction("Edit", new { id = customer.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SendWelcomeMessage(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + await _messageProviderService.SendCustomerWelcomeMessage(customer, _contextAccessor.StoreContext.CurrentStore, + _contextAccessor.WorkContext.WorkingLanguage.Id); + + Success(_translationService.GetResource("Admin.Customers.Customers.SendWelcomeMessage.Success")); + return RedirectToAction("Edit", new { id = customer.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ReSendActivationMessage(string id) + { + var customer = await GetStoreCustomer(id); + if (customer == null) + return RedirectToAction("List"); + + await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.AccountActivationToken, + Guid.NewGuid().ToString()); + await _messageProviderService.SendCustomerEmailValidationMessage(customer, + _contextAccessor.StoreContext.CurrentStore, _contextAccessor.WorkContext.WorkingLanguage.Id); + + Success(_translationService.GetResource("Admin.Customers.Customers.ReSendActivationMessage.Success")); + return RedirectToAction("Edit", new { id = customer.Id }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SendEmail(CustomerModel.SendEmailModel model) + { + var customer = await GetStoreCustomer(model.Id); + if (customer == null) + return RedirectToAction("List"); + + try + { + if (string.IsNullOrWhiteSpace(customer.Email)) + throw new GrandException("Customer email is empty"); + if (!CommonHelper.IsValidEmail(customer.Email)) + throw new GrandException("Customer email is not valid"); + if (string.IsNullOrWhiteSpace(model.Subject)) + throw new GrandException("Email subject is empty"); + if (string.IsNullOrWhiteSpace(model.Body)) + throw new GrandException("Email body is empty"); + + await _customerViewModelService.SendEmail(customer, model); + Success(_translationService.GetResource("Admin.Customers.Customers.SendEmail.Queued")); + } + catch (GrandException exc) + { + Error(exc.Message); + } + + return RedirectToAction("Edit", new { id = customer.Id }); + } + + #endregion + + #region Loyalty points history + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task LoyaltyPointsHistorySelect(string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + throw new ArgumentException("No customer found with the specified id"); + + var model = (await _customerViewModelService.PrepareLoyaltyPointsHistoryModel(customerId)).ToList(); + var gridModel = new DataSourceResult { + Data = model, + Total = model.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task LoyaltyPointsHistoryAdd(string customerId, string storeId, + int addLoyaltyPointsValue, string addLoyaltyPointsMessage) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new { Result = false }); + + await _customerViewModelService.InsertLoyaltyPointsHistory(customer, CurrentStoreId, addLoyaltyPointsValue, + addLoyaltyPointsMessage); + + return Json(new { Result = true }); + } + + #endregion + + #region Addresses + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task AddressesSelect(string customerId, DataSourceRequest command) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + throw new ArgumentException("No customer found with the specified id", nameof(customerId)); + + var addresses = (await _customerViewModelService.PrepareAddressModel(customer)).ToList(); + var gridModel = new DataSourceResult { + Data = addresses, + Total = addresses.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AddressDelete(string id, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + throw new ArgumentException("No customer found with the specified id", nameof(customerId)); + + var address = customer.Addresses.FirstOrDefault(a => a.Id == id); + if (address == null) + return Content("No customer found with the specified id"); + if (ModelState.IsValid) + { + await _customerViewModelService.DeleteAddress(customer, address); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AddressCreate(string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return RedirectToAction("List"); + + var model = new CustomerAddressModel(); + await _customerViewModelService.PrepareAddressModel(model, null, customer, false); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AddressCreate(CustomerAddressModel model) + { + var customer = await GetStoreCustomer(model.CustomerId); + if (customer == null) + return RedirectToAction("List"); + + if (ModelState.IsValid) + { + var customAttributes = + await model.Address.ParseCustomAddressAttributes(_addressAttributeParser, _addressAttributeService); + var address = await _customerViewModelService.InsertAddressModel(customer, model, customAttributes); + Success(_translationService.GetResource("Admin.Customers.Customers.Addresses.Added")); + return RedirectToAction("AddressEdit", new { addressId = address.Id, customerId = model.CustomerId }); + } + + await _customerViewModelService.PrepareAddressModel(model, null, customer, true); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AddressEdit(string addressId, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return RedirectToAction("List"); + + var address = customer.Addresses.FirstOrDefault(x => x.Id == addressId); + if (address == null) + return RedirectToAction("Edit", new { id = customer.Id }); + + var model = new CustomerAddressModel(); + await _customerViewModelService.PrepareAddressModel(model, address, customer, false); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AddressEdit(CustomerAddressModel model) + { + var customer = await GetStoreCustomer(model.CustomerId); + if (customer == null) + return RedirectToAction("List"); + + var address = customer.Addresses.FirstOrDefault(x => x.Id == model.Address.Id); + if (address == null) + return RedirectToAction("Edit", new { id = customer.Id }); + + if (ModelState.IsValid) + { + var customAttributes = + await model.Address.ParseCustomAddressAttributes(_addressAttributeParser, _addressAttributeService); + address = await _customerViewModelService.UpdateAddressModel(customer, address, model, customAttributes); + Success(_translationService.GetResource("Admin.Customers.Customers.Addresses.Updated")); + return RedirectToAction("AddressEdit", new { addressId = model.Address.Id, customerId = model.CustomerId }); + } + + await _customerViewModelService.PrepareAddressModel(model, address, customer, true); + return View(model); + } + + #endregion + + #region Orders + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task OrderList(string customerId, DataSourceRequest command, + [FromServices] IOrderViewModelService orderViewModelService) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var model = new OrderListModel { + CustomerId = customerId, + StoreId = CurrentStoreId + }; + + var (orderModels, totalCount) = + await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = orderModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task OrderDetails(string orderId, + [FromServices] IOrderService orderService, [FromServices] IOrderViewModelService orderViewModelService) + { + var order = await orderService.GetOrderById(orderId); + if (order == null || order.StoreId != CurrentStoreId) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var ordermodel = new OrderModel(); + await orderViewModelService.PrepareOrderDetailsModel(ordermodel, order); + var gridModel = new DataSourceResult { + Data = ordermodel.Items, + Total = ordermodel.Items.Count + }; + + return Json(gridModel); + } + + #endregion + + #region Reviews + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ReviewList(string customerId, DataSourceRequest command) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var productReviews = await _productReviewService.GetAllProductReviews(customerId, null, + null, null, "", CurrentStoreId, "", command.Page - 1, command.PageSize); + var items = new List(); + foreach (var x in productReviews) + { + var m = new ProductReviewModel(); + await _productViewModelService.PrepareProductReviewModel(m, x, false, true); + items.Add(m); + } + + var gridModel = new DataSourceResult { + Data = items, + Total = productReviews.TotalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ReviewDelete(string id) + { + var productReview = await _productReviewService.GetProductReviewById(id); + if (productReview == null || productReview.StoreId != CurrentStoreId) + throw new ArgumentException("No review found with the specified id", nameof(id)); + + await _productReviewViewModelService.DeleteProductReview(productReview); + return new JsonResult(""); + } + + #endregion + + #region Current shopping cart / wishlist + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task GetCartList(string customerId, int cartTypeId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var cart = await _customerViewModelService.PrepareShoppingCartItemModel(customerId, cartTypeId); + var gridModel = new DataSourceResult { + Data = cart, + Total = cart.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task UpdateCart(string id, string customerId, double? unitPriceValue) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + throw new ArgumentException("No customer found with the specified id", nameof(customerId)); + + var warnings = await _customerViewModelService.UpdateCart(customer, id, unitPriceValue); + if (warnings.Any()) + return ErrorForKendoGridJson(string.Join(",", warnings)); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task DeleteCart(string id, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + throw new ArgumentException("No customer found with the specified id", nameof(customerId)); + + await _customerViewModelService.DeleteCart(customer, id); + return new JsonResult(""); + } + + #endregion + + #region Customer Product Personalize / Price + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductsPrice(DataSourceRequest command, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var (productPriceModels, totalCount) = + await _customerViewModelService.PrepareProductPriceModel(customerId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = productPriceModels.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task PersonalizedProducts(DataSourceRequest command, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var (productModels, totalCount) = + await _customerViewModelService.PreparePersonalizedProducts(customerId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = productModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAddPopup(string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return RedirectToAction("List"); + + var model = await _customerViewModelService.PrepareCustomerModelAddProductModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopupList(DataSourceRequest command, CustomerModel.AddProductModel model) + { + var products = await _customerViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.products.ToList(), + Total = products.totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAddPopup(string customerId, bool personalized, + CustomerModel.AddProductModel model) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Content(""); + + if (model.SelectedProductIds != null) + await _customerViewModelService.InsertCustomerAddProductModel(customerId, personalized, model); + return Content(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task UpdateProductPrice(CustomerModel.ProductPriceModel model) + { + await _customerViewModelService.UpdateProductPrice(model); + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task DeleteProductPrice(string id) + { + await _customerViewModelService.DeleteProductPrice(id); + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task UpdatePersonalizedProduct(CustomerModel.ProductModel model) + { + await _customerViewModelService.UpdatePersonalizedProduct(model); + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task DeletePersonalizedProduct(string id) + { + await _customerViewModelService.DeletePersonalizedProduct(id); + return new JsonResult(""); + } + + #endregion + + #region Out of stock subscriptions + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task OutOfStockSubscriptionList(DataSourceRequest command, string customerId) + { + var customer = await GetStoreCustomer(customerId); + if (customer == null) + return Json(new DataSourceResult { Data = null, Total = 0 }); + + var (outOfStockSubscriptionModels, totalCount) = + await _customerViewModelService.PrepareOutOfStockSubscriptionModel(customerId, command.Page, + command.PageSize); + var gridModel = new DataSourceResult { + Data = outOfStockSubscriptionModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + #endregion +} diff --git a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json index e32f347f7..ffb1a975d 100644 --- a/src/Web/Grand.Web.Vendor/App_Data/appsettings.json +++ b/src/Web/Grand.Web.Vendor/App_Data/appsettings.json @@ -21,6 +21,12 @@ //Gets or sets the value to enable a middleware for logging additional information about CurrentCustomer and CurrentStore "EnableContextLoggingMiddleware": true }, + "Customer": { + //Scope customer accounts per store. Must match the value used by the storefront (Grand.Web). + //false (default): the e-mail/username is a GLOBAL unique key across the whole installation. + //true: the uniqueness key becomes (e-mail/username + StoreId) - the same e-mail can be registered independently per store. + "RegisterCustomersPerStore": false + }, //only for advanced users, allow to set ConnectionString for MongoDb "ConnectionStrings": { "Mongodb": "" diff --git a/src/Web/Grand.Web.Vendor/Services/MerchandiseReturnViewModelService.cs b/src/Web/Grand.Web.Vendor/Services/MerchandiseReturnViewModelService.cs index 7adb0e302..0c0bb2400 100644 --- a/src/Web/Grand.Web.Vendor/Services/MerchandiseReturnViewModelService.cs +++ b/src/Web/Grand.Web.Vendor/Services/MerchandiseReturnViewModelService.cs @@ -11,6 +11,7 @@ using Grand.Domain.Localization; using Grand.Domain.Orders; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Web.Common.Localization; using Grand.Web.Vendor.Extensions; using Grand.Web.Vendor.Interfaces; @@ -41,6 +42,7 @@ public class MerchandiseReturnViewModelService : IMerchandiseReturnViewModelServ private readonly IAddressAttributeService _addressAttributeService; private readonly IAddressAttributeParser _addressAttributeParser; private readonly IEnumTranslationService _enumTranslationService; + private readonly CustomerConfig _customerConfig; #endregion Fields @@ -61,8 +63,9 @@ public MerchandiseReturnViewModelService( ICountryService countryService, IAddressAttributeService addressAttributeService, IAddressAttributeParser addressAttributeParser, - OrderSettings orderSettings, - IEnumTranslationService enumTranslationService) + OrderSettings orderSettings, + IEnumTranslationService enumTranslationService, + CustomerConfig customerConfig) { _orderService = orderService; _contextAccessor = contextAccessor; @@ -80,6 +83,7 @@ public MerchandiseReturnViewModelService( _addressAttributeParser = addressAttributeParser; _orderSettings = orderSettings; _enumTranslationService = enumTranslationService; + _customerConfig = customerConfig; } #endregion @@ -139,7 +143,12 @@ public virtual async Task PrepareMerchandiseReturnModel( var customerId = string.Empty; if (!string.IsNullOrEmpty(model.SearchCustomerEmail)) { - var customer = await _customerService.GetCustomerByEmail(model.SearchCustomerEmail); + //with per-store customer identity the same e-mail may exist in several stores - scope the + //lookup to the current store so the search matches this store's customer + var storeId = _customerConfig.RegisterCustomersPerStore + ? _contextAccessor.StoreContext.CurrentStore.Id + : ""; + var customer = await _customerService.GetCustomerByEmail(model.SearchCustomerEmail, storeId); customerId = customer != null ? customer.Id : "00000000-0000-0000-0000-000000000000"; } diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 4c2d343e3..c5d8f6c74 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -21,6 +21,16 @@ //Gets or sets the value to enable a middleware for logging additional information about CurrentCustomer and CurrentStore "EnableContextLoggingMiddleware": true }, + "Customer": { + //Scope customer accounts per store. + //false (default): the e-mail/username is a GLOBAL unique key - an e-mail belongs to one customer across the whole installation. + //true: the uniqueness key becomes (e-mail/username + StoreId) - the same e-mail can be registered independently in two different + // stores as two separate accounts. Affects registration, storefront login, auth-cookie re-resolution, password recovery and + // the Admin/Store customer editors. Customers stay in a single collection (discriminated by StoreId); the lookup index is the + // compound (Email + StoreId) index. Set this BEFORE going live - flipping it on an installation that already contains + // duplicate e-mails across stores can make some accounts unreachable. + "RegisterCustomersPerStore": false + }, //only for advanced users, allow to set ConnectionString for MongoDb "ConnectionStrings": { "Mongodb": "" diff --git a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs index 6af8544b9..134e5dcf4 100644 --- a/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs +++ b/src/Web/Grand.Web/Commands/Handler/Customers/SubAccountEditCommandHandler.cs @@ -34,11 +34,11 @@ public async Task Handle(SubAccountEditCommand request, CancellationToken await _customerService.UpdateCustomerField(customer, x => x.Email, request.EditModel.Email); } - //update password + //update password (scope the lookup to the sub-account's store - safe with or without per-store identity) if (!string.IsNullOrEmpty(request.EditModel.Password)) await _customerManagerService.ChangePassword( new ChangePasswordRequest(customer.Email, _customerSettings.DefaultPasswordFormat, - request.EditModel.Password)); + request.EditModel.Password), customer.StoreId); //update active customer.Active = request.EditModel.Active; diff --git a/src/Web/Grand.Web/Controllers/AccountController.cs b/src/Web/Grand.Web/Controllers/AccountController.cs index 2ed396c62..8169012a0 100644 --- a/src/Web/Grand.Web/Controllers/AccountController.cs +++ b/src/Web/Grand.Web/Controllers/AccountController.cs @@ -10,6 +10,7 @@ using Grand.Domain.Customers; using Grand.Domain.Stores; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Attributes; using Grand.Web.Commands.Models.Customers; @@ -44,7 +45,8 @@ public AccountController( IMediator mediator, IMessageProviderService messageProviderService, CaptchaSettings captchaSettings, - CustomerSettings customerSettings) + CustomerSettings customerSettings, + CustomerConfig customerConfig) { _authenticationService = authenticationService; _translationService = translationService; @@ -57,8 +59,16 @@ public AccountController( _messageProviderService = messageProviderService; _captchaSettings = captchaSettings; _mediator = mediator; + _customerConfig = customerConfig; } + /// + /// Returns the current store id when per-store customer identity is enabled, otherwise an empty + /// string (which makes the store-aware customer lookups fall back to the global behaviour). + /// + private string CustomerStoreId => + _customerConfig.RegisterCustomersPerStore ? _contextAccessor.StoreContext.CurrentStore.Id : ""; + #endregion #region My account / Auctions @@ -160,6 +170,7 @@ public virtual async Task Courses() private readonly IMediator _mediator; private readonly IMessageProviderService _messageProviderService; private readonly CustomerSettings _customerSettings; + private readonly CustomerConfig _customerConfig; private readonly CaptchaSettings _captchaSettings; #endregion @@ -192,14 +203,14 @@ public virtual async Task Login(LoginModel model, string returnUr { var loginResult = await _customerManagerService.LoginCustomer( - _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password); + _customerSettings.UsernamesEnabled ? model.Username : model.Email, model.Password, CustomerStoreId); switch (loginResult) { case CustomerLoginResults.Successful: { var customer = _customerSettings.UsernamesEnabled - ? await _customerService.GetCustomerByUsername(model.Username) - : await _customerService.GetCustomerByEmail(model.Email); + ? await _customerService.GetCustomerByUsername(model.Username, CustomerStoreId) + : await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); //sign in return await SignInAction(customer, model.RememberMe, returnUrl); } @@ -230,8 +241,8 @@ public async Task TwoFactorAuthorization() return RedirectToRoute("HomePage"); var customer = _customerSettings.UsernamesEnabled - ? await _customerService.GetCustomerByUsername(username) - : await _customerService.GetCustomerByEmail(username); + ? await _customerService.GetCustomerByUsername(username, CustomerStoreId) + : await _customerService.GetCustomerByEmail(username, CustomerStoreId); if (customer == null) return RedirectToRoute("HomePage"); @@ -261,8 +272,8 @@ public async Task TwoFactorAuthorization(string token, return RedirectToRoute("HomePage"); var customer = _customerSettings.UsernamesEnabled - ? await _customerService.GetCustomerByUsername(username) - : await _customerService.GetCustomerByEmail(username); + ? await _customerService.GetCustomerByUsername(username, CustomerStoreId) + : await _customerService.GetCustomerByEmail(username, CustomerStoreId); if (customer == null) return RedirectToRoute("Login"); @@ -362,7 +373,7 @@ public virtual async Task> PasswordRecovery( { if (!ModelState.IsValid) return View(model); - var customer = await _customerService.GetCustomerByEmail(model.Email); + var customer = await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); await _mediator.Send(new PasswordRecoverySendCommand { Customer = customer, Store = _contextAccessor.StoreContext.CurrentStore, @@ -379,7 +390,7 @@ await _mediator.Send(new PasswordRecoverySendCommand { [PublicStore(true)] public virtual async Task> PasswordRecoveryConfirm(string token, string email) { - var customer = await _customerService.GetCustomerByEmail(email); + var customer = await _customerService.GetCustomerByEmail(email, CustomerStoreId); if (customer == null) return RedirectToRoute("HomePage"); @@ -396,10 +407,10 @@ public virtual async Task PasswordRecoveryConfirm(PasswordRecover { if (!ModelState.IsValid) return View(model); - var customer = await _customerService.GetCustomerByEmail(model.Email); + var customer = await _customerService.GetCustomerByEmail(model.Email, CustomerStoreId); await _customerManagerService.ChangePassword(new ChangePasswordRequest(model.Email, - _customerSettings.DefaultPasswordFormat, model.NewPassword)); + _customerSettings.DefaultPasswordFormat, model.NewPassword), CustomerStoreId); await _customerService.UpdateUserField(customer, SystemCustomerFieldNames.PasswordRecoveryToken, ""); @@ -577,7 +588,7 @@ public virtual async Task CheckUsernameAvailability(string userna } else { - var customer = await _customerService.GetCustomerByUsername(username); + var customer = await _customerService.GetCustomerByUsername(username, CustomerStoreId); if (customer != null) return Json(new { Available = false, Text = statusText }); statusText = _translationService.GetResource("Account.CheckUsernameAvailability.Available"); usernameAvailable = true; @@ -591,7 +602,7 @@ public virtual async Task CheckUsernameAvailability(string userna [PublicStore(true)] public virtual async Task AccountActivation(string token, string email) { - var customer = await _customerService.GetCustomerByEmail(email); + var customer = await _customerService.GetCustomerByEmail(email, CustomerStoreId); if (customer == null) return RedirectToRoute("HomePage"); diff --git a/src/Web/Grand.Web/Validators/Customer/CustomerInfoValidator.cs b/src/Web/Grand.Web/Validators/Customer/CustomerInfoValidator.cs index 685363d53..214f5fea5 100644 --- a/src/Web/Grand.Web/Validators/Customer/CustomerInfoValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/CustomerInfoValidator.cs @@ -4,6 +4,7 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Extensions; using Grand.Web.Features.Models.Customers; @@ -21,9 +22,14 @@ public CustomerInfoValidator( IMediator mediator, ICustomerAttributeParser customerAttributeParser, ITranslationService translationService, ICountryService countryService, - CustomerSettings customerSettings) + CustomerSettings customerSettings, + CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.Email).NotEmpty() .WithMessage(translationService.GetResource("Account.Fields.Email.Required")); RuleFor(x => x.Email).EmailAddress().WithMessage(translationService.GetResource("Common.WrongEmail")); @@ -128,7 +134,7 @@ public CustomerInfoValidator( if (x.Email.Length > 100) context.AddFailure(translationService.GetResource("Account.EmailUsernameErrors.EmailTooLong")); - var customer2 = await customerService.GetCustomerByEmail(x.Email); + var customer2 = await customerService.GetCustomerByEmail(x.Email, storeId); if (customer2 != null) context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailAlreadyExists")); @@ -140,7 +146,7 @@ public CustomerInfoValidator( if (x.Username.ToLower().Length > 100) context.AddFailure(translationService.GetResource("Account.EmailUsernameErrors.UsernameTooLong")); - var customer2 = await customerService.GetCustomerByUsername(x.Username.ToLower()); + var customer2 = await customerService.GetCustomerByUsername(x.Username.ToLower(), storeId); if (customer2 != null) context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.UsernameAlreadyExists")); diff --git a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs index ba33fa992..9520be907 100644 --- a/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/LoginValidator.cs @@ -6,6 +6,8 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Common; using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Models; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; @@ -22,9 +24,14 @@ public LoginValidator( IEnumerable> validatorsCaptcha, ICustomerService customerService, IGroupService groupService, IEncryptionService encryptionService, ITranslationService translationService, CustomerSettings customerSettings, CaptchaSettings captchaSettings, - IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator) + IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator, + IContextAccessor workContextAccessor, CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? workContextAccessor.StoreContext.CurrentStore.Id + : ""; + if (!customerSettings.UsernamesEnabled) { RuleFor(x => x.Email).NotEmpty() @@ -44,8 +51,8 @@ public LoginValidator( RuleFor(x => x).CustomAsync(async (x, context, _) => { var customer = customerSettings.UsernamesEnabled - ? await customerService.GetCustomerByUsername(x.Username) - : await customerService.GetCustomerByEmail(x.Email); + ? await customerService.GetCustomerByUsername(x.Username, storeId) + : await customerService.GetCustomerByEmail(x.Email, storeId); switch (customer) { diff --git a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs index f9227086c..55d279294 100644 --- a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryConfirmValidator.cs @@ -3,6 +3,8 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.Web.Models.Customer; @@ -15,9 +17,14 @@ public PasswordRecoveryConfirmValidator( ICustomerService customerService, IGroupService groupService, ICustomerManagerService customerManagerService, ICustomerHistoryPasswordService customerHistoryPasswordService, - ITranslationService translationService, CustomerSettings customerSettings) + ITranslationService translationService, CustomerSettings customerSettings, + IContextAccessor contextAccessor, CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.NewPassword).NotEmpty() .WithMessage(translationService.GetResource("Account.PasswordRecovery.NewPassword.Required")); @@ -33,7 +40,7 @@ public PasswordRecoveryConfirmValidator( RuleFor(x => x).CustomAsync(async (x, context, _) => { - var customer = await customerService.GetCustomerByEmail(x.Email); + var customer = await customerService.GetCustomerByEmail(x.Email, storeId); switch (customer) { diff --git a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryValidator.cs b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryValidator.cs index 9cc23f971..1a518adef 100644 --- a/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/PasswordRecoveryValidator.cs @@ -2,6 +2,8 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Common; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Models; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; @@ -17,15 +19,20 @@ public PasswordRecoveryValidator( IEnumerable> validatorsCaptcha, ICustomerService customerService, CaptchaSettings captchaSettings, IHttpContextAccessor contextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator, - ITranslationService translationService) + ITranslationService translationService, + IContextAccessor workContextAccessor, CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? workContextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.Email).NotEmpty() .WithMessage(translationService.GetResource("Account.PasswordRecovery.Email.Required")); RuleFor(x => x.Email).EmailAddress().WithMessage(translationService.GetResource("Common.WrongEmail")); RuleFor(x => x).CustomAsync(async (x, context, _) => { - var customer = await customerService.GetCustomerByEmail(x.Email); + var customer = await customerService.GetCustomerByEmail(x.Email, storeId); switch (customer) { diff --git a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs index 9bd0bb8c5..ff41ee029 100644 --- a/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/RegisterValidator.cs @@ -5,6 +5,7 @@ using Grand.Domain.Common; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Models; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Captcha; @@ -27,10 +28,15 @@ public RegisterValidator( IHttpContextAccessor httpcontextAccessor, IGoogleReCaptchaValidator googleReCaptchaValidator, IMediator mediator, ICustomerAttributeParser customerAttributeParser, ICustomerService customerService, - IGroupService groupService, IContextAccessor contextAccessor + IGroupService groupService, IContextAccessor contextAccessor, + CustomerConfig customerConfig ) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.Email).NotEmpty().WithMessage(translationService.GetResource("Account.Fields.Email.Required")); RuleFor(x => x.Email).EmailAddress().WithMessage(translationService.GetResource("Common.WrongEmail")); @@ -142,14 +148,14 @@ public RegisterValidator( //validate unique user - if (await customerService.GetCustomerByEmail(x.Email) != null) + if (await customerService.GetCustomerByEmail(x.Email, storeId) != null) { context.AddFailure(translationService.GetResource("Account.Register.Errors.EmailAlreadyExists")); return; } if (customerSettings.UsernamesEnabled) - if (await customerService.GetCustomerByUsername(x.Username) != null) + if (await customerService.GetCustomerByUsername(x.Username, storeId) != null) context.AddFailure(translationService.GetResource("Account.Register.Errors.UsernameAlreadyExists")); }); } diff --git a/src/Web/Grand.Web/Validators/Customer/SubAccountCreateValidator.cs b/src/Web/Grand.Web/Validators/Customer/SubAccountCreateValidator.cs index 603f1f1c5..c4e31d13f 100644 --- a/src/Web/Grand.Web/Validators/Customer/SubAccountCreateValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/SubAccountCreateValidator.cs @@ -2,6 +2,8 @@ using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.Web.Models.Customer; @@ -13,9 +15,14 @@ public SubAccountCreateValidator( IEnumerable> validators, ICustomerService customerService, ITranslationService translationService, - CustomerSettings customerSettings) + CustomerSettings customerSettings, + IContextAccessor contextAccessor, CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.Email).NotEmpty() .WithMessage(translationService.GetResource("Account.Fields.Email.Required")); RuleFor(x => x.Email).EmailAddress().WithMessage(translationService.GetResource("Common.WrongEmail")); @@ -34,7 +41,7 @@ public SubAccountCreateValidator( RuleFor(x => x).CustomAsync(async (x, context, _) => { - var customer = await customerService.GetCustomerByEmail(x.Email); + var customer = await customerService.GetCustomerByEmail(x.Email, storeId); if (customer != null) context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailAlreadyExists")); diff --git a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs index 674b1b526..c719ff68c 100644 --- a/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs +++ b/src/Web/Grand.Web/Validators/Customer/SubAccountEditValidator.cs @@ -4,6 +4,7 @@ using Grand.Business.Core.Interfaces.Customers; using Grand.Domain.Customers; using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Validators; using Grand.SharedKernel.Extensions; using Grand.Web.Models.Customer; @@ -17,9 +18,14 @@ public SubAccountEditValidator( ICustomerService customerService, IGroupService groupService, ITranslationService translationService, IContextAccessor contextAccessor, - CustomerSettings customerSettings) + CustomerSettings customerSettings, + CustomerConfig customerConfig) : base(validators) { + var storeId = customerConfig.RegisterCustomersPerStore + ? contextAccessor.StoreContext.CurrentStore.Id + : ""; + RuleFor(x => x.Email).NotEmpty().WithMessage(translationService.GetResource("Account.Fields.Email.Required")); RuleFor(x => x.Email).EmailAddress().WithMessage(translationService.GetResource("Common.WrongEmail")); @@ -66,7 +72,7 @@ public SubAccountEditValidator( if (x.Email.Length > 100) context.AddFailure(translationService.GetResource("Account.EmailUsernameErrors.EmailTooLong")); - var customer2 = await customerService.GetCustomerByEmail(x.Email); + var customer2 = await customerService.GetCustomerByEmail(x.Email, storeId); if (customer2 != null && customer.Id != customer2.Id) context.AddFailure( translationService.GetResource("Account.EmailUsernameErrors.EmailAlreadyExists")); diff --git a/src/Web/Grand.Web/Views/Shared/Partials/HeaderLinks.cshtml b/src/Web/Grand.Web/Views/Shared/Partials/HeaderLinks.cshtml index 3af7bd23a..32c581983 100644 --- a/src/Web/Grand.Web/Views/Shared/Partials/HeaderLinks.cshtml +++ b/src/Web/Grand.Web/Views/Shared/Partials/HeaderLinks.cshtml @@ -11,7 +11,7 @@ var isCustomerImpersonated = contextAccessor.WorkContext.OriginalCustomerIfImpersonated != null; var isAdminAccess = await permissionService.Authorize(StandardPermission.ManageAccessAdminPanel); var isVendor = contextAccessor.WorkContext.CurrentVendor != null; - var isStaffStore = contextAccessor.WorkContext.CurrentCustomer.StaffStoreId != null; + var isStaffStore = !string.IsNullOrEmpty(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); } @await Component.InvokeAsync("Widget", new { widgetZone = "header_links_before" })