From 504de013a7f797aa80e1527c4706d313d5a04483 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Nov 2026 20:08:36 -0700 Subject: [PATCH 1/4] fix: make frontend customer account and site management work end to end --- DESIGN.md | 26 ++++ .../class-payment-edit-admin-page.php | 4 +- inc/class-current.php | 128 ++++++++++++++---- inc/gateways/class-base-stripe-gateway.php | 16 ++- inc/gateways/class-manual-gateway.php | 20 +-- inc/ui/class-account-summary-element.php | 31 ++++- inc/ui/class-current-site-element.php | 2 +- inc/ui/class-payment-methods-element.php | 2 +- inc/ui/class-site-actions-element.php | 47 +++++-- inc/ui/class-template-switching-element.php | 6 +- tests/WP_Ultimo/Current_Test.php | 77 +++++++++++ .../account-summary-empty.php | 33 +++++ views/dashboard-widgets/account-summary.php | 5 +- views/dashboard-widgets/current-site.php | 2 +- views/shortcodes/shortcodes.php | 20 +++ 15 files changed, 355 insertions(+), 64 deletions(-) create mode 100644 views/dashboard-widgets/account-summary-empty.php diff --git a/DESIGN.md b/DESIGN.md index 7104ee977..9aa510eac 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -52,3 +52,29 @@ Keep the existing stacked widget layout on desktop and mobile. List nameservers on separate lines and allow long hostnames to wrap without truncation. Hide the summary during loading or lookup errors; preserve the existing record table and provider-managed editing controls. + +## Frontend customer accounts + +Keep account-wide membership, billing and invoices separate from site-specific +summary and limits. Account Summary must offer a sign-in link when logged out, +an owner-scoped site chooser before site selection, and a neutral no-sites state +instead of silently rendering nothing. Do not silently select the first site: +customers may own several. Once a site is selected, omit self-referential +"See More" links and show remaining trial days from the actual membership date. +Use the existing stacked widget styles on desktop and mobile, with native links +and wrapping site names; do not add fixed-width panels or a second design system. +Frontend account pages belong on the main site; customer-site admin Account +screens remain an alternative interface to the same membership. + +On combined pages, Manage must visibly land on the selected site's controls, +using a keyboard-focusable native fragment target (Account Summary is the +fallback). Your Sites clears selected-site context and returns to the page +permalink. Successful frontend deletion returns to the account landing page, +unless an explicit destination is configured; never retain a deleted-site URL. +Sites without a template initialize the selector to numeric zero so their first +selection exposes the same destructive-action confirmation as template changes. + +Payment-method changes must return from Stripe Billing Portal to the originating +account page, using a validated local destination. Show the subscription's card +override when present; otherwise show the customer's default payment method, +including after the portal clears an override while replacing the default card. diff --git a/inc/admin-pages/class-payment-edit-admin-page.php b/inc/admin-pages/class-payment-edit-admin-page.php index 5f3a9b712..81857a697 100644 --- a/inc/admin-pages/class-payment-edit-admin-page.php +++ b/inc/admin-pages/class-payment-edit-admin-page.php @@ -1186,10 +1186,10 @@ public function register_widgets(): void { 'confirm_membership' => [ 'type' => 'toggle', 'title' => __('Activate Membership?', 'ultimate-multisite'), - 'desc' => __('This payment belongs to a pending membership. If you toggle this option, this change in status will also apply to the membership. If any sites are pending, they are also going to be published automatically.', 'ultimate-multisite'), + 'desc' => __('This payment belongs to a pending or on-hold membership. Enable this option to activate or renew the membership when confirming the payment. Any pending sites will also be published automatically.', 'ultimate-multisite'), 'value' => 0, 'wrapper_html_attr' => [ - 'v-if' => 'status !== original_status && status === "completed" && membership_status === "pending"', + 'v-if' => 'status !== original_status && status === "completed" && (membership_status === "pending" || membership_status === "on-hold")', 'v-cloak' => '1', ], ], diff --git a/inc/class-current.php b/inc/class-current.php index 1acaa2786..3d5e170e1 100644 --- a/inc/class-current.php +++ b/inc/class-current.php @@ -232,20 +232,37 @@ public static function get_manage_url($id, $type = 'site') { $site_hash = \WP_Ultimo\Helpers\Hash::encode($id, $type); if ( ! is_admin()) { - $current_url = rtrim((string) wu_get_current_url(), '/'); - $url_param = self::param_key($type); + $remove = 'site' === $type ? [$url_param, self::param_key('membership')] : [$url_param]; + $url = remove_query_arg($remove, (string) wu_get_current_url()); + $parts = wp_parse_url($url); + $suffix = empty($parts['query']) ? '' : '?' . $parts['query']; + $fragment = empty($parts['fragment']) ? '' : '#' . $parts['fragment']; + $post = get_post(); + + // On combined account pages, land on the controls that actually changed. + if ('site' === $type && $post) { + if (has_shortcode($post->post_content, 'wu_current_site') || has_block('wp-ultimo/current-site', $post)) { + $fragment = '#wu-site-' . $site_hash; + } elseif (has_shortcode($post->post_content, 'wu_account_summary') || has_block('wp-ultimo/account-summary', $post)) { + $fragment = '#wu-summary-' . $site_hash; + } + } /* - * Check if the current URL already has a site parameter and remove it. + * Replace the trailing context in the path, not inside the query string. + * Site selection also clears membership context so the site's own + * membership is selected, rather than one left over from another site. */ - if (str_contains($current_url, '/' . $url_param . '/')) { - $current_url = preg_replace('/\/' . $url_param . '\/(.+)/', '/', $current_url); - } + $current_url = preg_replace('/[?#].*$/', '', $url); + $current_url = preg_replace('/\/' . preg_quote($url_param, '/') . '\/[^\/]+\/?$/', '', $current_url); + $current_url = rtrim($current_url, '/'); - $pretty_url = $current_url . '/' . $url_param . '/' . $site_hash; + $pretty_url = $current_url . '/' . $url_param . '/' . $site_hash . $suffix . $fragment; - $manage_site_url = get_option('permalink_structure') ? $pretty_url : add_query_arg($url_param, $site_hash); + $plain_url = add_query_arg($url_param, $site_hash, preg_replace('/#.*$/', '', $url)) . $fragment; + + $manage_site_url = get_option('permalink_structure') ? $pretty_url : $plain_url; } else { $manage_site_url = get_admin_url($id); } @@ -280,6 +297,17 @@ public function load_currents(): void { return; } + // Never retain an earlier context when a later URL override is rejected. + $this->site = false; + $this->customer = false; + $this->membership = false; + $this->site_set_via_request = false; + $this->customer_set_via_request = false; + $this->membership_set_via_request = false; + $site_set_via_request = false; + $customer_set_via_request = false; + $membership_set_via_request = false; + /* * `get_query_var()` reads from the global `$wp_query`, which * WordPress only populates during `parse_query` (just before @@ -322,21 +350,23 @@ public function load_currents(): void { $site_hash = wu_request($site_url_param, $site_hash_default); - $site_from_url = wu_get_site_by_hash($site_hash); + if ($site_hash) { + if ( ! is_string($site_hash)) { + return; + } - if ($site_from_url) { - $this->site_set_via_request = true; + $site = wu_get_site_by_hash($site_hash); - $site = $site_from_url; + if ( ! $this->can_access_context($site)) { + return; + } + + $site_set_via_request = true; } } else { $site = wu_get_current_site(); } - if ($site) { - $this->set_site($site); - } - $customer = wu_get_current_customer(); /** @@ -348,17 +378,23 @@ public function load_currents(): void { */ $customer_url_param = self::param_key('customer'); - $customer_from_url = wu_get_customer(wu_request($customer_url_param, 0)); + $customer_id = wu_request($customer_url_param, 0); + + if ($customer_id) { + if ( ! is_scalar($customer_id) || ! ctype_digit((string) $customer_id)) { + return; + } - if ($customer_from_url) { - $this->customer_set_via_request = true; + $customer = wu_get_customer($customer_id); - $customer = $customer_from_url; + if ( ! $this->can_access_context($customer)) { + return; + } + + $customer_set_via_request = true; } } - $this->set_customer($customer); - $membership = false; /* @@ -371,9 +407,17 @@ public function load_currents(): void { $membership_hash = wu_request($membership_url_param, $membership_hash_default); if ($membership_hash) { - $this->membership_set_via_request = true; + if ( ! is_string($membership_hash)) { + return; + } $membership = wu_get_membership_by_hash($membership_hash); + + if ( ! $this->can_access_context($membership)) { + return; + } + + $membership_set_via_request = true; } elseif ($site) { $membership = $site->get_membership(); } @@ -384,9 +428,47 @@ public function load_currents(): void { $membership = wu_get_isset($memberships, 0, false); } + if ($membership && ! $this->can_access_context($membership)) { + return; + } + + $this->site_set_via_request = $site_set_via_request; + $this->customer_set_via_request = $customer_set_via_request; + $this->membership_set_via_request = $membership_set_via_request; + + $this->set_site($site); + $this->set_customer($customer); $this->set_membership($membership); } + /** + * Authorizes account context against the authenticated user, never URL state. + * + * Hashes select objects; they are not access credentials. Pass an explicit + * customer ID to the model checks to avoid resolving this singleton again. + * + * @param \WP_Ultimo\Models\Customer|\WP_Ultimo\Models\Site|\WP_Ultimo\Models\Membership|false $context Requested object. + * @return bool + */ + private function can_access_context($context): bool { + + if ( ! $context || ! is_user_logged_in()) { + return false; + } + + if (current_user_can('manage_network')) { + return true; + } + + if ($context instanceof \WP_Ultimo\Models\Customer) { + return (int) $context->get_user_id() === get_current_user_id(); + } + + $customer = wu_get_current_customer(); + + return $customer && $context->is_customer_allowed($customer->get_id()); + } + /** * Get the current site instance. * diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 95d6a627c..42249136c 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -824,6 +824,7 @@ public function get_change_payment_method_url($membership) { [ 'wu-stripe-portal' => true, 'membership' => $membership->get_hash(), + 'redirect_url' => wu_request('redirect_url', wu_get_current_url()), ], home_url('/') ); @@ -848,11 +849,16 @@ public function get_payment_method_display($membership) { $subscription = $this->get_stripe_client()->subscriptions->retrieve( $sub_id, - ['expand' => ['default_payment_method']] + ['expand' => ['default_payment_method', 'customer.invoice_settings.default_payment_method']] ); $pm = $subscription->default_payment_method; + // Portal updates may clear the subscription override to use the customer default. + if ( ! $pm) { + $pm = $subscription->customer->invoice_settings->default_payment_method ?? null; + } + if ( ! $pm || ! $pm->card) { return null; } @@ -906,12 +912,14 @@ public function maybe_redirect_to_portal(): void { $s_customer_id = $membership->get_gateway_customer_id(); $stored_redirect = get_user_meta(get_current_user_id(), '_wu_change_payment_redirect', true); + $redirect_url = wu_request('redirect_url', $stored_redirect); + $fallback_url = remove_query_arg(['wu-stripe-portal', 'redirect_url'], wu_get_current_url()); - if ($stored_redirect) { + if ($redirect_url && is_string($redirect_url)) { delete_user_meta(get_current_user_id(), '_wu_change_payment_redirect'); - $return_url = add_query_arg('updated', 'payment_method', $stored_redirect); + $return_url = add_query_arg('updated', 'payment_method', wp_validate_redirect($redirect_url, $fallback_url)); } else { - $return_url = remove_query_arg('wu-stripe-portal', wu_get_current_url()); + $return_url = $fallback_url; } try { diff --git a/inc/gateways/class-manual-gateway.php b/inc/gateways/class-manual-gateway.php index 623ef28bb..235fe4e37 100644 --- a/inc/gateways/class-manual-gateway.php +++ b/inc/gateways/class-manual-gateway.php @@ -215,13 +215,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) * simply throw a exception and Ultimate Multisite will * catch it and rollback any changes. */ - if ('new' === $type) { - - // Your logic here. - } elseif ('renewal' === $type) { - - // Your logic here. - } elseif ('downgrade' === $type) { + if ('downgrade' === $type) { /* * When downgrading, we need to schedule a swap for the end of the * current expiration date. @@ -238,7 +232,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) * Saves the membership with the changes. */ $status = $membership->save(); - } elseif ('upgrade' === $type || 'addon' === $type) { + } elseif (in_array($type, ['upgrade', 'addon', 'reactivation'], true)) { /* * After everything is said and done, * we need to swap the membership to the new products @@ -254,7 +248,12 @@ public function process_checkout($payment, $membership, $customer, $cart, $type) * It doesn't save the membership, though, so * you'll have to do that manually (example below). */ - $membership->swap($cart); + if ('reactivation' !== $type) { + $membership->swap($cart); + } + + $membership->set_gateway($this->get_id()); + $membership->set_auto_renew(false); /* * Mark the membership as pending, as we need to @@ -333,7 +332,8 @@ public function process_cancellation($membership, $customer) {} * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. - * @return void|bool + * @throws \Exception When the refund fails. + * @return void */ public function process_refund($amount, $payment, $membership, $customer): void { diff --git a/inc/ui/class-account-summary-element.php b/inc/ui/class-account-summary-element.php index b7dc1a034..09d2c8828 100644 --- a/inc/ui/class-account-summary-element.php +++ b/inc/ui/class-account-summary-element.php @@ -121,7 +121,7 @@ public function get_title() { */ public function get_description() { - return __('Displays a summary of the customer\'s account including membership and site overview.', 'ultimate-multisite'); + return __('Displays the selected site\'s plan, trial time and disk usage. On the main-site frontend, customers can select a site to view its summary.', 'ultimate-multisite'); } /** @@ -220,7 +220,11 @@ public function setup() { $this->site = WP_Ultimo()->currents->get_site(); - if ( ! $this->site || ! $this->site->is_customer_allowed()) { + if ( ! $this->site) { + return; + } + + if ( ! $this->site->is_customer_allowed()) { $this->set_display(false); return; @@ -240,8 +244,18 @@ public function setup() { is_multisite() && restore_current_blog(); + $trial_days = 0; + + if ($this->membership && 'trialing' === $this->membership->get_status()) { + $trial_end = strtotime($this->membership->get_date_trial_end() . ' UTC'); + + if ($trial_end) { + $trial_days = max(0, (int) ceil(($trial_end - time()) / DAY_IN_SECONDS)); + } + } + $this->atts = [ - 'site_trial' => 0, // @todo: fix this + 'site_trial' => $trial_days, 'space_used' => $space_used, 'space_allowed' => $space_allowed, 'percentage' => $percentage, @@ -298,8 +312,15 @@ public function output($atts, $content = null) { $this->ensure_setup(); - // Return empty if no site available (e.g., during SEO processing) if ( ! $this->site) { + if ( ! is_admin()) { + $customer = WP_Ultimo()->currents->get_customer(); + + $atts['sites'] = $customer ? $customer->get_sites() : []; + + wu_get_template('dashboard-widgets/account-summary-empty', $atts); + } + return; } @@ -311,6 +332,8 @@ public function output($atts, $content = null) { $atts['product'] = $this->product; + $atts['hide_manage_link'] = WP_Ultimo()->currents->is_site_set_via_request(); + wu_get_template('dashboard-widgets/account-summary', $atts); } diff --git a/inc/ui/class-current-site-element.php b/inc/ui/class-current-site-element.php index d38bfc3e0..ca0849a21 100644 --- a/inc/ui/class-current-site-element.php +++ b/inc/ui/class-current-site-element.php @@ -384,7 +384,7 @@ public function output($atts, $content = null) { $my_sites_id = $atts['breadcrumbs_my_sites_page']; - $my_sites_url = empty($my_sites_id) ? remove_query_arg('site') : get_page_link($my_sites_id); + $my_sites_url = empty($my_sites_id) ? get_permalink() : get_page_link($my_sites_id); $atts['my_sites_url'] = is_admin() ? admin_url('admin.php?page=sites') : $my_sites_url; diff --git a/inc/ui/class-payment-methods-element.php b/inc/ui/class-payment-methods-element.php index 651d0b387..47e3aec6f 100644 --- a/inc/ui/class-payment-methods-element.php +++ b/inc/ui/class-payment-methods-element.php @@ -194,7 +194,7 @@ public function output($atts, $content = null) { $gateway_display = ''; if ($gateway) { - $gateway_display = $gateway->get_title(); + $gateway_display = $gateway->get_public_title(); $payment_info = $gateway->get_payment_method_display($this->membership); $change_url = $gateway->get_change_payment_method_url($this->membership); } diff --git a/inc/ui/class-site-actions-element.php b/inc/ui/class-site-actions-element.php index 13cc498ee..5921ffbd2 100644 --- a/inc/ui/class-site-actions-element.php +++ b/inc/ui/class-site-actions-element.php @@ -476,7 +476,7 @@ public function get_danger_zone_actions($atts) { 'delete_site', [ 'site' => $this->site->get_hash(), - 'redirect_url' => ! $atts['redirect_after_delete'] ? false : get_page_link($atts['redirect_after_delete']), + 'redirect_url' => $atts['redirect_after_delete'] ? get_page_link($atts['redirect_after_delete']) : (is_admin() ? user_admin_url() : get_permalink()), ] ), ], @@ -917,13 +917,7 @@ public function handle_change_email(): void { wp_send_json_error($user_id); } - // Update customer email if exists. - $customer = wu_get_current_customer(); - - if ($customer) { - $customer->set_email_address($new_email); - $customer->save(); - } + // Customer::get_email_address() reads the linked WordPress user's email. $referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_url(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; @@ -1006,6 +1000,12 @@ public function handle_change_default_site(): void { $new_primary_site = wu_request('new_primary_site'); + if ( ! is_scalar($new_primary_site) || ! ctype_digit((string) $new_primary_site) || ! isset(get_blogs_of_user(get_current_user_id())[ (int) $new_primary_site ])) { + wp_send_json_error(new \WP_Error('invalid-site', __('Please select a site from your account.', 'ultimate-multisite'))); + + return; + } + if ($new_primary_site) { update_user_meta(get_current_user_id(), 'primary_blog', $new_primary_site); $referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_url(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; @@ -1317,7 +1317,21 @@ public function handle_cancel_membership(): void { 'other' => wu_request('cancellation_explanation'), ]; - $reason = wu_get_isset($cancellation_options, wu_request('cancellation_reason'), ''); + if (__('CANCEL', 'ultimate-multisite') !== wu_request('confirm')) { + wp_send_json_error(new \WP_Error('not-confirmed', __('Please type CANCEL to confirm the membership cancellation.', 'ultimate-multisite'))); + + return; + } + + $reason_key = wu_request('cancellation_reason', ''); + + if ( ! is_string($reason_key) || ! array_key_exists($reason_key, $cancellation_options)) { + wp_send_json_error(new \WP_Error('invalid-reason', __('Please select a cancellation reason.', 'ultimate-multisite'))); + + return; + } + + $reason = $cancellation_options[ $reason_key ]; try { /* * Cancel the subscription on the payment gateway before @@ -1493,14 +1507,21 @@ public function handle_resubscribe_membership(): void { } /* - * Build the checkout URL pre-filled with the customer's previous plan. - * The products[] query parameter pre-selects the plan on the checkout form. + * Reuse the existing membership, rather than starting a new site signup. + * Reactivation carts restore the original products themselves. Passing + * product slugs in GET conflicts with the form's product IDs in POST. */ - $checkout_url = wu_get_registration_url(); + $checkout_url = wu_get_membership_update_url($membership); + + // Preselect the required product field without conflicting GET products. + $session = wu_get_session('signup'); + $signup = (array) $session->get('signup'); + $signup['products'] = [$plan->get_id()]; + $session->set('signup', $signup); + $session->commit(); $checkout_url = add_query_arg( [ - 'products[]' => $plan->get_slug(), 'wu_resubscribe' => $membership->get_hash(), ], $checkout_url diff --git a/inc/ui/class-template-switching-element.php b/inc/ui/class-template-switching-element.php index ea1bd2afd..71a4cd1e3 100644 --- a/inc/ui/class-template-switching-element.php +++ b/inc/ui/class-template-switching-element.php @@ -721,13 +721,13 @@ public function output($atts, $content = null) { * giving Vue a chance to bind it before the `template_id` * watcher runs. The watcher itself also defends against the * inverse ordering (see template-switching.js: it bails out - * early when original_template_id <= 0). + * early when original_template_id < 0). */ $checkout_fields['template_id'] = [ 'type' => 'hidden', 'html_attr' => [ - 'v-init:original_template_id' => $this->site->get_template_id(), - 'v-init:template_id' => $this->site->get_template_id(), + 'v-init:original_template_id' => $current_template_id, + 'v-init:template_id' => $current_template_id, 'v-model' => 'template_id', ], ]; diff --git a/tests/WP_Ultimo/Current_Test.php b/tests/WP_Ultimo/Current_Test.php index 279695fa5..5d687a611 100644 --- a/tests/WP_Ultimo/Current_Test.php +++ b/tests/WP_Ultimo/Current_Test.php @@ -440,4 +440,81 @@ public function test_load_currents_skips_rerun_during_ajax(): void { remove_filter('wp_doing_ajax', '__return_true'); } + + /** URL-selected account objects require the real owner's authentication. */ + public function test_request_context_requires_ownership(): void { + + $owner_id = self::factory()->user->create(['role' => 'subscriber']); + $stranger_id = self::factory()->user->create(['role' => 'subscriber']); + $admin_id = self::factory()->user->create(['role' => 'administrator']); + $customer = wu_create_customer(['user_id' => $owner_id]); + $stranger = wu_create_customer(['user_id' => $stranger_id]); + $this->assertNotWPError($customer); + $this->assertNotWPError($stranger); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'status' => 'active', + 'skip_validation' => true, + ] + ); + $this->assertNotWPError($membership); + + $site = wu_get_site(self::factory()->blog->create(['user_id' => $owner_id])); + $site->set_type('customer_owned'); + $site->set_customer_id($customer->get_id()); + $site->set_membership_id($membership->get_id()); + $this->assertNotWPError($site->save()); + + $this->go_to(home_url('/')); + set_current_screen('front'); + grant_super_admin($admin_id); + + try { + foreach ([$owner_id, $stranger_id, 0, $admin_id] as $viewer_id) { + wp_set_current_user($viewer_id); + + foreach (['membership', 'site', 'pretty_site', 'customer', 'spoofed_customer'] as $context_type) { + $_GET = []; + $_REQUEST = []; + set_query_var('site_hash', ''); + + if ('pretty_site' === $context_type) { + set_query_var('site_hash', $site->get_hash()); + } elseif ('spoofed_customer' === $context_type) { + $_GET = [ + 'customer' => $customer->get_id(), + 'membership' => $membership->get_hash(), + ]; + } else { + $values = [ + 'membership' => $membership->get_hash(), + 'site' => $site->get_hash(), + 'customer' => $customer->get_id(), + ]; + $_GET = [$context_type => $values[$context_type]]; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Synthetic request selection; ownership is the boundary under test. + $_REQUEST = $_GET; + $this->current->load_currents(); + + if (in_array($viewer_id, [$owner_id, $admin_id], true)) { + $this->assertSame($membership->get_id(), $this->current->get_membership()->get_id(), $context_type); + } else { + $this->assertFalse($this->current->get_membership(), $context_type); + $this->assertFalse($this->current->get_customer(), $context_type); + $this->assertFalse($this->current->get_site(), $context_type); + $this->assertFalse($this->current->is_site_set_via_request()); + $this->assertFalse($this->current->is_membership_set_via_request()); + } + } + } + } finally { + revoke_super_admin($admin_id); + set_query_var('site_hash', ''); + wp_set_current_user(0); + } + } } diff --git a/views/dashboard-widgets/account-summary-empty.php b/views/dashboard-widgets/account-summary-empty.php new file mode 100644 index 000000000..5ab0adaf1 --- /dev/null +++ b/views/dashboard-widgets/account-summary-empty.php @@ -0,0 +1,33 @@ + +
+
+ +

