diff --git a/class-two-factor-core.php b/class-two-factor-core.php
index 1f039866..6ac37e78 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.
*
@@ -149,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();
}
@@ -203,6 +222,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 +269,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 );
}
@@ -398,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 ),
@@ -422,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.
*
@@ -1186,7 +1253,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..8ff26783
--- /dev/null
+++ b/settings/class-two-factor-network-settings.php
@@ -0,0 +1,120 @@
+
' . 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' ) . '
';
+ }
+ }
+
+ // 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 '
';
+
+ 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 '
';
+ 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..64ef14e1
--- /dev/null
+++ b/tests/class-two-factor-network-settings.php
@@ -0,0 +1,356 @@
+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 );
+ }
+
+ /**
+ * Cleanup after each test.
+ */
+ 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' );
+ 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 );
+ }
+
+ /**
+ * 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.
+ *
+ * @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 );
+ }
+
+ /**
+ * 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 );
+ }
+}
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 ) );
}