From edb19380948d6a0fd56e44f1aa45f991e0f0cfc2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 14 Aug 2026 14:08:39 +0200 Subject: [PATCH 1/3] Fix cross-store IDOR gaps in Grand.Web.Store Audit of the store-owner/store-manager panel (scoped by WorkContext.CurrentCustomer.StaffStoreId) found several places where a loaded entity, or an entity referenced by id in a request, was mutated without verifying it belonged to the current store manager's store. - ProductController: ~20 sub-resource mutation actions (category/ collection/related/similar/bundle/cross-sell/recommended/associated product mappings, pictures, spec attributes, prices, tier prices, attribute mappings/values) had no ownership check at all, even though the paired GET/list action next to each of them did. Added AccessToEntityByStore checks before every mutation, and filter SelectedProductIds down to store-owned products where the shared ProductViewModelService mutates each selected entity by id. - CategoryController/CollectionController: ProductUpdate/ProductDelete/ ProductAddPopup mutate the product's category/collection list, not the category/collection entity - the missing check was on the product. - MerchandiseReturnController.MerchandiseReturnNoteAdd: an unchecked orderId parameter was used to build a customer-facing notification email, letting a manager forge/misdirect it with another store's order data. Now validated against the merchandise return's own OrderId. - NewsController.List: scoped by request host (StoreContext.CurrentStore) instead of StaffStoreId, letting the result set diverge from the manager's actual store depending on which host the panel is reached through. - ShippingController.RestrictionSave: mutated restricted-countries/ restricted-groups on global (store-independent) shipping methods returned alongside store-owned ones, not just store-owned methods. - MessageTemplateController.Edit/Delete and SettingController's IsStoreOwnerAccessAllowed (MerchandiseReturnReason/Action) used a non-exclusive "is my store one of the assigned stores" check instead of requiring exclusive ownership, letting one store's manager edit-and- reassign-away or delete an entity another store still depends on. Updated ShippingControllerTests fixtures that encoded the old (vulnerable) behavior and added a regression test for the fix. Verified with dotnet build (Store, Admin, Vendor - all reference the touched shared AdminShared extension) and dotnet test on Grand.Web.Store.Tests. --- .../Controllers/ShippingControllerTests.cs | 27 +++- .../Controllers/CategoryController.cs | 33 ++++- .../Controllers/CollectionController.cs | 33 ++++- .../MerchandiseReturnController.cs | 1 + .../Controllers/MessageTemplateController.cs | 10 +- .../Controllers/NewsController.cs | 2 +- .../Controllers/ProductController.cs | 138 +++++++++++++++++- .../Controllers/SettingController.cs | 10 +- .../Controllers/ShippingController.cs | 4 +- 9 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ShippingControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ShippingControllerTests.cs index a49a2cde4..fbf89ff47 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ShippingControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ShippingControllerTests.cs @@ -101,7 +101,7 @@ public async Task RestrictionSave_AddRestriction_UpdateShippingMethod() { var country = new Country { Id = "countryId", Name = "Poland" }; var customerGroup = new CustomerGroup { Name = "Guests" }; - var shippingMethod = new ShippingMethod { Name = "Ground" }; + var shippingMethod = new ShippingMethod { Name = "Ground", StoreId = StoreId }; SetupCommonData(country, shippingMethod, customerGroup); var form = new Dictionary { @@ -124,7 +124,7 @@ public async Task RestrictionSave_NoFormValues_ClearExistingRestrictions() { var country = new Country { Id = "countryId", Name = "Poland" }; var customerGroup = new CustomerGroup { Name = "Guests" }; - var shippingMethod = new ShippingMethod { Name = "Ground" }; + var shippingMethod = new ShippingMethod { Name = "Ground", StoreId = StoreId }; shippingMethod.RestrictedCountries.Add(country); shippingMethod.RestrictedGroups.Add(customerGroup.Id); SetupCommonData(country, shippingMethod, customerGroup); @@ -142,7 +142,7 @@ public async Task RestrictionSave_NoChanges_NotUpdateShippingMethod() { var country = new Country { Id = "countryId", Name = "Poland" }; var customerGroup = new CustomerGroup { Name = "Guests" }; - var shippingMethod = new ShippingMethod { Name = "Ground" }; + var shippingMethod = new ShippingMethod { Name = "Ground", StoreId = StoreId }; SetupCommonData(country, shippingMethod, customerGroup); var result = await _controller.RestrictionSave(new Dictionary()); @@ -150,4 +150,25 @@ public async Task RestrictionSave_NoChanges_NotUpdateShippingMethod() Assert.IsInstanceOfType(result); _shippingMethodServiceMock.Verify(s => s.UpdateShippingMethod(It.IsAny()), Times.Never); } + + [TestMethod] + public async Task RestrictionSave_GlobalShippingMethod_NotUpdated() + { + // GetAllShippingMethods(storeId) also returns global (StoreId=="") shipping methods, shared by + // every store - RestrictionSave must not mutate restrictions on a method it doesn't exclusively own. + var country = new Country { Id = "countryId", Name = "Poland" }; + var customerGroup = new CustomerGroup { Name = "Guests" }; + var globalShippingMethod = new ShippingMethod { Name = "Ground", StoreId = "" }; + SetupCommonData(country, globalShippingMethod, customerGroup); + + var form = new Dictionary { + [$"restrict_{globalShippingMethod.Id}"] = ["countryId"] + }; + + var result = await _controller.RestrictionSave(form); + + Assert.IsInstanceOfType(result); + Assert.IsFalse(globalShippingMethod.RestrictedCountries.Any(c => c.Id == "countryId")); + _shippingMethodServiceMock.Verify(s => s.UpdateShippingMethod(It.IsAny()), Times.Never); + } } diff --git a/src/Web/Grand.Web.Store/Controllers/CategoryController.cs b/src/Web/Grand.Web.Store/Controllers/CategoryController.cs index f67ed0255..22d220ba4 100644 --- a/src/Web/Grand.Web.Store/Controllers/CategoryController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CategoryController.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Categories; +using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Domain.Permissions; using Grand.Infrastructure; @@ -26,7 +27,8 @@ public CategoryController( ILanguageService languageService, ITranslationService translationService, IContextAccessor contextAccessor, - IPictureViewModelService pictureViewModelService) + IPictureViewModelService pictureViewModelService, + IProductService productService) { _categoryService = categoryService; _categoryViewModelService = categoryViewModelService; @@ -34,6 +36,7 @@ public CategoryController( _translationService = translationService; _contextAccessor = contextAccessor; _pictureViewModelService = pictureViewModelService; + _productService = productService; } #endregion @@ -46,6 +49,7 @@ public CategoryController( private readonly ITranslationService _translationService; private readonly IContextAccessor _contextAccessor; private readonly IPictureViewModelService _pictureViewModelService; + private readonly IProductService _productService; #endregion @@ -276,6 +280,10 @@ public async Task ProductList(DataSourceRequest command, string c [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task ProductUpdate(CategoryModel.CategoryProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson("This is not your product"); + if (ModelState.IsValid) { await _categoryViewModelService.UpdateProductCategoryModel(model); @@ -288,6 +296,10 @@ public async Task ProductUpdate(CategoryModel.CategoryProductMode [PermissionAuthorizeAction(PermissionActionName.Edit)] public async Task ProductDelete(CategoryModel.CategoryProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson("This is not your product"); + if (ModelState.IsValid) { await _categoryViewModelService.DeleteProductCategoryModel(model.Id, model.ProductId); @@ -322,9 +334,26 @@ public async Task ProductAddPopupList(DataSourceRequest command, [HttpPost] public async Task ProductAddPopup(CategoryModel.AddCategoryProductModel model) { + var category = await _categoryService.GetCategoryById(model.CategoryId); + if (category == null || !category.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content("This is not your category"); + if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await _categoryViewModelService.InsertCategoryProductModel(model); + //InsertCategoryProductModel mutates each selected product's ProductCategories collection, + //so every selected id must also belong to the current store. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await _productService.GetProductById(id); + if (selected != null && selected.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + validIds.Add(id); + } + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Any()) await _categoryViewModelService.InsertCategoryProductModel(model); + } return Content(""); } diff --git a/src/Web/Grand.Web.Store/Controllers/CollectionController.cs b/src/Web/Grand.Web.Store/Controllers/CollectionController.cs index e172bdb1f..0d5103a37 100644 --- a/src/Web/Grand.Web.Store/Controllers/CollectionController.cs +++ b/src/Web/Grand.Web.Store/Controllers/CollectionController.cs @@ -1,5 +1,6 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Catalog.Collections; +using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Domain.Catalog; @@ -29,7 +30,8 @@ public CollectionController( ILanguageService languageService, ITranslationService translationService, IGroupService groupService, - IPictureViewModelService pictureViewModelService) + IPictureViewModelService pictureViewModelService, + IProductService productService) { _collectionViewModelService = collectionViewModelService; _collectionService = collectionService; @@ -38,6 +40,7 @@ public CollectionController( _translationService = translationService; _groupService = groupService; _pictureViewModelService = pictureViewModelService; + _productService = productService; } #endregion @@ -51,6 +54,7 @@ public CollectionController( private readonly ITranslationService _translationService; private readonly IGroupService _groupService; private readonly IPictureViewModelService _pictureViewModelService; + private readonly IProductService _productService; #endregion @@ -313,6 +317,10 @@ public async Task ProductList(DataSourceRequest command, string c [HttpPost] public async Task ProductUpdate(CollectionModel.CollectionProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson("This is not your product"); + if (ModelState.IsValid) { await _collectionViewModelService.ProductUpdate(model); @@ -326,6 +334,10 @@ public async Task ProductUpdate(CollectionModel.CollectionProduct [HttpPost] public async Task ProductDelete(CollectionModel.CollectionProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson("This is not your product"); + if (ModelState.IsValid) { await _collectionViewModelService.ProductDelete(model.Id, model.ProductId); @@ -362,9 +374,26 @@ public async Task ProductAddPopupList(DataSourceRequest command, [HttpPost] public async Task ProductAddPopup(CollectionModel.AddCollectionProductModel model) { + var collection = await _collectionService.GetCollectionById(model.CollectionId); + if (collection == null || !collection.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content("This is not your collection"); + if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await _collectionViewModelService.InsertCollectionProductModel(model); + //InsertCollectionProductModel mutates each selected product's ProductCollections collection, + //so every selected id must also belong to the current store. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await _productService.GetProductById(id); + if (selected != null && selected.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + validIds.Add(id); + } + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Any()) await _collectionViewModelService.InsertCollectionProductModel(model); + } return Content(""); } diff --git a/src/Web/Grand.Web.Store/Controllers/MerchandiseReturnController.cs b/src/Web/Grand.Web.Store/Controllers/MerchandiseReturnController.cs index 4f28f0b5c..c2bfcab1e 100644 --- a/src/Web/Grand.Web.Store/Controllers/MerchandiseReturnController.cs +++ b/src/Web/Grand.Web.Store/Controllers/MerchandiseReturnController.cs @@ -230,6 +230,7 @@ public async Task MerchandiseReturnNoteAdd(string merchandiseRetu return Json(new { Result = false }); if (merchandiseReturn.StoreId != _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId) return Json(new { Result = false }); + if (order.Id != merchandiseReturn.OrderId) return Json(new { Result = false }); await _merchandiseReturnViewModelService.InsertMerchandiseReturnNote(merchandiseReturn, order, downloadId, displayToCustomer, message); diff --git a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs index 9d80fa9c8..bc8f34fbb 100644 --- a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs +++ b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs @@ -5,6 +5,7 @@ using Grand.Domain.Permissions; using Grand.Infrastructure; using Grand.SharedKernel; +using Grand.Web.AdminShared.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; using Grand.Web.AdminShared.Models.Messages; using Grand.Web.Common.DataSource; @@ -165,8 +166,9 @@ public async Task Edit(string id) if (messageTemplate.LimitedToStores && !messageTemplate.Stores.Contains(CurrentStoreId)) return RedirectToAction("List"); - // Global templates (LimitedToStores=false) are shown read-only - ViewBag.IsReadOnly = !messageTemplate.LimitedToStores; + // 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). + ViewBag.IsReadOnly = !messageTemplate.AccessToEntityByStore(CurrentStoreId); var model = messageTemplate.ToModel(); model.SendImmediately = !model.DelayBeforeSend.HasValue; @@ -196,7 +198,7 @@ public async Task Edit(MessageTemplateModel model, bool continueE if (messageTemplate == null) return RedirectToAction("List"); - if (!messageTemplate.LimitedToStores || !messageTemplate.Stores.Contains(CurrentStoreId)) + if (!messageTemplate.AccessToEntityByStore(CurrentStoreId)) return RedirectToAction("List"); var prevAttachment = messageTemplate.AttachedDownloadId; @@ -249,7 +251,7 @@ public async Task Delete(string id) if (messageTemplate == null) return RedirectToAction("List"); - if (!messageTemplate.LimitedToStores || !messageTemplate.Stores.Contains(CurrentStoreId)) + if (!messageTemplate.AccessToEntityByStore(CurrentStoreId)) return RedirectToAction("List"); await messageTemplateService.DeleteMessageTemplate(messageTemplate); diff --git a/src/Web/Grand.Web.Store/Controllers/NewsController.cs b/src/Web/Grand.Web.Store/Controllers/NewsController.cs index 4f16e8a2c..e5959b998 100644 --- a/src/Web/Grand.Web.Store/Controllers/NewsController.cs +++ b/src/Web/Grand.Web.Store/Controllers/NewsController.cs @@ -71,7 +71,7 @@ public IActionResult List() [HttpPost] public async Task List(DataSourceRequest command, NewsItemListModel model) { - var storeId = _contextAccessor.StoreContext.CurrentStore.Id; + var storeId = _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; var newsSettings = await _settingService.LoadSetting(storeId); var news = await _newsService.GetAllNews(storeId, command.Page - 1, command.PageSize, newsTitle: model.SearchNewsTitle); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 7e294e4e2..5d41d45b5 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -401,6 +401,10 @@ public async Task ProductCategoryList(DataSourceRequest command, [HttpPost] public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) try { @@ -419,6 +423,10 @@ public async Task ProductCategoryInsert(ProductModel.ProductCateg [HttpPost] public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) try { @@ -437,6 +445,10 @@ public async Task ProductCategoryUpdate(ProductModel.ProductCateg [HttpPost] public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteProductCategory(model.Id, model.ProductId); @@ -472,6 +484,10 @@ public async Task ProductCollectionList(DataSourceRequest command [HttpPost] public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) try { @@ -490,6 +506,10 @@ public async Task ProductCollectionInsert(ProductModel.ProductCol [HttpPost] public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) try { @@ -508,6 +528,10 @@ public async Task ProductCollectionUpdate(ProductModel.ProductCol [HttpPost] public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteProductCollection(model.Id, model.ProductId); @@ -553,6 +577,10 @@ public async Task RelatedProductList(DataSourceRequest command, s [HttpPost] public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) { + var product = await _productService.GetProductById(model.ProductId1); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.UpdateRelatedProductModel(model); @@ -566,6 +594,10 @@ public async Task RelatedProductUpdate(ProductModel.RelatedProduc [HttpPost] public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) { + var product = await _productService.GetProductById(model.ProductId1); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteRelatedProductModel(model); @@ -602,6 +634,10 @@ public async Task RelatedProductAddPopupList(DataSourceRequest co [HttpPost] public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { if (model.SelectedProductIds != null) await _productViewModelService.InsertRelatedProductModel(model); @@ -649,6 +685,10 @@ public async Task SimilarProductList(DataSourceRequest command, s [HttpPost] public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) { + var product = await _productService.GetProductById(model.ProductId1); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.UpdateSimilarProductModel(model); @@ -662,6 +702,10 @@ public async Task SimilarProductUpdate(ProductModel.SimilarProduc [HttpPost] public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) { + var product = await _productService.GetProductById(model.ProductId1); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteSimilarProductModel(model); @@ -698,6 +742,10 @@ public async Task SimilarProductAddPopupList(DataSourceRequest co [HttpPost] public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { if (model.SelectedProductIds != null) await _productViewModelService.InsertSimilarProductModel(model); @@ -745,6 +793,10 @@ public async Task BundleProductList(DataSourceRequest command, st [HttpPost] public async Task BundleProductUpdate(ProductModel.BundleProductModel model) { + var product = await _productService.GetProductById(model.ProductBundleId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.UpdateBundleProductModel(model); @@ -758,6 +810,10 @@ public async Task BundleProductUpdate(ProductModel.BundleProductM [HttpPost] public async Task BundleProductDelete(ProductModel.BundleProductModel model) { + var product = await _productService.GetProductById(model.ProductBundleId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteBundleProductModel(model); @@ -794,6 +850,10 @@ public async Task BundleProductAddPopupList(DataSourceRequest com [HttpPost] public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { if (model.SelectedProductIds != null) await _productViewModelService.InsertBundleProductModel(model); @@ -840,6 +900,10 @@ public async Task CrossSellProductDelete(ProductModel.CrossSellPr { var product = await _productService.GetProductById(model.ProductId); if (product == null) throw new ArgumentException("Product not exists"); + + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); if (string.IsNullOrEmpty(crossSellProduct)) throw new ArgumentException("No cross-sell product found with the specified id"); @@ -880,6 +944,10 @@ public async Task CrossSellProductAddPopupList(DataSourceRequest [HttpPost] public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { if (model.SelectedProductIds != null) await _productViewModelService.InsertCrossSellProductModel(model); @@ -925,6 +993,10 @@ public async Task RecommendedProductDelete(ProductModel.Recommend { var product = await _productService.GetProductById(model.ProductId); if (product == null) throw new ArgumentException("Product not exists"); + + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); if (string.IsNullOrEmpty(recommendedProduct)) throw new ArgumentException("No recommended product found with the specified id"); @@ -965,6 +1037,10 @@ public async Task RecommendedProductAddPopupList(DataSourceReques [HttpPost] public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { if (model.SelectedProductIds != null) await _productViewModelService.InsertRecommendedProductModel(model); @@ -1018,6 +1094,9 @@ public async Task AssociatedProductUpdate(ProductModel.Associated if (associatedProduct == null) throw new ArgumentException("No associated product found with the specified id"); + if (!associatedProduct.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + associatedProduct.DisplayOrder = model.DisplayOrder; await _productService.UpdateAssociatedProduct(associatedProduct); @@ -1037,6 +1116,9 @@ public async Task AssociatedProductDelete(ProductModel.Associated if (product == null) throw new ArgumentException("No associated product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + await _productViewModelService.DeleteAssociatedProduct(product); return new JsonResult(""); } @@ -1070,9 +1152,26 @@ public async Task AssociatedProductAddPopupList(DataSourceRequest [HttpPost] public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) { + var parentProduct = await _productService.GetProductById(model.ProductId); + if (parentProduct == null || !parentProduct.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { - if (model.SelectedProductIds != null) await _productViewModelService.InsertAssociatedProductModel(model); + //InsertAssociatedProductModel reparents each selected product (writes ParentGroupedProductId on it), + //so every selected id must also belong to the current store, not just the parent. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await _productService.GetProductById(id); + if (selected != null && selected.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + validIds.Add(id); + } + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Any()) await _productViewModelService.InsertAssociatedProductModel(model); + } return Content(""); } @@ -1200,6 +1299,9 @@ public async Task ProductPicturePopup(ProductModel.ProductPicture if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + throw new ArgumentException(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) throw new ArgumentException("No product picture found with the specified id"); @@ -1217,6 +1319,10 @@ public async Task ProductPicturePopup(ProductModel.ProductPicture [HttpPost] public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) { + var product = await _productService.GetProductById(model.ProductId); + if (product == null || !product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (ModelState.IsValid) { await _productViewModelService.DeleteProductPicture(model); @@ -1297,6 +1403,9 @@ public async Task ProductSpecAttrPopup( if (product == null) return Content("Product not exists"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); if (psa == null) await _productViewModelService.InsertProductSpecificationAttributeModel(model, product); @@ -1334,6 +1443,9 @@ public async Task ProductSpecAttrDelete(ProductSpecificationAttri if (product == null) return Content("Product not exists"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); if (psa == null) throw new ArgumentException("No specification attribute found with the specified id"); @@ -1529,6 +1641,9 @@ public async Task ProductPriceInsert(ProductModel.ProductPriceMod if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) throw new ArgumentException("Currency code exists"); @@ -1558,6 +1673,9 @@ public async Task ProductPriceUpdate(ProductModel.ProductPriceMod if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); if (productPrice == null) throw new ArgumentException("Product price model not exists"); @@ -1592,6 +1710,9 @@ public async Task ProductPriceDelete(ProductModel.ProductPriceMod if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); if (productPrice == null) throw new ArgumentException("Product price model not exists"); @@ -1648,6 +1769,9 @@ public async Task TierPriceCreatePopup(ProductModel.TierPriceMode if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var tierPrice = model.ToEntity(_dateTimeService); await _productService.InsertTierPrice(tierPrice, product.Id); @@ -1716,6 +1840,9 @@ public async Task TierPriceDelete(ProductModel.TierPriceDeleteMod if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return ErrorForKendoGridJson(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); if (tierPrice == null) throw new ArgumentException("No tier price found with the specified id"); @@ -1782,6 +1909,9 @@ public async Task ProductAttributeMappingPopup(ProductModel.Produ if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return Content(_translationService.GetResource("Admin.Catalog.Products.Permissions")); + if (string.IsNullOrEmpty(model.Id)) await _productViewModelService.InsertProductAttributeMappingModel(model); else @@ -1987,6 +2117,9 @@ public async Task ProductAttributeValueCreatePopup(ProductModel.P if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return RedirectToAction("List", "Product"); + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); if (productAttributeMapping == null) @@ -2043,6 +2176,9 @@ public async Task ProductAttributeValueEditPopup(string productId if (product == null) throw new ArgumentException("No product found with the specified id"); + if (!product.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) + return RedirectToAction("List", "Product"); + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); if (pav == null) diff --git a/src/Web/Grand.Web.Store/Controllers/SettingController.cs b/src/Web/Grand.Web.Store/Controllers/SettingController.cs index 7afed8599..59cf921fc 100644 --- a/src/Web/Grand.Web.Store/Controllers/SettingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/SettingController.cs @@ -56,12 +56,14 @@ private async Task ClearCache() /// /// Returns true if the store owner is allowed to access (edit/delete) the given store-linked item. - /// Store owners can only access items that are explicitly assigned to their store - /// (LimitedToStores = true and their storeId is in the Stores collection). - /// Items available to all stores (LimitedToStores = false) are not editable by store owners. + /// Store owners can only access items exclusively assigned to their store + /// (LimitedToStores = true and Stores contains only their storeId). + /// Items available to all stores (LimitedToStores = false) or shared with other stores are not + /// editable/deletable by a single store owner - editing/deleting would silently affect every + /// other store the item is also assigned to. /// private static bool IsStoreOwnerAccessAllowed(string storeId, bool limitedToStores, ICollection stores) - => string.IsNullOrEmpty(storeId) || (limitedToStores && stores.Contains(storeId)); + => string.IsNullOrEmpty(storeId) || (limitedToStores && stores.Count == 1 && stores.Contains(storeId)); #endregion diff --git a/src/Web/Grand.Web.Store/Controllers/ShippingController.cs b/src/Web/Grand.Web.Store/Controllers/ShippingController.cs index 7a88bbf45..515692fa5 100644 --- a/src/Web/Grand.Web.Store/Controllers/ShippingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ShippingController.cs @@ -637,7 +637,9 @@ public async Task RestrictionSave(IDictionary m var countries = await countryService.GetAllCountries(showHidden: true); var shippingMethods = await shippingMethodService.GetAllShippingMethods(storeId: CurrentStoreId); var customerGroups = await groupService.GetAllCustomerGroups(); - foreach (var shippingMethod in shippingMethods) + //GetAllShippingMethods also returns global (StoreId=="") shipping methods, shared by every store; + //only mutate restrictions on methods this store manager exclusively owns. + foreach (var shippingMethod in shippingMethods.Where(x => x.StoreId == CurrentStoreId)) { await SaveRestrictedCountries(model, shippingMethod, countries); await SaveRestrictedGroup(model, shippingMethod, customerGroups); From 4bd4b31aee3bf33ee42fdb80d78c5c1de5f26e66 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Fri, 14 Aug 2026 14:09:59 +0200 Subject: [PATCH 2/3] Replace ViewBag data carriers with typed model properties in Grand.Web.Store Of 183 ViewBag occurrences in Grand.Web.Store, 152 are ViewBag.Title, set only in views (the standard Razor idiom) - left untouched. Of the rest, 17 were dead: ViewBag.AllLanguages (Blog/Page/News controllers) and ViewBag.productIdsInput (ProductController) are set but never read anywhere in the solution. Removed them (two of the AllLanguages assignments in NewsController were also missing `await`, an unobserved-task bug hidden inside otherwise-dead code). The remaining 4 keys actually cross the controller-to-view boundary and were promoted to typed model properties, following the two patterns already used in this area for extending a shared Grand.Web.AdminShared model from the store panel: - MessageTemplateController.IsReadOnly: new MessageTemplateStoreModel : MessageTemplateModel + MessageTemplateStoreProfile AutoMapper profile, mirroring the existing ContactAttributeStoreModel/ CustomerAttributeStoreModel/AddressAttributeStoreModel pattern. - OrderController/PaymentTransactionController.RefreshPage: added directly to the shared OrderModel.UploadLicenseModel and PaymentTransactionModel, mirroring the existing CustomerAttributeModel.IsReadOnly pattern (harmless if unused by Admin/Vendor). - PageController.ShowCopyButton: same, added to the shared PageModel. - BlogController.Comments had no model at all; added a small Store-only BlogCommentListModel. Verified with dotnet build on Store, Admin, and Vendor (all three reference the touched shared AdminShared models) - 0 errors, only a pre-existing unrelated warning in Grand.Business.Common. dotnet test on Grand.Web.Store.Tests stays green (no behavior touched by this change is covered by unit tests, but the build is the safety net for Razor view compilation here). --- .../Models/Orders/OrderModel.cs | 5 +++++ .../Models/Orders/PaymentTransactionModel.cs | 6 +++++ .../Models/Pages/PageModel.cs | 6 +++++ .../Areas/Store/Views/Blog/Comments.cshtml | 6 +++-- .../Store/Views/MessageTemplate/Edit.cshtml | 4 ++-- .../Views/Order/UploadLicenseFilePopup.cshtml | 2 +- .../Areas/Store/Views/Page/Edit.cshtml | 4 ++-- .../PartiallyPaidPopup.cshtml | 2 +- .../PartiallyRefundPopup.cshtml | 2 +- .../Controllers/BlogController.cs | 13 +++-------- .../Controllers/MessageTemplateController.cs | 9 +++++--- .../Controllers/NewsController.cs | 4 ---- .../Controllers/OrderController.cs | 5 +---- .../Controllers/PageController.cs | 6 +---- .../PaymentTransactionController.cs | 4 ++-- .../Controllers/ProductController.cs | 1 - .../Mapper/MessageTemplateStoreProfile.cs | 22 +++++++++++++++++++ .../Models/Blogs/BlogCommentListModel.cs | 9 ++++++++ .../Messages/MessageTemplateStoreModel.cs | 12 ++++++++++ 19 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs create mode 100644 src/Web/Grand.Web.Store/Models/Blogs/BlogCommentListModel.cs create mode 100644 src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs diff --git a/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs index f64743d3c..0985a4a40 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/OrderModel.cs @@ -344,6 +344,11 @@ public class UploadLicenseModel : BaseModel public string OrderItemId { get; set; } [UIHint("Download")] public string LicenseDownloadId { get; set; } + + /// <summary> + /// Set by the controller after a successful save, so the popup view can signal the parent page to refresh. + /// </summary> + public bool RefreshPage { get; set; } } public class AddOrderProductModel : BaseModel diff --git a/src/Web/Grand.Web.AdminShared/Models/Orders/PaymentTransactionModel.cs b/src/Web/Grand.Web.AdminShared/Models/Orders/PaymentTransactionModel.cs index b0b91dc00..886229e52 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Orders/PaymentTransactionModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Orders/PaymentTransactionModel.cs @@ -95,4 +95,10 @@ public class PaymentTransactionModel : BaseEntityModel [GrandResourceDisplayName("Admin.PaymentTransaction.Fields.PartialRefund.AmountToPaid")] public double AmountToPaid { get; set; } + + /// <summary> + /// Set by the controller after a successful partial refund/paid, so the popup view can signal + /// the parent page to refresh. + /// </summary> + public bool RefreshPage { get; set; } } \ No newline at end of file diff --git a/src/Web/Grand.Web.AdminShared/Models/Pages/PageModel.cs b/src/Web/Grand.Web.AdminShared/Models/Pages/PageModel.cs index a2cec7839..9eeb3f8e8 100644 --- a/src/Web/Grand.Web.AdminShared/Models/Pages/PageModel.cs +++ b/src/Web/Grand.Web.AdminShared/Models/Pages/PageModel.cs @@ -95,6 +95,12 @@ public class PageModel : BaseEntityModel, ILocalizedModel<PageLocalizedModel>, I [GrandResourceDisplayName("Admin.Content.Pages.Fields.LimitedToStores")] [UIHint("Stores")] public string[] Stores { get; set; } + + /// <summary> + /// True when the page is global or shared with more than one store, so a store manager may copy it + /// into their own store instead of editing it directly. + /// </summary> + public bool ShowCopyButton { get; set; } } public class PageLocalizedModel : ILocalizedModelLocal, ISlugModelLocal diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Comments.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Comments.cshtml index 16f1e41a0..6a3b380f9 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Comments.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Blog/Comments.cshtml @@ -1,8 +1,10 @@ -@inject AdminAreaSettings adminAreaSettings +@using Grand.Web.Store.Models.Blogs +@model BlogCommentListModel +@inject AdminAreaSettings adminAreaSettings @{ //page title ViewBag.Title = Loc["Admin.Content.Blog.Comments"]; - string filterByBlogPostId = ViewBag.FilterByBlogPostId; + var filterByBlogPostId = Model.FilterByBlogPostId; } <div class="row"> diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml index e47a3d174..24a98da06 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/MessageTemplate/Edit.cshtml @@ -1,8 +1,8 @@ -@model MessageTemplateModel +@model MessageTemplateStoreModel @{ ViewBag.Title = Loc["Admin.Content.MessageTemplates.EditMessageTemplateDetails"]; Layout = Constants.LayoutStore; - var isReadOnly = (bool)(ViewBag.IsReadOnly ?? false); + var isReadOnly = Model.IsReadOnly; } <form asp-area="@Constants.AreaStore" asp-controller="MessageTemplate" asp-action="Edit" method="post"> diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Order/UploadLicenseFilePopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Order/UploadLicenseFilePopup.cshtml index 3b6571398..5ed1dfa16 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Order/UploadLicenseFilePopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Order/UploadLicenseFilePopup.cshtml @@ -27,7 +27,7 @@ </div> </div> <div asp-validation-summary="All"></div> - @if (ViewBag.RefreshPage == true) + @if (Model.RefreshPage) { <script> try { diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml index 4262faa6d..e4b83f088 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Page/Edit.cshtml @@ -32,7 +32,7 @@ <button class="btn btn-success" type="submit" name="save-continue"> <i class="fa fa-check-circle"></i> @Loc["Admin.Common.SaveContinue"] </button> - @if (ViewBag.ShowCopyButton == true) + @if (Model.ShowCopyButton) { <button type="submit" form="page-copy-form" class="btn blue"> <i class="fa fa-copy"></i> @Loc["Admin.Common.Copy"] @@ -52,7 +52,7 @@ </div> </form> <admin-delete-confirmation button-id="page-delete"/> -@if (ViewBag.ShowCopyButton == true) +@if (Model.ShowCopyButton) { <form id="page-copy-form" asp-area="@Constants.AreaStore" asp-controller="Page" asp-action="Copy" method="post"> <input type="hidden" name="id" value="@Model.Id"/> diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml index 0cbacceda..c1bab3fb9 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyPaidPopup.cshtml @@ -47,7 +47,7 @@ </div> <div asp-validation-summary="All"></div> - @if (ViewBag.RefreshPage == true) + @if (Model.RefreshPage) { <script> try { diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml index c1afd93e7..34188d004 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/PaymentTransaction/PartiallyRefundPopup.cshtml @@ -47,7 +47,7 @@ </div> <div asp-validation-summary="All"></div> - @if (ViewBag.RefreshPage == true) + @if (Model.RefreshPage) { <script> try { diff --git a/src/Web/Grand.Web.Store/Controllers/BlogController.cs b/src/Web/Grand.Web.Store/Controllers/BlogController.cs index 7cac921c1..ec2886c66 100644 --- a/src/Web/Grand.Web.Store/Controllers/BlogController.cs +++ b/src/Web/Grand.Web.Store/Controllers/BlogController.cs @@ -14,6 +14,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Models.Blogs; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; @@ -92,7 +93,6 @@ public async Task<IActionResult> List(DataSourceRequest command) [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task<IActionResult> Create() { - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = new BlogPostModel { //default values AllowComments = true, @@ -118,7 +118,6 @@ public async Task<IActionResult> Create(BlogPostModel model, bool continueEditin } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); return View(model); } @@ -142,7 +141,6 @@ public async Task<IActionResult> Edit(string id) return RedirectToAction("List"); } - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = blogPost.ToModel(_dateTimeService); //locales @@ -188,7 +186,6 @@ public async Task<IActionResult> Edit(BlogPostModel model, bool continueEditing) } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); return View(model); } @@ -285,8 +282,8 @@ public async Task<IActionResult> PicturePopup(PictureModel model) public IActionResult Comments(string filterByBlogPostId) { - ViewBag.FilterByBlogPostId = filterByBlogPostId; - return View(); + var model = new BlogCommentListModel { FilterByBlogPostId = filterByBlogPostId }; + return View(model); } [PermissionAuthorizeAction(PermissionActionName.List)] @@ -450,7 +447,6 @@ public async Task<IActionResult> CategoryList(DataSourceRequest command) [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task<IActionResult> CategoryCreate() { - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = new BlogCategoryModel(); //locales await AddLocales(_languageService, model.Locales); @@ -480,7 +476,6 @@ public async Task<IActionResult> CategoryCreate(BlogCategoryModel model, bool co } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); //locales await AddLocales(_languageService, model.Locales); return View(model); @@ -497,7 +492,6 @@ public async Task<IActionResult> CategoryEdit(string id) if (!blogCategory.AccessToEntityByStore(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId)) return RedirectToAction(CategoryListAction); - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = blogCategory.ToModel(); //locales await AddLocales(_languageService, model.Locales, (locale, languageId) => @@ -542,7 +536,6 @@ public async Task<IActionResult> CategoryEdit(BlogCategoryModel model, bool cont } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); //locales await AddLocales(_languageService, model.Locales, (locale, languageId) => diff --git a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs index bc8f34fbb..98c1e3baa 100644 --- a/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs +++ b/src/Web/Grand.Web.Store/Controllers/MessageTemplateController.cs @@ -2,8 +2,10 @@ 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.Extensions.Mapping; @@ -11,6 +13,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Filters; using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Models.Messages; using Microsoft.AspNetCore.Mvc; namespace Grand.Web.Store.Controllers; @@ -166,11 +169,11 @@ public async Task<IActionResult> Edit(string id) if (messageTemplate.LimitedToStores && !messageTemplate.Stores.Contains(CurrentStoreId)) return RedirectToAction("List"); + var model = messageTemplate.MapTo<MessageTemplate, MessageTemplateStoreModel>(); + // 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). - ViewBag.IsReadOnly = !messageTemplate.AccessToEntityByStore(CurrentStoreId); - - var model = messageTemplate.ToModel(); + model.IsReadOnly = !messageTemplate.AccessToEntityByStore(CurrentStoreId); model.SendImmediately = !model.DelayBeforeSend.HasValue; model.HasAttachedDownload = !string.IsNullOrEmpty(model.AttachedDownloadId); model.AllowedTokens = messageTokenProvider.GetListOfAllowedTokens(); diff --git a/src/Web/Grand.Web.Store/Controllers/NewsController.cs b/src/Web/Grand.Web.Store/Controllers/NewsController.cs index e5959b998..e931e9288 100644 --- a/src/Web/Grand.Web.Store/Controllers/NewsController.cs +++ b/src/Web/Grand.Web.Store/Controllers/NewsController.cs @@ -93,7 +93,6 @@ public async Task<IActionResult> List(DataSourceRequest command, NewsItemListMod [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task<IActionResult> Create() { - ViewBag.AllLanguages = _languageService.GetAllLanguages(true); var model = new NewsItemModel { //default values Published = true, @@ -121,7 +120,6 @@ public async Task<IActionResult> Create(NewsItemModel model, bool continueEditin } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = _languageService.GetAllLanguages(true); return View(model); } @@ -145,7 +143,6 @@ public async Task<IActionResult> Edit(string id) return RedirectToAction("List"); } - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = newsItem.ToModel(_dateTimeService); //locales await AddLocales(_languageService, model.Locales, (locale, languageId) => @@ -192,7 +189,6 @@ public async Task<IActionResult> Edit(NewsItemModel model, bool continueEditing) } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); return View(model); } diff --git a/src/Web/Grand.Web.Store/Controllers/OrderController.cs b/src/Web/Grand.Web.Store/Controllers/OrderController.cs index 460ae46b9..fe9ac87d6 100644 --- a/src/Web/Grand.Web.Store/Controllers/OrderController.cs +++ b/src/Web/Grand.Web.Store/Controllers/OrderController.cs @@ -612,7 +612,7 @@ public async Task<IActionResult> UploadLicenseFilePopup(OrderModel.UploadLicense await orderService.UpdateOrder(order); //success - ViewBag.RefreshPage = true; + model.RefreshPage = true; return View(model); } @@ -637,9 +637,6 @@ public async Task<IActionResult> DeleteLicenseFilePopup(OrderModel.UploadLicense orderItem.LicenseDownloadId = null; await orderService.UpdateOrder(order); - //success - ViewBag.RefreshPage = true; - return RedirectToAction("Edit", "Order", new { id = model.OrderId }); } diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs index 41ffb81ea..2bd66d41f 100644 --- a/src/Web/Grand.Web.Store/Controllers/PageController.cs +++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs @@ -118,7 +118,6 @@ public async Task<IActionResult> GlobalPagesList(DataSourceRequest command, Page [PermissionAuthorizeAction(PermissionActionName.Create)] public async Task<IActionResult> Create() { - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); var model = new PageModel { DisplayOrder = 1, Published = true @@ -142,7 +141,6 @@ public async Task<IActionResult> Create(PageModel model, bool continueEditing) } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); await _pageViewModelService.PrepareLayoutsModel(model); return View(model); } @@ -165,9 +163,8 @@ public async Task<IActionResult> Edit(string id) return RedirectToAction("List"); } - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); - ViewBag.ShowCopyButton = !page.LimitedToStores || page.Stores.Count > 1; var model = page.ToModel(_dateTimeService); + model.ShowCopyButton = !page.LimitedToStores || page.Stores.Count > 1; model.Url = Url.RouteUrl("Page", new { SeName = page.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }, Request.Scheme); await _pageViewModelService.PrepareLayoutsModel(model); await AddLocales(_languageService, model.Locales, (locale, languageId) => @@ -211,7 +208,6 @@ public async Task<IActionResult> Edit(PageModel model, bool continueEditing) } //If we got this far, something failed, redisplay form - ViewBag.AllLanguages = await _languageService.GetAllLanguages(true); model.Url = Url.RouteUrl("Page", new { SeName = page.GetSeName(_contextAccessor.WorkContext.WorkingLanguage.Id) }, "http"); await _pageViewModelService.PrepareLayoutsModel(model); return View(model); diff --git a/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs b/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs index d029eb564..32f071ead 100644 --- a/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs +++ b/src/Web/Grand.Web.Store/Controllers/PaymentTransactionController.cs @@ -427,7 +427,7 @@ await _mediator.Send(new PartiallyRefundOfflineCommand if (errors.Count == 0) { //success - ViewBag.RefreshPage = true; + model.RefreshPage = true; return View(model); } @@ -488,7 +488,7 @@ public async Task<IActionResult> PartiallyPaidPopup(string id, bool online, Paym await _mediator.Send(new PartiallyPaidOfflineCommand { PaymentTransaction = paymentTransaction, AmountToPaid = amountToPaid }); - ViewBag.RefreshPage = true; + model.RefreshPage = true; return View(model); } catch (Exception exc) diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 5d41d45b5..7cb0dfab3 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -357,7 +357,6 @@ public async Task<IActionResult> LoadProductFriendlyNames(string productIds) public async Task<IActionResult> RequiredProductAddPopup(string productIdsInput) { var model = await _productViewModelService.PrepareAddRequiredProductModel(_contextAccessor.WorkContext.CurrentCustomer.StaffStoreId); - ViewBag.productIdsInput = productIdsInput; return View(model); } diff --git a/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs b/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs new file mode 100644 index 000000000..93f0e3d0c --- /dev/null +++ b/src/Web/Grand.Web.Store/Mapper/MessageTemplateStoreProfile.cs @@ -0,0 +1,22 @@ +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<MessageTemplate, MessageTemplateStoreModel>() + .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/Blogs/BlogCommentListModel.cs b/src/Web/Grand.Web.Store/Models/Blogs/BlogCommentListModel.cs new file mode 100644 index 000000000..492b27387 --- /dev/null +++ b/src/Web/Grand.Web.Store/Models/Blogs/BlogCommentListModel.cs @@ -0,0 +1,9 @@ +namespace Grand.Web.Store.Models.Blogs; + +/// <summary> +/// View model for the blog comments list page, optionally filtered to one blog post. +/// </summary> +public class BlogCommentListModel +{ + public string FilterByBlogPostId { get; set; } +} diff --git a/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs b/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs new file mode 100644 index 000000000..f01be10db --- /dev/null +++ b/src/Web/Grand.Web.Store/Models/Messages/MessageTemplateStoreModel.cs @@ -0,0 +1,12 @@ +using Grand.Web.AdminShared.Models.Messages; + +namespace Grand.Web.Store.Models.Messages; + +public class MessageTemplateStoreModel : MessageTemplateModel +{ + /// <summary> + /// True when the store manager can only preview the template (global, or shared with other stores), + /// not save changes to it. + /// </summary> + public bool IsReadOnly { get; set; } +} From 510c571979753adb3b1ffa899a9092325143ee30 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak <krzysiek@grandnode.com> Date: Fri, 14 Aug 2026 20:22:18 +0200 Subject: [PATCH 3/3] Add missing field --- .../AdminShared/ContentMappingTests.Page_ToModel.verified.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tests/Grand.Mapping.Tests/AdminShared/ContentMappingTests.Page_ToModel.verified.txt b/src/Tests/Grand.Mapping.Tests/AdminShared/ContentMappingTests.Page_ToModel.verified.txt index de87b088d..495b64b26 100644 --- a/src/Tests/Grand.Mapping.Tests/AdminShared/ContentMappingTests.Page_ToModel.verified.txt +++ b/src/Tests/Grand.Mapping.Tests/AdminShared/ContentMappingTests.Page_ToModel.verified.txt @@ -16,5 +16,6 @@ MetaDescription: About us, MetaTitle: About Us, SeName: about-us, + ShowCopyButton: false, Id: page-001 } \ No newline at end of file