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",