From 109353f268908cf8f0c94867d8324a1dff618630 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Tue, 30 Jun 2026 23:10:46 +0600 Subject: [PATCH 1/2] fix(core): add network-level provider settings for Multisite - Add network options for enabled providers and subsite override toggle. - Network Admin settings page under Settings -> Two-Factor (network-activated only). - Apply network-wide provider list; subsites can only narrow it when override is enabled. - Keep site-level settings as fallback when the network has not configured providers. - Show read-only notice on subsite settings when network-managed, with disabled providers visible. - Add tests and uninstall cleanup for the network options. Fixes #894 --- class-two-factor-core.php | 32 +- .../class-two-factor-network-settings.php | 116 ++++++++ settings/class-two-factor-settings.php | 92 ++++-- tests/class-two-factor-network-settings.php | 275 ++++++++++++++++++ two-factor.php | 144 +++++++-- 5 files changed, 618 insertions(+), 41 deletions(-) create mode 100644 settings/class-two-factor-network-settings.php create mode 100644 tests/class-two-factor-network-settings.php diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 1f039866..ad62f6e7 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -37,6 +37,24 @@ class Two_Factor_Core { */ const ENABLED_PROVIDERS_OPTION_KEY = 'two_factor_enabled_providers'; + /** + * The network-wide enabled providers option key. + * + * @since 0.17.0 + * + * @type string + */ + const ENABLED_PROVIDERS_NETWORK_OPTION_KEY = 'two_factor_network_enabled_providers'; + + /** + * The network option key that controls whether subsites can override provider settings. + * + * @since 0.17.0 + * + * @type string + */ + const NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY = 'two_factor_network_allow_site_override'; + /** * The user meta nonce key. * @@ -203,6 +221,12 @@ public static function uninstall() { self::ENABLED_PROVIDERS_OPTION_KEY, ); + // Network options are stored in sitemeta on Multisite and in wp_options on single-site. + $network_option_keys = array( + self::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, + self::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, + ); + $providers = self::get_default_providers(); /** This filter is documented in the get_providers() method */ @@ -244,6 +268,12 @@ public static function uninstall() { } } + if ( ! empty( $network_option_keys ) ) { + foreach ( $network_option_keys as $option_key ) { + delete_site_option( $option_key ); + } + } + foreach ( $user_meta_keys as $meta_key ) { delete_metadata( 'user', null, $meta_key, null, true ); } @@ -1186,7 +1216,7 @@ public static function login_html( $user, $login_nonce, $redirect_to, $error_msg foreach ( $backup_providers as $backup_provider_key => $backup_provider ) { $backup_link_args['provider'] = $backup_provider_key; - $links[] = array( + $links[] = array( 'url' => self::login_url( $backup_link_args ), 'label' => $backup_provider->get_alternative_provider_label(), ); diff --git a/settings/class-two-factor-network-settings.php b/settings/class-two-factor-network-settings.php new file mode 100644 index 00000000..ed8a81bf --- /dev/null +++ b/settings/class-two-factor-network-settings.php @@ -0,0 +1,116 @@ +

' . esc_html__( 'Settings saved.', 'two-factor' ) . '

'; + } + + // Default to all providers enabled when the option has never been saved. + $saved_enabled = get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, $all_provider_keys ); + $allow_override = (bool) get_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, false ); + + echo '
'; + echo '

' . esc_html__( 'Two-Factor Network Settings', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Enabled Providers', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Choose which Two-Factor providers are available across the network. All providers are enabled by default.', 'two-factor' ) . '

'; + echo '
'; + wp_nonce_field( 'two_factor_save_network_settings', 'two_factor_network_settings_nonce' ); + + echo '
' . esc_html__( 'Providers', 'two-factor' ) . ''; + echo ''; + + if ( empty( $provider_instances ) ) { + echo ''; + } else { + // Render a compact stacked list of provider checkboxes below the title/description. + echo ''; + echo ''; + echo ''; + } + + echo '
' . esc_html__( 'No providers found.', 'two-factor' ) . '
'; + foreach ( $provider_instances as $provider_key => $instance ) { + $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + + echo '

