diff --git a/assets/src/Components/CloudLibrary/common.js b/assets/src/Components/CloudLibrary/common.js index 139b861d..63bd0fbe 100644 --- a/assets/src/Components/CloudLibrary/common.js +++ b/assets/src/Components/CloudLibrary/common.js @@ -4,7 +4,7 @@ import apiFetch from '@wordpress/api-fetch'; import { stringifyUrl } from 'query-string'; import { v4 as uuidv4 } from 'uuid'; import { models, loadPromise } from '@wordpress/api'; -import { cleanTemplateContent } from '../../../../shared/utils'; +import { cleanTemplateContent, hasTemplatesCloudAccess } from '../../../../shared/utils'; export const changeOption = ( option, value ) => { const model = new models.Settings( { @@ -80,6 +80,31 @@ export const fetchLibrary = async ( } }; +/** + * Validate a license key against the licensing API. + * + * @param {string} licenseKey The license key to check. + * @return {Promise} `{ success, status, license, message }`. + */ +export const licenseCheck = async ( licenseKey ) => { + const { success, templates: license, message } = + ( await fetchLibrary( false, { + license_id: licenseKey, + license_check: 1, + } ) ) || {}; + + if ( ! success || ! hasTemplatesCloudAccess( license ) ) { + return { + success: false, + status: 'invalid', + license, + message, + }; + } + + return { success: true, status: 'valid', license }; +}; + export const updateTemplate = async ( id, name ) => { const url = stringifyUrl( { url: tiobDash.endpoint + 'templates/' + id, diff --git a/assets/src/Components/Header.js b/assets/src/Components/Header.js index 0abfa209..54cc8cff 100644 --- a/assets/src/Components/Header.js +++ b/assets/src/Components/Header.js @@ -17,7 +17,6 @@ const TabNavigation = ( { setCurrentTab, currentTab, isFetching, - license, } ) => { const buttons = {}; @@ -33,7 +32,6 @@ const TabNavigation = ( { const [ isSyncing, setSyncing ] = useState( false ); const { isLicenseOpen, setLicenseOpen } = useContext( LicensePanelContext ); - const isValid = 'valid' === license?.valid || 'valid' === license?.license; const sync = () => { setSyncing( true ); @@ -158,7 +156,6 @@ const Header = ( { cancelOnboarding, setCurrentTab, currentTab, - license, } ) => { return (
@@ -169,7 +166,6 @@ const Header = ( {
@@ -204,13 +200,11 @@ export default compose( getOnboardingStatus, getCurrentTab, getFetching, - getLicense, } = select( 'neve-onboarding' ); return { isOnboarding: getOnboardingStatus(), currentTab: getCurrentTab(), isFetching: getFetching(), - license: getLicense(), }; } ) )( Header ); diff --git a/assets/src/Components/License.js b/assets/src/Components/License.js index c8b2acb9..3c147100 100644 --- a/assets/src/Components/License.js +++ b/assets/src/Components/License.js @@ -1,3 +1,4 @@ +/* global tiobDash */ import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { compose } from '@wordpress/compose'; @@ -11,7 +12,11 @@ import { Icon, } from '@wordpress/components'; import { models } from '@wordpress/api'; -import { fetchLibrary as licenseCheck } from './CloudLibrary/common'; +import { licenseCheck } from './CloudLibrary/common'; +import { + hasTemplatesCloudAccess, + isLicenseValid, +} from '../../../shared/utils'; const License = ( { setLicense, license } ) => { const keyValue = license?.key !== '' && license?.key !== 'free' ? license?.key : ''; @@ -19,8 +24,13 @@ const License = ( { setLicense, license } ) => { const [ loading, setLoading ] = useState( false ); const [ resultMsg, setResultMsg ] = useState( {} ); + const isStored = isLicenseValid( license ); + const hasAccess = hasTemplatesCloudAccess( license ); - const isValid = 'valid' === license?.valid || 'valid' === license?.license; + const notEntitledMsg = __( + 'Your license is valid, but Templates Cloud is not included in your plan.', + 'templates-patterns-collection' + ); const delay = (time) => new Promise(resolve => setTimeout(resolve, time)); @@ -61,10 +71,10 @@ const License = ( { setLicense, license } ) => { return; } - const { success, templates } = await licenseCheck( false, { license_id: data.key, license_check: 1 } ); + const { success, license: licenseData } = await licenseCheck( data.key ); if ( success ) { - setLicense( templates ); + setLicense( licenseData ); await updateKey( data.key ); } else { createNotice( @@ -77,7 +87,7 @@ const License = ( { setLicense, license } ) => { const toggleLicense = ( event ) => { onSaveLicense( { - action: isValid ? 'deactivate' : 'activate', + action: isStored ? 'deactivate' : 'activate', key: licenseKey, } ); @@ -85,9 +95,9 @@ const License = ( { setLicense, license } ) => { }; const futureDate = new Date( new Date().setFullYear( new Date().getFullYear() + 10 ) ); - const expiration = isValid && license?.expires === 'lifetime' ? futureDate.toDateString() : new Date( license.expires ).toDateString(); + const expiration = isStored && license?.expires === 'lifetime' ? futureDate.toDateString() : new Date( license.expires ).toDateString(); - const licenseStatusMsg = isValid ? ( + const licenseStatusMsg = hasAccess ? ( <> { 'Verified - Expires at'} { expiration } @@ -97,6 +107,18 @@ const License = ( { setLicense, license } ) => { '' ); + const renderEntitlementMsg = + isStored && ! hasAccess ? ( + + { notEntitledMsg }{ ' ' } + + { __( 'Upgrade to PRO', 'templates-patterns-collection' ) } + + + ) : ( + '' + ); + const renderResultMsg = Object.keys( resultMsg ).length > 0 ? ( @@ -110,11 +132,11 @@ const License = ( { setLicense, license } ) => { <>
{ type="submit" variant="primary" > - { isValid + { isStored ? __( 'Deactivate', 'templates-patterns-collection' ) : __( 'Activate', 'templates-patterns-collection' ) }
{ licenseStatusMsg }
+ { renderEntitlementMsg } { renderResultMsg } ); diff --git a/assets/src/store/selectors.js b/assets/src/store/selectors.js index bfaf3a92..afc12120 100644 --- a/assets/src/store/selectors.js +++ b/assets/src/store/selectors.js @@ -1,3 +1,5 @@ +import { isTemplatesCloudTier } from '../../../shared/utils'; + export default { getSites: ( state ) => state.sites, getMigrationData: ( state ) => state.migrationData, @@ -15,28 +17,10 @@ export default { getTemplateModal: ( state ) => state.templateModal, getSearchQuery: ( state ) => state.searchQuery, getUserStatus: ( state ) => { - const acceptedTiers = [ - 6, - 17, - 23, - 5, - 9, - 14, - 20, - 1, - 7, - 12, - 18, - 3, - 8, - 13, - 19, - ]; - return ( state.license && state.license.tier && - acceptedTiers.includes( state.license.tier ) + isTemplatesCloudTier( state.license.tier ) ); }, getLicense: ( state ) => { diff --git a/e2e-tests/config/mocks.js b/e2e-tests/config/mocks.js index 77000f62..97e59640 100644 --- a/e2e-tests/config/mocks.js +++ b/e2e-tests/config/mocks.js @@ -108,6 +108,23 @@ export const MOCK_TEMPLATES = [ }, ]; +// License payloads returned for a browser-side license check (license_check=1). +// AGENCY_LICENSE matches the license the mu-plugin seeds; PERSONAL_LICENSE is a +// valid license on a tier that does not include Templates Cloud. +export const AGENCY_LICENSE = { + license: 'valid', + key: 'tpc-e2e-key', + tier: 3, + expires: 'lifetime', +}; + +export const PERSONAL_LICENSE = { + license: 'valid', + key: 'tpc-e2e-personal-key', + tier: 2, + expires: 'lifetime', +}; + // apiFetch sends credentialed requests, so the mocked cross-origin responses // must echo the exact origin (a wildcard is rejected by the browser). const corsHeaders = (route) => ({ @@ -142,10 +159,20 @@ export async function mockOnboardingRoutes(page) { export const TEMPLATE_CONTENT_TEXT = 'TPC E2E imported content'; export const TEMPLATE_CONTENT = `

${TEMPLATE_CONTENT_TEXT}

`; -export async function mockTemplatesCloudRoutes(page, templates = MOCK_TEMPLATES) { +export async function mockTemplatesCloudRoutes( + page, + templates = MOCK_TEMPLATES, + license = AGENCY_LICENSE, +) { await page.route('**/api.themeisle.com/templates-cloud/**', (route) => { const url = new URL(route.request().url()); + // license_check=1 returns the license data instead of templates - this + // is what the license panel activates against. + if (url.searchParams.get('license_check')) { + return fulfillJson(route, license); + } + // GET templates/{id}/import returns the template's block content. if (url.pathname.endsWith('/import')) { return fulfillJson(route, { diff --git a/e2e-tests/mu-plugins/tpc-e2e.php b/e2e-tests/mu-plugins/tpc-e2e.php index 1fb24b51..decccd87 100644 --- a/e2e-tests/mu-plugins/tpc-e2e.php +++ b/e2e-tests/mu-plugins/tpc-e2e.php @@ -62,7 +62,7 @@ function () { 'args' => array( 'mode' => array( 'type' => 'string', - 'enum' => array( '', 'down', 'invalid' ), + 'enum' => array( '', 'down', 'invalid', 'personal' ), ), ), 'callback' => function ( $request ) { @@ -110,9 +110,11 @@ function ( $preempt, $args, $url ) { $is_themeisle_api = false !== strpos( $url, 'api.themeisle.com' ) || false !== strpos( $url, 'ai.themeisle.com' ); // Scenario modes (Otter pattern), set per spec via tpc-e2e/v1/api-mode: - // 'down' => ThemeIsle APIs unreachable. - // 'invalid' => license check rejects the key (a code/message body is - // what License::check_license treats as invalid). + // 'down' => ThemeIsle APIs unreachable. + // 'invalid' => license check rejects the key (a code/message body is + // what License::check_license treats as invalid). + // 'personal' => the key is accepted, on a tier that does not include + // Templates Cloud. $mode = get_option( 'tpc_e2e_api_mode', '' ); if ( 'down' === $mode && $is_themeisle_api ) { @@ -130,6 +132,21 @@ function ( $preempt, $args, $url ) { ); } + // A valid license on a tier that does not include Templates Cloud. + // Must match PERSONAL_LICENSE in config/mocks.js. + if ( 'personal' === $mode && false !== strpos( $url, 'api.themeisle.com/templates-cloud/' ) ) { + return tpc_e2e_response( + wp_json_encode( + array( + 'license' => 'valid', + 'key' => 'tpc-e2e-personal-key', + 'tier' => 2, + 'expires' => 'lifetime', + ) + ) + ); + } + // Starter sites feed (Sites_Listing::API). if ( false !== strpos( $url, 'api.themeisle.com/sites/wp-json/demosites-api/sites' ) ) { return tpc_e2e_response( file_get_contents( TPC_E2E_FIXTURES_DIR . '/sites.json' ) ); diff --git a/e2e-tests/specs/license.spec.js b/e2e-tests/specs/license.spec.js new file mode 100644 index 00000000..1904cff0 --- /dev/null +++ b/e2e-tests/specs/license.spec.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { + AGENCY_LICENSE, + MOCK_TEMPLATES, + PERSONAL_LICENSE, + mockTemplatesCloudRoutes, +} from '../config/mocks'; + +const setApiMode = (requestUtils, mode) => + requestUtils.rest({ + path: '/tpc-e2e/v1/api-mode', + method: 'POST', + data: { mode }, + }); + +const setLegacyTc = (requestUtils, enabled) => + requestUtils.rest({ + path: '/tpc-e2e/v1/legacy-tc', + method: 'POST', + data: { enabled }, + }); + +// The license panel lives in the tiob-plugin dashboard settings, which only +// exists for "legacy Templates Cloud" installs. The 'invalid' API mode drops the +// license the mu-plugin seeds, so the activation form is the starting state. +test.describe('License activation', () => { + const SETTINGS_URL = 'admin.php?page=tiob-plugin#settings'; + + test.beforeEach(async ({ requestUtils }) => { + await setLegacyTc(requestUtils, true); + await setApiMode(requestUtils, 'invalid'); + }); + + test.afterEach(async ({ requestUtils }) => { + await setApiMode(requestUtils, ''); + await setLegacyTc(requestUtils, false); + }); + + test('not activating personal plan', async ({ + page, + admin, + }) => { + await mockTemplatesCloudRoutes(page, MOCK_TEMPLATES, PERSONAL_LICENSE); + await admin.visitAdminPage(SETTINGS_URL); + + await page.getByLabel('License Key').fill(PERSONAL_LICENSE.key); + await page.getByRole('button', { name: 'Activate' }).click(); + + await expect( + page.locator('#tpc-app').getByText('Can not activate this license!', { exact: true }) + ).toBeVisible(); + }); + + test('activating an agency plan', async ({ + page, + admin, + }) => { + await mockTemplatesCloudRoutes(page, MOCK_TEMPLATES, AGENCY_LICENSE); + await admin.visitAdminPage(SETTINGS_URL); + + await page.getByLabel('License Key').fill(AGENCY_LICENSE.key); + await page.getByRole('button', { name: 'Activate' }).click(); + + await expect(page.getByRole('button', { name: 'Deactivate' })).toBeVisible(); + }); +}); + +// The 'personal' API mode is the state inherit_license_from_neve() stores for a +// Neve Personal key: the license is valid, its tier is not eligible. The panel +// has to treat it as a stored license and still say why Templates Cloud is locked. +test.describe('A stored license on an ineligible plan', () => { + const SETTINGS_URL = 'admin.php?page=tiob-plugin#settings'; + + test.beforeEach(async ({ requestUtils, admin }) => { + await setLegacyTc(requestUtils, true); + await setApiMode(requestUtils, 'personal'); + await admin.visitAdminPage(SETTINGS_URL); + }); + + test.afterEach(async ({ requestUtils }) => { + await setApiMode(requestUtils, ''); + await setLegacyTc(requestUtils, false); + }); + + test('can be deactivated', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Deactivate' })).toBeVisible(); + }); + + test('is masked in the license field', async ({ page }) => { + await expect(page.getByLabel('License Key')).toHaveValue( + '******************************l-key', + ); + }); + + test('is not reported as verified', async ({ page }) => { + await expect(page.getByText('Verified - Expires at')).toBeHidden(); + }); + + test('explains that the plan does not include Templates Cloud', async ({ page }) => { + await expect( + page.getByText( + 'Your license is valid, but Templates Cloud is not included in your plan.', + ), + ).toBeVisible(); + }); +}); diff --git a/onboarding/src/store/selectors.js b/onboarding/src/store/selectors.js index 0808d2da..9bce46e1 100644 --- a/onboarding/src/store/selectors.js +++ b/onboarding/src/store/selectors.js @@ -1,3 +1,5 @@ +import { isTemplatesCloudTier } from '../../../shared/utils'; + export default { getThemeAction: ( state ) => state.themeAction, getCurrentStep: ( state ) => state.onboardingStep, @@ -11,14 +13,10 @@ export default { getError: ( state ) => state.error, getPluginOptions: ( state ) => state.pluginOptions, getUserStatus: ( state ) => { - const acceptedTiers = [ - 6, 17, 23, 5, 9, 14, 20, 1, 7, 12, 18, 3, 8, 13, 19, - ]; - return ( state.license && state.license.tier && - acceptedTiers.includes( state.license.tier ) + isTemplatesCloudTier( state.license.tier ) ); }, getUserCustomSettings: ( state ) => state.userCustomSettings, diff --git a/shared/utils.js b/shared/utils.js index e044fe41..d14da652 100644 --- a/shared/utils.js +++ b/shared/utils.js @@ -23,3 +23,54 @@ export const cleanTemplateContent = ( templateContent, cleanFunc ) => { loopElementorElement( item, cleanFunc ); } ); }; + +/** + * License tiers entitled to Templates Cloud. + */ +export const TEMPLATES_CLOUD_TIERS = [ + 6, + 17, + 23, + 5, + 9, + 14, + 20, + 1, + 7, + 12, + 18, + 3, + 8, + 13, + 19, +]; + +/** + * Check if a license tier is entitled to Templates Cloud. + * + * @param {number|string} tier The tier key returned by the licensing API. + * @return {boolean} Whether the tier includes Templates Cloud. + */ +export const isTemplatesCloudTier = ( tier ) => { + const parsed = Number( tier ); + + return Number.isInteger( parsed ) && TEMPLATES_CLOUD_TIERS.includes( parsed ); +}; + +/** + * Check if the license itself is valid, regardless of its tier. + * + * @param {Object} license The license data. + * @return {boolean} Whether the license is valid. + */ +export const isLicenseValid = ( license ) => + 'valid' === license?.valid || 'valid' === license?.license; + +/** + * Check if the license grants access to Templates Cloud. + * + * @param {Object} license The license data. + * @return {boolean} Whether the license is valid and includes Templates Cloud. + */ +export const hasTemplatesCloudAccess = ( license ) => + isLicenseValid( license ) && isTemplatesCloudTier( license?.tier );