+ + +

+ + +

+ + +
+
diff --git a/views/dashboard-widgets/account-summary.php b/views/dashboard-widgets/account-summary.php index bf6a38b6c..d3d625224 100644 --- a/views/dashboard-widgets/account-summary.php +++ b/views/dashboard-widgets/account-summary.php @@ -6,8 +6,9 @@ */ defined('ABSPATH') || exit; +// phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase -- className is the shared block/template attribute. ?> -
+
@@ -24,7 +25,7 @@ - +
diff --git a/views/dashboard-widgets/current-site.php b/views/dashboard-widgets/current-site.php index 152332c34..c8ee236a4 100644 --- a/views/dashboard-widgets/current-site.php +++ b/views/dashboard-widgets/current-site.php @@ -7,7 +7,7 @@ defined('ABSPATH') || exit; ?> -
+
diff --git a/views/shortcodes/shortcodes.php b/views/shortcodes/shortcodes.php index 8f38792db..2d9d1302e 100644 --- a/views/shortcodes/shortcodes.php +++ b/views/shortcodes/shortcodes.php @@ -15,6 +15,26 @@
+
+
+

+

+
[wu_account_summary]
+[wu_current_membership]
+[wu_billing_info]
+[wu_payment_methods]
+[wu_invoices]
+[wu_current_site]
+[wu_limits]
+[wu_site_actions]
+

