Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<div class="notice notice-info wu-m-0 wu-p-4"><p>%s</p></div>',
esc_html__('Template switching is not available right now. Please contact your network administrator.', 'ultimate-multisite')
);
}
);
}
}
5 changes: 4 additions & 1 deletion inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 4 additions & 1 deletion inc/models/class-site.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
88 changes: 86 additions & 2 deletions inc/ui/class-template-switching-element.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = [];

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'] = [];
Expand Down Expand Up @@ -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(
'<div class="notice notice-warning wu-m-0 wu-p-4"><p>%s</p></div>',
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(
'<div class="notice notice-info wu-m-0 wu-mb-4 wu-p-4"><p>%s</p></div>',
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.
*
Expand Down
24 changes: 24 additions & 0 deletions tests/WP_Ultimo/Models/Site_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
62 changes: 62 additions & 0 deletions tests/WP_Ultimo/UI/Template_Switching_Element_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public function tear_down(): void {
}

$ref->setValue( $element, null );
WP_Ultimo()->currents->set_customer( false );

parent::tear_down();
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'] );
}

}
Loading