> Assignee lists already built in this request. */ private static $assignees = array(); public static function init() { // Older add-ons registered the same controls. Core owns them now, including // when an old add-on is still installed during the Free update. $legacy = 'AccessibleForms\\Pro\\Access'; if ( class_exists( $legacy, false ) ) { foreach ( array( 'accessible_forms_capabilities_installed' => array( 'install', 10 ), 'accessible_forms_editor_section' => array( 'editor', 20 ), 'accessible_forms_editor_saved' => array( 'save', 10 ), 'accessible_forms_tools_tabs' => array( 'tools_tabs', 10 ), 'accessible_forms_tools_tab' => array( 'tools_tab', 10 ), 'accessible_forms_tools_action' => array( 'tools_action', 10 ), 'wp_ajax_accessible_forms_team_members' => array( 'search', 10 ), ) as $hook => $callback ) { remove_filter( $hook, array( $legacy, $callback[0] ), $callback[1] ); } } if ( (int) get_option( 'accessible_forms_access_version' ) !== self::VERSION || ! get_role( 'accessible_forms_manager' ) || ! get_role( 'accessible_forms_reviewer' ) ) { add_action( 'init', array( self::class, 'install' ), 2 ); } // Per-form access lists are enforced by the free plugin alone (Capabilities::form() // and its SQL scope), so one rule decides every screen, count and export. add_action( 'accessible_forms_editor_section', array( self::class, 'editor' ), 20, 3 ); // The assignee list is cached; anything that changes who may read a form clears it. foreach ( array( 'accessible_forms_access_changed', 'set_user_role', 'add_user_role', 'remove_user_role', 'deleted_user', 'profile_update' ) as $hook ) { add_action( $hook, array( self::class, 'forget_assignees' ) ); } add_action( 'accessible_forms_editor_saved', array( self::class, 'save' ) ); add_filter( 'accessible_forms_tools_tabs', array( self::class, 'tools_tabs' ) ); add_action( 'accessible_forms_tools_tab', array( self::class, 'tools_tab' ) ); add_action( 'accessible_forms_tools_action', array( self::class, 'tools_action' ) ); add_action( 'wp_ajax_accessible_forms_team_members', array( self::class, 'search' ) ); add_action( 'admin_enqueue_scripts', array( self::class, 'assets' ), 30 ); } /** Only core's member picker runs when an older add-on also prepares its script. */ public static function assets() { if ( wp_script_is( 'accessible-forms-team', 'enqueued' ) ) { wp_dequeue_script( 'accessible-forms-pro-team' ); } } /** Small, ordered pages of eligible members; selected members are rendered separately. */ public static function members( $search = '', $page = 1 ) { $query = new \WP_User_Query( array( 'capability' => 'read_accessible_form_entries', 'number' => 20, 'paged' => max( 1, (int) $page ), 'orderby' => array( 'display_name' => 'ASC', 'ID' => 'ASC' ), 'search' => $search ? '*' . wp_html_excerpt( sanitize_text_field( $search ), 100, '' ) . '*' : '', 'search_columns' => array( 'display_name', 'user_login' ), ) ); return array( 'items' => array_map( static function ( $user ) { return array( 'id' => (int) $user->ID, 'label' => $user->display_name . ' (' . $user->user_login . ')' ); }, $query->get_results() ), 'pages' => (int) ceil( $query->get_total() / 20 ), ); } public static function search() { check_ajax_referer( 'accessible_forms_team_members', 'nonce' ); $id = absint( $_POST['form_id'] ?? 0 ); if ( ! current_user_can( 'manage_accessible_forms_settings' ) || ! current_user_can( 'edit_post', $id ) || 'accessible_form' !== get_post_type( $id ) ) { wp_send_json_error( array( 'message' => __( 'You cannot change team access for this form.', 'accessible-forms-by-accessible-org' ) ), 403 ); } wp_send_json_success( self::members( sanitize_text_field( wp_unslash( $_POST['search'] ?? '' ) ), absint( $_POST['page'] ?? 1 ) ) ); } /** Roles for people who manage forms or only review submissions. */ public static function roles() { return array( 'accessible_forms_manager' => array( __( 'Form manager', 'accessible-forms-by-accessible-org' ), array_merge( array( 'read' ), Capabilities::forms(), array_diff( array_keys( Capabilities::labels() ), array( 'manage_accessible_forms_settings' ) ) ), ), 'accessible_forms_reviewer' => array( __( 'Submission reviewer', 'accessible-forms-by-accessible-org' ), array( 'read', 'read_accessible_form_entries', 'manage_accessible_form_entries' ), ), ); } /** Create the roles, or give existing ones their capabilities back after a reset. */ public static function install( $restore_capabilities = false ) { foreach ( self::roles() as $name => [ $label, $caps ] ) { $role = get_role( $name ); if ( ! $role ) { add_role( $name, $label, array_fill_keys( $caps, true ) ); continue; } // Adopting existing roles must preserve administrator choices. Only // an explicit capability reset restores their original defaults. if ( $restore_capabilities ) { foreach ( $caps as $cap ) { $role->add_cap( $cap ); } } } Persistence::option( 'accessible_forms_access_version', self::VERSION ); } public static function uninstall( $delete ) { delete_option( 'accessible_forms_access_version' ); if ( $delete ) { remove_role( 'accessible_forms_manager' ); remove_role( 'accessible_forms_reviewer' ); } } /** * A form's access list. * * @param int $form_id Form ID. * @return int[] Empty when everyone with submission access may read it. */ public static function users( $form_id ) { $raw = get_post_meta( $form_id, self::META, true ); $users = '' === $raw ? array() : Capabilities::restriction( $raw ); return is_array( $users ) ? array_map( 'intval', $users ) : array(); } /** * Users who may be assigned a form's submissions, as ID => display name. * Checking every eligible user's permissions is slow, so the list is kept * for the request and, with a persistent object cache, for a minute. Role * and access-list changes clear it at once, and a saved assignment is * checked again against the live permissions. * * @param int $form_id Form ID. * @return array */ public static function assignees( $form_id ) { $form_id = (int) $form_id; $key = 'assignees:' . get_current_blog_id() . ':' . $form_id . ':' . self::generation(); if ( isset( self::$assignees[ $key ] ) ) { return self::$assignees[ $key ]; } $cached = wp_cache_get( $key, self::CACHE_GROUP ); if ( is_array( $cached ) ) { self::$assignees[ $key ] = $cached; return $cached; } $users = array(); foreach ( get_users( array( 'capability' => 'read_accessible_form_entries', 'number' => 100, 'orderby' => 'display_name', 'order' => 'ASC' ) ) as $user ) { if ( Capabilities::form( $form_id, 'read_accessible_form_entries', $user->ID ) ) { $users[ (int) $user->ID ] = $user->display_name; } } wp_cache_set( $key, $users, self::CACHE_GROUP, MINUTE_IN_SECONDS ); self::$assignees[ $key ] = $users; return $users; } /** Forget every cached assignee list. */ public static function forget_assignees() { self::$assignees = array(); wp_cache_set( 'assignees_generation', (string) microtime( true ), self::CACHE_GROUP ); } /** A marker that changes whenever cached assignee lists become stale. */ private static function generation() { $generation = wp_cache_get( 'assignees_generation', self::CACHE_GROUP ); if ( ! is_string( $generation ) || '' === $generation ) { $generation = (string) microtime( true ); wp_cache_set( 'assignees_generation', $generation, self::CACHE_GROUP ); } return $generation; } /** Access list in the editor's Privacy & protection section. */ public static function editor( $section, array $schema, $post = null ) { if ( 'privacy' !== $section || ! $post || ! current_user_can( 'manage_accessible_forms_settings' ) ) { return; } $selected = self::users( $post->ID ); $raw = get_post_meta( $post->ID, self::META, true ); $damaged = '' !== $raw && null === Capabilities::restriction( $raw ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only member pagination; saving verifies the editor nonce. $number = max( 1, absint( $_GET['af_team_page'] ?? 1 ) ); $page = self::members( '', $number ); echo '
' . esc_html__( 'Team access to submissions', 'accessible-forms-by-accessible-org' ) . '

' . esc_html__( 'Choose who can read this form’s submissions. Settings administrators retain access. You can change this list in the Free plugin.', 'accessible-forms-by-accessible-org' ) . '

'; if ( $damaged ) { echo ''; } Editor::select( 'af_entry_access_mode', __( 'Submission access', 'accessible-forms-by-accessible-org' ), array( 'everyone' => __( 'Everyone with submission permissions', 'accessible-forms-by-accessible-org' ), 'selected' => __( 'Selected team members only', 'accessible-forms-by-accessible-org' ), ), $selected || $damaged ? 'selected' : 'everyone' ); echo '

' . esc_html__( 'Select at least one member to apply a restriction.', 'accessible-forms-by-accessible-org' ) . '

'; echo '
'; $visible = $page['items']; // Selected members must survive pagination and capability changes. $kept = $selected ? get_users( array( 'include' => $selected ) ) : array(); $members = array(); foreach ( $kept as $user ) { $members[ $user->ID ] = array( 'id' => (int) $user->ID, 'label' => $user->display_name . ' (' . $user->user_login . ')' ); } foreach ( $visible as $user ) { $members[ $user['id'] ] = $user; } foreach ( $members as $user ) { echo ''; } echo '

'; } /** Validate the access portion of a verified editor request without writing data. */ public static function posted_rule( $post_id ) { if ( ! isset( $_POST['af_entry_users_present'] ) ) { return null; } $nonce = isset( $_POST[ \AccessibleForms\Admin\Admin::NONCE . '_nonce' ] ) && is_string( $_POST[ \AccessibleForms\Admin\Admin::NONCE . '_nonce' ] ) ? sanitize_text_field( wp_unslash( $_POST[ \AccessibleForms\Admin\Admin::NONCE . '_nonce' ] ) ) : ''; if ( ! wp_verify_nonce( $nonce, \AccessibleForms\Admin\Admin::NONCE ) || ! current_user_can( 'manage_accessible_forms_settings' ) || ! current_user_can( 'edit_post', $post_id ) || 'accessible_form' !== get_post_type( $post_id ) ) { return new \WP_Error( 'access_forbidden', __( 'Only a settings administrator with a valid editor request can change submission access.', 'accessible-forms-by-accessible-org' ) ); } $users = array(); $posted_users = isset( $_POST['af_entry_users'] ) && is_array( $_POST['af_entry_users'] ) ? map_deep( wp_unslash( $_POST['af_entry_users'] ), 'sanitize_text_field' ) : array(); foreach ( $posted_users as $user ) { if ( is_scalar( $user ) && preg_match( '/^[0-9]+$/D', (string) $user ) && (int) $user > 0 && get_userdata( (int) $user ) ) { $users[] = (string) (int) $user; } } $users = array_values( array_unique( $users ) ); $mode = isset( $_POST['af_entry_access_mode'] ) ? ( is_string( $_POST['af_entry_access_mode'] ) ? sanitize_key( wp_unslash( $_POST['af_entry_access_mode'] ) ) : '' ) : ( $users ? 'selected' : 'everyone' ); if ( 'selected' === $mode && ! $users ) { return new \WP_Error( 'access_members_required', __( 'Select at least one team member, or choose everyone with submission permissions.', 'accessible-forms-by-accessible-org' ) ); } if ( ! in_array( $mode, array( 'everyone', 'selected' ), true ) ) { return new \WP_Error( 'access_mode_invalid', __( 'Choose a valid submission access option.', 'accessible-forms-by-accessible-org' ) ); } if ( 'everyone' === $mode ) { $users = array(); } return wp_json_encode( $users ); } /** Validate again immediately before persisting a per-form access rule. */ public static function save( $post_id ) { $acl = self::posted_rule( $post_id ); if ( null === $acl ) { return; } if ( is_wp_error( $acl ) ) { if ( 'access_forbidden' === $acl->get_error_code() ) { return; } throw new \RuntimeException( esc_html( $acl->get_error_code() ) ); } if ( get_post_meta( $post_id, self::META, true ) !== $acl ) { // Anything prepared under the old list must go, such as exports. if ( ! Extensions::notify( 'accessible_forms_access_changed', $post_id ) ) { throw new \RuntimeException( 'access_exports_storage' ); } if ( ! \AccessibleForms\Persistence::meta( $post_id, self::META, $acl ) ) { throw new \RuntimeException( 'access_policy_storage' ); } } } public static function tools_tabs( array $tabs ) { if ( current_user_can( 'manage_accessible_forms_settings' ) ) { $tabs['team'] = __( 'Team access', 'accessible-forms-by-accessible-org' ); } return $tabs; } public static function tools_tab( $tab ) { if ( 'team' !== $tab || ! current_user_can( 'manage_accessible_forms_settings' ) ) { return; } echo '

' . esc_html__( 'Administrators keep full access. Editors can build forms but do not automatically receive submission access. Per-form restrictions are available in Privacy & protection.', 'accessible-forms-by-accessible-org' ) . '

'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen selection; every action verifies a POST nonce and capability. $role_name = sanitize_key( wp_unslash( $_GET['role'] ?? 'accessible_forms_reviewer' ) ); $roles = wp_roles()->get_names(); unset( $roles['administrator'] ); if ( ! isset( $roles[ $role_name ] ) ) { $role_name = isset( $roles['accessible_forms_reviewer'] ) ? 'accessible_forms_reviewer' : (string) array_key_first( $roles ); } echo '
'; Editor::select( 'role', __( 'Role to review', 'accessible-forms-by-accessible-org' ), $roles, $role_name ); Management::submit( __( 'Review access', 'accessible-forms-by-accessible-org' ), 'secondary' ); echo '
'; $role = get_role( $role_name ); Management::form_start( 'team', array( 'role' => $role_name ) ); echo '
' . esc_html( $roles[ $role_name ] ?? $role_name ) . ''; foreach ( Capabilities::labels() as $cap => $label ) { if ( 'manage_accessible_forms_settings' !== $cap ) { echo '

'; } } echo '
'; if ( ! Extensions::registered( 'pro' ) ) { echo '

' . esc_html__( 'The export permission is saved for use when Pro is active. It does not enable exports in the free plugin.', 'accessible-forms-by-accessible-org' ) . '

'; } Management::submit( __( 'Save role access', 'accessible-forms-by-accessible-org' ) ); echo ''; } /** The free plugin verified the tools nonce before this runs. */ public static function tools_action( $operation ) { if ( 'team' !== $operation ) { return; } if ( ! current_user_can( 'manage_accessible_forms_settings' ) ) { Management::fail( __( 'You do not have permission to use this tool.', 'accessible-forms-by-accessible-org' ) ); } check_admin_referer( 'accessible_forms_tools' ); $role_name = sanitize_key( wp_unslash( $_POST['role'] ?? '' ) ); $role = get_role( $role_name ); if ( ! $role || 'administrator' === $role_name ) { Management::fail( __( 'Choose a non-administrator role.', 'accessible-forms-by-accessible-org' ) ); } $selected = array_map( 'sanitize_key', (array) wp_unslash( $_POST['caps'] ?? array() ) ); foreach ( array_keys( Capabilities::labels() ) as $cap ) { if ( 'manage_accessible_forms_settings' === $cap ) { continue; } if ( in_array( $cap, $selected, true ) ) { $role->add_cap( $cap ); } else { $role->remove_cap( $cap ); } } Extensions::notify( 'accessible_forms_access_changed' ); Audit::record( 'team_permissions_changed' ); wp_safe_redirect( Management::url( 'team', array( 'updated' => 1, 'role' => $role_name ) ) ); exit(); } }