+

Memberships > Default Membership Update Page.', 'ultimate-multisite'); ?>

+

[wu_checkout slug="wu-checkout"]

+

+

+
+
+
From 8d6823d539e59d8b39a5db2f984ffa9a2a58793f Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 4 Nov 2026 20:23:12 -0700 Subject: [PATCH 2/4] test: restore account singleton state after ownership regression checks --- tests/WP_Ultimo/Current_Test.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/WP_Ultimo/Current_Test.php b/tests/WP_Ultimo/Current_Test.php index 5d687a611..5bbd88d16 100644 --- a/tests/WP_Ultimo/Current_Test.php +++ b/tests/WP_Ultimo/Current_Test.php @@ -444,6 +444,9 @@ public function test_load_currents_skips_rerun_during_ajax(): void { /** URL-selected account objects require the real owner's authentication. */ public function test_request_context_requires_ownership(): void { + $saved_current = clone $this->current; + $saved_user_id = get_current_user_id(); + $owner_id = self::factory()->user->create(['role' => 'subscriber']); $stranger_id = self::factory()->user->create(['role' => 'subscriber']); $admin_id = self::factory()->user->create(['role' => 'administrator']); @@ -514,7 +517,15 @@ public function test_request_context_requires_ownership(): void { } finally { revoke_super_admin($admin_id); set_query_var('site_hash', ''); - wp_set_current_user(0); + wp_set_current_user($saved_user_id); + + // Do not leave the final synthetic membership in the shared singleton. + foreach ((new \ReflectionClass(Current::class))->getProperties() as $property) { + if ( ! $property->isStatic()) { + $property->setAccessible(true); + $property->setValue($this->current, $property->getValue($saved_current)); + } + } } } } From 46031b735b50e31ad83435ca90baffd25644ef0d Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 5 Nov 2026 15:23:59 -0700 Subject: [PATCH 3/4] fix: preserve account block settings and style editor previews --- DESIGN.md | 7 +++++++ inc/builders/block-editor/assets/js/blocks.js | 3 ++- inc/builders/block-editor/assets/js/blocks.min.js | 2 +- .../class-block-editor-widget-manager.php | 15 ++++++++++++++- inc/ui/class-base-element.php | 7 ++++++- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9aa510eac..68fb6b7d2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,3 +78,10 @@ Payment-method changes must return from Stripe Billing Portal to the originating account page, using a validated local destination. Show the subscription's card override when present; otherwise show the customer's default payment method, including after the portal clears an override while replacing the default card. + +Native account blocks share shortcode renderers, settings and ownership checks. +Load widget preview styles inside the block editor canvas, including iframe +editors. Numeric controls save integers and toggle defaults use booleans. Group +and Columns layouts must preserve nested element attributes during early setup, +especially the Checkout form slug. Editor previews use sample data; verify real +customer actions on the published page rather than treating a preview as proof. diff --git a/inc/builders/block-editor/assets/js/blocks.js b/inc/builders/block-editor/assets/js/blocks.js index 92be99e0e..96b762590 100644 --- a/inc/builders/block-editor/assets/js/blocks.js +++ b/inc/builders/block-editor/assets/js/blocks.js @@ -109,7 +109,8 @@ const obj = {}; - obj[ field_slug ] = value; + // NumberControl emits strings; the block REST schema requires integers. + obj[ field_slug ] = field.type === 'number' ? Math.trunc(Number(value)) : value; props.setAttributes(obj); diff --git a/inc/builders/block-editor/assets/js/blocks.min.js b/inc/builders/block-editor/assets/js/blocks.min.js index 3b527ef3a..096a41135 100644 --- a/inc/builders/block-editor/assets/js/blocks.min.js +++ b/inc/builders/block-editor/assets/js/blocks.min.js @@ -1 +1 @@ -((e,p,c,d)=>{let h=p.createElement,l=e.registerBlockType,o=wp.serverSideRender,g=wp.components.PanelBody,m=wp.components.TextControl,w=wp.components.__experimentalNumberControl,b=wp.components.RangeControl,v=wp.components.ToggleControl,f=wp.components.TextareaControl,y=wp.components.Notice,k=wp.components.SelectControl,C=wp.blockEditor.InspectorControls,_=wp.compose.useDebounce||(e=>e);function x(t){let[e,n]=p.useState("");return h(m,{label:t.label,value:e,onChange(e){n(e),t.onSearch(e)}})}function R(t){var[e,n]=p.useState(""),n=_(n,300),l=String(t.value||0);let s=Number(l)||0,r=e.trim();e=wp.data.useSelect(function(e){var t=e("core"),e=e("core/editor"),e=e&&e.getCurrentPostId?Number(e.getCurrentPostId()):0,n={status:"publish",per_page:20,_fields:"id,title",search:r,search_columns:"post_title"},l=["postType","page",s,{_fields:"id,title,status"}],o=["postType","page",n],i=2<=r.length,n=(0{let i=[],a=[],s=null,r=null,u=!1,p=!0;return d.each(e,function(e,n){"group"===e.type&&(t=e.fields,(e=d.first(d.values(t))).desc="",n=d.first(d.keys(t)));var t=e.page_search?R:((e,t)=>{let n=m;switch(e){case"toggle":n=v;break;case"number":n=t.max&&t.min?b:w;break;case"textarea":n=f;break;case"select":n=k;break;case"note":n=y;break;default:n=m}return n})(e.type,e);d.isObject(e.required)||(e.required={});let l=!0;d.each(e.required,function(e,t){l=o.attributes[t]==e}),l&&a.push(h(t,{key:n,label:e.title,help:e.desc,value:o.attributes[n],checked:o.attributes[n],options:(e=>{let n=[];return d.each(e,function(e,t){n.push({label:e,value:t})}),n})(e.options),placeholder:e.placeholder,min:e.min,max:e.max,onChange(e){var t={};t[n]=e,o.setAttributes(t)}})),"header"===e.type&&(a.pop(),(u=null!==s?!0:u)&&(i.push(h(g,{key:r,title:s.title,description:s.desc,initialOpen:p},a)),p=!1),s=e,r=n,a=[])}),i.push(h(g,{key:r,title:s.title,help:s.desc,initialOpen:p},a)),h(C,{key:"wp-ultimo"},i)})(t.fields,e)]},save(){return null}})})})(window.wp.blocks,window.wp.element,window.wp.components,window._); \ No newline at end of file +((e,p,c,d)=>{let h=p.createElement,l=e.registerBlockType,o=wp.serverSideRender,m=wp.components.PanelBody,g=wp.components.TextControl,w=wp.components.__experimentalNumberControl,b=wp.components.RangeControl,v=wp.components.ToggleControl,f=wp.components.TextareaControl,y=wp.components.Notice,k=wp.components.SelectControl,C=wp.blockEditor.InspectorControls,_=wp.compose.useDebounce||(e=>e);function x(t){let[e,n]=p.useState("");return h(g,{label:t.label,value:e,onChange(e){n(e),t.onSearch(e)}})}function R(t){var[e,n]=p.useState(""),n=_(n,300),l=String(t.value||0);let s=Number(l)||0,r=e.trim();e=wp.data.useSelect(function(e){var t=e("core"),e=e("core/editor"),e=e&&e.getCurrentPostId?Number(e.getCurrentPostId()):0,n={status:"publish",per_page:20,_fields:"id,title",search:r,search_columns:"post_title"},l=["postType","page",s,{_fields:"id,title,status"}],o=["postType","page",n],i=2<=r.length,n=(0{let t=[],a=[],s=null,r=null,u=!1,p=!0;return d.each(e,function(n,l){"group"===n.type&&(e=n.fields,(n=d.first(d.values(e))).desc="",l=d.first(d.keys(e)));var e=n.page_search?R:((e,t)=>{let n=g;switch(e){case"toggle":n=v;break;case"number":n=t.max&&t.min?b:w;break;case"textarea":n=f;break;case"select":n=k;break;case"note":n=y;break;default:n=g}return n})(n.type,n);d.isObject(n.required)||(n.required={});let o=!0;d.each(n.required,function(e,t){o=i.attributes[t]==e}),o&&a.push(h(e,{key:l,label:n.title,help:n.desc,value:i.attributes[l],checked:i.attributes[l],options:(e=>{let n=[];return d.each(e,function(e,t){n.push({label:e,value:t})}),n})(n.options),placeholder:n.placeholder,min:n.min,max:n.max,onChange(e){var t={};t[l]="number"===n.type?Math.trunc(Number(e)):e,i.setAttributes(t)}})),"header"===n.type&&(a.pop(),(u=null!==s?!0:u)&&(t.push(h(m,{key:r,title:s.title,description:s.desc,initialOpen:p},a)),p=!1),s=n,r=l,a=[])}),t.push(h(m,{key:r,title:s.title,help:s.desc,initialOpen:p},a)),h(C,{key:"wp-ultimo"},t)})(t.fields,e)]},save(){return null}})})})(window.wp.blocks,window.wp.element,window.wp.components,window._); \ No newline at end of file diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index 1a5e89ec0..1a312da59 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -125,6 +125,7 @@ public function register_block($element): void { [ 'attributes' => $attributes, 'editor_script' => 'wu-blocks', + 'editor_style' => 'wu-admin', 'render_callback' => \Closure::fromCallable([$element, 'get_content']), ] ); @@ -236,9 +237,21 @@ public function get_attributes_from_fields($element) { } $default_value = wu_get_isset($defaults, $field_id, ''); + $value = wu_get_isset($field, 'value', $default_value); + + // Block defaults must have the same types as their REST schema. + if ( ! is_callable($value)) { + if ('boolean' === $type) { + $value = (bool) $value; + } elseif ('integer' === $type) { + $value = (int) $value; + } else { + $value = (string) $value; + } + } $_fields[ $field_id ] = [ - 'default' => wu_get_isset($field, 'value', $default_value), + 'default' => $value, 'type' => $type, ]; } diff --git a/inc/ui/class-base-element.php b/inc/ui/class-base-element.php index 1edd6024b..0196601a3 100644 --- a/inc/ui/class-base-element.php +++ b/inc/ui/class-base-element.php @@ -573,10 +573,15 @@ protected function maybe_extract_arguments($content, $type = 'shortcode') { */ $block_content = parse_blocks($content); - foreach ($block_content as $block) { + while ($block_content) { + $block = array_shift($block_content); + if ($this->get_id() === $block['blockName']) { return $block['attrs']; } + + // Groups and Columns are normal account-page layout containers. + $block_content = array_merge($block['innerBlocks'] ?? [], $block_content); } return false; From 0de65df0956706613369affd7f5f452c0d40e1ad Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 5 Nov 2026 22:30:10 -0700 Subject: [PATCH 4/4] test: protect account context cleanup during fixture setup --- tests/WP_Ultimo/Current_Test.php | 63 +++++++++++++++++--------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/tests/WP_Ultimo/Current_Test.php b/tests/WP_Ultimo/Current_Test.php index 5bbd88d16..d105b1f6d 100644 --- a/tests/WP_Ultimo/Current_Test.php +++ b/tests/WP_Ultimo/Current_Test.php @@ -446,35 +446,37 @@ public function test_request_context_requires_ownership(): void { $saved_current = clone $this->current; $saved_user_id = get_current_user_id(); - - $owner_id = self::factory()->user->create(['role' => 'subscriber']); - $stranger_id = self::factory()->user->create(['role' => 'subscriber']); - $admin_id = self::factory()->user->create(['role' => 'administrator']); - $customer = wu_create_customer(['user_id' => $owner_id]); - $stranger = wu_create_customer(['user_id' => $stranger_id]); - $this->assertNotWPError($customer); - $this->assertNotWPError($stranger); - - $membership = wu_create_membership( - [ - 'customer_id' => $customer->get_id(), - 'status' => 'active', - 'skip_validation' => true, - ] - ); - $this->assertNotWPError($membership); - - $site = wu_get_site(self::factory()->blog->create(['user_id' => $owner_id])); - $site->set_type('customer_owned'); - $site->set_customer_id($customer->get_id()); - $site->set_membership_id($membership->get_id()); - $this->assertNotWPError($site->save()); - - $this->go_to(home_url('/')); - set_current_screen('front'); - grant_super_admin($admin_id); + $saved_hash = get_query_var('site_hash', ''); + $admin_id = 0; try { + $owner_id = self::factory()->user->create(['role' => 'subscriber']); + $stranger_id = self::factory()->user->create(['role' => 'subscriber']); + $admin_id = self::factory()->user->create(['role' => 'administrator']); + $customer = wu_create_customer(['user_id' => $owner_id]); + $stranger = wu_create_customer(['user_id' => $stranger_id]); + $this->assertNotWPError($customer); + $this->assertNotWPError($stranger); + + $membership = wu_create_membership( + [ + 'customer_id' => $customer->get_id(), + 'status' => 'active', + 'skip_validation' => true, + ] + ); + $this->assertNotWPError($membership); + + $site = wu_get_site(self::factory()->blog->create(['user_id' => $owner_id])); + $site->set_type('customer_owned'); + $site->set_customer_id($customer->get_id()); + $site->set_membership_id($membership->get_id()); + $this->assertNotWPError($site->save()); + + $this->go_to(home_url('/')); + set_current_screen('front'); + grant_super_admin($admin_id); + foreach ([$owner_id, $stranger_id, 0, $admin_id] as $viewer_id) { wp_set_current_user($viewer_id); @@ -515,8 +517,11 @@ public function test_request_context_requires_ownership(): void { } } } finally { - revoke_super_admin($admin_id); - set_query_var('site_hash', ''); + if ($admin_id) { + revoke_super_admin($admin_id); + } + + set_query_var('site_hash', $saved_hash); wp_set_current_user($saved_user_id); // Do not leave the final synthetic membership in the shared singleton.