'; + } + + echo '
'; + echo '
'; + + echo '

' . esc_html__( 'Subsite Override', 'two-factor' ) . '

'; + echo '
'; + echo '

'; + echo '

' . esc_html__( 'When enabled, a subsite can only narrow the network list. Subsites cannot enable providers that are disabled here.', 'two-factor' ) . '

'; + echo '
'; + + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_network_settings_submit' ); + echo '
'; + + echo '
'; + } +} diff --git a/settings/class-two-factor-settings.php b/settings/class-two-factor-settings.php index 9e7fa886..7da7f4d1 100644 --- a/settings/class-two-factor-settings.php +++ b/settings/class-two-factor-settings.php @@ -28,41 +28,83 @@ public static function render_settings_page() { return; } - // Handle save. - if ( isset( $_POST['two_factor_settings_submit'] ) ) { + // Build provider list for display and validation using public core API. + $provider_instances = array(); + if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_providers' ) ) { + $provider_instances = Two_Factor_Core::get_providers(); + if ( ! is_array( $provider_instances ) ) { + $provider_instances = array(); + } + } + $all_provider_keys = array_keys( $provider_instances ); + + // Determine whether the site is operating under a network-level policy. + $network_enabled = null; + $network_override = false; + if ( function_exists( 'two_factor_is_network_mode' ) && two_factor_is_network_mode() ) { + $network_enabled = get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, null ); + if ( null !== $network_enabled ) { + $network_override = (bool) get_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, false ); + } + } + $network_managed = null !== $network_enabled && ! $network_override; + + // Handle save. Site-level values are only meaningful when the network + // allows subsite overrides or when the network has not configured a list. + if ( ! $network_managed && isset( $_POST['two_factor_settings_submit'] ) ) { check_admin_referer( 'two_factor_save_settings', 'two_factor_settings_nonce' ); $posted = isset( $_POST['two_factor_enabled_providers'] ) && is_array( $_POST['two_factor_enabled_providers'] ) ? wp_unslash( $_POST['two_factor_enabled_providers'] ) : array(); // Sanitize posted values immediately. $posted = array_map( 'sanitize_text_field', (array) $posted ); - // Remove empty values. - $enabled = array_values( array_filter( $posted, 'strlen' ) ); + // Remove empty values and keys that are not registered providers. + $enabled = array_values( + array_filter( + array_unique( $posted ), + function ( $key ) use ( $all_provider_keys ) { + return strlen( $key ) && in_array( $key, $all_provider_keys, true ); + } + ) + ); + + // When the network allows overrides, a subsite cannot expand beyond the network list. + if ( $network_override && is_array( $network_enabled ) ) { + $enabled = array_values( array_intersect( $enabled, $network_enabled ) ); + } - update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array_values( array_unique( $enabled ) ) ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, $enabled ); echo '

' . esc_html__( 'Settings saved.', 'two-factor' ) . '

'; } - // Build provider list for display using public core API. - $provider_instances = array(); - if ( class_exists( 'Two_Factor_Core' ) && method_exists( 'Two_Factor_Core', 'get_providers' ) ) { - $provider_instances = Two_Factor_Core::get_providers(); - if ( ! is_array( $provider_instances ) ) { - $provider_instances = array(); - } + // Default to all providers enabled when the site option has never been saved. + $saved_enabled = get_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, $all_provider_keys ); + if ( $network_managed ) { + // Managed by network: show the network list, regardless of any stale site option. + $saved_enabled = $network_enabled; + } elseif ( $network_override && is_array( $network_enabled ) ) { + // Override allowed: show the effective intersection so disabled providers are unchecked. + $saved_enabled = array_values( array_intersect( (array) $saved_enabled, $network_enabled ) ); } - // Default to all providers enabled when the option has never been saved. - $all_provider_keys = array_keys( $provider_instances ); - $saved_enabled = get_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, $all_provider_keys ); - echo '
'; echo '

