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 ) => ( +
+
+ +
+ { benefit } +
+ ) ) } +
+ ); +}; + +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 && (