diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 4b975dc..a79c149 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -47,7 +47,12 @@ final class ExposurePolicy /** @var array Group ids switched to "nur lesen". */ private array $readonlyGroups = []; - /** @var array|null Ability name => group id; null until resolved. */ + /** + * Ability name => group id. [] = no groups known (withGroups() not called: group entries have + * no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it). + * + * @var array|null + */ private ?array $groups = []; /** @var (callable(): array)|null Lazy source of $groups. */ @@ -60,17 +65,17 @@ final class ExposurePolicy public function __construct(array $overrides = []) { foreach ($overrides as $key => $value) { - $key = (string) $key; - if (str_starts_with($key, self::GROUP_PREFIX)) { + ['kind' => $kind, 'id' => $id] = self::parseKey((string) $key); + if ($kind === 'group') { if (is_string($value) && in_array($value, self::MODES, true)) { - $this->groupModes[substr($key, strlen(self::GROUP_PREFIX))] = $value; + $this->groupModes[$id] = $value; } - } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + } elseif ($kind === 'readonly') { if ($value === true) { - $this->readonlyGroups[substr($key, strlen(self::READONLY_PREFIX))] = true; + $this->readonlyGroups[$id] = true; } } elseif (is_string($value)) { - $this->tools[$key] = $value; + $this->tools[$id] = $value; } } } @@ -103,7 +108,7 @@ final class ExposurePolicy public function mode(Ability $ability): string { - if (in_array($ability->name(), self::REQUIRED, true)) { + if (self::isRequired($ability->name())) { return $this->defaultMode($ability); } @@ -119,7 +124,7 @@ final class ExposurePolicy { $name = $ability->name(); $groupMode = $this->groupModeOf($name); - if ($groupMode === null || in_array($name, self::REQUIRED, true)) { + if ($groupMode === null || self::isRequired($name)) { return $this->defaultMode($ability); } @@ -133,7 +138,7 @@ final class ExposurePolicy $groupMode = $this->groupModeOf($name); return $groupMode !== null - && ! in_array($name, self::REQUIRED, true) + && ! self::isRequired($name) && ! self::isAllowed($name, $groupMode); } @@ -145,7 +150,7 @@ final class ExposurePolicy return false; } - if (in_array($ability->name(), self::REQUIRED, true)) { + if (self::isRequired($ability->name())) { return false; } @@ -213,7 +218,7 @@ final class ExposurePolicy return false; } - if (in_array($name, self::REQUIRED, true)) { + if (self::isRequired($name)) { return $mode === self::DIRECT; } @@ -244,17 +249,18 @@ final class ExposurePolicy continue; } - if (str_starts_with($key, self::GROUP_PREFIX)) { + ['kind' => $kind, 'id' => $id] = self::parseKey($key); + if ($kind === 'group') { if ( - self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds) + self::isGroupId($id, $groupIds) && is_string($value) && in_array($value, self::MODES, true) ) { $clean[$key] = $value; } - } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + } elseif ($kind === 'readonly') { if ( - self::isGroupId(substr($key, strlen(self::READONLY_PREFIX)), $groupIds) + self::isGroupId($id, $groupIds) && ($value === true || $value === '1' || $value === 1) ) { $clean[$key] = true; @@ -272,6 +278,28 @@ final class ExposurePolicy return $clean; } + /** + * What an option key names: "@group:" a group's mode, "@readonly:" a group's + * read-only switch, anything else an ability (id = the whole key). + * + * @return array{kind: 'group'|'readonly'|'tool', id: string} + */ + private static function parseKey(string $key): array + { + foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) { + if (str_starts_with($key, $prefix)) { + return ['kind' => $kind, 'id' => substr($key, strlen($prefix))]; + } + } + + return ['kind' => 'tool', 'id' => $key]; + } + + private static function isRequired(string $name): bool + { + return in_array($name, self::REQUIRED, true); + } + /** @param list|null $groupIds */ private static function isGroupId(string $id, ?array $groupIds): bool { diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index 5ddd9be..0b98129 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -15,17 +15,14 @@ final class AccessTab implements AdminTab public const GROUP = 'lit_mcp_access'; public const REVOKE_ACTION = 'lit_mcp_revoke'; - /** @var callable(): void */ - private $terminate; + private AdminPostAction $action; public function __construct( private Access $access, private ConnectionRepository $connections, ?callable $terminate = null ) { - $this->terminate = $terminate ?? static function (): void { - exit; - }; + $this->action = new AdminPostAction($terminate); } public function slug(): string @@ -121,9 +118,7 @@ final class AccessTab implements AdminTab public function handleRevoke(): void { - if (! current_user_can('manage_options')) { - wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]); - } + $this->action->guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below. $uuid = isset($_POST['uuid']) && is_string($_POST['uuid']) @@ -134,9 +129,7 @@ final class AccessTab implements AdminTab check_admin_referer(self::REVOKE_ACTION . '_' . $uuid); $revoked = $this->connections->revoke($userId, $uuid); - $notice = $revoked ? 'revoked' : 'revoke_failed'; - wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => $notice])); - ($this->terminate)(); + $this->action->redirect($this->slug(), $revoked ? 'revoked' : 'revoke_failed'); } private static function warnOnce(string $code, string $message): void diff --git a/src/Admin/AdminPostAction.php b/src/Admin/AdminPostAction.php new file mode 100644 index 0000000..2708dc0 --- /dev/null +++ b/src/Admin/AdminPostAction.php @@ -0,0 +1,39 @@ +terminate = $terminate ?? static function (): void { + exit; + }; + } + + /** Stops with 403 unless the current user may manage options. */ + public function guard(): void + { + if (! current_user_can('manage_options')) { + wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]); + } + } + + /** Redirects to settings tab $slug with "lit_mcp_notice" = $notice and ends the request. */ + public function redirect(string $slug, string $notice): void + { + wp_safe_redirect(SettingsPage::url($slug, ['lit_mcp_notice' => $notice])); + ($this->terminate)(); + } +} diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index fc83c4d..d0630c3 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -16,14 +16,11 @@ final class LogTab implements AdminTab public const CLEAR_ACTION = 'lit_mcp_clear_log'; private const RESULTS = ['ok', 'partial']; - /** @var callable(): void */ - private $terminate; + private AdminPostAction $action; public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null) { - $this->terminate = $terminate ?? static function (): void { - exit; - }; + $this->action = new AdminPostAction($terminate); } public function slug(): string @@ -210,13 +207,9 @@ final class LogTab implements AdminTab public function handleClear(): void { - if (! current_user_can('manage_options')) { - wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]); - } - + $this->action->guard(); check_admin_referer(self::CLEAR_ACTION); $this->log->clear(); - wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared'])); - ($this->terminate)(); + $this->action->redirect($this->slug(), 'cleared'); } } diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php index 8e0be67..6ef5113 100644 --- a/src/Admin/ToolsTab.php +++ b/src/Admin/ToolsTab.php @@ -68,8 +68,12 @@ final class ToolsTab implements AdminTab public function render(): void { + // Bind the policy to the groups of exactly the abilities listed below. The plugin's policy + // carries a lazy map of the same registry (resolved here anyway); a policy built without + // withGroups() would otherwise ignore every group mode and "nur lesen" switch on this page. $policy = $this->policy->withGroups($this->registry->groupMap()); $groups = $this->registry->grouped(); + $labels = $this->registry->labels(); $this->renderIntro(isset($groups['divi'])); $this->renderCounter(count($policy->directNames($this->registry->abilities()))); @@ -84,7 +88,7 @@ final class ToolsTab implements AdminTab echo '
'; $closed = $this->savedClosedBoxes(); foreach ($groups as $group => $abilities) { - $this->renderGroup($policy, $group, $abilities, $closed); + $this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed); } echo '
'; submit_button(); @@ -158,10 +162,14 @@ final class ToolsTab implements AdminTab * @param list $abilities * @param list|null $closedBoxes */ - private function renderGroup(ExposurePolicy $policy, string $group, array $abilities, ?array $closedBoxes): void - { + private function renderGroup( + ExposurePolicy $policy, + string $group, + string $label, + array $abilities, + ?array $closedBoxes + ): void { $count = count($abilities); - $label = $this->groupLabel($group); $boxId = 'lit-mcp-group-' . self::idPart($group); $modes = [ExposurePolicy::DIRECT => 0, ExposurePolicy::INDIRECT => 0, ExposurePolicy::DISABLED => 0]; $mixed = false; @@ -374,18 +382,6 @@ final class ToolsTab implements AdminTab return trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($value)), '-'); } - private function groupLabel(string $group): string - { - return match ($group) { - 'core' => __('Grundlagen', 'wp-lit-mcp'), - 'wordpress' => __('WordPress', 'wp-lit-mcp'), - 'media' => __('Medien', 'wp-lit-mcp'), - 'pods' => __('Pods', 'wp-lit-mcp'), - 'divi' => __('Divi', 'wp-lit-mcp'), - default => $group, - }; - } - private function modeLabel(string $mode): string { return match ($mode) { diff --git a/src/Integrations/Core/CoreIntegration.php b/src/Integrations/Core/CoreIntegration.php index 18883c6..5c4c762 100644 --- a/src/Integrations/Core/CoreIntegration.php +++ b/src/Integrations/Core/CoreIntegration.php @@ -19,6 +19,11 @@ final class CoreIntegration implements Integration return 'core'; } + public function label(): string + { + return __('Grundlagen', 'wp-lit-mcp'); + } + public function isAvailable(): bool { return true; diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index ad5b9ca..81f2a9e 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -48,6 +48,11 @@ final class DiviIntegration implements Integration return 'divi'; } + public function label(): string + { + return __('Divi', 'wp-lit-mcp'); + } + public function isAvailable(): bool { return DiviModules::isAvailable(); diff --git a/src/Integrations/Integration.php b/src/Integrations/Integration.php index e19a45c..932aa74 100644 --- a/src/Integrations/Integration.php +++ b/src/Integrations/Integration.php @@ -13,6 +13,9 @@ interface Integration { public function id(): string; + /** Translated name of the group in the admin UI, e.g. "Medien". */ + public function label(): string; + public function isAvailable(): bool; /** @return list */ diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 68345e7..9df6be2 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -67,6 +67,17 @@ final class IntegrationRegistry return $map; } + /** @return array Integration id => label, of the available integrations. */ + public function labels(): array + { + $labels = []; + foreach ($this->loaded() as [$integration]) { + $labels[$integration->id()] = $integration->label(); + } + + return $labels; + } + /** @return list Ids of all known integrations, available or not. */ public function ids(): array { diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index a16171c..404cc98 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -24,6 +24,11 @@ final class MediaIntegration implements Integration return 'media'; } + public function label(): string + { + return __('Medien', 'wp-lit-mcp'); + } + public function isAvailable(): bool { return true; diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index d440821..6fe11e0 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -59,6 +59,11 @@ final class WordPressIntegration implements Integration return 'wordpress'; } + public function label(): string + { + return __('WordPress', 'wp-lit-mcp'); + } + public function isAvailable(): bool { return true; diff --git a/tests/Unit/Admin/ToolsTabTest.php b/tests/Unit/Admin/ToolsTabTest.php index 7e43185..e66bc02 100644 --- a/tests/Unit/Admin/ToolsTabTest.php +++ b/tests/Unit/Admin/ToolsTabTest.php @@ -30,6 +30,9 @@ final class ToolsTabTest extends TestCase } $integration = Mockery::mock(Integration::class); $integration->allows('id')->andReturn($id); + $integration->allows('label')->andReturn( + ['core' => 'Grundlagen', 'media' => 'Medien', 'divi' => 'Divi', 'wordpress' => 'WordPress'][$id] ?? $id + ); $integration->allows('isAvailable')->andReturn($available); $integration->allows('abilities')->andReturn($mocks); diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php index 8389160..2e05f90 100644 --- a/tests/Unit/Integrations/IntegrationRegistryTest.php +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -4,9 +4,17 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations; +use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; +use LIT\MCP\Integrations\Core\CoreIntegration; +use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; +use LIT\MCP\Integrations\Media\MediaIntegration; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; +use LIT\MCP\Integrations\WordPress\WordPressIntegration; +use LIT\MCP\Limits; +use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -49,6 +57,28 @@ final class IntegrationRegistryTest extends TestCase self::assertSame(['core', 'divi', 'pods'], $registry->ids()); } + public function testLabelsOfTheAvailableIntegrations(): void + { + $core = $this->integration('core', true, []); + $core->allows('label')->andReturn('Grundlagen'); + + $registry = new IntegrationRegistry($core, $this->integration('pods', false, [])); + + self::assertSame(['core' => 'Grundlagen'], $registry->labels()); + } + + /** M9: the integrations own their admin labels (formerly hard-coded in ToolsTab). */ + public function testEveryIntegrationNamesItsGroup(): void + { + Functions\stubTranslationFunctions(); + $schema = Mockery::mock(ContentSchema::class); + + self::assertSame('Grundlagen', (new CoreIntegration(''))->label()); + self::assertSame('WordPress', (new WordPressIntegration($schema, FakeChangeLog::forbidden()))->label()); + self::assertSame('Medien', (new MediaIntegration($schema, FakeChangeLog::forbidden(), new Limits()))->label()); + self::assertSame('Divi', (new DiviIntegration($schema, FakeChangeLog::forbidden()))->label()); + } + private function ability(string $name, bool $direct): Ability { $ability = Mockery::mock(Ability::class);