diff --git a/assets/other-scripts/co-authors-plus/index.js b/assets/other-scripts/co-authors-plus/index.js new file mode 100644 index 0000000000..766a26b6c7 --- /dev/null +++ b/assets/other-scripts/co-authors-plus/index.js @@ -0,0 +1,68 @@ +/* globals guestAuthorRole, jQuery */ + +jQuery( document ).ready( function ( $ ) { + $( 'select#role' ).change( function () { + if ( guestAuthorRole.role !== $( this ).val() ) { + deactivateGuestAuthor(); + } else { + activateGuestAuthor(); + } + } ); + + const loginLabel = $( 'label[for="user_login"]' )[ 0 ].innerHTML; + + function activateGuestAuthor() { + // Make email not required. + $( 'label[for="email"] > span.description' ).hide(); + $( '#createuser input[name=email]' ).closest( 'tr' ).removeClass( 'form-required' ); + + // User login. + if ( 'new' === guestAuthorRole.screen ) { + $( 'label[for="user_login"]' )[ 0 ].innerHTML = guestAuthorRole.displayNameLabel; + } else { + $( 'label[for="user_login"]' ).parents( 'tr' ).hide(); + } + + // Password. + $( 'label[for="pass1"]' ).parents( 'tr' ).hide(); + + // Email notification. + $( 'input#send_user_notification' ).parents( 'tr' ).hide(); + + // User profile fields. + $( 'label[for="rich_editing"]' ).parents( 'tr' ).hide(); + $( 'label[for="comment_shortcuts"]' ).parents( 'tr' ).hide(); + $( 'label[for="admin_bar_front"]' ).parents( 'tr' ).hide(); + $( 'label[for="locale"]' ).parents( 'tr' ).hide(); + $( 'tr.user-admin-color-wrap' ).hide(); + } + + function deactivateGuestAuthor() { + // Make email required. + $( 'label[for="email"] > span.description' ).show(); + $( '#createuser input[name=email]' ).closest( 'tr' ).addClass( 'form-required' ); + + // User login. + if ( 'new' === guestAuthorRole.screen ) { + $( 'label[for="user_login"]' )[ 0 ].innerHTML = loginLabel; + } else { + $( 'label[for="user_login"]' ).parents( 'tr' ).show(); + } + + // Password. + $( 'label[for="pass1"]' ).parents( 'tr' ).show(); + + // Email notification. + $( 'input#send_user_notification' ).parents( 'tr' ).show(); + + // User profile fields. + $( 'label[for="rich_editing"]' ).parents( 'tr' ).show(); + $( 'label[for="comment_shortcuts"]' ).parents( 'tr' ).show(); + $( 'label[for="admin_bar_front"]' ).parents( 'tr' ).show(); + $( 'label[for="locale"]' ).parents( 'tr' ).show(); + $( 'tr.user-admin-color-wrap' ).show(); + } + + // Trigger change event on page load. + $( 'select#role' ).change(); +} ); diff --git a/includes/plugins/class-co-authors-plus.php b/includes/plugins/class-co-authors-plus.php index 62d8a79b21..9fc03513f5 100644 --- a/includes/plugins/class-co-authors-plus.php +++ b/includes/plugins/class-co-authors-plus.php @@ -10,8 +10,15 @@ defined( 'ABSPATH' ) || exit; +use WP_User; + /** - * Main class. + * This class implements an alternative for the Guest Authors feature of Co-Authors Plus. + * + * The Non Editing Contributor role behaves similarly to the Guest Authors feature, but it's a custom role that can be assigned to users. + * + * This role can also be assigned to users who have other roles, so they can be assigned as co-authors of a post without having the capability to edit posts. + * This is done via a custom UI in the user profile. */ class Co_Authors_Plus { /** @@ -37,6 +44,34 @@ public static function init() { add_filter( 'coauthors_edit_author_cap', [ __CLASS__, 'coauthors_edit_author_cap' ] ); add_action( 'admin_init', [ __CLASS__, 'setup_custom_role_and_capability' ] ); add_action( 'template_redirect', [ __CLASS__, 'prevent_myaccount_update' ] ); + add_action( 'newspack_before_delete_account', [ __CLASS__, 'before_delete_account' ] ); + + if ( defined( 'NEWSPACK_DISABLE_CAP_GUEST_AUTHORS' ) && NEWSPACK_DISABLE_CAP_GUEST_AUTHORS ) { + add_filter( 'coauthors_guest_authors_enabled', '__return_false' ); + add_action( 'admin_menu', [ __CLASS__, 'guest_author_menu_replacement' ] ); + } + + // Do not allow guest authors to login. + \add_filter( 'wp_authenticate_user', [ __CLASS__, 'wp_authenticate_user' ], 10, 2 ); + + // Modify the user profile and user creation forms. + \add_action( 'admin_footer', [ __CLASS__, 'admin_footer' ] ); + \add_filter( 'user_profile_update_errors', [ __CLASS__, 'user_profile_update_errors' ], 10, 3 ); + \add_action( 'admin_print_scripts-user-new.php', [ __CLASS__, 'admin_footer' ] ); + \add_action( 'admin_print_scripts-user-edit.php', [ __CLASS__, 'admin_footer' ] ); + + \add_filter( 'option_default_role', [ __CLASS__, 'create_user_default_role' ] ); + \add_filter( 'option_cme_capabilities_add_user_multi_roles', [ __CLASS__, 'cme_capabilities_add_user_multi_roles' ] ); + + // Disable some features from the user profile. + \add_filter( 'show_password_fields', [ __CLASS__, 'disable_feature' ], 10, 2 ); + \add_filter( 'wp_is_application_passwords_available_for_user', [ __CLASS__, 'disable_feature' ], 10, 2 ); + \add_filter( 'allow_password_reset', [ __CLASS__, 'disable_feature' ], 10, 2 ); + \add_filter( 'woocommerce_current_user_can_edit_customer_meta_fields', [ __CLASS__, 'disable_feature' ], 10, 2 ); + + // Add UI to the user profile to assign the custom role. + add_action( 'edit_user_profile', [ __CLASS__, 'edit_user_profile' ] ); + add_action( 'wp_update_user', [ __CLASS__, 'edit_user_profile_update' ] ); } /** @@ -48,6 +83,20 @@ public static function coauthors_edit_author_cap( $edit_cap ) { return self::ASSIGNABLE_TO_POSTS_CAPABILITY_NAME; } + /** + * Determines whether a user is only a "guest author", meaning it only has the custom role and no other role. + * + * In this case, the user won't be able to login and will have some features removed from their profile. + * + * Users who have more than one role other than non_edit_contributor are still able to login and a have a full profile. + * + * @param WP_User $user The user to check. + * @return bool + */ + private static function is_guest_author( WP_User $user ) { + return 1 === count( $user->roles ) && self::CONTRIBUTOR_NO_EDIT_ROLE_NAME === array_shift( $user->roles ); + } + /** * Prevent users from updating their account details in My Account, if they have the custom role. */ @@ -73,6 +122,27 @@ public static function prevent_myaccount_update() { } } + /** + * Prevents the Delete Account email to be sent and display an error message to the user + * + * @param int $user_id The user ID trying to delete the account. + * @return void + */ + public static function before_delete_account( $user_id ) { + if ( user_can( $user_id, self::ASSIGNABLE_TO_POSTS_CAPABILITY_NAME ) ) { + \wp_safe_redirect( + \add_query_arg( + [ + 'message' => __( 'It looks like you are an author on this site. Please contact a site adminstrator to get your account deactivated.', 'newspack-plugin' ), + 'is_error' => true, + ], + \remove_query_arg( WooCommerce_My_Account::DELETE_ACCOUNT_URL_PARAM ) + ) + ); + exit; + } + } + /** * Create the custom role and then add custom capability. */ @@ -103,5 +173,265 @@ public static function setup_custom_role_and_capability() { \update_option( self::SETTINGS_VERSION_OPTION_NAME, $current_settings_version ); } + + /** + * Filters user validation to allow empty emails for guest authors + * + * When creating a new user, also automatically generate a username from the display name. + * + * @param WP_Error $errors WP_Error object (passed by reference). + * @param bool $update Whether this is a user update. + * @param stdClass $user User object (passed by reference). + * @return WP_Error + */ + public static function user_profile_update_errors( $errors, $update, $user ) { + + if ( self::CONTRIBUTOR_NO_EDIT_ROLE_NAME !== $user->role ) { + return $errors; + } + + if ( ! empty( $errors->errors['empty_email'] ) ) { + $errors->remove( 'empty_email' ); + } + + if ( ! empty( $errors->errors['user_login'] ) ) { + $errors->remove( 'user_login' ); + } + + // We still don't want users with duplicate emails. + if ( ! empty( $errors->errors['email_exists'] ) ) { + return $errors; + } + + if ( ! $update ) { + // For guest authors, the form is modified via JS and we get the display name in the username field. + $user->display_name = $user->user_login; + + // Create user name from Display name. + $user->user_login = self::generate_username( $user->display_name ); + } + + if ( empty( $user->user_email ) ) { + // Create a placeholder email address to avoid any issues with empty emails. + $user->user_email = $user->user_login . '@example.com'; + } + + return $errors; + } + + /** + * Generates a unique username from a display name. + * + * @param string $display_name The user's display name. + * @return string + */ + public static function generate_username( $display_name ) { + $username = \sanitize_user( $display_name, true ); + $username = \sanitize_title( $username ); + + while ( \username_exists( $username ) ) { + $username = $username . '-' . \wp_rand( 1, 100 ); + } + + return $username; + } + + /** + * Enqueues the JS that modifies the user profile and user creation forms. + * + * @return void + */ + public static function admin_footer() { + global $pagenow; + \wp_enqueue_script( + 'newspack-co-authors-plus', + Newspack::plugin_url() . '/dist/other-scripts/co-authors-plus.js', + [ 'jquery' ], + NEWSPACK_PLUGIN_VERSION, + true + ); + + wp_localize_script( + 'newspack-co-authors-plus', + 'guestAuthorRole', + [ + 'role' => self::CONTRIBUTOR_NO_EDIT_ROLE_NAME, + 'displayNameLabel' => __( 'Display name', 'newspack-plugin' ), + 'screen' => $pagenow === 'user-new.php' ? 'new' : 'edit', + ] + ); + } + + /** + * A generic callback applied to filters that check if a user has access to a feature, or if a certain field should be displayed in its profile. + * + * These callbacks pass the return of the check as the first argument ant the user or user ID as the second. + * + * @param bool $result The result of the check. + * @param int|WP_User $user A user ID or user object. + * @return bool + */ + public static function disable_feature( $result, $user ) { + if ( is_int( $user ) ) { + $user = \get_user_by( 'id', $user ); + } + + if ( ! is_a( $user, 'WP_User' ) ) { + return $result; + } + + if ( self::is_guest_author( $user ) ) { + return false; + } + + return $result; + } + + /** + * Filters user authentication to prevent guest authors from logging in. + * + * @param WP_Error|WP_User $user The logged in user or login error. + * @param string $password The user's password. + * @return WP_Error|WP_User + */ + public static function wp_authenticate_user( $user, $password ) { + if ( ! is_a( $user, 'WP_User' ) ) { + return $user; + } + + if ( self::is_guest_author( $user ) ) { + return new WP_Error( 'guest_authors_cannot_login', __( 'Non-Editing Contributors cannot login.', 'newspack-plugin' ) ); + } + + return $user; + } + + /** + * Adds a replacement Guest Authors menu item. + */ + public static function guest_author_menu_replacement() { + add_submenu_page( + 'users.php', + __( 'Guest Authors', 'newspack-plugin' ), + __( 'Guest Authors', 'newspack-plugin' ), + 'list_users', + 'newspack-view-guest-authors', + [ __CLASS__, 'render_guest_authors_replacement_page' ] + ); + } + + /** + * Render the replacement Guest Authors page. + */ + public static function render_guest_authors_replacement_page() { + ?> +
+

+ +

+

+ + + +
+ add_role( self::CONTRIBUTOR_NO_EDIT_ROLE_NAME ); + } else { + $user->remove_role( self::CONTRIBUTOR_NO_EDIT_ROLE_NAME ); + } + } + + /** + * Add user profile fields. + * + * @param WP_User $user The current WP_User object. + */ + public static function edit_user_profile( $user ) { + + if ( self::is_guest_author( get_userdata( $user->ID ) ) ) { // For some reason $user is not the full user object. + return; + } + $current_status = user_can( $user->ID, self::CONTRIBUTOR_NO_EDIT_ROLE_NAME ); + ?> +
+ +

+ + + + + + + +
+ $form_nonce,