diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/SystemMappingTests.MessageTemplate_ToModel.verified.txt b/src/Tests/Grand.Mapping.Tests/AdminShared/SystemMappingTests.MessageTemplate_ToModel.verified.txt index 6d883adf68..4c0e5f4877 100644 --- a/src/Tests/Grand.Mapping.Tests/AdminShared/SystemMappingTests.MessageTemplate_ToModel.verified.txt +++ b/src/Tests/Grand.Mapping.Tests/AdminShared/SystemMappingTests.MessageTemplate_ToModel.verified.txt @@ -11,5 +11,7 @@ Stores: [ store-001 ], + IsReadOnly: false, + CanCopy: false, Id: mt-001 } \ No newline at end of file diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseMessageTemplateControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseMessageTemplateControllerTests.cs new file mode 100644 index 0000000000..877ad69813 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseMessageTemplateControllerTests.cs @@ -0,0 +1,387 @@ +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain; +using Grand.Domain.Messages; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.AdminShared.Models.Messages; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseMessageTemplateControllerTests +{ + private Mock _messageTemplateService; + private Mock _emailAccountService; + private Mock _languageService; + private Mock _translationService; + private Mock _messageTokenProvider; + private Mock _downloadService; + private Mock> _scope; + private EmailAccountSettings _emailAccountSettings; + + private class TestableMessageTemplateController( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + ILanguageService languageService, + ITranslationService translationService, + IMessageTokenProvider messageTokenProvider, + IDownloadService downloadService, + IAdminDataScope scope, + EmailAccountSettings emailAccountSettings) + : BaseMessageTemplateController(messageTemplateService, emailAccountService, languageService, + translationService, messageTokenProvider, downloadService, scope, emailAccountSettings); + + private TestableMessageTemplateController CreateController() + { + var controller = new TestableMessageTemplateController(_messageTemplateService.Object, _emailAccountService.Object, + _languageService.Object, _translationService.Object, _messageTokenProvider.Object, _downloadService.Object, + _scope.Object, _emailAccountSettings); + + var httpContext = new DefaultHttpContext(); + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())).Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + + return controller; + } + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => cfg.AddProfile()); + AutoMapperConfig.Init(mapperConfig); + + _messageTemplateService = new Mock(); + _emailAccountService = new Mock(); + _languageService = new Mock(); + _translationService = new Mock(); + _messageTokenProvider = new Mock(); + _downloadService = new Mock(); + _scope = new Mock>(); + _emailAccountSettings = new EmailAccountSettings { DefaultEmailAccountId = "default-ea" }; + + _messageTokenProvider.Setup(s => s.GetListOfAllowedTokens()).Returns(Array.Empty()); + _emailAccountService.Setup(s => s.GetAllEmailAccounts(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _translationService.Setup(s => s.GetResource(It.IsAny())).Returns((string k) => k); + _languageService.Setup(s => s.GetAllLanguages(It.IsAny(), It.IsAny())).ReturnsAsync(new List()); + } + + [TestMethod] + public async Task EditGet_NotFound_RedirectsToList() + { + _messageTemplateService.Setup(s => s.GetMessageTemplateById("missing")).ReturnsAsync((MessageTemplate)null); + + var result = await CreateController().Edit("missing") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task EditGet_CanViewFalse_RedirectsToList() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(false); + + var result = await CreateController().Edit("mt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + } + + [TestMethod] + public async Task EditGet_GlobalTemplate_Store_IsReadOnlyTrue_CanCopyTrue() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = false, Stores = [] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.IsTrue(model.IsReadOnly); + Assert.IsTrue(model.CanCopy); + } + + [TestMethod] + public async Task EditGet_OwnStoreTemplate_Store_IsReadOnlyFalse_CanCopyFalse() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.IsFalse(model.IsReadOnly); + Assert.IsFalse(model.CanCopy); + } + + [TestMethod] + public async Task EditGet_OtherStoreSharedTemplate_Store_IsReadOnlyTrue_CanCopyFalse() + { + // Regression test alongside CopyTemplate_Store_OtherStoreExclusiveTemplate_Denied: a + // template shared with (or exclusively owned by) a DIFFERENT store has HasAccess == + // false, same as a fully global template — but unlike global, it must NOT be + // copyable. CanView allows opening it read-only (multi-store-shared case), but + // CanCopy must stay false since CopyTemplate itself denies it server-side. + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1", "store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + _scope.Setup(s => s.DefaultStoreId).Returns("store-3"); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.IsTrue(model.IsReadOnly); + Assert.IsFalse(model.CanCopy); + } + + [TestMethod] + public async Task EditGet_Admin_AlwaysNotReadOnly_AlwaysCanCopy() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.IsFalse(model.IsReadOnly); + Assert.IsTrue(model.CanCopy); + } + + [TestMethod] + public async Task EditGet_Admin_EmptyLocaleEmailAccountId_FallsBackToDefaultEmailAccount() + { + // Regression test restoring Admin's original Edit(GET) fallback (6deba9db9), dropped + // silently by the consolidation: when a language has no per-language EmailAccountId + // translation, Admin must preselect EmailAccountSettings.DefaultEmailAccountId rather + // than leaving the dropdown empty. + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = false, Stores = [] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + _languageService.Setup(s => s.GetAllLanguages(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.AreEqual(1, model.Locales.Count); + Assert.AreEqual("default-ea", model.Locales[0].EmailAccountId); + } + + [TestMethod] + public async Task EditGet_Store_EmptyLocaleEmailAccountId_StaysEmpty_NoFallback() + { + // Store's original Edit(GET) never had the default-email-account fallback; the fix + // for the Admin regression must not introduce it for Store. + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.CanView(template)).ReturnsAsync(true); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + _languageService.Setup(s => s.GetAllLanguages(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = "lang-1" } }); + + var result = await CreateController().Edit("mt-1") as ViewResult; + var model = result?.Model as MessageTemplateModel; + + Assert.IsNotNull(model); + Assert.AreEqual(1, model.Locales.Count); + Assert.IsTrue(string.IsNullOrEmpty(model.Locales[0].EmailAccountId)); + } + + [TestMethod] + public async Task EditPost_HasAccessFalse_RedirectsToListWithoutSaving() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + + var result = await CreateController().Edit(new MessageTemplateModel { Id = "mt-1" }, false) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _messageTemplateService.Verify(s => s.UpdateMessageTemplate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task EditPost_Store_ForcesLimitedToStoresRegardlessOfSubmittedModel() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1"], Name = "N" }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + + MessageTemplate saved = null; + _messageTemplateService.Setup(s => s.UpdateMessageTemplate(It.IsAny())) + .Callback(t => saved = t) + .Returns(Task.CompletedTask); + + var model = new MessageTemplateModel { Id = "mt-1", Name = "N", Stores = [] }; + await CreateController().Edit(model, false); + + Assert.IsNotNull(saved); + Assert.IsTrue(saved.LimitedToStores); + CollectionAssert.AreEqual(new[] { "store-1" }, saved.Stores.ToArray()); + } + + [TestMethod] + public async Task EditPost_Admin_DoesNotForceLimitedToStores() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = false, Stores = [], Name = "N" }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + + MessageTemplate saved = null; + _messageTemplateService.Setup(s => s.UpdateMessageTemplate(It.IsAny())) + .Callback(t => saved = t) + .Returns(Task.CompletedTask); + + var model = new MessageTemplateModel { Id = "mt-1", Name = "N" }; + await CreateController().Edit(model, false); + + Assert.IsNotNull(saved); + Assert.IsFalse(saved.LimitedToStores); + } + + [TestMethod] + public async Task Delete_HasAccessFalse_DoesNotDelete() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + + var result = await CreateController().Delete("mt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _messageTemplateService.Verify(s => s.DeleteMessageTemplate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Delete_HasAccessTrue_Deletes() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(true); + + var result = await CreateController().Delete("mt-1") as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _messageTemplateService.Verify(s => s.DeleteMessageTemplate(template), Times.Once); + } + + [TestMethod] + public async Task CopyTemplate_Store_OwnTemplate_Denied() + { + var template = new MessageTemplate { Id = "mt-1", LimitedToStores = true, Stores = ["store-1"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await CreateController().CopyTemplate(new MessageTemplateModel { Id = "mt-1" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _messageTemplateService.Verify(s => s.CopyMessageTemplate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CopyTemplate_Store_OtherStoreExclusiveTemplate_Denied() + { + // Regression test for a real Critical found via live smoke test: a template owned + // exclusively by ANOTHER store (HasAccess == false, but also LimitedToStores == true) + // must be denied, not copied. The bug: an earlier version of the guard checked + // scope.HasAccess(messageTemplate) instead of messageTemplate.LimitedToStores, which + // let store1 successfully copy store2's exclusive template's content. + var template = new MessageTemplate { Id = "mt-1", Name = "N", LimitedToStores = true, Stores = ["store-2"] }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + + var result = await CreateController().CopyTemplate(new MessageTemplateModel { Id = "mt-1" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("List", result.ActionName); + _messageTemplateService.Verify(s => s.CopyMessageTemplate(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CopyTemplate_Store_GlobalTemplate_CopiesAndAssignsToCurrentStore() + { + var template = new MessageTemplate { Id = "mt-1", Name = "N", LimitedToStores = false, Stores = [] }; + var copy = new MessageTemplate { Id = "mt-2", Name = "N" }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.HasAccess(template)).ReturnsAsync(false); + _scope.Setup(s => s.DefaultStoreId).Returns("store-1"); + _messageTemplateService.Setup(s => s.GetAllMessageTemplates("", "N", 0, int.MaxValue)).ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _messageTemplateService.Setup(s => s.CopyMessageTemplate(template)).ReturnsAsync(copy); + + MessageTemplate updated = null; + _messageTemplateService.Setup(s => s.UpdateMessageTemplate(It.IsAny())) + .Callback(t => updated = t) + .Returns(Task.CompletedTask); + + var result = await CreateController().CopyTemplate(new MessageTemplateModel { Id = "mt-1" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + Assert.IsNotNull(updated); + Assert.IsTrue(updated.LimitedToStores); + CollectionAssert.AreEqual(new[] { "store-1" }, updated.Stores.ToArray()); + } + + [TestMethod] + public async Task CopyTemplate_Admin_Unrestricted() + { + var template = new MessageTemplate { Id = "mt-1", Name = "N", LimitedToStores = true, Stores = ["store-1"] }; + var copy = new MessageTemplate { Id = "mt-2", Name = "N" }; + _messageTemplateService.Setup(s => s.GetMessageTemplateById("mt-1")).ReturnsAsync(template); + _scope.Setup(s => s.DefaultStoreId).Returns((string)null); + _messageTemplateService.Setup(s => s.CopyMessageTemplate(template)).ReturnsAsync(copy); + + var result = await CreateController().CopyTemplate(new MessageTemplateModel { Id = "mt-1" }) as RedirectToActionResult; + + Assert.IsNotNull(result); + Assert.AreEqual("Edit", result.ActionName); + _messageTemplateService.Verify(s => s.UpdateMessageTemplate(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/MessageTemplateControllerAttributeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/MessageTemplateControllerAttributeTests.cs new file mode 100644 index 0000000000..b5c4298be2 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/MessageTemplateControllerAttributeTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class MessageTemplateControllerAttributeTests +{ + [TestMethod] + public void IsSubclassOfBaseMessageTemplateController() + { + Assert.IsTrue(typeof(BaseMessageTemplateController).IsAssignableFrom(typeof(MessageTemplateController))); + Assert.AreEqual(typeof(BaseMessageTemplateController), typeof(MessageTemplateController).BaseType); + } + + [TestMethod] + public void HasAuthorizeAdminAttribute() + { + var attr = typeof(MessageTemplateController).GetCustomAttributes(typeof(AuthorizeAdminAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaAdminAttribute() + { + var attr = typeof(MessageTemplateController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Admin", attr.RouteValue); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(MessageTemplateController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(MessageTemplateController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedMessageTemplateDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedMessageTemplateDataScopeTests.cs new file mode 100644 index 0000000000..ce0fe2c5f5 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/RoutedMessageTemplateDataScopeTests.cs @@ -0,0 +1,77 @@ +using Grand.Domain.Messages; +using Grand.Web.AdminShared.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class RoutedMessageTemplateDataScopeTests +{ + private static RoutedMessageTemplateDataScope CreateScope(string area) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues = new RouteValueDictionary { ["area"] = area }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(a => a.HttpContext).Returns(httpContext); + + var contextAccessor = new Mock(); + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(new Grand.Domain.Customers.Customer { StaffStoreId = "store-1" }); + contextAccessor.Setup(c => c.WorkContext).Returns(workContext.Object); + + var storeScope = new StoreMessageTemplateDataScope(contextAccessor.Object); + return new RoutedMessageTemplateDataScope(httpContextAccessor.Object, + new GlobalAdminDataScope(), storeScope); + } + + [TestMethod] + public async Task AdminArea_ResolvesToGlobalScope_HasAccessAlwaysTrue() + { + var scope = CreateScope("Admin"); + var result = await scope.HasAccess(new MessageTemplate { LimitedToStores = true, Stores = ["store-2"] }); + Assert.IsTrue(result); + } + + [TestMethod] + public void AdminArea_DefaultStoreId_IsNull() + { + var scope = CreateScope("Admin"); + Assert.IsNull(scope.DefaultStoreId); + } + + [TestMethod] + public async Task StoreArea_ResolvesToStoreScope_HasAccessMatchesOwnership() + { + var scope = CreateScope("Store"); + var owned = await scope.HasAccess(new MessageTemplate { LimitedToStores = true, Stores = ["store-1"] }); + var other = await scope.HasAccess(new MessageTemplate { LimitedToStores = true, Stores = ["store-2"] }); + Assert.IsTrue(owned); + Assert.IsFalse(other); + } + + [TestMethod] + public void StoreArea_DefaultStoreId_IsStaffStoreId() + { + var scope = CreateScope("Store"); + Assert.AreEqual("store-1", scope.DefaultStoreId); + } + + [TestMethod] + public async Task UnrecognizedArea_ThrowsInvalidOperationException() + { + var scope = CreateScope("Vendor"); + await Assert.ThrowsAsync( + () => scope.HasAccess(new MessageTemplate { LimitedToStores = true, Stores = ["store-1"] })); + } + + [TestMethod] + public async Task MissingArea_ThrowsInvalidOperationException() + { + var scope = CreateScope(null); + await Assert.ThrowsAsync( + () => scope.HasAccess(new MessageTemplate { LimitedToStores = true, Stores = ["store-1"] })); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreMessageTemplateDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreMessageTemplateDataScopeTests.cs new file mode 100644 index 0000000000..990ba5ea6d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreMessageTemplateDataScopeTests.cs @@ -0,0 +1,114 @@ +using Grand.Domain.Customers; +using Grand.Domain.Messages; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class StoreMessageTemplateDataScopeTests +{ + private static StoreMessageTemplateDataScope CreateScope(string staffStoreId) + { + var customer = new Customer { StaffStoreId = staffStoreId }; + var workContext = new Mock(); + workContext.Setup(w => w.CurrentCustomer).Returns(customer); + var contextAccessor = new Mock(); + contextAccessor.Setup(c => c.WorkContext).Returns(workContext.Object); + return new StoreMessageTemplateDataScope(contextAccessor.Object); + } + + [TestMethod] + public async Task HasAccess_ExclusivelyOwnedByCurrentStore_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-1"] }; + Assert.IsTrue(await scope.HasAccess(template)); + } + + [TestMethod] + public async Task HasAccess_SharedWithMultipleStores_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-1", "store-2"] }; + Assert.IsFalse(await scope.HasAccess(template)); + } + + [TestMethod] + public async Task HasAccess_OwnedByOtherStore_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-2"] }; + Assert.IsFalse(await scope.HasAccess(template)); + } + + [TestMethod] + public async Task HasAccess_Global_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = false, Stores = [] }; + Assert.IsFalse(await scope.HasAccess(template)); + } + + [TestMethod] + public async Task HasAccess_NullEntity_ReturnsFalse() + { + var scope = CreateScope("store-1"); + Assert.IsFalse(await scope.HasAccess(null)); + } + + [TestMethod] + public async Task CanView_ExclusivelyOwned_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-1"] }; + Assert.IsTrue(await scope.CanView(template)); + } + + [TestMethod] + public async Task CanView_SharedWithMultipleStores_IncludingCurrent_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-1", "store-2"] }; + Assert.IsTrue(await scope.CanView(template)); + } + + [TestMethod] + public async Task CanView_Global_ReturnsTrue() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = false, Stores = [] }; + Assert.IsTrue(await scope.CanView(template)); + } + + [TestMethod] + public async Task CanView_OwnedByOtherStoreOnly_ReturnsFalse() + { + var scope = CreateScope("store-1"); + var template = new MessageTemplate { LimitedToStores = true, Stores = ["store-2"] }; + Assert.IsFalse(await scope.CanView(template)); + } + + [TestMethod] + public async Task CanView_NullEntity_ReturnsFalse() + { + var scope = CreateScope("store-1"); + Assert.IsFalse(await scope.CanView(null)); + } + + [TestMethod] + public void DefaultStoreId_ReturnsStaffStoreId() + { + var scope = CreateScope("store-1"); + Assert.AreEqual("store-1", scope.DefaultStoreId); + } + + [TestMethod] + public void ShowStoreSelector_IsFalse() + { + var scope = CreateScope("store-1"); + Assert.IsFalse(scope.ShowStoreSelector); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/MessageTemplateControllerAttributeTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/MessageTemplateControllerAttributeTests.cs new file mode 100644 index 0000000000..f4720ce331 --- /dev/null +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/MessageTemplateControllerAttributeTests.cs @@ -0,0 +1,50 @@ +using System.Linq; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Store.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Web.Store.Tests.Controllers; + +[TestClass] +public class MessageTemplateControllerAttributeTests +{ + [TestMethod] + public void IsSubclassOfBaseMessageTemplateController() + { + Assert.IsTrue(typeof(BaseMessageTemplateController).IsAssignableFrom(typeof(MessageTemplateController))); + Assert.AreEqual(typeof(BaseMessageTemplateController), typeof(MessageTemplateController).BaseType); + } + + [TestMethod] + public void HasAuthorizeStoreAttribute() + { + var attr = typeof(MessageTemplateController).GetCustomAttributes(typeof(AuthorizeStoreAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAreaStoreAttribute() + { + var attr = typeof(MessageTemplateController) + .GetCustomAttributes(typeof(AreaAttribute), inherit: false) + .Cast().Single(); + Assert.AreEqual("Store", attr.RouteValue); + } + + [TestMethod] + public void HasAutoValidateAntiforgeryTokenAttribute() + { + var attr = typeof(MessageTemplateController) + .GetCustomAttributes(typeof(AutoValidateAntiforgeryTokenAttribute), inherit: true); + Assert.AreEqual(1, attr.Length); + } + + [TestMethod] + public void HasAuthorizeMenuAttribute() + { + var attr = typeof(MessageTemplateController).GetCustomAttributes(typeof(AuthorizeMenuAttribute), inherit: false); + Assert.AreEqual(1, attr.Length); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Create.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Create.cshtml deleted file mode 100644 index 2033b9f57e..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Create.cshtml +++ /dev/null @@ -1,37 +0,0 @@ -@model MessageTemplateModel -@{ - //page title - ViewBag.Title = Loc["Admin.Content.MessageTemplates.AddNew"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Content.MessageTemplates.AddNew"] - @Model.Name - - @Html.ActionLink(Loc["Admin.Content.MessageTemplates.BackToList"], "List") - -
-
-
- - - -
-
-
-
- -
-
-
-
-
\ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Edit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Edit.cshtml deleted file mode 100644 index a000bb8532..0000000000 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Edit.cshtml +++ /dev/null @@ -1,42 +0,0 @@ -@model MessageTemplateModel -@{ - //page title - ViewBag.Title = Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"]; -} -
- -
-
-
-
-
- - @Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"] - @Model.Name - - @Html.ActionLink(Loc["Admin.Content.MessageTemplates.BackToList"], "List") - -
-
-
- - - - - @Loc["Admin.Common.Delete"] - - -
-
-
-
- -
-
-
-
-
- \ No newline at end of file diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml new file mode 100644 index 0000000000..14cd7bf90b --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..32119f6aae --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 0000000000..2af8438cb4 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml new file mode 100644 index 0000000000..aeb6881d7a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs index cb1331e9fe..a1ebd1c8eb 100644 --- a/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/MessageTemplateController.cs @@ -1,11 +1,13 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Common.Stores; using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Interfaces.Storage; -using Grand.Domain.Permissions; using Grand.Domain.Messages; +using Grand.Domain.Permissions; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.AdminShared.Models.Messages; using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; @@ -15,59 +17,34 @@ namespace Grand.Web.Admin.Controllers; -[PermissionAuthorize(PermissionSystemName.MessageTemplates)] -public class MessageTemplateController : BaseAdminController +// Reduced to a thin subclass of BaseMessageTemplateController (ARCH-001 MessageTemplate +// consolidation). Create/Edit/Delete/CopyTemplate live in the shared base; List and its +// grid-data action stay here because Admin's single store-filterable grid is a genuinely +// different UI/workflow decision from Store's two-tab split (see the design spec) - same +// reasoning as other kept-List thin subclasses. +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class MessageTemplateController( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + ILanguageService languageService, + ITranslationService translationService, + IMessageTokenProvider messageTokenProvider, + IDownloadService downloadService, + IAdminDataScope scope, + IStoreService storeService, + EmailAccountSettings emailAccountSettings) + : BaseMessageTemplateController(messageTemplateService, emailAccountService, languageService, + translationService, messageTokenProvider, downloadService, scope, emailAccountSettings) { - #region Constructors - - public MessageTemplateController(IMessageTemplateService messageTemplateService, - IEmailAccountService emailAccountService, - ILanguageService languageService, - ITranslationService translationService, - IMessageTokenProvider messageTokenProvider, - IStoreService storeService, - IDownloadService downloadService, - EmailAccountSettings emailAccountSettings) - { - _messageTemplateService = messageTemplateService; - _emailAccountService = emailAccountService; - _languageService = languageService; - _translationService = translationService; - _messageTokenProvider = messageTokenProvider; - _storeService = storeService; - _downloadService = downloadService; - _emailAccountSettings = emailAccountSettings; - } - - #endregion - - #region Fields - - private readonly IMessageTemplateService _messageTemplateService; - private readonly IEmailAccountService _emailAccountService; - private readonly ILanguageService _languageService; - private readonly ITranslationService _translationService; - private readonly IMessageTokenProvider _messageTokenProvider; - private readonly IStoreService _storeService; - private readonly IDownloadService _downloadService; - private readonly EmailAccountSettings _emailAccountSettings; - - #endregion Fields - - #region Methods - - public IActionResult Index() - { - return RedirectToAction("List"); - } - public async Task List() { var model = new MessageTemplateListModel(); - //stores model.AvailableStores.Add(new SelectListItem - { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); - foreach (var s in await _storeService.GetAllStores()) + { Text = translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var s in await storeService.GetAllStores()) model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); return View(model); @@ -77,7 +54,7 @@ public async Task List() [HttpPost] public async Task List(DataSourceRequest command, MessageTemplateListModel model) { - var messageTemplates = await _messageTemplateService.GetAllMessageTemplates( + var messageTemplates = await messageTemplateService.GetAllMessageTemplates( model.SearchStoreId, keywords: model.Name, pageIndex: command.Page - 1, @@ -87,8 +64,7 @@ public async Task List(DataSourceRequest command, MessageTemplate foreach (var x in messageTemplates) { var templateModel = x.ToModel(); - var stores = (await _storeService - .GetAllStores()) + var stores = (await storeService.GetAllStores()) .Where(s => !x.LimitedToStores || templateModel.Stores.Contains(s.Id)) .ToList(); for (var i = 0; i < stores.Count; i++) @@ -101,199 +77,9 @@ public async Task List(DataSourceRequest command, MessageTemplate items.Add(templateModel); } - var gridModel = new DataSourceResult { + return Json(new DataSourceResult { Data = items, Total = messageTemplates.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new MessageTemplateModel { - //Stores - AllowedTokens = _messageTokenProvider.GetListOfAllowedTokens() - }; - - //available email accounts - foreach (var ea in await _emailAccountService.GetAllEmailAccounts()) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(MessageTemplateModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - var messageTemplate = model.ToEntity(); - //attached file - if (!model.HasAttachedDownload) - messageTemplate.AttachedDownloadId = ""; - if (model.SendImmediately) - messageTemplate.DelayBeforeSend = null; - - await _messageTemplateService.InsertMessageTemplate(messageTemplate); - - Success(_translationService.GetResource("Admin.Content.MessageTemplates.AddNew")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = messageTemplate.Id }); - } - - return RedirectToAction("List"); - } - - - //If we got this far, something failed, redisplay form - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = _messageTokenProvider.GetListOfAllowedTokens(); - //available email accounts - foreach (var ea in await _emailAccountService.GetAllEmailAccounts()) - model.AvailableEmailAccounts.Add(ea.ToModel()); - //Store - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var messageTemplate = await _messageTemplateService.GetMessageTemplateById(id); - if (messageTemplate == null) - //No message template found with the specified id - return RedirectToAction("List"); - - var model = messageTemplate.ToModel(); - model.SendImmediately = !model.DelayBeforeSend.HasValue; - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = _messageTokenProvider.GetListOfAllowedTokens(); - //available email accounts - foreach (var ea in await _emailAccountService.GetAllEmailAccounts()) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - //locales - await AddLocales(_languageService, model.Locales, (locale, languageId) => - { - locale.BccEmailAddresses = messageTemplate.GetTranslation(x => x.BccEmailAddresses, languageId, false); - locale.Subject = messageTemplate.GetTranslation(x => x.Subject, languageId, false); - locale.Body = messageTemplate.GetTranslation(x => x.Body, languageId, false); - - var emailAccountId = messageTemplate.GetTranslation(x => x.EmailAccountId, languageId, false); - locale.EmailAccountId = !string.IsNullOrEmpty(emailAccountId) - ? emailAccountId - : _emailAccountSettings.DefaultEmailAccountId; }); - - return View(model); } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(MessageTemplateModel model, bool continueEditing) - { - var messageTemplate = await _messageTemplateService.GetMessageTemplateById(model.Id); - if (messageTemplate == null) - //No message template found with the specified id - return RedirectToAction("List"); - - var prevAttachment = messageTemplate.AttachedDownloadId; - - if (ModelState.IsValid) - { - messageTemplate = model.ToEntity(messageTemplate); - //attached file - if (!model.HasAttachedDownload) - messageTemplate.AttachedDownloadId = ""; - if (model.SendImmediately) - messageTemplate.DelayBeforeSend = null; - - //delete an old "attachment" file - if (!string.IsNullOrEmpty(prevAttachment) && prevAttachment != messageTemplate.AttachedDownloadId) - { - var attachment = await _downloadService.GetDownloadById(prevAttachment); - if (attachment != null) - await _downloadService.DeleteDownload(attachment); - } - - await _messageTemplateService.UpdateMessageTemplate(messageTemplate); - - Success(_translationService.GetResource("Admin.Content.MessageTemplates.Updated")); - - if (continueEditing) - { - //selected tab - await SaveSelectedTabIndex(); - - return RedirectToAction("Edit", new { id = messageTemplate.Id }); - } - - return RedirectToAction("List"); - } - - //If we got this far, something failed, redisplay form - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = _messageTokenProvider.GetListOfAllowedTokens(); - //available email accounts - foreach (var ea in await _emailAccountService.GetAllEmailAccounts()) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var messageTemplate = await _messageTemplateService.GetMessageTemplateById(id); - if (messageTemplate == null) - //No message template found with the specified id - return RedirectToAction("List"); - - await _messageTemplateService.DeleteMessageTemplate(messageTemplate); - - //delete an old "attachment" file - if (!string.IsNullOrEmpty(messageTemplate.AttachedDownloadId)) - { - var attachment = await _downloadService.GetDownloadById(messageTemplate.AttachedDownloadId); - if (attachment != null) - await _downloadService.DeleteDownload(attachment); - } - - Success(_translationService.GetResource("Admin.Content.MessageTemplates.Deleted")); - return RedirectToAction("List"); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CopyTemplate(MessageTemplateModel model) - { - var messageTemplate = await _messageTemplateService.GetMessageTemplateById(model.Id); - if (messageTemplate == null) - //No message template found with the specified id - return RedirectToAction("List"); - - try - { - var newMessageTemplate = await _messageTemplateService.CopyMessageTemplate(messageTemplate); - Success("The message template has been copied successfully"); - return RedirectToAction("Edit", new { id = newMessageTemplate.Id }); - } - catch (Exception exc) - { - Error(exc.Message); - return RedirectToAction("Edit", new { id = model.Id }); - } - } - - #endregion -} \ No newline at end of file +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseMessageTemplateController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseMessageTemplateController.cs new file mode 100644 index 0000000000..08d81071ad --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseMessageTemplateController.cs @@ -0,0 +1,305 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Messages; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Messages; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Messages; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.MessageTemplates)] +[AutoValidateAntiforgeryToken] +public abstract class BaseMessageTemplateController( + IMessageTemplateService messageTemplateService, + IEmailAccountService emailAccountService, + ILanguageService languageService, + ITranslationService translationService, + IMessageTokenProvider messageTokenProvider, + IDownloadService downloadService, + IAdminDataScope scope, + EmailAccountSettings emailAccountSettings) + : BaseController +{ + public IActionResult Index() => RedirectToAction("List"); + + #region Create + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new MessageTemplateModel { + AllowedTokens = messageTokenProvider.GetListOfAllowedTokens() + }; + + foreach (var ea in await emailAccountService.GetAllEmailAccounts(scope.DefaultStoreId ?? "")) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Create(MessageTemplateModel model, bool continueEditing) + { + if (ModelState.IsValid) + { + // Store-only duplicate-name guard: a store manager may not create a second + // store-exclusive template with the same name for their own store. Admin has no + // such restriction (scope.DefaultStoreId is null there). + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) + { + var existingStoreTemplate = (await messageTemplateService.GetAllMessageTemplates("", keywords: model.Name)) + .FirstOrDefault(t => + t.Name == model.Name && + t.LimitedToStores && + t.Stores.Contains(scope.DefaultStoreId)); + if (existingStoreTemplate != null) + { + ModelState.AddModelError("Name", translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(scope.DefaultStoreId ?? "")) + model.AvailableEmailAccounts.Add(ea.ToModel()); + return View(model); + } + } + + var messageTemplate = model.ToEntity(); + if (!model.HasAttachedDownload) + messageTemplate.AttachedDownloadId = ""; + if (model.SendImmediately) + messageTemplate.DelayBeforeSend = null; + + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) + { + messageTemplate.LimitedToStores = true; + messageTemplate.Stores = [scope.DefaultStoreId]; + } + + await messageTemplateService.InsertMessageTemplate(messageTemplate); + + Success(translationService.GetResource("Admin.Content.MessageTemplates.AddNew")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = messageTemplate.Id }); + } + + return RedirectToAction("List"); + } + + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(scope.DefaultStoreId ?? "")) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + return View(model); + } + + #endregion + + #region Edit + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); + if (messageTemplate == null) + return RedirectToAction("List"); + + if (!await scope.CanView(messageTemplate)) + return RedirectToAction("List"); + + var model = messageTemplate.ToModel(); + model.IsReadOnly = !await scope.HasAccess(messageTemplate); + // Mirror CopyTemplate's own guard exactly (fixed alongside it after a live cross-tenant + // leak): only a fully global template is actually copyable, not merely "not owned by + // me" (HasAccess == false is also true for a template exclusively owned by ANOTHER + // store, which CopyTemplate correctly denies at the server). Using HasAccess here would + // show the Copy button on a screen where clicking it silently no-ops to List. + model.CanCopy = string.IsNullOrEmpty(scope.DefaultStoreId) || !messageTemplate.LimitedToStores; + model.SendImmediately = !model.DelayBeforeSend.HasValue; + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + + foreach (var ea in await emailAccountService.GetAllEmailAccounts(scope.DefaultStoreId ?? "")) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.BccEmailAddresses = messageTemplate.GetTranslation(x => x.BccEmailAddresses, languageId, false); + locale.Subject = messageTemplate.GetTranslation(x => x.Subject, languageId, false); + locale.Body = messageTemplate.GetTranslation(x => x.Body, languageId, false); + + var emailAccountId = messageTemplate.GetTranslation(x => x.EmailAccountId, languageId, false); + // Admin-only fallback, restored verbatim from Admin's original Edit(GET) (6deba9db9): + // when a language has no per-language email-account translation, preselect the + // system default rather than leaving the dropdown empty. Store's original never had + // this fallback, so it stays gated to the unscoped (Admin) case only. + locale.EmailAccountId = string.IsNullOrEmpty(scope.DefaultStoreId) && string.IsNullOrEmpty(emailAccountId) + ? emailAccountSettings.DefaultEmailAccountId + : emailAccountId; + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] + public async Task Edit(MessageTemplateModel model, bool continueEditing) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); + if (messageTemplate == null) + return RedirectToAction("List"); + + if (!await scope.HasAccess(messageTemplate)) + return RedirectToAction("List"); + + var prevAttachment = messageTemplate.AttachedDownloadId; + + if (ModelState.IsValid) + { + messageTemplate = model.ToEntity(messageTemplate); + if (!model.HasAttachedDownload) + messageTemplate.AttachedDownloadId = ""; + if (model.SendImmediately) + messageTemplate.DelayBeforeSend = null; + + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) + { + messageTemplate.LimitedToStores = true; + messageTemplate.Stores = [scope.DefaultStoreId]; + } + + if (!string.IsNullOrEmpty(prevAttachment) && prevAttachment != messageTemplate.AttachedDownloadId) + { + var attachment = await downloadService.GetDownloadById(prevAttachment); + if (attachment != null) + await downloadService.DeleteDownload(attachment); + } + + await messageTemplateService.UpdateMessageTemplate(messageTemplate); + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Updated")); + + if (continueEditing) + { + await SaveSelectedTabIndex(); + return RedirectToAction("Edit", new { id = messageTemplate.Id }); + } + + return RedirectToAction("List"); + } + + model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); + model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); + foreach (var ea in await emailAccountService.GetAllEmailAccounts(scope.DefaultStoreId ?? "")) + model.AvailableEmailAccounts.Add(ea.ToModel()); + + return View(model); + } + + #endregion + + #region Delete + + [PermissionAuthorizeAction(PermissionActionName.Delete)] + [HttpPost] + public async Task Delete(string id) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); + if (messageTemplate == null) + return RedirectToAction("List"); + + if (!await scope.HasAccess(messageTemplate)) + return RedirectToAction("List"); + + await messageTemplateService.DeleteMessageTemplate(messageTemplate); + + if (!string.IsNullOrEmpty(messageTemplate.AttachedDownloadId)) + { + var attachment = await downloadService.GetDownloadById(messageTemplate.AttachedDownloadId); + if (attachment != null) + await downloadService.DeleteDownload(attachment); + } + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Deleted")); + return RedirectToAction("List"); + } + + #endregion + + #region Copy + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CopyTemplate(MessageTemplateModel model) + { + var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); + if (messageTemplate == null) + return RedirectToAction("List"); + + // Store-only guard: only a fully global template (LimitedToStores == false) may be + // copied — matches Store's original behavior exactly (it never allowed copying any + // store-limited template, regardless of which store owned it). Admin + // (scope.DefaultStoreId == null) is unrestricted, preserving its original + // unlimited-copy behavior. + // + // NOTE: an earlier version of this guard used `scope.HasAccess(messageTemplate)` — + // "deny only if the caller already exclusively owns it" — which is NOT equivalent: + // it let a store copy another store's exclusive template (HasAccess is false there + // too), leaking that template's Name/Subject/Body into the caller's own store. Found + // live: store1 successfully copied store2's exclusive "Customer.PasswordRecovery" + // template via a crafted CopyTemplate POST. Fixed by checking LimitedToStores + // directly, not ownership of it. + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) + { + if (messageTemplate.LimitedToStores) + return RedirectToAction("List"); + + var existing = (await messageTemplateService.GetAllMessageTemplates("", keywords: messageTemplate.Name)) + .FirstOrDefault(t => t.Name == messageTemplate.Name && t.LimitedToStores && t.Stores.Contains(scope.DefaultStoreId)); + if (existing != null) + { + Error(translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); + return RedirectToAction("List"); + } + } + + try + { + var newMessageTemplate = await messageTemplateService.CopyMessageTemplate(messageTemplate); + + if (!string.IsNullOrEmpty(scope.DefaultStoreId)) + { + newMessageTemplate.LimitedToStores = true; + newMessageTemplate.Stores = [scope.DefaultStoreId]; + await messageTemplateService.UpdateMessageTemplate(newMessageTemplate); + } + + Success(translationService.GetResource("Admin.Content.MessageTemplates.Copied")); + return RedirectToAction("Edit", new { id = newMessageTemplate.Id }); + } + catch (Exception exc) + { + Error(exc.Message); + return string.IsNullOrEmpty(scope.DefaultStoreId) + ? RedirectToAction("Edit", new { id = model.Id }) + : RedirectToAction("List"); + } + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Models/Messages/MessageTemplateModel.cs b/src/Web/Grand.Web.AdminShared/Models/Messages/MessageTemplateModel.cs index ec03772202..cca113fc40 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Messages/MessageTemplateModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Messages/MessageTemplateModel.cs @@ -62,6 +62,26 @@ public class MessageTemplateModel : BaseEntityModel, ILocalizedModel + /// True when the caller can only preview the template (global, or shared with other + /// stores), not save changes to it. Always false for Admin (unscoped, full CRUD on every + /// template). For Store, mirrors !AccessToEntityByStore(CurrentStoreId) — set by + /// BaseMessageTemplateController.Edit(GET). + /// + public bool IsReadOnly { get; set; } + + /// + /// True when the caller is allowed to invoke CopyTemplate on this entity. Always + /// true for Admin (unrestricted copy, its original behavior). For Store, true exactly when + /// the template is fully global (LimitedToStores == false) — deliberately NOT "not + /// owned by me" (that is, NOT !HasAccess). A template exclusively owned by ANOTHER + /// store also has HasAccess == false, so an ownership-based predicate would + /// incorrectly mark another store's exclusive template as copyable and leak it. Set by + /// BaseMessageTemplateController.Edit(GET), mirroring CopyTemplate's own + /// guard exactly. + /// + public bool CanCopy { get; set; } } public class MessageTemplateLocalizedModel : ILocalizedModelLocal diff --git a/src/Web/Grand.Web.AdminShared/Services/RoutedMessageTemplateDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/RoutedMessageTemplateDataScope.cs new file mode 100644 index 0000000000..d54d4b7b37 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/RoutedMessageTemplateDataScope.cs @@ -0,0 +1,56 @@ +#nullable enable + +using Grand.Domain.Messages; +using Grand.Web.AdminShared.Interfaces; +using Microsoft.AspNetCore.Http; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Resolves the correct per-host +/// implementation at request time, based on the current request's "area" route value — same +/// fix and same reason as (see that file's doc +/// comment): Grand.Web (the combined host) loads Admin and Store together in one DI +/// container, so a plain AddScoped<IAdminDataScope<MessageTemplate>, X>() per +/// host would silently let whichever host's StartupApplication ran last win for every area in +/// that process. +/// +/// There is no Vendor branch: Vendor has no MessageTemplate screen at all, so any "Vendor" +/// (or other unrecognized/missing) area value fails closed. +/// +public class RoutedMessageTemplateDataScope( + IHttpContextAccessor httpContextAccessor, + GlobalAdminDataScope globalScope, + StoreMessageTemplateDataScope storeScope) : IAdminDataScope +{ + private IAdminDataScope Resolved + { + get + { + var area = httpContextAccessor.HttpContext?.Request.RouteValues["area"] as string; + return area switch { + "Admin" => globalScope, + "Store" => storeScope, + //fail closed: this object fronts store tenant isolation, so an unrecognized or + //missing area (including "Vendor" - MessageTemplate has no Vendor screen) must + //never silently resolve to the unscoped global scope + _ => throw new InvalidOperationException( + $"RoutedMessageTemplateDataScope: unrecognized or missing area '{area}'.") + }; + } + } + + public Task HasAccess(MessageTemplate entity) => Resolved.HasAccess(entity); + + public Task CanView(MessageTemplate entity) => Resolved.CanView(entity); + + public string? DefaultStoreId => Resolved.DefaultStoreId; + + public string ResourceKeyPrefix => Resolved.ResourceKeyPrefix; + + public bool ShowStoreSelector => Resolved.ShowStoreSelector; + + public string? DefaultVendorId => Resolved.DefaultVendorId; + + public bool CanFeatureOnHomepage => Resolved.CanFeatureOnHomepage; +} diff --git a/src/Web/Grand.Web.AdminShared/Services/StoreMessageTemplateDataScope.cs b/src/Web/Grand.Web.AdminShared/Services/StoreMessageTemplateDataScope.cs new file mode 100644 index 0000000000..50232f227f --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Services/StoreMessageTemplateDataScope.cs @@ -0,0 +1,40 @@ +#nullable enable + +using Grand.Domain.Messages; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Interfaces; + +namespace Grand.Web.AdminShared.Services; + +/// +/// Store's . Similar to +/// (it also uses the strict +/// ownership check), but bespoke +/// because Store never exposes a Stores picker for message templates (always self-assigned), +/// so must be false. +/// +/// A template can also be shared across N>1 stores without being exclusively owned by any +/// of them — visible/openable read-only, not mutable. is therefore +/// looser than : any membership in Stores, or fully global +/// (!LimitedToStores) — the same loose/strict split as +/// , just keyed off Stores.Contains instead of +/// an empty-StoreId sentinel. +/// +public class StoreMessageTemplateDataScope(IContextAccessor contextAccessor) : IAdminDataScope +{ + public Task HasAccess(MessageTemplate entity) => + Task.FromResult(entity is not null && + entity.AccessToEntityByStore(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)); + + public Task CanView(MessageTemplate entity) => + Task.FromResult(entity is not null && + (!entity.LimitedToStores || + entity.Stores.Contains(contextAccessor.WorkContext.CurrentCustomer.StaffStoreId))); + + public string? DefaultStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public string ResourceKeyPrefix => "Admin"; + public bool ShowStoreSelector => false; // Store never shows a Stores picker — always self-assigned + public string? DefaultVendorId => null; + public bool CanFeatureOnHomepage => true; +} diff --git a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs index 36195e6877..88646b591c 100644 --- a/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.AdminShared/Startup/StartupApplication.cs @@ -151,6 +151,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped, RoutedProductReviewDataScope>(); + // IAdminDataScope: registered once here for the same reason as + // Category/Collection/GiftVoucher above — see RoutedMessageTemplateDataScope's doc + // comment. No Vendor scope: MessageTemplate has no Vendor screen. + services.AddScoped>(); + services.AddScoped(); + services.AddScoped, RoutedMessageTemplateDataScope>(); + // IReportDataScope: NOT an IAdminDataScope registration (Reports has no entity — // see IReportDataScope's doc comment and ARCH-001 Reports consolidation spec §3). All three // hosts have a Reports screen, so all three concrete scopes are registered. diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Create.cshtml similarity index 84% rename from src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Create.cshtml index 566cc6135f..999bb77111 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Create.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Create.cshtml @@ -1,9 +1,9 @@ @model MessageTemplateModel @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewBag.Title = Loc["Admin.Content.MessageTemplates.AddNew"]; - Layout = Constants.LayoutStore; } -
+
@@ -11,7 +11,7 @@
- @Loc["Admin.Content.MessageTemplates.AddNew"] + @Loc["Admin.Content.MessageTemplates.AddNew"] - @Model.Name @Html.ActionLink(Loc["Admin.Content.MessageTemplates.BackToList"], "List") @@ -24,6 +24,7 @@ +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml similarity index 82% rename from src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml index 24a98da065..2a5bdd4852 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Edit.cshtml @@ -1,10 +1,9 @@ -@model MessageTemplateStoreModel +@model MessageTemplateModel @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewBag.Title = Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"]; - Layout = Constants.LayoutStore; - var isReadOnly = Model.IsReadOnly; } - +
@@ -19,7 +18,7 @@
- @if (!isReadOnly) + @if (!Model.IsReadOnly) { - @Loc["Admin.Common.Delete"] } + @if (Model.CanCopy) + { + + } +
@@ -42,7 +45,7 @@
-@if (!isReadOnly) +@if (!Model.IsReadOnly) { } diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml similarity index 96% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml index 19f5f646ad..ccb23f9ea1 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.TabInfo.cshtml @@ -1,9 +1,10 @@ -@using Grand.Domain.Media +@using Grand.Domain.Media @using Grand.Domain.Messages @using Microsoft.AspNetCore.Mvc.Razor @using Constants = Grand.SharedUIResources.Constants @model MessageTemplateModel @{ + var area = ViewContext.RouteData.Values["area"]?.ToString(); ViewData["DownloadType"] = DownloadType.MessageTemplate; ViewData["ReferenceId"] = Model.Id; } @@ -151,13 +152,16 @@ -
- -
- - + @if (area == "Admin") + { +
+ +
+ + +
-
+ }
@@ -229,4 +233,4 @@ } }); } - \ No newline at end of file + diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.cshtml similarity index 59% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.cshtml index aafb0d35af..90798f4208 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/MessageTemplate/Partials/CreateOrUpdate.cshtml @@ -1,8 +1,8 @@ -@model MessageTemplateModel +@model MessageTemplateModel
- + @@ -13,7 +13,7 @@
- + - \ No newline at end of file + diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml index 076e64751c..23fcf5e4ec 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/_ViewImports.cshtml @@ -18,6 +18,7 @@ @using Grand.Web.AdminShared.Models.Orders @using Grand.Web.AdminShared.Models.Customers @using Grand.Web.AdminShared.Models.Discounts +@using Grand.Web.AdminShared.Models.Messages @using Grand.Web.AdminShared.Interfaces @inject LocService Loc diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml index 87ecae4b27..43b0432ede 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/List.cshtml @@ -16,6 +16,7 @@ @Loc["Admin.Common.AddNew"] +
diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml deleted file mode 100644 index 8e3520ab30..0000000000 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/CreateOrUpdate.cshtml +++ /dev/null @@ -1,212 +0,0 @@ -@using Grand.Domain.Messages -@using Microsoft.AspNetCore.Mvc.Razor -@using Constants = Grand.SharedUIResources.Constants -@model MessageTemplateModel - -
- - - - - -@{ - Func - template = @
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
- - -
-
- @foreach (var token in Model.AllowedTokens) - { - - } -
- - -
-
-
- -
- - -
-
- -
; -} - -
-
-
- -
- @if (!string.IsNullOrEmpty(Model.Id)) - { - - - } - else - { - - } -
-
-
- -
- - -
-
-
- - -
-
- -
- - -
-
-
- -
- - -
-
-
- -
-
- - -
-
- @foreach (var token in Model.AllowedTokens) - { - - } -
- - -
-
-
- -
- - -
-
-
-
- -
-
- -
- - -
-
-
- -
-
- - -
-
- - -
-
-
-
- -
- -
- - -
-
-
-
-
- diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml new file mode 100644 index 0000000000..543b82dacb --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsBottom.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml new file mode 100644 index 0000000000..1dc4d4b23f --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsButtons.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml new file mode 100644 index 0000000000..18ad9c8329 --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTabs.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml new file mode 100644 index 0000000000..b8b8908a6e --- /dev/null +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Partials/WidgetZone.DetailsTop.cshtml @@ -0,0 +1,2 @@ +@model Grand.Web.AdminShared.Models.Messages.MessageTemplateModel + diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml index 6ea7e9f9ef..540d82658a 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/_ViewImports.cshtml @@ -38,7 +38,6 @@ @using Grand.Web.AdminShared.Models.Payments @using Grand.Web.AdminShared.Models.Discounts @using Grand.Web.AdminShared.Models.Tax -@using Grand.Web.Store.Models.Messages @using Grand.Web.Store.Models.Common @inject LocService Loc diff --git a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs index 98c1e3baae..6b7d9e44af 100644 --- a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs +++ b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs @@ -1,24 +1,27 @@ -using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Interfaces.Messages; using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Messages; using Grand.Domain.Permissions; using Grand.Infrastructure; -using Grand.Infrastructure.Mapper; -using Grand.SharedKernel; -using Grand.Web.AdminShared.Extensions; +using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Extensions.Mapping; -using Grand.Web.AdminShared.Models.Messages; +using Grand.Web.AdminShared.Interfaces; using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; -using Grand.Web.Store.Models.Messages; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; -[PermissionAuthorize(PermissionSystemName.MessageTemplates)] +// Reduced to a thin subclass of BaseMessageTemplateController (ARCH-001 MessageTemplate +// consolidation). Create/Edit/Delete/CopyTemplate live in the shared base; List/ListGlobal/ +// ListStore stay here — Store's own two-tab split, no Admin equivalent (see the design spec). +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] public class MessageTemplateController( IMessageTemplateService messageTemplateService, IEmailAccountService emailAccountService, @@ -26,19 +29,15 @@ public class MessageTemplateController( ITranslationService translationService, IMessageTokenProvider messageTokenProvider, IDownloadService downloadService, - IContextAccessor contextAccessor) : BaseStoreController + IAdminDataScope scope, + IContextAccessor contextAccessor, + EmailAccountSettings emailAccountSettings) + : BaseMessageTemplateController(messageTemplateService, emailAccountService, languageService, + translationService, messageTokenProvider, downloadService, scope, emailAccountSettings) { private string CurrentStoreId => contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; - public IActionResult Index() - { - return RedirectToAction("List"); - } - - public IActionResult List() - { - return View(); - } + public IActionResult List() => View(); [PermissionAuthorizeAction(PermissionActionName.List)] [HttpPost] @@ -56,12 +55,7 @@ public async Task ListGlobal(DataSourceRequest command) .Select(x => x.ToModel()) .ToList(); - var gridModel = new DataSourceResult { - Data = items, - Total = total - }; - - return Json(gridModel); + return Json(new DataSourceResult { Data = items, Total = total }); } [PermissionAuthorizeAction(PermissionActionName.List)] @@ -80,232 +74,6 @@ public async Task ListStore(DataSourceRequest command) .Select(x => x.ToModel()) .ToList(); - var gridModel = new DataSourceResult { - Data = items, - Total = total - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - public async Task Create() - { - var model = new MessageTemplateModel { - AllowedTokens = messageTokenProvider.GetListOfAllowedTokens() - }; - - foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - await AddLocales(languageService, model.Locales); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Create(MessageTemplateModel model, bool continueEditing) - { - if (ModelState.IsValid) - { - // Prevent duplicate: check only for store-specific templates with this name for the current store. - // Pass keywords to pre-filter by name at the DB level, then verify exact match and store ownership. - var existingStoreTemplate = (await messageTemplateService.GetAllMessageTemplates("", keywords: model.Name)) - .FirstOrDefault(t => - t.Name == model.Name && - t.LimitedToStores && - t.Stores.Contains(CurrentStoreId)); - if (existingStoreTemplate != null) - { - ModelState.AddModelError("Name", translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); - foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) - model.AvailableEmailAccounts.Add(ea.ToModel()); - return View(model); - } - - var messageTemplate = model.ToEntity(); - if (!model.HasAttachedDownload) - messageTemplate.AttachedDownloadId = ""; - if (model.SendImmediately) - messageTemplate.DelayBeforeSend = null; - - // Assign to the current store - messageTemplate.LimitedToStores = true; - messageTemplate.Stores = [CurrentStoreId]; - - await messageTemplateService.InsertMessageTemplate(messageTemplate); - - Success(translationService.GetResource("Admin.Content.MessageTemplates.AddNew")); - - if (continueEditing) - { - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = messageTemplate.Id }); - } - - return RedirectToAction("List"); - } - - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); - foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task Edit(string id) - { - var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); - if (messageTemplate == null) - return RedirectToAction("List"); - - // Block access to templates that belong to a different store - if (messageTemplate.LimitedToStores && !messageTemplate.Stores.Contains(CurrentStoreId)) - return RedirectToAction("List"); - - var model = messageTemplate.MapTo(); - - // Global or multi-store-shared templates are shown read-only; only an exclusively-owned - // template (LimitedToStores && Stores == [CurrentStoreId]) can actually be saved (see Edit POST). - model.IsReadOnly = !messageTemplate.AccessToEntityByStore(CurrentStoreId); - model.SendImmediately = !model.DelayBeforeSend.HasValue; - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); - - foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.BccEmailAddresses = messageTemplate.GetTranslation(x => x.BccEmailAddresses, languageId, false); - locale.Subject = messageTemplate.GetTranslation(x => x.Subject, languageId, false); - locale.Body = messageTemplate.GetTranslation(x => x.Body, languageId, false); - locale.EmailAccountId = messageTemplate.GetTranslation(x => x.EmailAccountId, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - [ArgumentNameFilter(KeyName = "save-continue", Argument = "continueEditing")] - public async Task Edit(MessageTemplateModel model, bool continueEditing) - { - var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); - if (messageTemplate == null) - return RedirectToAction("List"); - - if (!messageTemplate.AccessToEntityByStore(CurrentStoreId)) - return RedirectToAction("List"); - - var prevAttachment = messageTemplate.AttachedDownloadId; - - if (ModelState.IsValid) - { - messageTemplate = model.ToEntity(messageTemplate); - if (!model.HasAttachedDownload) - messageTemplate.AttachedDownloadId = ""; - if (model.SendImmediately) - messageTemplate.DelayBeforeSend = null; - - // Keep it assigned to the current store - messageTemplate.LimitedToStores = true; - messageTemplate.Stores = [CurrentStoreId]; - - if (!string.IsNullOrEmpty(prevAttachment) && prevAttachment != messageTemplate.AttachedDownloadId) - { - var attachment = await downloadService.GetDownloadById(prevAttachment); - if (attachment != null) - await downloadService.DeleteDownload(attachment); - } - - await messageTemplateService.UpdateMessageTemplate(messageTemplate); - - Success(translationService.GetResource("Admin.Content.MessageTemplates.Updated")); - - if (continueEditing) - { - await SaveSelectedTabIndex(); - return RedirectToAction("Edit", new { id = messageTemplate.Id }); - } - - return RedirectToAction("List"); - } - - model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); - model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); - foreach (var ea in await emailAccountService.GetAllEmailAccounts(CurrentStoreId)) - model.AvailableEmailAccounts.Add(ea.ToModel()); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task Delete(string id) - { - var messageTemplate = await messageTemplateService.GetMessageTemplateById(id); - if (messageTemplate == null) - return RedirectToAction("List"); - - if (!messageTemplate.AccessToEntityByStore(CurrentStoreId)) - return RedirectToAction("List"); - - await messageTemplateService.DeleteMessageTemplate(messageTemplate); - - if (!string.IsNullOrEmpty(messageTemplate.AttachedDownloadId)) - { - var attachment = await downloadService.GetDownloadById(messageTemplate.AttachedDownloadId); - if (attachment != null) - await downloadService.DeleteDownload(attachment); - } - - Success(translationService.GetResource("Admin.Content.MessageTemplates.Deleted")); - return RedirectToAction("List"); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CopyTemplate(MessageTemplateModel model) - { - var messageTemplate = await messageTemplateService.GetMessageTemplateById(model.Id); - if (messageTemplate == null) - return RedirectToAction("List"); - - // Only allow copying global templates (LimitedToStores = false) - if (messageTemplate.LimitedToStores) - return RedirectToAction("List"); - - // Prevent duplicate: check if a store-specific template with the same name already exists for the current store - var existing = (await messageTemplateService.GetAllMessageTemplates("", keywords: messageTemplate.Name)) - .FirstOrDefault(t => t.Name == messageTemplate.Name && t.LimitedToStores && t.Stores.Contains(CurrentStoreId)); - if (existing != null) - { - Error(translationService.GetResource("Admin.Content.MessageTemplates.Fields.Name.AlreadyExists")); - return RedirectToAction("List"); - } - - try - { - var newMessageTemplate = await messageTemplateService.CopyMessageTemplate(messageTemplate); - // Assign copy to the current store - newMessageTemplate.LimitedToStores = true; - newMessageTemplate.Stores = [CurrentStoreId]; - await messageTemplateService.UpdateMessageTemplate(newMessageTemplate); - - Success(translationService.GetResource("Admin.Content.MessageTemplates.Copied")); - return RedirectToAction("Edit", new { id = newMessageTemplate.Id }); - } - catch (GrandException exc) - { - Error(exc.Message); - return RedirectToAction("List"); - } + return Json(new DataSourceResult { Data = items, Total = total }); } } diff --git a/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs b/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs deleted file mode 100644 index 93f0e3d0c9..0000000000 --- a/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Grand.Domain.Messages; -using Grand.Infrastructure.Mapper; -using Grand.Mapping; -using Grand.Web.Store.Models.Messages; - -namespace Grand.Web.Store.Mapper; - -public class MessageTemplateStoreProfile : Profile, IAutoMapperProfile -{ - public MessageTemplateStoreProfile() - { - CreateMap() - .ForMember(dest => dest.Locales, mo => mo.Ignore()) - .ForMember(dest => dest.AllowedTokens, mo => mo.Ignore()) - .ForMember(dest => dest.HasAttachedDownload, mo => mo.Ignore()) - .ForMember(dest => dest.AvailableEmailAccounts, mo => mo.Ignore()) - .ForMember(dest => dest.ListOfStores, mo => mo.Ignore()) - .ForMember(dest => dest.IsReadOnly, mo => mo.Ignore()); - } - - public int Order => 0; -} diff --git a/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs b/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs deleted file mode 100644 index f01be10db8..0000000000 --- a/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Grand.Web.AdminShared.Models.Messages; - -namespace Grand.Web.Store.Models.Messages; - -public class MessageTemplateStoreModel : MessageTemplateModel -{ - /// - /// True when the store manager can only preview the template (global, or shared with other stores), - /// not save changes to it. - /// - public bool IsReadOnly { get; set; } -}