' . esc_html__( 'Two-Factor Settings', 'two-factor' ) . '

'; echo '

' . esc_html__( 'Enabled Providers', 'two-factor' ) . '

'; - echo '

' . esc_html__( 'Choose which Two-Factor providers are available on this site. All providers are enabled by default.', 'two-factor' ) . '

'; - echo '
'; - wp_nonce_field( 'two_factor_save_settings', 'two_factor_settings_nonce' ); + + if ( $network_managed ) { + echo '

' . esc_html__( 'Provider settings are managed at the network level.', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'The network administrator has chosen the providers available on this site.', 'two-factor' ) . '

'; + } elseif ( $network_override ) { + echo '

' . esc_html__( 'The network has enabled the following providers. This site can only narrow the list.', 'two-factor' ) . '

'; + } else { + echo '

' . esc_html__( 'Choose which Two-Factor providers are available on this site. All providers are enabled by default.', 'two-factor' ) . '

'; + } + + if ( ! $network_managed ) { + echo ''; + wp_nonce_field( 'two_factor_save_settings', 'two_factor_settings_nonce' ); + } echo '
' . esc_html__( 'Providers', 'two-factor' ) . ''; echo ''; @@ -74,10 +116,12 @@ public static function render_settings_page() { echo ''; echo '
'; foreach ( $provider_instances as $provider_key => $instance ) { - $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + $label = method_exists( $instance, 'get_label' ) ? $instance->get_label() : $provider_key; + $is_in_network = is_array( $network_enabled ) && in_array( $provider_key, $network_enabled, true ); + $disabled = $network_managed || ( $network_override && ! $is_in_network ); echo '

