diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/components/authorization.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/components/authorization.tsx
new file mode 100644
index 00000000000000..f57ac74381624c
--- /dev/null
+++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/components/authorization.tsx
@@ -0,0 +1,60 @@
+import { NextButton } from '@automattic/onboarding';
+import { check, Icon } from '@wordpress/icons';
+import { useTranslate } from 'i18n-calypso';
+
+const AuthorizationBenefits = ( { benefits }: { benefits: string[] } ) => {
+ return (
+
+ { benefits.map( ( benefit, index ) => (
+
+ ) ) }
+
+ );
+};
+
+interface AuthorizationProps {
+ onShareCredentialsClick: () => void;
+ onAuthorizationClick: () => void;
+}
+
+const Authorization = ( { onShareCredentialsClick, onAuthorizationClick }: AuthorizationProps ) => {
+ const translate = useTranslate();
+ return (
+
+
+
+ { translate( 'Authorize access' ) }
+
+
+
+
+
+
+
{ translate( "Here's what else you're getting" ) }
+
+
+
+ );
+};
+
+export default Authorization;
diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/hooks/use-store-application-password.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/hooks/use-store-application-password.ts
new file mode 100644
index 00000000000000..d29b1ee60e7433
--- /dev/null
+++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/hooks/use-store-application-password.ts
@@ -0,0 +1,35 @@
+import { useMutation } from '@tanstack/react-query';
+import wpcomRequest from 'wpcom-proxy-request';
+import { ApiError } from '../../site-migration-credentials/types';
+
+interface StoreApplicationPasswordResponse {
+ success: boolean;
+}
+
+interface StoreApplicationPasswordPayload {
+ password: string;
+ username: string;
+ source: string;
+}
+
+const useStoreApplicationPassword = ( siteSlug: string ) => {
+ return useMutation< StoreApplicationPasswordResponse, ApiError, StoreApplicationPasswordPayload >(
+ {
+ mutationFn: ( { password, username, source } ) => {
+ return wpcomRequest( {
+ path: `sites/${ siteSlug }/automated-migration/application-passwords`,
+ apiNamespace: 'wpcom/v2/',
+ apiVersion: '2',
+ method: 'POST',
+ body: {
+ password,
+ username,
+ from_url: source,
+ },
+ } );
+ },
+ }
+ );
+};
+
+export default useStoreApplicationPassword;
diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/index.tsx
new file mode 100644
index 00000000000000..18fa0fc61ab1f6
--- /dev/null
+++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/index.tsx
@@ -0,0 +1,147 @@
+import { StepContainer } from '@automattic/onboarding';
+import { useTranslate } from 'i18n-calypso';
+import { useEffect } from 'react';
+import DocumentHead from 'calypso/components/data/document-head';
+import FormattedHeader from 'calypso/components/formatted-header';
+import { LoadingEllipsis } from 'calypso/components/loading-ellipsis';
+import Notice from 'calypso/components/notice';
+import { useQuery } from 'calypso/landing/stepper/hooks/use-query';
+import { useSiteSlugParam } from 'calypso/landing/stepper/hooks/use-site-slug-param';
+import { recordTracksEvent } from 'calypso/lib/analytics/tracks';
+import Authorization from './components/authorization';
+import useStoreApplicationPassword from './hooks/use-store-application-password';
+import type { Step } from '../../types';
+import './style.scss';
+
+const SiteMigrationApplicationPasswordsAuthorization: Step = function ( { navigation } ) {
+ const translate = useTranslate();
+ const siteSlug = useSiteSlugParam();
+
+ const source = useQuery().get( 'from' ) ?? '';
+ const authorizationUrl = useQuery().get( 'authorizationUrl' ) ?? undefined;
+ const isAuthorizationRejected = useQuery().get( 'success' ) === 'false';
+ const applicationPassword = useQuery().get( 'password' );
+ const username = useQuery().get( 'user_login' );
+ const isAuthorizationSuccessful = !! ( applicationPassword && username );
+ const {
+ mutate: storeApplicationPasswordMutation,
+ isSuccess: isStoreApplicationPasswordSuccess,
+ isError: isStoreApplicationPasswordError,
+ isPending: isStoreApplicationPasswordPending,
+ } = useStoreApplicationPassword( siteSlug as string );
+ const hasStoreApplicationPasswordResponse =
+ isStoreApplicationPasswordSuccess || isStoreApplicationPasswordError;
+ const isLoading =
+ isAuthorizationSuccessful &&
+ ( ! hasStoreApplicationPasswordResponse || isStoreApplicationPasswordPending );
+
+ useEffect( () => {
+ if ( ! isAuthorizationSuccessful || ! siteSlug ) {
+ return;
+ }
+
+ storeApplicationPasswordMutation( {
+ password: applicationPassword,
+ username,
+ source,
+ } );
+ }, [ isAuthorizationSuccessful, siteSlug, useStoreApplicationPassword ] );
+
+ useEffect( () => {
+ if ( isStoreApplicationPasswordSuccess ) {
+ navigation?.submit?.( { action: 'migration-started' } );
+ }
+ }, [ isStoreApplicationPasswordSuccess, navigation ] );
+
+ const navigateToFallbackCredentials = () => {
+ navigation?.submit?.( { action: 'fallback-credentials', authorizationUrl } );
+ };
+
+ const startAuthorization = () => {
+ navigation?.submit?.( { action: 'authorization', authorizationUrl } );
+ };
+
+ const contactMe = () => {
+ navigation?.submit?.( { action: 'contact-me' } );
+ };
+
+ let notice = undefined;
+ if ( isStoreApplicationPasswordError ) {
+ notice = (
+
+ { translate( "We couldn't complete the authorization." ) }
+
+
+ );
+ } else if ( isAuthorizationRejected ) {
+ notice = (
+
+ { translate(
+ "We can't start your migration without your authorization. Please authorize WordPress.com in your WP Admin or share your credentials."
+ ) }
+
+ );
+ }
+
+ const sourceDomain = new URL( source ).host;
+
+ // translators: %(sourceDomain)s is the source domain that is being migrated.
+ const subHeaderText = translate(
+ "We're ready to migrate {{strong}}%(sourceDomain)s{{/strong}} to WordPress.com. To make sure everything goes smoothly, we need you to authorize us for access in your WordPress admin.",
+ {
+ args: {
+ sourceDomain,
+ },
+ components: {
+ strong: ,
+ },
+ }
+ );
+
+ const formattedHeader = ! isLoading ? (
+
+ ) : undefined;
+
+ return (
+ <>
+
+
+ ) : (
+
+
+
+ )
+ }
+ recordTracksEvent={ recordTracksEvent }
+ />
+ >
+ );
+};
+
+export default SiteMigrationApplicationPasswordsAuthorization;
diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/style.scss b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/style.scss
new file mode 100644
index 00000000000000..edf1400ff39bdf
--- /dev/null
+++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/style.scss
@@ -0,0 +1,56 @@
+@import "@wordpress/base-styles/breakpoints";
+@import "@wordpress/base-styles/mixins";
+
+.site-migration-application-password-authorization {
+ .site-migration-application-password-authorization__authorization {
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ align-items: center;
+ }
+ .site-migration-application-password-authorization__benefits-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 1em;
+ h3 {
+ font-size: $font-title-small;
+ margin-bottom: 0.75em;
+ }
+ }
+ .site-migration-application-password-authorization__benefits {
+ padding: 2em;
+ background-color: var(--color-border-shadow);
+ border-radius: 4px;
+ width: 100%;
+ @include break-small {
+ width: 350px;
+ }
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ }
+ .site-migration-application-password-authorization__benefits-item {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5em;
+ font-size: $font-body-small;
+ .site-migration-application-password-authorization__benefits-item-icon {
+ width: 42px;
+ height: 42px;
+ background-color: var(--studio-wordpress-blue-10);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ svg {
+ fill: var(--studio-wordpress-blue-50);
+ }
+ }
+ }
+ .site-migration-application-password-authorization__contact-me-button {
+ color: var(--color-text-inverted) !important;
+ margin-left: 0.25em;
+ }
+}
diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/test/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/test/index.tsx
new file mode 100644
index 00000000000000..82291d296fe0b3
--- /dev/null
+++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-application-password-authorization/test/index.tsx
@@ -0,0 +1,122 @@
+/**
+ * @jest-environment jsdom
+ */
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import nock from 'nock';
+import React from 'react';
+import wpcomRequest from 'wpcom-proxy-request';
+import { useSiteSlugParam } from 'calypso/landing/stepper/hooks/use-site-slug-param';
+import SiteMigrationApplicationPasswordAuthorization from '..';
+import { StepProps } from '../../../types';
+import { RenderStepOptions, mockStepProps, renderStep } from '../../test/helpers';
+
+const render = ( props?: Partial< StepProps >, renderOptions?: RenderStepOptions ) => {
+ const combinedProps = { ...mockStepProps( props ) };
+ return renderStep(
+ ,
+ renderOptions
+ );
+};
+
+jest.mock( 'wpcom-proxy-request', () => jest.fn() );
+jest.mock( 'calypso/landing/stepper/hooks/use-site-slug-param' );
+
+const { getByRole, getByTestId, findByText } = screen;
+
+const authorizationUrl = 'https://example.com/authorization.php?appId=123&appName=My%20App';
+const encodedAuthorizationUrl = encodeURIComponent(
+ 'https://example.com/authorization.php?appId=123&appName=My%20App'
+);
+const sourceUrl = encodeURIComponent( 'https://example.com' );
+
+describe( 'SiteMigrationApplicationPasswordAuthorization', () => {
+ beforeAll( () => nock.disableNetConnect() );
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ ( useSiteSlugParam as jest.Mock ).mockReturnValue( 'site-url.wordpress.com' );
+ } );
+
+ it( 'renders the loading state when the authorization is successful and the application password is not yet stored', async () => {
+ ( wpcomRequest as jest.Mock ).mockImplementation( () => new Promise( () => {} ) );
+
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }&user_login=test&password=test`;
+ render( {}, { initialEntry } );
+
+ await waitFor( () => {
+ expect( getByTestId( 'loading-ellipsis' ) ).toBeVisible();
+ } );
+ } );
+
+ it( 'redirects to the next step when the application password is stored', async () => {
+ const submit = jest.fn();
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }&user_login=test&password=test`;
+
+ ( wpcomRequest as jest.Mock ).mockResolvedValue( {
+ status: 200,
+ body: {
+ data: { status: 200 },
+ },
+ } );
+
+ render( { navigation: { submit } }, { initialEntry } );
+ await waitFor( () => {
+ expect( submit ).toHaveBeenCalledWith( { action: 'migration-started' } );
+ } );
+ } );
+
+ it( 'renders the error state when the application password fails to be stored', async () => {
+ ( wpcomRequest as jest.Mock ).mockRejectedValue( {
+ status: 500,
+ body: {
+ code: 'no_ticket_found',
+ message: 'No migration ticket found.',
+ data: { status: 500 },
+ },
+ } );
+
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }&user_login=test&password=test`;
+ render( {}, { initialEntry } );
+
+ const errorMessage = await findByText( /We couldn't complete the authorization./ );
+
+ await waitFor( () => {
+ expect( errorMessage ).toBeVisible();
+ } );
+ } );
+
+ it( 'renders the alert notice when the authorization is rejected', async () => {
+ const submit = jest.fn();
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }&success=false`;
+ render( { navigation: { submit } }, { initialEntry } );
+
+ const errorMessage = await findByText(
+ /We can't start your migration without your authorization. Please authorize WordPress.com in your WP Admin or share your credentials./
+ );
+
+ await waitFor( () => {
+ expect( errorMessage ).toBeVisible();
+ } );
+ } );
+
+ it( 'the authorization button redirects to the source URL when clicked', async () => {
+ const submit = jest.fn();
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }`;
+ render( { navigation: { submit } }, { initialEntry } );
+
+ await userEvent.click( getByRole( 'button', { name: 'Authorize access' } ) );
+
+ expect( submit ).toHaveBeenCalledWith( { action: 'authorization', authorizationUrl } );
+ } );
+
+ it( 'the share credentials button redirects to the fallback credentials step', async () => {
+ const submit = jest.fn();
+ const initialEntry = `/step?from=${ sourceUrl }&authorizationUrl=${ encodedAuthorizationUrl }`;
+ render( { navigation: { submit } }, { initialEntry } );
+
+ await userEvent.click( getByRole( 'button', { name: 'Share credentials instead' } ) );
+
+ expect( submit ).toHaveBeenCalledWith( { action: 'fallback-credentials', authorizationUrl } );
+ } );
+} );
diff --git a/client/landing/stepper/declarative-flow/internals/steps.tsx b/client/landing/stepper/declarative-flow/internals/steps.tsx
index e8a758dbf43b52..7a88772d87d1c1 100644
--- a/client/landing/stepper/declarative-flow/internals/steps.tsx
+++ b/client/landing/stepper/declarative-flow/internals/steps.tsx
@@ -252,10 +252,10 @@ export const STEPS = {
asyncComponent: () => import( './steps-repository/site-migration-fallback-credentials' ),
},
- SITE_MIGRATION_APPLICATION_PASSWORDS_APPROVAL: {
- slug: 'application-passwords-approval',
+ SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION: {
+ slug: 'site-migration-application-password-authorization',
asyncComponent: () =>
- import( './steps-repository/site-migration-application-passwords-approval' ),
+ import( './steps-repository/site-migration-application-password-authorization' ),
},
SITE_MIGRATION_IDENTIFY: {
diff --git a/client/landing/stepper/declarative-flow/site-migration-flow.ts b/client/landing/stepper/declarative-flow/site-migration-flow.ts
index 2da0b24aba48a0..2763f32b059706 100644
--- a/client/landing/stepper/declarative-flow/site-migration-flow.ts
+++ b/client/landing/stepper/declarative-flow/site-migration-flow.ts
@@ -52,11 +52,11 @@ const siteMigration: Flow = {
STEPS.ERROR,
STEPS.SITE_MIGRATION_ASSISTED_MIGRATION,
STEPS.SITE_MIGRATION_SOURCE_URL,
- STEPS.SITE_MIGRATION_APPLICATION_PASSWORDS_APPROVAL,
STEPS.SITE_MIGRATION_FALLBACK_CREDENTIALS,
STEPS.SITE_MIGRATION_CREDENTIALS,
STEPS.SITE_MIGRATION_ALREADY_WPCOM,
STEPS.SITE_MIGRATION_OTHER_PLATFORM_DETECTED_IMPORT,
+ STEPS.SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION,
];
const hostedVariantSteps = isHostedSiteMigrationFlow( this.variantSlug ?? FLOW_NAME )
@@ -466,7 +466,7 @@ const siteMigration: Flow = {
siteSlug,
authorizationUrl,
},
- STEPS.SITE_MIGRATION_APPLICATION_PASSWORDS_APPROVAL.slug
+ STEPS.SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION.slug
)
);
}
@@ -553,6 +553,40 @@ const siteMigration: Flow = {
)
);
}
+
+ case STEPS.SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION.slug: {
+ const { action, authorizationUrl } = providedDependencies as {
+ action: string;
+ authorizationUrl: string;
+ };
+
+ if ( action === 'authorization' ) {
+ const currentUrl = window.location.href;
+ const successUrl = encodeURIComponent( currentUrl );
+ window.location.href = authorizationUrl + `&success_url=${ successUrl }`;
+ return;
+ }
+
+ if ( action === 'fallback-credentials' ) {
+ return navigate(
+ addQueryArgs(
+ {
+ siteId,
+ siteSlug,
+ authorizationUrl,
+ backTo: STEPS.SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION.slug,
+ from: fromQueryParam,
+ },
+ STEPS.SITE_MIGRATION_FALLBACK_CREDENTIALS.slug
+ )
+ );
+ }
+
+ return navigate( STEPS.SITE_MIGRATION_STARTED.slug, {
+ siteId,
+ siteSlug,
+ } );
+ }
}
}
@@ -617,6 +651,10 @@ const siteMigration: Flow = {
case STEPS.SITE_MIGRATION_FALLBACK_CREDENTIALS.slug: {
return navigate( `${ STEPS.SITE_MIGRATION_CREDENTIALS.slug }?${ urlQueryParams }` );
}
+
+ case STEPS.SITE_MIGRATION_APPLICATION_PASSWORD_AUTHORIZATION.slug: {
+ return navigate( `${ STEPS.SITE_MIGRATION_CREDENTIALS.slug }?${ urlQueryParams }` );
+ }
}
};
diff --git a/packages/onboarding/src/step-container/index.tsx b/packages/onboarding/src/step-container/index.tsx
index 36456fcdcd0a07..f6b4cc634a26cb 100644
--- a/packages/onboarding/src/step-container/index.tsx
+++ b/packages/onboarding/src/step-container/index.tsx
@@ -21,6 +21,7 @@ interface Props {
backLabelText?: TranslateResult;
skipLabelText?: TranslateResult;
nextLabelText?: TranslateResult;
+ notice?: ReactElement;
formattedHeader?: ReactElement;
hideFormattedHeader?: boolean;
headerImageUrl?: string;
@@ -61,6 +62,7 @@ const StepContainer: React.FC< Props > = ( {
skipHeadingText,
hideNext = true,
nextLabelText,
+ notice,
formattedHeader,
headerImageUrl,
headerButton,
@@ -194,6 +196,7 @@ const StepContainer: React.FC< Props > = ( {
{ ! hideFormattedHeader && (
+ { notice }
{ formattedHeader }
{ headerImageUrl && (