From 2147c7a50b097d08f43ca4e4a8b0bff15342a909 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Thu, 26 Feb 2026 11:10:15 +0800 Subject: [PATCH 1/3] WIP permission refactor --- src/Plugin.php | 21 +- .../controllers/CreateTestUsersController.php | 114 +++++++++ src/controllers/ProductsController.php | 13 ++ src/controllers/VariantsController.php | 15 ++ src/elements/Product.php | 33 +-- src/elements/db/ProductQuery.php | 4 +- ...260226_120000_product_type_permissions.php | 114 +++++++++ src/services/ProductTypes.php | 69 +++--- src/stats/TopProductTypes.php | 2 +- src/stats/TopPurchasables.php | 2 +- src/translations/en/commerce.php | 6 + tests/unit/services/ProductPermissionTest.php | 216 ++++++++++++++---- 12 files changed, 495 insertions(+), 114 deletions(-) create mode 100644 src/console/controllers/CreateTestUsersController.php create mode 100644 src/migrations/m260226_120000_product_type_permissions.php diff --git a/src/Plugin.php b/src/Plugin.php index e8a60bf311..7bb3397c6f 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -259,7 +259,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '5.5.0.5'; + public string $schemaVersion = '5.5.0.6'; /** * @inheritdoc @@ -385,8 +385,8 @@ public function getCpNavItem(): ?array ]; } - $hasEditableProductTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(true); - if ($hasEditableProductTypes) { + $hasViewableProductTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true); + if ($hasViewableProductTypes) { $ret['subnav']['products'] = [ 'label' => Craft::t('commerce', 'Products'), 'url' => 'commerce/products', @@ -632,14 +632,21 @@ private function _registerProductTypePermission(): array foreach ($productTypes as $productType) { $suffix = ':' . $productType->uid; - $productTypePermissions['commerce-editProductType' . $suffix] = [ - 'label' => Craft::t('commerce', 'Edit “{type}” products', ['type' => $productType->name]), + $productTypePermissions['commerce-viewProductType' . $suffix] = [ + 'label' => Craft::t('commerce', 'View “{type}” products', ['type' => $productType->name]), + 'info' => Craft::t('commerce', 'Allows viewing existing products and creating drafts for them.'), 'nested' => [ - "commerce-createProducts$suffix" => [ + 'commerce-createProductType' . $suffix => [ 'label' => Craft::t('commerce', 'Create products'), + 'info' => Craft::t('commerce', 'Allows creating drafts of new products.'), ], - "commerce-deleteProducts$suffix" => [ + 'commerce-saveProductType' . $suffix => [ + 'label' => Craft::t('commerce', 'Save products'), + 'info' => Craft::t('commerce', 'Allows fully saving canonical products (directly or by applying drafts).'), + ], + 'commerce-deleteProductType' . $suffix => [ 'label' => Craft::t('commerce', 'Delete products'), + 'info' => Craft::t('commerce', 'Allows deleting products for all sites.'), ], ], ]; diff --git a/src/console/controllers/CreateTestUsersController.php b/src/console/controllers/CreateTestUsersController.php new file mode 100644 index 0000000000..0ca65a7706 --- /dev/null +++ b/src/console/controllers/CreateTestUsersController.php @@ -0,0 +1,114 @@ + + * @since 5.6.0 + */ +class CreateTestUsersController extends Controller +{ + /** + * Creates test users for every combination of commerce product type permissions. + * + * @return int + */ + public function actionIndex(): int + { + $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + $this->stderr("No product types found.\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + + $elementsService = Craft::$app->getElements(); + $permissionsService = Craft::$app->getUserPermissions(); + + // Permission keys and their short labels for username generation + $permissionKeys = [ + 'view' => 'commerce-viewProductType', + 'create' => 'commerce-createProductType', + 'save' => 'commerce-saveProductType', + 'delete' => 'commerce-deleteProductType', + ]; + + // Generate all combinations of create/save/delete (view is always required) + $nestedKeys = ['create', 'save', 'delete']; + $combos = [[]]; // start with view-only (no nested) + foreach ($nestedKeys as $key) { + $newCombos = []; + foreach ($combos as $combo) { + $newCombos[] = $combo; + $newCombos[] = array_merge($combo, [$key]); + } + $combos = $newCombos; + } + + $created = 0; + + foreach ($productTypes as $productType) { + $suffix = ':' . $productType->uid; + $typeHandle = $productType->handle; + + foreach ($combos as $combo) { + // Build username from permissions + $parts = ['view']; + $parts = array_merge($parts, $combo); + $username = $typeHandle . '-' . implode('-', $parts); + $email = $username . '@test.com'; + + // Check if user already exists + $existing = User::find()->username($username)->one(); + if ($existing) { + $this->stdout("User '$username' already exists, skipping.\n"); + continue; + } + + // Create the user + $user = new User(); + $user->username = $username; + $user->email = $email; + $user->active = true; + $user->newPassword = 'password'; + + if (!$elementsService->saveElement($user, false)) { + $this->stderr("Failed to create user '$username': " . implode(', ', $user->getFirstErrors()) . "\n"); + continue; + } + + // Build permission list - view is always included + $permissions = [ + 'accesscp', + 'accessplugin-commerce', + strtolower($permissionKeys['view'] . $suffix), + ]; + + foreach ($combo as $key) { + $permissions[] = strtolower($permissionKeys[$key] . $suffix); + } + + $permissionsService->saveUserPermissions($user->id, $permissions); + + $this->stdout("Created user '$username' with permissions: " . implode(', ', $parts) . "\n"); + $created++; + } + } + + $this->stdout("\nDone. Created $created test users.\n"); + return ExitCode::OK; + } +} diff --git a/src/controllers/ProductsController.php b/src/controllers/ProductsController.php index 9715724718..41703c4b22 100644 --- a/src/controllers/ProductsController.php +++ b/src/controllers/ProductsController.php @@ -29,6 +29,19 @@ */ class ProductsController extends BaseController { + /** + * @inheritdoc + * @throws ForbiddenHttpException + */ + public function init(): void + { + parent::init(); + + if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { + throw new ForbiddenHttpException('User is not permitted to view any product types.'); + } + } + /** * @throws InvalidConfigException */ diff --git a/src/controllers/VariantsController.php b/src/controllers/VariantsController.php index a6905ef7bc..f42ba12778 100755 --- a/src/controllers/VariantsController.php +++ b/src/controllers/VariantsController.php @@ -7,6 +7,8 @@ namespace craft\commerce\controllers; +use craft\commerce\Plugin; +use yii\web\ForbiddenHttpException; use yii\web\Response; /** @@ -17,6 +19,19 @@ */ class VariantsController extends BaseController { + /** + * @inheritdoc + * @throws ForbiddenHttpException + */ + public function init(): void + { + parent::init(); + + if (empty(Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(true))) { + throw new ForbiddenHttpException('User is not permitted to view any product types.'); + } + } + /** * @return Response */ diff --git a/src/elements/Product.php b/src/elements/Product.php index 8cbd45e220..efbf3ad2e2 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -222,7 +222,7 @@ public static function createCondition(): ElementConditionInterface protected static function defineSources(string $context = null): array { if ($context == 'index') { - $productTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypes(); + $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); $editable = true; } else { $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); @@ -253,7 +253,7 @@ protected static function defineSources(string $context = null): array foreach ($productTypes as $productType) { $key = 'productType:' . $productType->uid; - $canEditProducts = $user && $user->can('commerce-editProductType:' . $productType->uid); + $canEditProducts = $user && $user->can('commerce-saveProductType:' . $productType->uid); $sources[$key] = [ 'key' => $key, @@ -353,7 +353,7 @@ protected static function defineActions(string $source = null): array switch ($source) { case '*': { - $productTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypes(); + $productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes(); break; } default: @@ -395,14 +395,13 @@ protected static function defineActions(string $source = null): array } elseif (!empty($productTypes)) { $userSession = Craft::$app->getUser(); $currentUser = $userSession->getIdentity(); - $productTypeService = Plugin::getInstance()->getProductTypes(); foreach ($productTypes as $productType) { - $canDelete = $productTypeService->hasPermission($currentUser, $productType, 'commerce-deleteProducts'); - $canCreate = $productTypeService->hasPermission($currentUser, $productType, 'commerce-createProducts'); - $canEdit = $productTypeService->hasPermission($currentUser, $productType, 'commerce-editProductType'); + $canDelete = $currentUser->can('commerce-deleteProductType:' . $productType->uid); + $canCreate = $currentUser->can('commerce-createProductType:' . $productType->uid); + $canSave = $currentUser->can('commerce-saveProductType:' . $productType->uid); - if ($canCreate) { + if ($canCreate && $canSave) { // Duplicate $actions[] = Duplicate::class; } @@ -417,7 +416,7 @@ protected static function defineActions(string $source = null): array $actions[] = $deleteAction; } - if ($canEdit) { + if ($canSave) { $actions[] = SetStatus::class; } @@ -938,7 +937,7 @@ public function canView(User $user): bool return false; } - return $user->can('commerce-editProductType:' . $productType->uid); + return $user->can('commerce-viewProductType:' . $productType->uid); } /** @@ -956,7 +955,12 @@ public function canSave(User $user): bool return false; } - return $user->can('commerce-editProductType:' . $productType->uid); + // New products require create permission + if (!$this->id) { + return $user->can('commerce-createProductType:' . $productType->uid); + } + + return $user->can('commerce-saveProductType:' . $productType->uid); } /** @@ -974,7 +978,8 @@ public function canDuplicate(User $user): bool return false; } - return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-createProducts'); + return $user->can('commerce-createProductType:' . $productType->uid) + && $user->can('commerce-saveProductType:' . $productType->uid); } /** @@ -992,7 +997,7 @@ public function canDelete(User $user): bool return false; } - return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-deleteProducts'); + return $user->can('commerce-deleteProductType:' . $productType->uid); } /** @@ -1018,7 +1023,7 @@ protected function crumbs(): array { $productType = $this->getType(); - $productTypes = Collection::make(Plugin::getInstance()->getProductTypes()->getEditableProductTypes()); + $productTypes = Collection::make(Plugin::getInstance()->getProductTypes()->getViewableProductTypes()); /** @var Collection $productTypeOptions */ $productTypeOptions = $productTypes ->map(fn(ProductType $t) => [ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index c821df1847..30ed26d2d3 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -874,9 +874,9 @@ private function _applyEditableParam(): void throw new QueryAbortedException('Could not execute query for product when no user found'); } - // Limit the query to only the sections the user has permission to edit + // Limit the query to only the product types the user has permission to view $this->subQuery->andWhere([ - 'commerce_products.typeId' => Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(), + 'commerce_products.typeId' => Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(), ]); } diff --git a/src/migrations/m260226_120000_product_type_permissions.php b/src/migrations/m260226_120000_product_type_permissions.php new file mode 100644 index 0000000000..ac0539b2c8 --- /dev/null +++ b/src/migrations/m260226_120000_product_type_permissions.php @@ -0,0 +1,114 @@ +select(['uid']) + ->from('{{%commerce_producttypes}}') + ->column($this->db); + + // Build the permission mapping + $map = []; // oldPermission => [newPermission, ...] + foreach ($productTypeUids as $uid) { + // commerce-editProductType → commerce-viewProductType + commerce-saveProductType + $map[strtolower("commerce-editProductType:$uid")] = [ + strtolower("commerce-viewProductType:$uid"), + strtolower("commerce-saveProductType:$uid"), + ]; + // commerce-createProducts → commerce-createProductType + $map[strtolower("commerce-createProducts:$uid")] = [ + strtolower("commerce-createProductType:$uid"), + ]; + // commerce-deleteProducts → commerce-deleteProductType + $map[strtolower("commerce-deleteProducts:$uid")] = [ + strtolower("commerce-deleteProductType:$uid"), + ]; + } + + // Migrate user permissions in the database + foreach ($map as $oldPermission => $newPermissions) { + // Find all users with the old permission + $userIds = (new Query()) + ->select(['upu.userId']) + ->from(['upu' => Table::USERPERMISSIONS_USERS]) + ->innerJoin(['up' => Table::USERPERMISSIONS], '[[up.id]] = [[upu.permissionId]]') + ->where(['up.name' => $oldPermission]) + ->column($this->db); + + $userIds = array_unique($userIds); + + if (!empty($userIds)) { + foreach ($newPermissions as $newPermission) { + // Delete the permission if it already exists + $this->delete(Table::USERPERMISSIONS, [ + 'name' => $newPermission, + ]); + + $this->insert(Table::USERPERMISSIONS, [ + 'name' => $newPermission, + ]); + $newPermissionId = $this->db->getLastInsertID(Table::USERPERMISSIONS); + + $insert = []; + foreach ($userIds as $userId) { + $insert[] = [$newPermissionId, $userId]; + } + + $this->batchInsert(Table::USERPERMISSIONS_USERS, ['permissionId', 'userId'], $insert); + } + } + } + + // Migrate project config for user groups + $projectConfig = Craft::$app->getProjectConfig(); + + foreach ($projectConfig->get('users.groups') ?? [] as $uid => $group) { + $groupPermissions = array_flip($group['permissions'] ?? []); + $save = false; + + foreach ($map as $oldPermission => $newPermissions) { + if (isset($groupPermissions[$oldPermission])) { + foreach ($newPermissions as $newPermission) { + $groupPermissions[$newPermission] = true; + } + $save = true; + } + } + + if ($save) { + $projectConfig->set("users.groups.$uid.permissions", array_keys($groupPermissions)); + } + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + return true; + } +} diff --git a/src/services/ProductTypes.php b/src/services/ProductTypes.php index 1512f26967..e008a94569 100755 --- a/src/services/ProductTypes.php +++ b/src/services/ProductTypes.php @@ -121,11 +121,11 @@ class ProductTypes extends Component /** - * Returns all editable product types. + * Returns all viewable product types. * - * @return ProductType[] An array of all the editable product types. + * @return ProductType[] An array of all the viewable product types. */ - public function getEditableProductTypes(): array + public function getViewableProductTypes(): array { if (Craft::$app->getRequest()->getIsConsoleRequest()) { return $this->getAllProductTypes(); @@ -137,33 +137,33 @@ public function getEditableProductTypes(): array return []; } - $editableProductTypeIds = $this->getEditableProductTypeIds(); - $editableProductTypes = []; + $viewableProductTypeIds = $this->getViewableProductTypeIds(); + $viewableProductTypes = []; - foreach ($this->getAllProductTypes() as $productTypes) { - if (in_array($productTypes->id, $editableProductTypeIds)) { - $editableProductTypes[] = $productTypes; + foreach ($this->getAllProductTypes() as $productType) { + if (in_array($productType->id, $viewableProductTypeIds)) { + $viewableProductTypes[] = $productType; } } - return $editableProductTypes; + return $viewableProductTypes; } /** - * Returns all product type IDs that are editable by the current user. + * Returns all product type IDs that are viewable by the current user. * - * @return array An array of all the editable product types’ IDs. + * @return array An array of all the viewable product types' IDs. */ - public function getEditableProductTypeIds(bool $anySite = false): array + public function getViewableProductTypeIds(bool $anySite = false): array { - $editableIds = []; + $viewableIds = []; $user = Craft::$app->getUser()->getIdentity(); $allProductTypes = $this->getAllProductTypes(); $cpSite = Cp::requestedSite(); foreach ($allProductTypes as $productType) { - if (!Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-editProductType')) { + if (!$user->can('commerce-viewProductType:' . $productType->uid)) { continue; } @@ -171,10 +171,10 @@ public function getEditableProductTypeIds(bool $anySite = false): array continue; } - $editableIds[] = $productType->id; + $viewableIds[] = $productType->id; } - return $editableIds; + return $viewableIds; } /** @@ -190,7 +190,7 @@ public function getCreatableProductTypeIds(): array $allProductTypes = $this->getAllProductTypes(); foreach ($allProductTypes as $productType) { - if ($this->hasPermission($user, $productType, 'commerce-createProducts')) { + if ($user->can('commerce-createProductType:' . $productType->uid)) { $creatableIds[] = $productType->id; } } @@ -208,9 +208,9 @@ public function getCreatableProductTypes(): array $creatableProductTypeIds = $this->getCreatableProductTypeIds(); $creatableProductTypes = []; - foreach ($this->getAllProductTypes() as $productTypes) { - if (in_array($productTypes->id, $creatableProductTypeIds)) { - $creatableProductTypes[] = $productTypes; + foreach ($this->getAllProductTypes() as $productType) { + if (in_array($productType->id, $creatableProductTypeIds)) { + $creatableProductTypes[] = $productType; } } @@ -220,7 +220,7 @@ public function getCreatableProductTypes(): array /** * Returns all the product type IDs. * - * @return array An array of all the product types’ IDs. + * @return array An array of all the product types' IDs. */ public function getAllProductTypeIds(): array { @@ -612,7 +612,7 @@ public function handleChangedProductType(ConfigEvent $event): void foreach ($productIds as $productId) { App::maxPowerCaptain(); - // Loop through each of the changed sites and update all of the products’ slugs and + // Loop through each of the changed sites and update all of the products' slugs and // URIs foreach ($sitesWithNewUriFormats as $siteId) { $product = Product::find() @@ -837,7 +837,7 @@ public function getProductTypeByUid(string $uid): ?ProductType } /** - * Returns whether a product type’s products have URLs, and if the template path is valid. + * Returns whether a product type's products have URLs, and if the template path is valid. * * @param ProductType $productType The product for which to validate the template. * @param int $siteId The site for which to valid for @@ -1019,31 +1019,16 @@ private function _getProductTypeRecord(string $uid): ProductTypeRecord * @param ProductType $productType * @param string|null $checkPermissionName detailed product type permission. * @return bool + * @deprecated in 5.6.0. Use `$user->can()` directly instead. */ public function hasPermission(User $user, ProductType $productType, ?string $checkPermissionName = null): bool { - if ($user->admin) { - return true; - } - - $permissions = Craft::$app->getUserPermissions()->getPermissionsByUserId($user->id); - - $suffix = ':' . $productType->uid; + Craft::$app->getDeprecator()->log(__METHOD__, '`ProductTypes::hasPermission()` has been deprecated. Use `$user->can()` directly instead.'); if ($checkPermissionName !== null) { - $checkPermissionName = strtolower($checkPermissionName . $suffix); - if (!in_array(strtolower($checkPermissionName), $permissions)) { - return false; - } + return $user->can($checkPermissionName . ':' . $productType->uid); } - // Required for create and delete permission. - $editProductType = strtolower('commerce-editProductType' . $suffix); - - if (!in_array($editProductType, $permissions)) { - return false; - } - - return true; + return $user->can('commerce-viewProductType:' . $productType->uid); } } diff --git a/src/stats/TopProductTypes.php b/src/stats/TopProductTypes.php index a5048c26f0..ff883d538b 100644 --- a/src/stats/TopProductTypes.php +++ b/src/stats/TopProductTypes.php @@ -58,7 +58,7 @@ public function getData(): array $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(); + $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); $results = $this->_createStatQuery() ->select([ diff --git a/src/stats/TopPurchasables.php b/src/stats/TopPurchasables.php index ceb6f402ad..8196851821 100644 --- a/src/stats/TopPurchasables.php +++ b/src/stats/TopPurchasables.php @@ -55,7 +55,7 @@ public function getData(): array $selectTotalRevenue = new Expression('SUM([[li.total]]) as revenue'); $orderByRevenue = new Expression('SUM([[li.total]]) DESC'); - $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(); + $editableProductTypeIds = Plugin::getInstance()->getProductTypes()->getViewableProductTypeIds(); $topPurchasables = $this->_createStatQuery() ->select([ diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 5b9a986f32..34f53c9ca2 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -55,6 +55,10 @@ 'All customers' => 'All customers', 'All products' => 'All products', 'All' => 'All', + 'Allows creating drafts of new products.' => 'Allows creating drafts of new products.', + 'Allows deleting products for all sites.' => 'Allows deleting products for all sites.', + 'Allows fully saving canonical products (directly or by applying drafts).' => 'Allows fully saving canonical products (directly or by applying drafts).', + 'Allows viewing existing products and creating drafts for them.' => 'Allows viewing existing products and creating drafts for them.', 'Allow Checkout Without Payment' => 'Allow Checkout Without Payment', 'Allow Empty Cart On Checkout' => 'Allow Empty Cart On Checkout', 'Allow Partial Payment On Checkout' => 'Allow Partial Payment On Checkout', @@ -963,6 +967,7 @@ 'Save and return to all orders' => 'Save and return to all orders', 'Save and set rules' => 'Save and set rules', 'Save as a new rule' => 'Save as a new rule', + 'Save products' => 'Save products', 'Save product to all sites enabled for this product type' => 'Save product to all sites enabled for this product type', 'Save product to other sites in the same site group' => 'Save product to other sites in the same site group', 'Save product to other sites with the same language' => 'Save product to other sites with the same language', @@ -1298,6 +1303,7 @@ 'View customer' => 'View customer', 'View order' => 'View order', 'View product type - {productType}' => 'View product type - {productType}', + 'View “{type}” products' => 'View “{type}” products', 'View user' => 'View user', 'Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete “{name}”?' => 'Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete “{name}”?', 'Web' => 'Web', diff --git a/tests/unit/services/ProductPermissionTest.php b/tests/unit/services/ProductPermissionTest.php index bef346f4ba..995919428b 100644 --- a/tests/unit/services/ProductPermissionTest.php +++ b/tests/unit/services/ProductPermissionTest.php @@ -11,13 +11,11 @@ use Craft; use craft\commerce\elements\Product; use craft\commerce\models\ProductType; -use craft\commerce\Plugin; -use craft\commerce\services\ProductTypes; use craft\elements\User; use UnitTester; /** - * SalesTest + * ProductPermissionTest * * @author Pixel & Tonic, Inc. * @since 3.1.4 @@ -29,76 +27,207 @@ class ProductPermissionTest extends Unit */ protected $tester; - /** - * @var ProductTypes - */ - protected ProductTypes $productTypes; + public function testCanViewWithNoPermissions() + { + [$user, $product] = $this->_existingProduct(); + $this->mockPermissions([]); + $this->assertFalse($product->canView($user)); + } - public function testCanAUserCreateOrDeleteAProduct() + public function testCanViewWithViewPermission() { - $user = new User(); - $user->id = 1; - $user->admin = false; + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertTrue($product->canView($user)); + } + + public function testCanViewWithWrongProductType() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:anotherrandomuid']); + $this->assertFalse($product->canView($user)); + } + + public function testCanViewWithOnlySavePermission() + { + [$user, $product] = $this->_existingProduct(); + + // Save without view should not grant view + $this->mockPermissions(['commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canView($user)); + } + + public function testCanSaveExistingProductWithSavePermission() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertTrue($product->canSave($user)); + } + + public function testCannotSaveExistingProductWithViewOnly() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCannotSaveExistingProductWithCreatePermission() + { + [$user, $product] = $this->_existingProduct(); + + // Create permission does not grant save on existing products + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCanSaveNewProductWithCreatePermission() + { + [$user, $product] = $this->_newProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertTrue($product->canSave($user)); + } + + public function testCannotSaveNewProductWithViewOnly() + { + [$user, $product] = $this->_newProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCannotSaveNewProductWithSavePermission() + { + [$user, $product] = $this->_newProduct(); - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid']) ]); + // Save permission does not grant create on new products + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canSave($user)); + } + + public function testCanDeleteWithDeletePermission() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-deleteproducttype:randomuid']); + $this->assertTrue($product->canDelete($user)); + } + + public function testCannotDeleteWithViewOnly() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions(['commerce-viewproducttype:randomuid']); + $this->assertFalse($product->canDelete($user)); + } + + public function testCannotDeleteWithSavePermission() + { + [$user, $product] = $this->_existingProduct(); - // User has no create product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid']); + // Save permission does not grant delete + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canDelete($user)); + } - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + public function testCanDuplicateWithCreateAndSave() + { + [$user, $product] = $this->_existingProduct(); + + $this->mockPermissions([ + 'commerce-viewproducttype:randomuid', + 'commerce-createproducttype:randomuid', + 'commerce-saveproducttype:randomuid', + ]); + $this->assertTrue($product->canDuplicate($user)); + } - // User has create product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid', 'commerce-createproducts:randomuid']); + public function testCannotDuplicateWithCreateOnly() + { + [$user, $product] = $this->_existingProduct(); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-createproducttype:randomuid']); + $this->assertFalse($product->canDuplicate($user)); + } - // User has no delete product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid']); + public function testCannotDuplicateWithSaveOnly() + { + [$user, $product] = $this->_existingProduct(); - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-deleteProducts:randomuid')); + $this->mockPermissions(['commerce-viewproducttype:randomuid', 'commerce-saveproducttype:randomuid']); + $this->assertFalse($product->canDuplicate($user)); + } - // User has delete product permission on a specific product type. - $this->mockPermissions(['commerce-editproducttype:randomuid', 'commerce-deleteproducts:randomuid']); + public function testCanCreateDraftsAlwaysReturnsTrue() + { + [$user, $product] = $this->_existingProduct(); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-deleteProducts')); + $this->mockPermissions([]); + $this->assertTrue($product->canCreateDrafts($user)); } - public function testCanAUserEditThisProduct() + public function testAdminBypassesAllPermissions() { $user = new User(); $user->id = 1; - $user->admin = false; + $user->admin = true; - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid'])]); + $product = $this->make(Product::class, [ + 'id' => 100, + 'getType' => $this->_makeProductType(), + ]); $this->mockPermissions([]); + $this->assertTrue($product->canView($user)); + $this->assertTrue($product->canSave($user)); + $this->assertTrue($product->canDelete($user)); + $this->assertTrue($product->canDuplicate($user)); + } - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType())); + /** + * @return array{User, Product} + */ + private function _existingProduct(): array + { + $user = new User(); + $user->id = 1; + $user->admin = false; - $this->mockPermissions(['commerce-editproducttype:randomuid']); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-editproducttype')); + $product = $this->make(Product::class, [ + 'id' => 100, + 'getType' => $this->_makeProductType(), + ]); - // if user has access to another product type - $this->mockPermissions(['commerce-editProductType:anotherrandomuid']); - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-editproducttype')); + return [$user, $product]; } - public function testCanAdminUserAbleToEditProduct() + /** + * @return array{User, Product} + */ + private function _newProduct(): array { $user = new User(); $user->id = 1; - $user->admin = true; + $user->admin = false; - $product = $this->make(Product::class, ['getType' => $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid'])]); + $product = $this->make(Product::class, [ + 'getType' => $this->_makeProductType(), + ]); - $this->assertTrue($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + return [$user, $product]; + } - $user->admin = false; - $this->assertFalse($this->productTypes->hasPermission($user, $product->getType(), 'commerce-createProducts')); + private function _makeProductType(): ProductType + { + return $this->make(ProductType::class, ['id' => 1, 'uid' => 'randomuid']); } - private function mockPermissions(array $permissions = []) + private function mockPermissions(array $permissions = []): void { $this->tester->mockMethods( Craft::$app, @@ -109,11 +238,4 @@ private function mockPermissions(array $permissions = []) [] ); } - - protected function _before() - { - parent::_before(); - - $this->productTypes = Plugin::getInstance()->getProductTypes(); - } } From a2c362b0ffa59794603ab05c8089c0b6309f13b7 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Thu, 26 Feb 2026 13:49:47 +0800 Subject: [PATCH 2/3] Fix permission for draft creation --- src/elements/Product.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/elements/Product.php b/src/elements/Product.php index 073249bbbb..df2f68df53 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -11,6 +11,7 @@ use craft\base\Element; use craft\base\ElementInterface; use craft\base\Field; +use craft\behaviors\DraftBehavior; use craft\commerce\base\HasStoreInterface; use craft\commerce\base\StoreTrait; use craft\commerce\behaviors\CurrencyAttributeBehavior; @@ -966,6 +967,14 @@ public function canSave(User $user): bool return false; } + if ($this->getIsDraft()) { + /** + * @var static|DraftBehavior $this + * @phpstan-ignore-next-line + */ + return $this->canCreateDrafts($user); + } + // New products require create permission if (!$this->id) { return $user->can('commerce-createProductType:' . $productType->uid); From 90e73708afef7f27571e5fb039e6829510fa3951 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Thu, 26 Feb 2026 13:56:18 +0800 Subject: [PATCH 3/3] Release notes --- CHANGELOG-Permissions.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 CHANGELOG-Permissions.md diff --git a/CHANGELOG-Permissions.md b/CHANGELOG-Permissions.md new file mode 100644 index 0000000000..104313ec1e --- /dev/null +++ b/CHANGELOG-Permissions.md @@ -0,0 +1,10 @@ +# WIP Release notes for Commerce 5.6 + +### Development +- Product permissions have been refined into separate "View", "Create", "Save", and "Delete" permissions. + +### Extensibility +- Added `craft\commerce\services\ProductTypes::getViewableProductTypes()`. +- Added `craft\commerce\services\ProductTypes::getViewableProductTypeIds()`. +- Added `craft\commerce\services\ProductTypes::getCreatableProductTypeIds()`. +- Deprecated `craft\commerce\services\ProductTypes::hasPermission()`. Use `$user->can()` directly instead. \ No newline at end of file