'; } @@ -89,8 +133,10 @@ public static function render_settings_page() { echo '
'; echo '
'; - submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); - echo '
'; + if ( ! $network_managed ) { + submit_button( __( 'Save Settings', 'two-factor' ), 'primary', 'two_factor_settings_submit' ); + echo ''; + } echo '
'; } diff --git a/tests/class-two-factor-network-settings.php b/tests/class-two-factor-network-settings.php new file mode 100644 index 00000000..f053216a --- /dev/null +++ b/tests/class-two-factor-network-settings.php @@ -0,0 +1,275 @@ +admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $this->admin_user_id ); + } + + /** + * Cleanup after each test. + */ + public function tearDown(): void { + parent::tearDown(); + + wp_set_current_user( 0 ); + + remove_filter( 'two_factor_network_mode', '__return_true' ); + remove_filter( 'two_factor_network_mode', '__return_false' ); + + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + delete_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY ); + delete_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY ); + } + + /** + * Render the site settings page without the provider-enforcement filter. + * + * @return string + */ + private function render_site_settings_page() { + remove_filter( 'two_factor_providers', 'two_factor_filter_enabled_providers' ); + ob_start(); + Two_Factor_Settings::render_settings_page(); + $output = ob_get_clean(); + add_filter( 'two_factor_providers', 'two_factor_filter_enabled_providers' ); + return $output; + } + + /** + * Network mode is off by default in a single-site test environment. + * + * @covers two_factor_is_network_mode + */ + public function test_is_network_mode_false_by_default() { + $this->assertFalse( two_factor_is_network_mode() ); + } + + /** + * The two_factor_network_mode filter can force network mode for tests. + * + * @covers two_factor_is_network_mode + */ + public function test_is_network_mode_true_with_filter() { + add_filter( 'two_factor_network_mode', '__return_true' ); + $this->assertTrue( two_factor_is_network_mode() ); + } + + /** + * When no option is saved, the effective list is null (allow all providers). + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_returns_null_when_unsaved() { + add_filter( 'two_factor_network_mode', '__return_true' ); + $this->assertNull( two_factor_get_enabled_providers_option() ); + } + + /** + * Site option is used when the plugin is not in network mode. + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_uses_site_option_when_not_network_mode() { + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + $this->assertSame( array( 'Two_Factor_Email' ), two_factor_get_enabled_providers_option() ); + } + + /** + * Site option remains effective when network mode is active but the network + * option has never been saved. + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_uses_site_option_when_network_option_unsaved() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email', 'Two_Factor_Totp' ) ); + + $this->assertSame( array( 'Two_Factor_Email', 'Two_Factor_Totp' ), two_factor_get_enabled_providers_option() ); + } + + /** + * Network option is the exact effective list when override is not allowed. + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_uses_network_option_when_no_override() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 0 ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Totp' ) ); + + $this->assertSame( array( 'Two_Factor_Email' ), two_factor_get_enabled_providers_option() ); + } + + /** + * When override is allowed, the effective list is the intersection of the + * network and site lists. + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_intersects_when_override_allowed() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email', 'Two_Factor_Totp' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 1 ); + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Totp', 'Two_Factor_Backup_Codes' ) ); + + $this->assertSame( array( 'Two_Factor_Totp' ), two_factor_get_enabled_providers_option() ); + } + + /** + * When override is allowed but the subsite has never saved a list, the + * network list is used. + * + * @covers two_factor_get_enabled_providers_option + */ + public function test_get_enabled_providers_option_uses_network_when_override_and_site_unsaved() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 1 ); + + $this->assertSame( array( 'Two_Factor_Email' ), two_factor_get_enabled_providers_option() ); + } + + /** + * The registered providers filter enforces the network provider list. + * + * @covers two_factor_filter_enabled_providers + */ + public function test_filter_enabled_providers_enforces_network_option() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + $providers = array( + 'Two_Factor_Email' => '/path/to/email.php', + 'Two_Factor_Totp' => '/path/to/totp.php', + 'Two_Factor_Backup_Codes' => '/path/to/backup.php', + ); + $filtered = two_factor_filter_enabled_providers( $providers ); + + $this->assertSame( array( 'Two_Factor_Email' ), array_keys( $filtered ) ); + } + + /** + * The per-user enabled providers filter enforces the network provider list. + * + * @covers two_factor_filter_enabled_providers_for_user + */ + public function test_filter_enabled_providers_for_user_enforces_network_option() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + $user_enabled = array( 'Two_Factor_Email', 'Two_Factor_Totp' ); + $filtered = two_factor_filter_enabled_providers_for_user( $user_enabled, 0 ); + + $this->assertSame( array( 'Two_Factor_Email' ), $filtered ); + } + + /** + * Plugin uninstall removes network options. + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_removes_network_options() { + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 1 ); + + $this->assertSame( + array( 'Two_Factor_Email' ), + get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY ), + 'Network enabled providers option was set' + ); + $this->assertSame( + 1, + get_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY ), + 'Network override option was set' + ); + + Two_Factor_Core::uninstall(); + + $this->assertFalse( + get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, false ), + 'Network enabled providers option was deleted during uninstall' + ); + $this->assertFalse( + get_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, false ), + 'Network override option was deleted during uninstall' + ); + } + + /** + * Site settings page shows a network-managed notice when network override is disabled. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_site_settings_page_shows_network_managed_notice() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 0 ); + + $output = $this->render_site_settings_page(); + + $this->assertStringContainsString( 'Provider settings are managed at the network level.', $output ); + $this->assertStringNotContainsString( 'name="two_factor_settings_submit"', $output ); + $this->assertStringContainsString( 'disabled="disabled"', $output ); + } + + /** + * Site settings page shows a narrowing notice when network override is enabled. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_site_settings_page_shows_override_notice() { + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 1 ); + + $output = $this->render_site_settings_page(); + + $this->assertStringContainsString( 'The network has enabled the following providers. This site can only narrow the list.', $output ); + $this->assertStringContainsString( 'name="two_factor_settings_submit"', $output ); + $this->assertStringContainsString( 'disabled="disabled"', $output ); + } + + /** + * Site settings page remains editable when the network has not configured providers. + * + * @covers Two_Factor_Settings::render_settings_page + */ + public function test_site_settings_page_editable_when_network_unconfigured() { + add_filter( 'two_factor_network_mode', '__return_true' ); + // Intentionally not saving the network option. + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 0 ); + + $output = $this->render_site_settings_page(); + + $this->assertStringContainsString( 'Choose which Two-Factor providers are available on this site.', $output ); + $this->assertStringContainsString( 'name="two_factor_settings_submit"', $output ); + $this->assertStringNotContainsString( 'disabled="disabled"', $output ); + } +} diff --git a/two-factor.php b/two-factor.php index b1db2039..b2401546 100644 --- a/two-factor.php +++ b/two-factor.php @@ -51,8 +51,9 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-compat.php'; -// Load settings UI class so the settings page can be rendered. +// Load settings UI classes so the settings pages can be rendered. require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; +require_once TWO_FACTOR_DIR . 'settings/class-two-factor-network-settings.php'; $two_factor_compat = new Two_Factor_Compat(); @@ -71,6 +72,10 @@ function two_factor_register_admin_hooks() { add_action( 'admin_menu', 'two_factor_add_settings_page' ); } + if ( two_factor_is_network_mode() ) { + add_action( 'network_admin_menu', 'two_factor_add_network_settings_page' ); + } + // Load settings page assets when in admin. // Settings assets handled inline via standard markup; no extra CSS enqueued. @@ -97,6 +102,23 @@ function two_factor_add_settings_page() { } +/** + * Add the Two Factor network settings page under Network Admin Settings. + * + * @since 0.17.0 + */ +function two_factor_add_network_settings_page() { + add_submenu_page( + 'settings.php', + __( 'Two-Factor Settings', 'two-factor' ), + __( 'Two-Factor', 'two-factor' ), + 'manage_network_options', + 'two-factor-network-settings', + 'two_factor_render_network_settings_page' + ); +} + + /** * Render the settings page via the settings class if available. * @@ -119,6 +141,54 @@ function two_factor_render_settings_page() { } +/** + * Render the network settings page via the network settings class if available. + * + * @since 0.17.0 + */ +function two_factor_render_network_settings_page() { + if ( ! current_user_can( 'manage_network_options' ) ) { + return; + } + + if ( class_exists( 'Two_Factor_Network_Settings' ) && is_callable( array( 'Two_Factor_Network_Settings', 'render_settings_page' ) ) ) { + Two_Factor_Network_Settings::render_settings_page(); + return; + } + + // Fallback: no UI available. + echo '

' . esc_html__( 'Two-Factor Network Settings', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Settings not available.', 'two-factor' ) . '

