Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG-Permissions.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 13 additions & 6 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,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',
Expand Down Expand Up @@ -639,14 +639,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.'),
],
],
];
Expand Down
114 changes: 114 additions & 0 deletions src/console/controllers/CreateTestUsersController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\commerce\console\controllers;

use Craft;
use craft\commerce\console\Controller;
use craft\commerce\Plugin;
use craft\elements\User;
use yii\console\ExitCode;

/**
* Creates test users with various commerce product type permission combinations.
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @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;
}
}
13 changes: 13 additions & 0 deletions src/controllers/ProductsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
*/
class ProductsController extends BaseCpController
{
/**
* @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
*/
Expand Down
15 changes: 15 additions & 0 deletions src/controllers/VariantsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

namespace craft\commerce\controllers;

use craft\commerce\Plugin;
use yii\web\ForbiddenHttpException;
use yii\web\Response;

/**
Expand All @@ -17,6 +19,19 @@
*/
class VariantsController extends BaseCpController
{
/**
* @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
*/
Expand Down
42 changes: 28 additions & 14 deletions src/elements/Product.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -222,7 +223,7 @@
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();
Expand Down Expand Up @@ -253,7 +254,7 @@

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,
Expand Down Expand Up @@ -353,7 +354,7 @@
switch ($source) {
case '*':
{
$productTypes = Plugin::getInstance()->getProductTypes()->getEditableProductTypes();
$productTypes = Plugin::getInstance()->getProductTypes()->getViewableProductTypes();
break;
}
default:
Expand Down Expand Up @@ -395,14 +396,13 @@
} 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[] = [
'type' => Duplicate::class,
Expand All @@ -420,7 +420,7 @@
$actions[] = $deleteAction;
}

if ($canEdit) {
if ($canSave) {
$actions[] = SetStatus::class;
}

Expand Down Expand Up @@ -949,7 +949,7 @@
return false;
}

return $user->can('commerce-editProductType:' . $productType->uid);
return $user->can('commerce-viewProductType:' . $productType->uid);
}

/**
Expand All @@ -967,7 +967,20 @@
return false;
}

return $user->can('commerce-editProductType:' . $productType->uid);
if ($this->getIsDraft()) {
/**
* @var static|DraftBehavior $this
* @phpstan-ignore-next-line
*/
return $this->canCreateDrafts($user);

Check failure on line 975 in src/elements/Product.php

View workflow job for this annotation

GitHub Actions / ci / Code Quality / PHPStan / PHPStan

No error to ignore is reported on line 975.
}

// New products require create permission
if (!$this->id) {
return $user->can('commerce-createProductType:' . $productType->uid);
}

return $user->can('commerce-saveProductType:' . $productType->uid);
}

/**
Expand All @@ -985,7 +998,8 @@
return false;
}

return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-createProducts');
return $user->can('commerce-createProductType:' . $productType->uid)
&& $user->can('commerce-saveProductType:' . $productType->uid);
}

/**
Expand All @@ -1003,7 +1017,7 @@
return false;
}

return Plugin::getInstance()->getProductTypes()->hasPermission($user, $productType, 'commerce-deleteProducts');
return $user->can('commerce-deleteProductType:' . $productType->uid);
}

/**
Expand All @@ -1029,7 +1043,7 @@
{
$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) => [
Expand Down
4 changes: 2 additions & 2 deletions src/elements/db/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -848,8 +848,8 @@ protected function beforePrepare(): bool
}

$this->_applyHasVariantParam();
$this->_applyEditableParam($this->editable, 'commerce-editProductType');
$this->_applyEditableParam($this->savable, 'commerce-editProductType');
$this->_applyEditableParam($this->editable, 'commerce-viewProductType');
$this->_applyEditableParam($this->savable, 'commerce-saveProductType');
$this->_applyRefParam();

return parent::beforePrepare();
Expand Down
Loading
Loading