diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js
index 2a7daba0d2dc4..c3d49310e8897 100644
--- a/src/js/_enqueues/admin/common.js
+++ b/src/js/_enqueues/admin/common.js
@@ -2356,3 +2356,242 @@ $( function( $ ) {
// Expose public methods.
return pub;
})();
+
+/**
+ * Validate the delete-and-reassign users form and surface an accessible
+ * error summary instead of disabling the submit button.
+ *
+ * Disabled buttons can't be discovered by assistive technology, so rather
+ * than blocking submission we let the form submit, intercept it when content
+ * decisions are still missing, and present a focusable error summary that
+ * lists how many decisions remain and links straight to each one.
+ *
+ * Shared by both the single-site (wp-admin/users.php) and multisite/network
+ * (confirm_delete_users() in wp-admin/includes/ms.php) deletion forms. The two
+ * differ in markup: single site has one content decision per user, multisite
+ * has one decision per site a user belongs to (several radio groups per
+ * fieldset), and their reassign dropdowns use different "no selection" values.
+ * The logic below works per radio group so it covers both.
+ *
+ * @since 7.1.0
+ */
+(function(){
+ const { _n, sprintf } = wp.i18n;
+ const usersForm = document.querySelector( '.delete-and-reassign-users-form' );
+
+ // Check if the form exists and contains any radio buttons.
+ if ( ! usersForm || ! usersForm.querySelector( 'input[type="radio"]' ) ) {
+ return;
+ }
+
+ const summaryId = 'delete-users-error-summary';
+
+ /**
+ * Whether a reassign dropdown has no user selected.
+ *
+ * The "Select a user" placeholder value differs between the forms: the
+ * single-site dropdown uses an empty string, the multisite one uses the
+ * wp_dropdown_users() default of '-1'.
+ *
+ * @param {HTMLSelectElement} select The reassign dropdown.
+ * @return {boolean} True when no real user is selected.
+ */
+ function hasNoSelectedUser( select ) {
+ return '' === select.value || '-1' === select.value;
+ }
+
+ /**
+ * Builds a human-readable label for a radio group's decision.
+ *
+ * Combines the fieldset legend (the user) with the site context that
+ * precedes the group on multisite, so each summary entry is identifiable.
+ *
+ * @param {HTMLElement} group The radio group (
) element.
+ * @return {string} The composed label.
+ */
+ function getDecisionLabel( group ) {
+ const fieldset = group.closest( 'fieldset' );
+ const legend = fieldset ? fieldset.querySelector( 'legend' ) : null;
+ const parts = [];
+
+ if ( legend ) {
+ parts.push( legend.textContent.trim() );
+ }
+
+ // On multisite each radio group is preceded by a "Site: …" paragraph.
+ const previous = group.previousElementSibling;
+ if ( previous && previous !== legend && previous.textContent.trim() ) {
+ parts.push( previous.textContent.trim() );
+ }
+
+ return parts.join( ' – ' );
+ }
+
+ // Keep the radio selection in sync with the reassign dropdown.
+ usersForm.querySelectorAll( 'select' ).forEach( function( selectElement ) {
+ selectElement.addEventListener( 'change', function( e ) {
+ const item = e.target.closest( 'li' );
+ const radio = item ? item.querySelector( 'input[type="radio"]' ) : null;
+ if ( radio ) {
+ radio.checked = ! hasNoSelectedUser( e.target );
+ }
+ });
+ });
+
+ /**
+ * Returns the radio groups whose content decision is still incomplete.
+ *
+ * A decision unit is a single radio group (), which maps to one user on
+ * single site and one site-per-user on multisite.
+ *
+ * @return {Array} Objects describing each incomplete decision.
+ */
+ function getIncompleteDecisions() {
+ const incomplete = [];
+
+ usersForm.querySelectorAll( 'fieldset ul' ).forEach( function( group ) {
+ const radios = group.querySelectorAll( 'input[type="radio"]' );
+ if ( ! radios.length ) {
+ return;
+ }
+
+ const checked = group.querySelector( 'input[type="radio"]:checked' );
+
+ // No option chosen yet.
+ if ( ! checked ) {
+ incomplete.push( { target: radios[ 0 ], label: getDecisionLabel( group ) } );
+ return;
+ }
+
+ // "Attribute to another user" chosen, but no user selected.
+ if ( 'reassign' === checked.value ) {
+ const select = group.querySelector( 'select' );
+ if ( select && hasNoSelectedUser( select ) ) {
+ incomplete.push( { target: select, label: getDecisionLabel( group ) } );
+ }
+ }
+ });
+
+ return incomplete;
+ }
+
+ /**
+ * Builds or refreshes the error summary markup.
+ *
+ * @param {Array} incomplete Incomplete decisions from getIncompleteDecisions().
+ * @return {string} The summary title, for announcing to assistive technology.
+ */
+ function renderErrorSummary( incomplete ) {
+ let summary = document.getElementById( summaryId );
+
+ if ( ! summary ) {
+ summary = document.createElement( 'div' );
+ summary.id = summaryId;
+ summary.className = 'notice notice-error';
+ summary.setAttribute( 'tabindex', '-1' );
+
+ // The wrapper contains the form on single site and wraps it on
+ // multisite; insert the summary right after the page heading.
+ const wrap = usersForm.querySelector( '.wrap' ) || usersForm.closest( '.wrap' ) || usersForm;
+ const heading = wrap.querySelector( 'h1' );
+ wrap.insertBefore( summary, heading ? heading.nextSibling : wrap.firstChild );
+ }
+
+ const count = incomplete.length;
+ const title = sprintf(
+ /* translators: %s: Number of content decisions still required. */
+ _n(
+ '%s content decision is still required before you can delete.',
+ '%s content decisions are still required before you can delete.',
+ count
+ ),
+ count
+ );
+
+ // Clear any previous markup and invalid states before rebuilding,
+ // so decisions resolved since the last render are no longer flagged.
+ summary.textContent = '';
+ usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
+ el.removeAttribute( 'aria-invalid' );
+ });
+
+ const titleEl = document.createElement( 'p' );
+ const strong = document.createElement( 'strong' );
+ strong.textContent = title;
+ titleEl.appendChild( strong );
+ summary.appendChild( titleEl );
+
+ const list = document.createElement( 'ul' );
+ incomplete.forEach( function( item ) {
+ const li = document.createElement( 'li' );
+ const link = document.createElement( 'a' );
+ link.href = '#' + item.target.id;
+ link.textContent = item.label;
+ link.addEventListener( 'click', function( e ) {
+ e.preventDefault();
+ item.target.focus();
+ });
+ li.appendChild( link );
+ list.appendChild( li );
+
+ item.target.setAttribute( 'aria-invalid', 'true' );
+ });
+ summary.appendChild( list );
+
+ return title;
+ }
+
+ /**
+ * Removes the error summary and clears invalid states.
+ */
+ function clearErrorState() {
+ const summary = document.getElementById( summaryId );
+ if ( summary ) {
+ summary.remove();
+ }
+ usersForm.querySelectorAll( '[aria-invalid]' ).forEach( function( el ) {
+ el.removeAttribute( 'aria-invalid' );
+ });
+ }
+
+ /**
+ * Refreshes the error summary to match the current form state.
+ *
+ * @param {boolean} moveFocus Whether to move focus to the summary and
+ * announce it (used on a failed submit).
+ * @return {number} The number of incomplete decisions.
+ */
+ function updateSummary( moveFocus ) {
+ const incomplete = getIncompleteDecisions();
+
+ if ( ! incomplete.length ) {
+ clearErrorState();
+ return 0;
+ }
+
+ const title = renderErrorSummary( incomplete );
+
+ if ( moveFocus ) {
+ document.getElementById( summaryId ).focus();
+ if ( window.wp && window.wp.a11y ) {
+ window.wp.a11y.speak( title, 'assertive' );
+ }
+ }
+
+ return incomplete.length;
+ }
+
+ usersForm.addEventListener( 'submit', function( e ) {
+ if ( updateSummary( true ) > 0 ) {
+ e.preventDefault();
+ }
+ });
+
+ // Keep an existing summary current as decisions are resolved, without
+ // stealing focus on every interaction.
+ usersForm.addEventListener( 'change', function() {
+ if ( document.getElementById( summaryId ) ) {
+ updateSummary( false );
+ }
+ });
+})();
diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css
index 53933f0ac28a2..47ef4cf07dcae 100644
--- a/src/wp-admin/css/common.css
+++ b/src/wp-admin/css/common.css
@@ -2605,6 +2605,23 @@ body.iframe {
display: block;
}
+.delete-and-reassign-users-form legend {
+ margin: 1em 0;
+ line-height: 1.5;
+ float: inline-start;
+}
+
+.delete-and-reassign-users-form fieldset > fieldset,
+.delete-and-reassign-users-form fieldset > ul {
+ clear: both;
+}
+
+.delete-and-reassign-users-form fieldset:has(select[aria-invalid="true"]):not(fieldset:has(fieldset)) {
+ border-left: 4px solid #cc1818;
+ background: #fcf0f0;
+ padding-left: 12px;
+}
+
.importers {
font-size: 16px;
width: auto;
diff --git a/src/wp-admin/includes/deprecated.php b/src/wp-admin/includes/deprecated.php
index 86524fb897311..1dcf08a0a34c1 100644
--- a/src/wp-admin/includes/deprecated.php
+++ b/src/wp-admin/includes/deprecated.php
@@ -1589,3 +1589,14 @@ function image_attachment_fields_to_save( $post, $attachment ) {
return $post;
}
+
+/**
+ * Was used to add JavaScript to the delete users form.
+ *
+ * @since 3.5.0
+ * @deprecated 7.1.0
+ * @access private
+ */
+function delete_users_add_js() {
+ _deprecated_function( __FUNCTION__, '7.1.0' );
+}
diff --git a/src/wp-admin/includes/ms.php b/src/wp-admin/includes/ms.php
index be87d05aead58..50066bf14d18c 100644
--- a/src/wp-admin/includes/ms.php
+++ b/src/wp-admin/includes/ms.php
@@ -856,12 +856,15 @@ function _thickbox_path_admin_subfolder() {
* @return bool
*/
function confirm_delete_users( $users ) {
+ global $wpdb;
+
$current_user = wp_get_current_user();
if ( ! is_array( $users ) || empty( $users ) ) {
return false;
}
+
?>
-
+
@@ -869,17 +872,15 @@ function confirm_delete_users( $users ) {
-
-
- ';
- confirm_delete_users( $_POST['allusers'] );
+ confirm_delete_users( $allusers );
echo '';
require_once ABSPATH . 'wp-admin/admin-footer.php';
@@ -188,6 +189,32 @@
wp_die( __( 'Sorry, you are not allowed to access this page.' ), 403 );
}
+ /*
+ * Validate that every "reassign" choice has a real target before making
+ * any changes. The confirmation form blocks this client-side, so this is
+ * a defense-in-depth guard against an invalid reassignment (e.g. content
+ * reassigned to the non-existent user "-1") when JavaScript is bypassed.
+ * Bail out entirely so the deletion is all-or-nothing.
+ */
+ if ( ! empty( $_POST['blog'] ) && is_array( $_POST['blog'] ) && ! empty( $_POST['delete'] ) ) {
+ foreach ( $_POST['blog'] as $id => $blogs ) {
+ foreach ( $blogs as $blogid => $reassign_user_id ) {
+ if ( isset( $_POST['delete'][ $blogid ][ $id ] )
+ && 'reassign' === $_POST['delete'][ $blogid ][ $id ]
+ && (int) $reassign_user_id < 1
+ ) {
+ wp_redirect(
+ add_query_arg(
+ array( 'error' => 'missing_reassign' ),
+ network_admin_url( 'users.php' )
+ )
+ );
+ exit;
+ }
+ }
+ }
+ }
+
if ( ! empty( $_POST['blog'] ) && is_array( $_POST['blog'] ) ) {
foreach ( $_POST['blog'] as $id => $users ) {
foreach ( $users as $blogid => $user_id ) {
@@ -310,6 +337,17 @@
)
);
}
+
+if ( isset( $_REQUEST['error'] ) && 'missing_reassign' === $_REQUEST['error'] ) {
+ wp_admin_notice(
+ __( 'No users were deleted because no user was selected for content reassignment.' ),
+ array(
+ 'type' => 'error',
+ 'dismissible' => true,
+ 'id' => 'message',
+ )
+ );
+}
?>
diff --git a/src/wp-admin/users.php b/src/wp-admin/users.php
index 650f81027592c..a94a9eff4a9cf 100644
--- a/src/wp-admin/users.php
+++ b/src/wp-admin/users.php
@@ -213,12 +213,17 @@
continue;
}
- switch ( $_REQUEST['delete_option'] ) {
+ if ( 'reassign' === $_REQUEST['delete_option'][ $id ] && empty( $_REQUEST['reassign_user'][ $id ] ) ) {
+ $update = 'err_missing_reassign';
+ continue;
+ }
+
+ switch ( $_REQUEST['delete_option'][ $id ] ) {
case 'delete':
wp_delete_user( $id );
break;
case 'reassign':
- wp_delete_user( $id, $_REQUEST['reassign_user'] );
+ wp_delete_user( $id, $_REQUEST['reassign_user'][ $id ] );
break;
}
@@ -306,40 +311,9 @@
$user_ids = array_diff( $user_ids, array( $current_user->ID ) );
}
- /**
- * Filters whether the users being deleted have additional content
- * associated with them outside of the `post_author` and `link_owner` relationships.
- *
- * @since 5.2.0
- *
- * @param bool $users_have_additional_content Whether the users have additional content. Default false.
- * @param int[] $user_ids Array of IDs for users being deleted.
- */
- $users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $user_ids );
-
- if ( $user_ids && ! $users_have_content ) {
- if ( $wpdb->get_var(
- "SELECT ID FROM {$wpdb->posts}
- WHERE post_author IN( " . implode( ',', $user_ids ) . ' )
- LIMIT 1'
- ) ) {
- $users_have_content = true;
- } elseif ( $wpdb->get_var(
- "SELECT link_id FROM {$wpdb->links}
- WHERE link_owner IN( " . implode( ',', $user_ids ) . ' )
- LIMIT 1'
- ) ) {
- $users_have_content = true;
- }
- }
-
- if ( $users_have_content ) {
- add_action( 'admin_head', 'delete_users_add_js' );
- }
-
require_once ABSPATH . 'wp-admin/admin-header.php';
?>
-