diff --git a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php
index 071691702..912fd00ce 100644
--- a/inc/admin-pages/customer-panel/class-template-switching-admin-page.php
+++ b/inc/admin-pages/customer-panel/class-template-switching-admin-page.php
@@ -164,7 +164,30 @@ public function output(): void {
* @return void
*/
public function register_widgets(): void {
- \WP_Ultimo\UI\Simple_Text_Element::get_instance()->as_inline_content(get_current_screen()->id, 'wu_dash_before_metaboxes');
- \WP_Ultimo\UI\Template_Switching_Element::get_instance()->as_inline_content(get_current_screen()->id, 'wu_dash_before_metaboxes');
+ add_action(
+ 'wu_dash_before_metaboxes',
+ function () {
+ $screen_id = get_current_screen()->id;
+
+ ob_start();
+
+ \WP_Ultimo\UI\Simple_Text_Element::get_instance()->as_inline_content($screen_id, 'wu_template_switching_content');
+ \WP_Ultimo\UI\Template_Switching_Element::get_instance()->as_inline_content($screen_id, 'wu_template_switching_content');
+
+ do_action('wu_template_switching_content');
+
+ $content = ob_get_clean();
+
+ if (trim($content)) {
+ echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Buffered widget output is escaped by each element.
+ return;
+ }
+
+ printf(
+ '
',
+ esc_html__('Template switching is not available right now. Please contact your network administrator.', 'ultimate-multisite')
+ );
+ }
+ );
}
}
diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php
index 7b746c0e4..a25d00c8f 100644
--- a/inc/models/class-membership.php
+++ b/inc/models/class-membership.php
@@ -467,7 +467,10 @@ public function is_customer_allowed($customer_id = false) {
$customer_id = $customer ? $customer->get_id() : 0;
}
- $allowed = absint($customer_id) === absint($this->get_customer_id());
+ $customer_id = absint($customer_id);
+ $membership_customer_id = absint($this->get_customer_id());
+
+ $allowed = $customer_id && $membership_customer_id && $membership_customer_id === $customer_id;
return apply_filters('wu_membership_is_customer_allowed', $allowed, $customer_id, $this);
}
diff --git a/inc/models/class-site.php b/inc/models/class-site.php
index 3dceaa8a1..cef45e255 100644
--- a/inc/models/class-site.php
+++ b/inc/models/class-site.php
@@ -1053,7 +1053,10 @@ public function is_customer_allowed($customer_id = false) {
$customer_id = $customer ? $customer->get_id() : 0;
}
- $allowed = absint($customer_id) === absint($this->get_customer_id());
+ $customer_id = absint($customer_id);
+ $site_customer_id = absint($this->get_customer_id());
+
+ $allowed = $customer_id && $site_customer_id && $site_customer_id === $customer_id;
return apply_filters('wu_site_is_customer_allowed', $allowed, $customer_id, $this);
}
diff --git a/inc/ui/class-template-switching-element.php b/inc/ui/class-template-switching-element.php
index a49a33dc6..968d2b943 100644
--- a/inc/ui/class-template-switching-element.php
+++ b/inc/ui/class-template-switching-element.php
@@ -23,6 +23,30 @@ class Template_Switching_Element extends Base_Element {
use \WP_Ultimo\Traits\Singleton;
+ /**
+ * Permission state when the current customer can switch templates.
+ *
+ * @since 2.9.3
+ * @var string
+ */
+ const STATE_OK = 'ok';
+
+ /**
+ * Permission state when the current site is not linked to a membership.
+ *
+ * @since 2.9.3
+ * @var string
+ */
+ const STATE_NO_MEMBERSHIP = 'no_membership';
+
+ /**
+ * Permission state when the current customer is not allowed to manage the site.
+ *
+ * @since 2.9.3
+ * @var string
+ */
+ const STATE_NOT_ALLOWED = 'not_allowed';
+
/**
* The id of the element.
*
@@ -57,6 +81,14 @@ class Template_Switching_Element extends Base_Element {
*/
protected $products;
+ /**
+ * Current template-switching permission state.
+ *
+ * @since 2.9.3
+ * @var string
+ */
+ protected $permission_state = self::STATE_NOT_ALLOWED;
+
/**
* The icon of the UI element.
* e.g. return fa fa-search
@@ -228,12 +260,13 @@ public function setup() {
$this->site = wu_get_current_site();
if ( ! $this->site || ! $this->site->is_customer_allowed()) {
- $this->set_display(false);
+ $this->permission_state = self::STATE_NOT_ALLOWED;
return;
}
$this->membership = $this->site->get_membership();
+ $this->permission_state = $this->membership ? self::STATE_OK : self::STATE_NO_MEMBERSHIP;
$this->products = [];
@@ -284,6 +317,11 @@ public function switch_template() {
return;
}
+ if ( ! $this->site->is_customer_allowed()) {
+ wp_send_json_error(new \WP_Error('not_authorized', __('You are not allowed to switch templates for this site.', 'ultimate-multisite')));
+ return;
+ }
+
$template_id = (int) wu_request('template_id', '');
// false means MODE_DEFAULT (no restriction) — all templates are allowed.
@@ -356,12 +394,30 @@ public function switch_template() {
*/
public function output($atts, $content = null) {
+ if ( ! $this->site || self::STATE_NOT_ALLOWED === $this->permission_state) {
+ $this->render_not_allowed_notice();
+
+ return;
+ }
+
if ($this->site) {
+ if (self::STATE_NO_MEMBERSHIP === $this->permission_state) {
+ $this->render_no_membership_notice();
+
+ $atts['template_selection_sites'] = $this->defaults()['template_selection_sites'];
+ }
+
$filter_template_limits = \WP_Ultimo\Limits\Site_Template_Limits::get_instance();
$atts['products'] = $this->products;
- $template_selection_field = $filter_template_limits->maybe_filter_template_selection_options($atts);
+ if (self::STATE_NO_MEMBERSHIP === $this->permission_state) {
+ $template_selection_field = [
+ 'sites' => array_filter(array_map('absint', explode(',', $atts['template_selection_sites']))),
+ ];
+ } else {
+ $template_selection_field = $filter_template_limits->maybe_filter_template_selection_options($atts);
+ }
if ( ! isset($template_selection_field['sites'])) {
$template_selection_field['sites'] = [];
@@ -505,6 +561,34 @@ public function output($atts, $content = null) {
}
}
+ /**
+ * Render the not-authorized notice.
+ *
+ * @since 2.9.3
+ * @return void
+ */
+ protected function render_not_allowed_notice() {
+
+ printf(
+ '',
+ esc_html__('Template switching is not available right now. Please contact your network administrator for help with this site.', 'ultimate-multisite')
+ );
+ }
+
+ /**
+ * Render the no-membership informational notice.
+ *
+ * @since 2.9.3
+ * @return void
+ */
+ protected function render_no_membership_notice() {
+
+ printf(
+ '',
+ esc_html__('This site is not currently linked to a membership, so plan-specific template restrictions do not apply.', 'ultimate-multisite')
+ );
+ }
+
/**
* Returns the list of available pricing table templates.
*
diff --git a/tests/WP_Ultimo/Models/Site_Test.php b/tests/WP_Ultimo/Models/Site_Test.php
index f30d90b85..df4aa2b0e 100644
--- a/tests/WP_Ultimo/Models/Site_Test.php
+++ b/tests/WP_Ultimo/Models/Site_Test.php
@@ -996,6 +996,30 @@ public function test_is_customer_allowed_different_customer(): void {
$this->assertFalse($result, 'is_customer_allowed should return false for a different customer ID.');
}
+ /**
+ * Test is_customer_allowed denies zero requester versus zero owner.
+ */
+ public function test_is_customer_allowed_zero_versus_zero_denied(): void {
+ wp_set_current_user(0);
+ $this->site->set_customer_id(0);
+
+ $result = $this->site->is_customer_allowed(0);
+
+ $this->assertFalse($result, 'is_customer_allowed should deny access when neither side has a known customer ID.');
+ }
+
+ /**
+ * Test is_customer_allowed denies known requester versus unlinked site.
+ */
+ public function test_is_customer_allowed_known_customer_versus_unlinked_site_denied(): void {
+ $customer_id = $this->customer->get_id();
+ $this->site->set_customer_id(0);
+
+ $result = $this->site->is_customer_allowed($customer_id);
+
+ $this->assertFalse($result, 'is_customer_allowed should deny access when the site has no linked customer ID.');
+ }
+
/**
* Test get_customer returns false when customer_id is 0.
*/
diff --git a/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php b/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php
index 7f4711af5..739991a8a 100644
--- a/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php
+++ b/tests/WP_Ultimo/UI/Template_Switching_Element_Test.php
@@ -53,6 +53,7 @@ public function tear_down(): void {
}
$ref->setValue( $element, null );
+ WP_Ultimo()->currents->set_customer( false );
parent::tear_down();
}
@@ -167,6 +168,9 @@ public function test_switch_template_missing_site_emits_json_error(): void {
* Empty template_id must yield a JSON error body, not silence.
*/
public function test_switch_template_missing_template_id_emits_json_error(): void {
+ $user_id = $this->factory()->user->create( [ 'role' => 'administrator' ] );
+ grant_super_admin( $user_id );
+ wp_set_current_user( $user_id );
// Force a fake site object so the "missing site context" guard is bypassed
// and we hit the template_id check.
@@ -196,4 +200,62 @@ public function test_switch_template_missing_template_id_emits_json_error(): voi
$this->assertSame( false, $decoded['success'] );
}
+ /**
+ * Unauthorized callers must be rejected before template override runs.
+ */
+ public function test_switch_template_rejects_unauthorized_caller(): void {
+
+ $owner_user_id = $this->factory()->user->create( [ 'role' => 'subscriber' ] );
+ $other_user_id = $this->factory()->user->create( [ 'role' => 'subscriber' ] );
+
+ $owner_customer = wu_create_customer(
+ [
+ 'user_id' => $owner_user_id,
+ 'email_address' => 'template-owner@example.com',
+ ]
+ );
+
+ $other_customer = wu_create_customer(
+ [
+ 'user_id' => $other_user_id,
+ 'email_address' => 'template-other@example.com',
+ ]
+ );
+
+ if ( is_wp_error( $owner_customer ) || is_wp_error( $other_customer ) ) {
+ $this->markTestSkipped( 'Customer creation failed.' );
+ }
+
+ $site_id = $this->factory()->blog->create();
+ $site = wu_get_site( $site_id );
+ $site->set_type( 'customer_owned' );
+ $site->set_customer_id( $owner_customer->get_id() );
+ $site->save();
+
+ WP_Ultimo()->currents->set_customer( $other_customer );
+ wp_set_current_user( $other_user_id );
+ $_REQUEST['template_id'] = '1';
+
+ $element = Template_Switching_Element::get_instance();
+ $ref = new \ReflectionProperty( $element, 'site' );
+
+ if ( PHP_VERSION_ID < 80100 ) {
+ $ref->setAccessible( true );
+ }
+
+ $ref->setValue( $element, $site );
+
+ $result = $this->call_switch_template();
+
+ $this->assertTrue(
+ $result['exception'],
+ 'wp_send_json_error must be reached for unauthorized template switching.'
+ );
+
+ $decoded = $this->decode_json( $result['output'] );
+
+ $this->assertSame( false, $decoded['success'] );
+ $this->assertSame( 'not_authorized', $decoded['data'][0]['code'] );
+ }
+
}