diff --git a/projects/packages/protect/changelog/add-protect-dashboard-login-protection b/projects/packages/protect/changelog/add-protect-dashboard-login-protection
new file mode 100644
index 000000000000..6eb28c842152
--- /dev/null
+++ b/projects/packages/protect/changelog/add-protect-dashboard-login-protection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add the Login protection section to the Protect dashboard, covering brute force protection, account protection and WordPress.com login.
diff --git a/projects/packages/protect/composer.json b/projects/packages/protect/composer.json
index cfe3c49e8b72..63d669f4c533 100644
--- a/projects/packages/protect/composer.json
+++ b/projects/packages/protect/composer.json
@@ -7,6 +7,7 @@
"php": ">=7.4",
"automattic/jetpack-admin-ui": "@dev",
"automattic/jetpack-connection": "@dev",
+ "automattic/jetpack-ip": "@dev",
"automattic/jetpack-protect-models": "@dev",
"automattic/jetpack-protect-status": "@dev",
"automattic/jetpack-status": "@dev",
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx
index 28996967d55a..153dc0392bf2 100644
--- a/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/index.tsx
@@ -1,5 +1,12 @@
+import LoginProtectionCard from './login-protection-card';
+import LoginProtectionSettingsCard from './login-protection-settings-card';
import type { ProtectSection } from '../types';
+import type { LoginProtectionState } from './types';
-const section: ProtectSection = { key: 'loginProtection' };
+const section: ProtectSection< LoginProtectionState | undefined > = {
+ key: 'loginProtection',
+ OverviewCard: LoginProtectionCard,
+ SettingsCard: LoginProtectionSettingsCard,
+};
export default section;
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx
new file mode 100644
index 000000000000..62107a7311f9
--- /dev/null
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-card.tsx
@@ -0,0 +1,148 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { lock } from '@wordpress/icons';
+import { Badge, Stack, Text } from '@wordpress/ui';
+import { CardRow, ProtectCard, Stat } from '../../components/card';
+import TabLink from '../../components/tab-link';
+import isModuleActive from '../../data/is-module-active';
+import type { LoginProtectionContext, ModuleState } from './types';
+import type { CardStatus } from '../../components/card';
+
+/**
+ * One way of protecting logins, with whether it's on.
+ *
+ * @param props - Component props.
+ * @param props.name - The method's name.
+ * @param props.description - What it does.
+ * @param props.state - Its module's live state.
+ * @return The row.
+ */
+function Method( {
+ name,
+ description,
+ state,
+}: {
+ name: string;
+ description: string;
+ state: ModuleState;
+} ) {
+ let badge = { __( 'Off', 'jetpack-protect-pkg' ) };
+ if ( ! state.available ) {
+ badge = { __( 'Unavailable', 'jetpack-protect-pkg' ) };
+ } else if ( state.active ) {
+ badge = { __( 'On', 'jetpack-protect-pkg' ) };
+ }
+
+ return (
+
+
+
+ { name }
+
+ { description }
+
+
+ { badge }
+
+
+ );
+}
+
+/**
+ * The card badge's intent: neutral when no method is available.
+ *
+ * @param on - How many methods are on.
+ * @param available - How many methods are available.
+ * @return The intent.
+ */
+function getStatusIntent( on: number, available: number ): CardStatus[ 'intent' ] {
+ if ( available === 0 ) {
+ return 'none';
+ }
+ return on === available ? 'stable' : 'medium';
+}
+
+/**
+ * The Login protection card: brute force protection, account protection and WordPress.com login.
+ *
+ * @param props - The dashboard context.
+ * @param props.state - This section's state from PHP.
+ * @param props.settings - Jetpack settings, for live module state.
+ * @param props.openTab - Switches dashboard tabs, for the link to Settings.
+ * @return The card, or nothing without the section's state.
+ */
+export default function LoginProtectionCard( {
+ state: login,
+ settings,
+ openTab,
+}: LoginProtectionContext ) {
+ if ( ! login ) {
+ return null;
+ }
+
+ const live = ( module: string, initial: ModuleState ): ModuleState => ( {
+ available: initial.available,
+ active: isModuleActive( settings.settings, module, initial.active ),
+ } );
+ const bruteForce = live( 'protect', login.bruteForce );
+ const accountProtection = live( 'account-protection', login.accountProtection );
+ // The module can stay active after the owner disconnects, but WordPress.com login doesn't run then.
+ const sso = live( 'sso', { ...login.sso, available: login.sso.available && login.ssoUsable } );
+
+ const methods = [ bruteForce, accountProtection, sso ];
+ const on = methods.filter( method => method.available && method.active ).length;
+ const available = methods.filter( method => method.available ).length;
+
+ return (
+
+ { bruteForce.active && (
+
+
+
+ ) }
+
+
+
+
+
+ { __( 'Configure login protection', 'jetpack-protect-pkg' ) }
+
+
+
+ );
+}
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx
new file mode 100644
index 000000000000..560ac88349ce
--- /dev/null
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/login-protection-settings-card.tsx
@@ -0,0 +1,226 @@
+import { ToggleControl } from '@wordpress/components';
+import { useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { lock } from '@wordpress/icons';
+import { AlertDialog, Stack, Text } from '@wordpress/ui';
+import { CardRow, ProtectCard } from '../../components/card';
+import IpListField from '../../components/settings/ip-list-field';
+import SettingToggle from '../../components/settings/setting-toggle';
+import type { LoginProtectionContext, LoginProtectionState } from './types';
+import type { ProtectSettingsData } from '../../data/use-protect-settings';
+
+const noop = () => {};
+const NO_LOCKS: LoginProtectionState[ 'ssoLocks' ] = { matchByEmail: false, twoStep: false };
+
+/**
+ * A WordPress.com login option; a locked one shows the value SSO actually applies.
+ *
+ * @param props - Component props.
+ * @param props.data - The settings and their save function.
+ * @param props.name - The setting key.
+ * @param props.label - The toggle's label.
+ * @param props.lockedHelp - Help text shown while a filter or constant forces the value.
+ * @param props.locked - Whether a filter or constant forces the value.
+ * @param props.effective - The forced value.
+ * @param props.ssoOn - Whether WordPress.com login is on.
+ * @return The toggle.
+ */
+function SsoOptionToggle( {
+ data,
+ name,
+ label,
+ lockedHelp,
+ locked,
+ effective,
+ ssoOn,
+}: {
+ data: ProtectSettingsData;
+ name: string;
+ label: string;
+ lockedHelp: string;
+ locked: boolean;
+ effective: boolean;
+ ssoOn: boolean;
+} ) {
+ if ( ! locked ) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * What the WordPress.com login toggle says under it: what it does, or why it can't be changed.
+ *
+ * @param login - This section's state from PHP.
+ * @return The help text.
+ */
+function getSsoHelp( login: LoginProtectionState | undefined ): string {
+ if ( login && ! login.sso.available ) {
+ return __( 'WordPress.com login isn’t available on this site.', 'jetpack-protect-pkg' );
+ }
+ if ( login?.isOffline ) {
+ return __( 'WordPress.com login isn’t available in offline mode.', 'jetpack-protect-pkg' );
+ }
+ if ( ! login?.ssoUsable ) {
+ return __(
+ 'Needs the site owner’s WordPress.com account to be connected.',
+ 'jetpack-protect-pkg'
+ );
+ }
+ return __(
+ 'If you have several sites with this on, you can log in to all of them with the same credentials.',
+ 'jetpack-protect-pkg'
+ );
+}
+
+/**
+ * The Login protection settings: brute force protection, account protection and WordPress.com login.
+ *
+ * @param props - The dashboard context.
+ * @param props.state - This section's state from PHP.
+ * @param props.settings - The settings and their save function.
+ * @return The card.
+ */
+export default function LoginProtectionSettingsCard( {
+ state: login,
+ settings: data,
+}: LoginProtectionContext ) {
+ const settings = data.settings ?? {};
+ const ssoLocks = login?.ssoLocks ?? NO_LOCKS;
+ const ssoEffective = login?.ssoEffective ?? NO_LOCKS;
+ const ssoUsable = login?.ssoUsable ?? false;
+ const ssoOn = Boolean( settings.sso );
+ const { save } = data;
+
+ const [ confirmingSsoOff, setConfirmingSsoOff ] = useState( false );
+ const onSsoChange = useCallback(
+ ( value: boolean ) => ( value ? save( { sso: true } ) : setConfirmingSsoOff( true ) ),
+ [ save ]
+ );
+ const disableSso = useCallback( () => save( { sso: false } ), [ save ] );
+
+ return (
+
+
+
+ } className="jp-protect-card__subheading">
+ { __( 'Brute force protection', 'jetpack-protect-pkg' ) }
+
+
+
+ { /* Hidden, not unmounted, so unsaved addresses survive the toggle going off or a failed save. */ }
+
+
+
+
+
+
+
+ } className="jp-protect-card__subheading">
+ { __( 'Account protection', 'jetpack-protect-pkg' ) }
+
+
+
+
+
+
+ } className="jp-protect-card__subheading">
+ { __( 'WordPress.com login', 'jetpack-protect-pkg' ) }
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx
new file mode 100644
index 000000000000..d332aed8f1fa
--- /dev/null
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-card.test.tsx
@@ -0,0 +1,91 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import LoginProtectionCard from '../login-protection-card';
+import type { ProtectSettingsData } from '../../../data/use-protect-settings';
+import type { LoginProtectionState } from '../types';
+
+const on = { available: true, active: true };
+const off = { available: true, active: false };
+const unavailable = { available: false, active: false };
+
+const renderCard = (
+ state: Partial< LoginProtectionState > = {},
+ settings: ProtectSettingsData[ 'settings' ] = null,
+ openTab = jest.fn()
+) =>
+ render(
+
+ );
+
+describe( 'LoginProtectionCard', () => {
+ it.each( [
+ [ 'every method is on', {}, null, '3 of 3 on', true ],
+ [ 'one method is off', { sso: off }, null, '2 of 3 on', true ],
+ [ 'a method is unavailable', { sso: unavailable }, null, '2 of 2 on', true ],
+ [ 'brute force protection is off', { bruteForce: off }, null, '2 of 3 on', false ],
+ [ 'a method was turned off since page load', {}, { protect: false }, '2 of 3 on', false ],
+ [ 'a method was turned on since page load', { sso: off }, { sso: true }, '3 of 3 on', true ],
+ [ 'WordPress.com login is set but can’t run', { ssoUsable: false }, null, '2 of 2 on', true ],
+ [
+ 'an unavailable method is still set',
+ { sso: unavailable },
+ { sso: true },
+ '2 of 2 on',
+ true,
+ ],
+ ] )( 'counts the methods that are on when %s', ( _name, state, settings, badge, hasCount ) => {
+ renderCard( state, settings );
+
+ expect( screen.getByText( badge ) ).toBeInTheDocument();
+ expect( screen.queryByText( '1,234' ) !== null ).toBe( hasCount );
+ } );
+
+ it.each( [
+ [ 'on', { sso: on }, { On: 1, Off: 2, Unavailable: 0 } ],
+ [ 'off', { sso: off }, { On: 0, Off: 3, Unavailable: 0 } ],
+ [ 'unavailable', { sso: unavailable }, { On: 0, Off: 2, Unavailable: 1 } ],
+ [ 'on but unable to run', { sso: on, ssoUsable: false }, { On: 0, Off: 2, Unavailable: 1 } ],
+ ] )( 'badges a method that is %s', ( _name, state, badges ) => {
+ renderCard( { bruteForce: off, accountProtection: off, ...state } );
+
+ Object.entries( badges ).forEach( ( [ text, count ] ) =>
+ expect( screen.queryAllByText( text ) ).toHaveLength( count )
+ );
+ } );
+
+ it( 'renders nothing when PHP registered no section', () => {
+ const { container } = render(
+
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'opens the Settings tab from its link', async () => {
+ const openTab = jest.fn();
+ renderCard( {}, null, openTab );
+
+ await userEvent
+ .setup()
+ .click( screen.getByRole( 'link', { name: 'Configure login protection' } ) );
+
+ expect( openTab ).toHaveBeenCalledWith( 'settings', undefined );
+ } );
+} );
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx
new file mode 100644
index 000000000000..3454341024ab
--- /dev/null
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/test/login-protection-settings-card.test.tsx
@@ -0,0 +1,158 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import LoginProtectionSettingsCard from '../login-protection-settings-card';
+import type { ProtectSettings, ProtectSettingsData } from '../../../data/use-protect-settings';
+import type { LoginProtectionState } from '../types';
+
+const SSO = 'Allow users to log in with their WordPress.com account';
+const MATCH_BY_EMAIL = 'Match accounts using email addresses';
+const TWO_STEP = 'Require accounts to use WordPress.com Two-Step Authentication';
+const ALLOW_LIST = 'Always allowed IP addresses';
+const BRUTE_FORCE = 'Block repeated failed login attempts';
+const ACCOUNT_PROTECTION = 'Protect accounts with weak or leaked passwords';
+
+const available = { available: true, active: true };
+
+type CardOptions = {
+ state?: Partial< LoginProtectionState >;
+ settings?: ProtectSettings;
+ save?: jest.Mock;
+};
+
+const card = ( { state = {}, settings = {}, save = jest.fn() }: CardOptions = {} ) => (
+ false,
+ save,
+ } as unknown as ProtectSettingsData
+ }
+ openTab={ jest.fn() }
+ />
+);
+
+const renderCard = ( options: CardOptions = {} ) => render( card( options ) );
+
+describe( 'LoginProtectionSettingsCard', () => {
+ it( 'asks before turning WordPress.com login off', async () => {
+ const save = jest.fn();
+ const user = userEvent.setup();
+ renderCard( { save } );
+
+ await user.click( screen.getByLabelText( SSO ) );
+
+ expect( save ).not.toHaveBeenCalled();
+
+ await user.click( screen.getByRole( 'button', { name: 'Turn off' } ) );
+
+ expect( save ).toHaveBeenCalledWith( { sso: false } );
+ } );
+
+ it( 'turns WordPress.com login on without asking', async () => {
+ const save = jest.fn();
+ renderCard( { save, settings: { sso: false } } );
+
+ await userEvent.setup().click( screen.getByLabelText( SSO ) );
+
+ expect( save ).toHaveBeenCalledWith( { sso: true } );
+ } );
+
+ it.each( [
+ [ 'it is usable', {}, {}, true, true ],
+ [ 'it is off', {}, { sso: false }, true, false ],
+ [ 'no owner is connected', { ssoUsable: false }, {}, false, true ],
+ [ 'the module is unavailable', { sso: { available: false, active: false } }, {}, false, true ],
+ ] )(
+ 'enables the right WordPress.com login toggles when %s',
+ ( _name, state, settings, canToggle, canSetOptions ) => {
+ renderCard( { state, settings } );
+
+ const enabled = ( label: string ) =>
+ ! screen.getByLabelText( label ).hasAttribute( 'disabled' );
+ expect( enabled( SSO ) ).toBe( canToggle );
+ expect( enabled( MATCH_BY_EMAIL ) ).toBe( canSetOptions );
+ expect( enabled( TWO_STEP ) ).toBe( canSetOptions );
+ }
+ );
+
+ it.each( [
+ [
+ 'no owner is connected',
+ { ssoUsable: false },
+ 'Needs the site owner’s WordPress.com account to be connected.',
+ ],
+ [
+ 'the site is in offline mode',
+ { ssoUsable: false, isOffline: true },
+ 'WordPress.com login isn’t available in offline mode.',
+ ],
+ [
+ 'the module is unavailable',
+ { sso: { available: false, active: false } },
+ 'WordPress.com login isn’t available on this site.',
+ ],
+ ] )( 'says why WordPress.com login can’t be changed when %s', ( _name, state, reason ) => {
+ renderCard( { state } );
+
+ expect( screen.getByText( reason ) ).toBeInTheDocument();
+ } );
+
+ it.each( [
+ [ BRUTE_FORCE, 'bruteForce' ],
+ [ ACCOUNT_PROTECTION, 'accountProtection' ],
+ ] )( 'locks "%s" when its module is unavailable', ( label, key ) => {
+ renderCard( { state: { [ key ]: { available: false, active: false } } } );
+
+ expect( screen.getByLabelText( label ) ).toBeDisabled();
+ } );
+
+ it( 'locks a forced option to the value WordPress.com login applies', () => {
+ renderCard( {
+ state: {
+ ssoLocks: { matchByEmail: false, twoStep: true },
+ ssoEffective: { matchByEmail: false, twoStep: true },
+ },
+ settings: { jetpack_sso_require_two_step: false },
+ } );
+
+ expect( screen.getByLabelText( TWO_STEP ) ).toBeDisabled();
+ expect( screen.getByLabelText( TWO_STEP ) ).toBeChecked();
+ expect(
+ screen.getByText( 'Two-Step Authentication is enforced by your site’s configuration.' )
+ ).toBeInTheDocument();
+ expect( screen.getByLabelText( MATCH_BY_EMAIL ) ).toBeEnabled();
+ } );
+
+ it.each( [
+ [ 'shows', true ],
+ [ 'hides', false ],
+ ] )( '%s the allowed addresses with their toggle', ( _name, enabled ) => {
+ renderCard( { settings: { jetpack_waf_ip_allow_list_enabled: enabled } } );
+
+ // Hidden rather than unmounted, so only an accessible query tells the two apart.
+ expect( screen.queryByRole( 'textbox', { name: ALLOW_LIST } ) !== null ).toBe( enabled );
+ } );
+
+ it( 'keeps unsaved addresses when the allow list toggle goes off and back on', async () => {
+ const { rerender } = renderCard( { settings: { jetpack_waf_ip_allow_list_enabled: true } } );
+ await userEvent.setup().type( screen.getByLabelText( ALLOW_LIST ), '203.0.113.9' );
+
+ rerender( card( { settings: { jetpack_waf_ip_allow_list_enabled: false } } ) );
+ rerender( card( { settings: { jetpack_waf_ip_allow_list_enabled: true } } ) );
+
+ expect( screen.getByLabelText( ALLOW_LIST ) ).toHaveValue( '203.0.113.9' );
+ } );
+} );
diff --git a/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts b/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts
new file mode 100644
index 000000000000..02367261dba6
--- /dev/null
+++ b/projects/packages/protect/routes/dashboard/sections/login-protection/types.ts
@@ -0,0 +1,28 @@
+import type { DashboardContext } from '../types';
+
+export type ModuleState = {
+ available: boolean;
+ active: boolean;
+};
+
+type SsoOptions = { matchByEmail: boolean; twoStep: boolean };
+
+export type LoginProtectionState = {
+ bruteForce: ModuleState;
+ accountProtection: ModuleState;
+ sso: ModuleState;
+ /** All-time blocked login attempts, formatted for the site's locale. */
+ blockedCount: string;
+ /** Whether WordPress.com login can be turned on: it needs a connected owner and no offline mode. */
+ ssoUsable: boolean;
+ /** Whether the site is in offline mode, one of the two reasons WordPress.com login can't be used. */
+ isOffline: boolean;
+ /** Settings a filter or constant forces, so they can't be changed here. */
+ ssoLocks: SsoOptions;
+ /** The values SSO actually applies, after filters and constants. */
+ ssoEffective: SsoOptions;
+ currentIp: string;
+};
+
+/** Undefined when PHP registered no Login protection section. */
+export type LoginProtectionContext = DashboardContext< LoginProtectionState | undefined >;
diff --git a/projects/packages/protect/src/sections/class-login-protection.php b/projects/packages/protect/src/sections/class-login-protection.php
new file mode 100644
index 000000000000..b4ee42665165
--- /dev/null
+++ b/projects/packages/protect/src/sections/class-login-protection.php
@@ -0,0 +1,72 @@
+is_offline_mode();
+
+ return array(
+ 'bruteForce' => Dashboard::get_module_state( 'protect' ),
+ 'accountProtection' => Dashboard::get_module_state( 'account-protection' ),
+ 'sso' => Dashboard::get_module_state( 'sso' ),
+ 'blockedCount' => number_format_i18n( (int) get_site_option( 'jetpack_protect_blocked_attempts', 0 ) ),
+ // WordPress.com login needs a connected owner and doesn't work in offline mode.
+ 'ssoUsable' => ! $is_offline && ( new Connection_Manager( 'jetpack-protect-pkg' ) )->has_connected_owner(),
+ 'isOffline' => $is_offline,
+ // A filter or constant can force these, and Jetpack Settings then locks the toggle.
+ 'ssoLocks' => array(
+ 'matchByEmail' => method_exists( SSO_Helpers::class, 'is_match_by_email_checkbox_disabled' ) && SSO_Helpers::is_match_by_email_checkbox_disabled(),
+ 'twoStep' => method_exists( SSO_Helpers::class, 'is_require_two_step_checkbox_disabled' ) && SSO_Helpers::is_require_two_step_checkbox_disabled(),
+ ),
+ 'ssoEffective' => array(
+ 'matchByEmail' => SSO_Helpers::match_by_email(),
+ 'twoStep' => SSO_Helpers::is_two_step_required(),
+ ),
+ 'currentIp' => (string) IP_Utils::get_ip(),
+ );
+ }
+
+ /**
+ * The section uses the core Jetpack settings endpoints, so it has no routes of its own.
+ *
+ * @return void
+ */
+ public function register_routes() {}
+}
diff --git a/projects/packages/protect/tests/php/Login_Protection_Section_Test.php b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php
new file mode 100644
index 000000000000..2a3a8b36ecc4
--- /dev/null
+++ b/projects/packages/protect/tests/php/Login_Protection_Section_Test.php
@@ -0,0 +1,142 @@
+reset_connection_status();
+ parent::tear_down();
+ }
+
+ /**
+ * Test that the blocked attempt count is formatted for display.
+ */
+ public function test_blocked_count_is_formatted() {
+ update_site_option( 'jetpack_protect_blocked_attempts', 12345 );
+
+ $this->assertSame( '12,345', ( new Login_Protection() )->get_state()['blockedCount'] );
+ }
+
+ /**
+ * Test that WordPress.com login needs a connected owner and a site that isn't in offline mode.
+ *
+ * @dataProvider provide_sso_conditions
+ *
+ * @param bool $has_owner Whether the site has a connected owner.
+ * @param bool $is_offline Whether the site is in offline mode.
+ * @param bool $usable Whether WordPress.com login can be turned on.
+ */
+ #[DataProvider( 'provide_sso_conditions' )]
+ public function test_sso_is_usable_with_a_connected_owner_and_not_offline( $has_owner, $is_offline, $usable ) {
+ if ( $has_owner ) {
+ $user_id = wp_insert_user(
+ array(
+ 'user_login' => 'owner',
+ 'user_pass' => 'password',
+ 'role' => 'administrator',
+ )
+ );
+ Jetpack_Options::update_option( 'master_user', $user_id );
+ Jetpack_Options::update_option( 'user_tokens', array( $user_id => "token.secret.$user_id" ) );
+ }
+ add_filter( 'jetpack_offline_mode', $is_offline ? '__return_true' : '__return_false' );
+
+ $state = ( new Login_Protection() )->get_state();
+
+ $this->assertSame( $usable, $state['ssoUsable'] );
+ $this->assertSame( $is_offline, $state['isOffline'] );
+ }
+
+ /**
+ * Data provider for the conditions WordPress.com login depends on.
+ *
+ * @return array [ $has_owner, $is_offline, $usable ]
+ */
+ public static function provide_sso_conditions() {
+ return array(
+ 'connected owner' => array( true, false, true ),
+ 'no connected owner' => array( false, false, false ),
+ 'connected owner, offline' => array( true, true, false ),
+ );
+ }
+
+ /**
+ * Test that stored SSO options are reported as changeable.
+ */
+ public function test_stored_sso_options_are_not_locked() {
+ update_option( 'jetpack_sso_match_by_email', 0 );
+ update_option( 'jetpack_sso_require_two_step', 1 );
+
+ $state = ( new Login_Protection() )->get_state();
+
+ $unlocked = array(
+ 'matchByEmail' => false,
+ 'twoStep' => false,
+ );
+ $this->assertSame( $unlocked, $state['ssoLocks'] );
+ $this->assertSame(
+ array(
+ 'matchByEmail' => false,
+ 'twoStep' => true,
+ ),
+ $state['ssoEffective']
+ );
+ }
+
+ /**
+ * Test that a filter locks an SSO option and its value wins over the stored one.
+ */
+ public function test_filtered_sso_options_are_locked_to_the_filtered_value() {
+ update_option( 'jetpack_sso_match_by_email', 1 );
+ update_option( 'jetpack_sso_require_two_step', 0 );
+ add_filter( 'jetpack_sso_match_by_email', '__return_false' );
+ add_filter( 'jetpack_sso_require_two_step', '__return_true' );
+
+ $state = ( new Login_Protection() )->get_state();
+
+ $this->assertSame(
+ array(
+ 'matchByEmail' => true,
+ 'twoStep' => true,
+ ),
+ $state['ssoLocks']
+ );
+ $this->assertSame(
+ array(
+ 'matchByEmail' => false,
+ 'twoStep' => true,
+ ),
+ $state['ssoEffective']
+ );
+ }
+}
diff --git a/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection b/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection
new file mode 100644
index 000000000000..230c46a64ef5
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-protect-dashboard-login-protection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: other
+
+Protect: Add the Login protection section to the Protect dashboard, covering brute force protection, account protection and WordPress.com login.
diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock
index 873c4e8746ab..61fd26437967 100644
--- a/projects/plugins/jetpack/composer.lock
+++ b/projects/plugins/jetpack/composer.lock
@@ -3236,11 +3236,12 @@
"dist": {
"type": "path",
"url": "../../packages/protect",
- "reference": "bfe0e82f7a2d9fac6019ec6ea1c07fe797fcf5ec"
+ "reference": "e77538b1f6a9c17f87c62e22e0542f0d1d3e17d6"
},
"require": {
"automattic/jetpack-admin-ui": "@dev",
"automattic/jetpack-connection": "@dev",
+ "automattic/jetpack-ip": "@dev",
"automattic/jetpack-protect-models": "@dev",
"automattic/jetpack-protect-status": "@dev",
"automattic/jetpack-status": "@dev",