'; +} + + +/** + * Determine whether the plugin is running in network-activated mode on Multisite. + * + * @since 0.17.0 + * + * @return bool + */ +function two_factor_is_network_mode() { + $network_mode = false; + if ( is_multisite() ) { + if ( ! function_exists( 'is_plugin_active_for_network' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $network_mode = is_plugin_active_for_network( plugin_basename( TWO_FACTOR_DIR . 'two-factor.php' ) ); + } + + /** + * Filter whether the plugin is running in network-activated mode. + * + * @since 0.17.0 + * + * @param bool $network_mode Whether the plugin is network-activated. + */ + return (bool) apply_filters( 'two_factor_network_mode', $network_mode ); +} + + /** * Helper: retrieve the site-enabled providers option. * Returns null when the option has never been saved (meaning all providers are allowed). @@ -128,7 +198,7 @@ function two_factor_render_settings_page() { * * @return array|null */ -function two_factor_get_enabled_providers_option() { +function two_factor_get_site_enabled_providers_option() { $enabled = get_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, null ); if ( null === $enabled ) { return null; // Never saved — allow everything. @@ -138,29 +208,69 @@ function two_factor_get_enabled_providers_option() { /** - * Filter the registered providers to only those in the site-enabled list. + * Helper: retrieve the effective enabled providers list for the current context. + * + * In network-activated mode, the network option wins unless subsites are allowed + * to override. If override is allowed and the subsite has saved a list, the + * effective list is the intersection of the network and site lists (subsite can + * only narrow, never expand). + * + * @since 0.17.0 + * + * @return array|null Array of allowed provider classnames, or null when no option has been saved. + */ +function two_factor_get_enabled_providers_option() { + $site_enabled = two_factor_get_site_enabled_providers_option(); + + if ( ! two_factor_is_network_mode() ) { + return $site_enabled; + } + + $network_enabled = get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, null ); + if ( null === $network_enabled ) { + return $site_enabled; + } + + $network_enabled = is_array( $network_enabled ) ? $network_enabled : array(); + $override = (bool) get_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, false ); + + if ( ! $override ) { + return $network_enabled; + } + + if ( null === $site_enabled ) { + return $network_enabled; + } + + return array_values( array_intersect( $network_enabled, $site_enabled ) ); +} + + +/** + * Filter the registered providers to only those in the effective enabled list. * This filter receives providers in core format: classname => path. * * @since 0.16 - * + * * @param array $providers Registered providers in classname => path format. * @return array Filtered list of enabled providers. */ function two_factor_filter_enabled_providers( $providers ) { - $site_enabled = two_factor_get_enabled_providers_option(); + $enabled = two_factor_get_enabled_providers_option(); // null means the option was never saved — allow all providers. - if ( null === $site_enabled ) { + if ( null === $enabled ) { return $providers; } - // On the settings page itself, show all providers so admins can change the selection. - if ( is_admin() && isset( $_GET['page'] ) && 'two-factor-settings' === $_GET['page'] ) { + // On the settings pages, show all providers so admins can change the selection. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( is_admin() && isset( $_GET['page'] ) && in_array( $_GET['page'], array( 'two-factor-settings', 'two-factor-network-settings' ), true ) ) { return $providers; } foreach ( $providers as $key => $path ) { - if ( ! in_array( $key, $site_enabled, true ) ) { + if ( ! in_array( $key, $enabled, true ) ) { unset( $providers[ $key ] ); } } @@ -170,21 +280,21 @@ function two_factor_filter_enabled_providers( $providers ) { /** - * Filter enabled providers for a user (classnames array) to enforce the site-enabled list. + * Filter enabled providers for a user (classnames array) to enforce the effective enabled list. * * @since 0.16 * - * @param array $enabled Enabled provider classnames for the user. - * @param int $user_id ID of the user being filtered. - * @return array Filtered list of provider classnames allowed by the site. + * @param array $enabled Enabled provider classnames for the user. + * @param int $user_id ID of the user being filtered. Unused, but required by the filter signature. + * @return array Filtered list of provider classnames allowed by the site or network. */ -function two_factor_filter_enabled_providers_for_user( $enabled, $user_id ) { - $site_enabled = two_factor_get_enabled_providers_option(); +function two_factor_filter_enabled_providers_for_user( $enabled, $user_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + $allowed = two_factor_get_enabled_providers_option(); // null means the option was never saved — allow all. - if ( null === $site_enabled ) { + if ( null === $allowed ) { return $enabled; } - return array_values( array_intersect( (array) $enabled, $site_enabled ) ); + return array_values( array_intersect( (array) $enabled, $allowed ) ); } From 90a29b09487455167042d9893ebabae786249cf8 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 1 Jul 2026 12:13:08 +0600 Subject: [PATCH 2/2] fix(network): address PR #915 review feedback - Make plugin Settings action link network-aware: points to Network Admin Two-Factor settings when network-activated and user can manage_network_options. - Add network_admin_plugin_action_links handler so the link appears on Network Admin > Plugins. - Guard Network Settings save against empty provider list to prevent network-wide lockout; show error notice and preserve existing config. - Add PHPUnit tests for network-aware link and empty-list rejection. Refs #915 --- class-two-factor-core.php | 39 ++++++++- .../class-two-factor-network-settings.php | 10 ++- tests/class-two-factor-network-settings.php | 81 +++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index ad62f6e7..6ac37e78 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -167,6 +167,7 @@ public static function add_hooks( $compat ) { // Add Settings link to plugin action links. add_filter( 'plugin_action_links_' . plugin_basename( TWO_FACTOR_DIR . 'two-factor.php' ), array( __CLASS__, 'add_settings_action_link' ) ); + add_filter( 'network_admin_plugin_action_links', array( __CLASS__, 'add_network_settings_action_link' ), 10, 4 ); $compat->init(); } @@ -428,7 +429,11 @@ public static function enable_dummy_method_for_debug( $methods ) { * @return string[] Modified array with the User Settings link added. */ public static function add_settings_action_link( $links ) { - $plugin_settings_url = admin_url( 'options-general.php?page=two-factor-settings' ); + if ( two_factor_is_network_mode() && current_user_can( 'manage_network_options' ) ) { + $plugin_settings_url = network_admin_url( 'settings.php?page=two-factor-network-settings' ); + } else { + $plugin_settings_url = admin_url( 'options-general.php?page=two-factor-settings' ); + } $plugin_settings_link = sprintf( '%s', esc_url( $plugin_settings_url ), @@ -452,6 +457,38 @@ public static function add_settings_action_link( $links ) { return $links; } + /** + * Add Network Settings link to plugin action links on the Network Admin plugins screen. + * + * @since 0.17.0 + * + * @param string[] $actions An array of plugin action links. + * @param string $plugin_file Path to the plugin file relative to the plugins directory. + * @param array $plugin_data An array of plugin data. Unused. + * @param string $context The plugin context status. Unused. + * @return string[] Modified array with the Network Settings link added. + */ + public static function add_network_settings_action_link( $actions, $plugin_file, $plugin_data, $context ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( plugin_basename( TWO_FACTOR_DIR . 'two-factor.php' ) !== $plugin_file ) { + return $actions; + } + + if ( ! two_factor_is_network_mode() || ! current_user_can( 'manage_network_options' ) ) { + return $actions; + } + + $network_settings_url = network_admin_url( 'settings.php?page=two-factor-network-settings' ); + $settings_link = sprintf( + '%s', + esc_url( $network_settings_url ), + esc_html__( 'Plugin Settings', 'two-factor' ) + ); + + array_unshift( $actions, $settings_link ); + + return $actions; + } + /** * Register an error associated with the current request. * diff --git a/settings/class-two-factor-network-settings.php b/settings/class-two-factor-network-settings.php index ed8a81bf..8ff26783 100644 --- a/settings/class-two-factor-network-settings.php +++ b/settings/class-two-factor-network-settings.php @@ -57,10 +57,14 @@ function ( $key ) use ( $all_provider_keys ) { ) ); - update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, $enabled ); - update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, ! empty( $_POST['two_factor_network_allow_site_override'] ) ? 1 : 0 ); + if ( empty( $enabled ) ) { + echo '

' . esc_html__( 'At least one provider must be enabled at the network level. No changes were saved.', 'two-factor' ) . '

'; + } else { + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, $enabled ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, ! empty( $_POST['two_factor_network_allow_site_override'] ) ? 1 : 0 ); - echo '

' . esc_html__( 'Settings saved.', 'two-factor' ) . '

'; + echo '

' . esc_html__( 'Settings saved.', 'two-factor' ) . '

'; + } } // Default to all providers enabled when the option has never been saved. diff --git a/tests/class-two-factor-network-settings.php b/tests/class-two-factor-network-settings.php index f053216a..64ef14e1 100644 --- a/tests/class-two-factor-network-settings.php +++ b/tests/class-two-factor-network-settings.php @@ -29,6 +29,8 @@ public function setUp(): void { $this->admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); wp_set_current_user( $this->admin_user_id ); + + add_filter( 'map_meta_cap', array( $this, 'filter_map_meta_cap' ), 10, 4 ); } /** @@ -37,6 +39,8 @@ public function setUp(): void { public function tearDown(): void { parent::tearDown(); + remove_filter( 'map_meta_cap', array( $this, 'filter_map_meta_cap' ), 10 ); + wp_set_current_user( 0 ); remove_filter( 'two_factor_network_mode', '__return_true' ); @@ -47,6 +51,22 @@ public function tearDown(): void { delete_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY ); } + /** + * Map manage_network_options to manage_options for the test admin in single-site. + * + * @param string[] $caps Primitive caps required. + * @param string $cap Capability being checked. + * @param int $user_id User ID. + * @param array $args Extra args. + * @return string[] Modified caps. + */ + public function filter_map_meta_cap( $caps, $cap, $user_id, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + if ( 'manage_network_options' === $cap && $user_id === $this->admin_user_id ) { + return array( 'manage_options' ); + } + return $caps; + } + /** * Render the site settings page without the provider-enforcement filter. * @@ -272,4 +292,65 @@ public function test_site_settings_page_editable_when_network_unconfigured() { $this->assertStringContainsString( 'name="two_factor_settings_submit"', $output ); $this->assertStringNotContainsString( 'disabled="disabled"', $output ); } + + /** + * Plugin Settings link points to the network settings page for network admins. + * + * @covers Two_Factor_Core::add_settings_action_link + */ + public function test_settings_action_link_points_to_network_settings_for_network_admin() { + add_filter( 'two_factor_network_mode', '__return_true' ); + + $links = Two_Factor_Core::add_settings_action_link( array() ); + + $this->assertStringContainsString( 'settings.php?page=two-factor-network-settings', $links[0] ); + $this->assertStringNotContainsString( 'options-general.php?page=two-factor-settings', $links[0] ); + } + + /** + * Plugin Settings link points to the site settings page for non-network admins. + * + * @covers Two_Factor_Core::add_settings_action_link + */ + public function test_settings_action_link_points_to_site_settings_for_non_network_admin() { + add_filter( 'two_factor_network_mode', '__return_true' ); + remove_filter( 'map_meta_cap', array( $this, 'filter_map_meta_cap' ), 10 ); + + $links = Two_Factor_Core::add_settings_action_link( array() ); + + add_filter( 'map_meta_cap', array( $this, 'filter_map_meta_cap' ), 10, 4 ); + + $this->assertStringContainsString( 'options-general.php?page=two-factor-settings', $links[0] ); + $this->assertStringNotContainsString( 'settings.php?page=two-factor-network-settings', $links[0] ); + } + + /** + * Empty network provider list is rejected and the existing option is preserved. + * + * @covers Two_Factor_Network_Settings::render_settings_page + */ + public function test_network_settings_empty_provider_list_not_saved() { + get_userdata( $this->admin_user_id )->add_cap( 'manage_network_options', true ); + + add_filter( 'two_factor_network_mode', '__return_true' ); + update_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY, array( 'Two_Factor_Email' ) ); + update_site_option( Two_Factor_Core::NETWORK_ALLOW_SITE_OVERRIDE_OPTION_KEY, 0 ); + + $_POST['two_factor_network_settings_submit'] = '1'; + $_POST['two_factor_network_settings_nonce'] = wp_create_nonce( 'two_factor_save_network_settings' ); + $_REQUEST['two_factor_network_settings_nonce'] = $_POST['two_factor_network_settings_nonce']; + + ob_start(); + Two_Factor_Network_Settings::render_settings_page(); + $output = ob_get_clean(); + + unset( $_POST['two_factor_network_settings_submit'], $_POST['two_factor_network_settings_nonce'], $_REQUEST['two_factor_network_settings_nonce'] ); + + $this->assertSame( + array( 'Two_Factor_Email' ), + get_site_option( Two_Factor_Core::ENABLED_PROVIDERS_NETWORK_OPTION_KEY ), + 'Network provider list must not be changed when saving an empty list.' + ); + $this->assertStringContainsString( 'At least one provider must be enabled at the network level.', $output ); + } }