diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ec60018..9985810 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,9 +10,16 @@ on: - develop jobs: + build: + uses: mailchimp/wordpress/.github/workflows/build-release-zip.yml@develop + cypress: + needs: build name: ${{ matrix.core.name }} runs-on: ubuntu-latest + env: + CYPRESS_MAILCHIMP_USERNAME: ${{ secrets.MAILCHIMP_USERNAME }} + CYPRESS_MAILCHIMP_PASSWORD: ${{ secrets.MAILCHIMP_PASSWORD }} strategy: matrix: core: @@ -24,6 +31,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Download build zip + uses: actions/download-artifact@v4 + with: + name: ${{ github.event.repository.name }} + path: ${{ github.event.repository.name }} + + - name: Display structure of downloaded files + run: ls -R + working-directory: ${{ github.event.repository.name }} + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' @@ -31,8 +48,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Set the core version - run: ./tests/bin/set-core-version.js ${{ matrix.core.version }} + - name: Set the core version and plugins config + run: ./tests/bin/set-core-version.js --core=${{ matrix.core.version }} --plugins=./${{ github.event.repository.name }} - name: Set up WP environment run: npm run env:start @@ -41,6 +58,7 @@ jobs: run: npm run cypress:run - name: Update summary + if: always() run: | npx mochawesome-merge ./tests/cypress/reports/*.json -o tests/cypress/reports/mochawesome.json rm -rf ./tests/cypress/reports/mochawesome-*.json diff --git a/README.md b/README.md index 1b3cdd2..d93147a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > Add a Mailchimp signup form block, widget, or shortcode to your WordPress site. -[![Support Level](https://img.shields.io/badge/support-active-green.svg?label=Support)](#support-level) [![GPL-2.0-or-later License](https://img.shields.io/github/license/mailchimp/wordpress?label=License)](https://github.com/mailchimp/wordpress/blob/develop/LICENSE.md) ![WordPress Plugin Version](https://img.shields.io/wordpress/plugin/v/mailchimp?label=Version) ![WordPress Minimum](https://img.shields.io/wordpress/plugin/wp-version/mailchimp?label=WordPress%20minimum) ![PHP Minimum](https://img.shields.io/wordpress/plugin/required-php/mailchimp?label=PHP%20minimum) ![WordPress Tested Up To](https://img.shields.io/wordpress/plugin/tested/mailchimp?label=WordPress) [![E2E Cypress Tests](https://github.com/mailchimp/wordpress/actions/workflows/e2e.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/e2e.yml) [![PHP Compatibility](https://github.com/mailchimp/wordpress/actions/workflows/php-compat.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/php-compat.yml) [![PHP Linting](https://github.com/mailchimp/wordpress/actions/workflows/phpcs.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/phpcs.yml) [![JS Linting](https://github.com/mailchimp/wordpress/actions/workflows/eslint.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/eslint.yml) +[![Support Level](https://img.shields.io/badge/support-active-green.svg?label=Support)](#support-level) [![GPL-2.0-or-later License](https://img.shields.io/github/license/mailchimp/wordpress?label=License)](https://github.com/mailchimp/wordpress/blob/develop/LICENSE.md) ![WordPress Plugin Version](https://img.shields.io/wordpress/plugin/v/mailchimp?label=Version) ![WordPress Minimum](https://img.shields.io/wordpress/plugin/wp-version/mailchimp?label=WordPress%20minimum) ![PHP Minimum](https://img.shields.io/wordpress/plugin/required-php/mailchimp?label=PHP%20minimum) ![WordPress Tested Up To](https://img.shields.io/wordpress/plugin/tested/mailchimp?label=WordPress) [![E2E Cypress Tests](https://github.com/mailchimp/wordpress/actions/workflows/e2e.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/e2e.yml) [![PHP Compatibility](https://github.com/mailchimp/wordpress/actions/workflows/php-compat.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/php-compat.yml) [![PHP Linting](https://github.com/mailchimp/wordpress/actions/workflows/phpcs.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/phpcs.yml) [![JS Linting](https://github.com/mailchimp/wordpress/actions/workflows/eslint.yml/badge.svg)](https://github.com/mailchimp/wordpress/actions/workflows/eslint.yml) ## Overview @@ -18,6 +18,21 @@ After installation, you’ll log in with your API key, select your Mailchimp lis WordPress.com compatibility is limited to Business tier users only. [How to add a signup form if you have a WordPress.com site](https://mailchimp.com/help/ways-to-add-a-signup-form-in-wordpress/). +## Access Token Encryption + +Starting in version 1.6.0, authentication has changed to use OAuth. As part of this process, we retrieve an access token that can be used to make API requests. To provide a high-level of security, this access token is encrypted before being stored in the WordPress database. In order to ensure this access token can be decrypted when used, the plugin relies on certain security constants that should remain unchanged. + +With no additional configuration, we use the standard `LOGGED_IN_KEY` and `LOGGED_IN_SALT` constants that are normally set in your site's `wp-config.php` file. Some sites make use of security plugins that rotate these constants on a periodic basis. When this happens, we won't be able to decrypt the access token and you’ll need to reconnect your Mailchimp account to generate a new access token. + +To prevent such issues, it is recommended to define two additional constants in your site's `wp-config.php` file: `MAILCHIMP_SF_ENCRYPTION_KEY` and `MAILCHIMP_SF_ENCRYPTION_SALT`. These constants should consist of a combination of characters, preferably at least 32 characters long. Once set, these values should not be changed. For strong values, you can copy some of the values from [here](https://api.wordpress.org/secret-key/1.1/salt/) and use them. You'll end up with additional code like the following in your `wp-config.php` file: + +```php +define( 'MAILCHIMP_SF_ENCRYPTION_KEY', 'put your unique phrase here' ); +define( 'MAILCHIMP_SF_ENCRYPTION_SALT', 'put your unique phrase here' ); +``` + +If these constants are added after you've already authenticated with Mailchimp, you will need to reconnect your account. To avoid this, you can copy the values from `LOGGED_IN_KEY` and `LOGGED_IN_SALT` (if they exist) to `MAILCHIMP_SF_ENCRYPTION_KEY` and `MAILCHIMP_SF_ENCRYPTION_SALT` respectively. + ## Installation This section describes how to install the plugin and get started using it. @@ -57,7 +72,7 @@ If you are upgrading to version 1.6.0, you will need to updated any references t If you have a custom-coded sidebar or something that prevent enabling widgets through the WordPress GUI, complete these steps instead. -WordPress v2.8 or higher: +WordPress v2.8 or higher: ` [mailchimpsf_form] ` If you are adding it inside a php code block, add this: @@ -84,6 +99,23 @@ You need to ensure that the fields are enabled both in your Mailchimp account (A Internationalization (i18n) is available on GlotPress at [https://translate.wordpress.org/projects/wp-plugins/mailchimp/](https://translate.wordpress.org/projects/wp-plugins/mailchimp/). Any assistance [translating the plugin](https://translate.wordpress.org/projects/wp-plugins/mailchimp/) is greatly appreciated! +## E2E tests +The `tests` directory contains end-to-end tests for the project, utilizing Cypress to run tests in an environment created using wp-env. + +### Pre-requisites +- Node.js v20 +- Docker +- Create an account in [Mailchimp](https://mailchimp.com/) + +### Run E2E tests in local +1. Run `npm install`. +2. Run `npm run build`. +3. Run `npm run env:start`. +4. Set Mailchimp credentials as environment variables: + - run `export CYPRESS_MAILCHIMP_USERNAME="your mailchimp username"` + - run `export CYPRESS_MAILCHIMP_PASSWORD="your mailchimp password"` +5. Run `npm run cypress:run`. You can also run `npm run cypress:open` to run tests in UI mode. + ## Support Level **Active:** Mailchimp is actively working on this, and we expect to continue work for the foreseeable future including keeping tested up to the most recent version of WordPress. Bug reports, feature requests, questions, and pull requests are welcome. diff --git a/css/admin.css b/css/admin.css index 1a8e1cc..cfb42fc 100644 --- a/css/admin.css +++ b/css/admin.css @@ -254,3 +254,15 @@ th.mailchimp-connect { #mc-message { margin-top: 26px; } + +/** + * Mailchimp OAuth CSS + */ +.mailchimp-sf-oauth-section .oauth-error { + display: block; + color: #db3a1b; +} + +.mailchimp-sf-oauth-connect-wrapper { + display: flex; +} diff --git a/includes/class-mailchimp-admin.php b/includes/class-mailchimp-admin.php new file mode 100644 index 0000000..26a62c3 --- /dev/null +++ b/includes/class-mailchimp-admin.php @@ -0,0 +1,296 @@ +<?php +/** + * Class responsible for admin side functionalities. + * + * @package Mailchimp + */ + +// Exit if accessed directly. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Class Mailchimp_Admin + * + * @since 1.6.0 + */ +class Mailchimp_Admin { + + /** + * The OAuth base endpoint + * + * @since 1.6.0 + * @var string + */ + private $oauth_url = 'https://woocommerce.mailchimpapp.com'; + + /** + * Initialize the class + */ + public function init() { + add_action( 'admin_notices', array( $this, 'admin_notices' ) ); + add_action( 'wp_ajax_mailchimp_sf_oauth_start', array( $this, 'start_oauth_process' ) ); + add_action( 'wp_ajax_mailchimp_sf_oauth_finish', array( $this, 'finish_oauth_process' ) ); + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_page_scripts' ) ); + } + + /** + * Start the OAuth process. + * + * This function is called via AJAX. + * + * It starts the OAuth process by the calling the OAuth middleware + * server and sending the response to the front-end. + */ + public function start_oauth_process() { + // Validate the nonce and permissions. + if ( + ! current_user_can( 'manage_options' ) || + ! isset( $_POST['nonce'] ) || + ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'mailchimp_sf_oauth_start_nonce' ) + ) { + wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to perform this action.', 'mailchimp' ) ) ); + } + + // Generate a secret and send it to the OAuth server. + $secret = uniqid( 'mailchimp_sf_' ); + $args = array( + 'domain' => site_url(), + 'secret' => $secret, + ); + + $options = array( + 'headers' => array( + 'Content-type' => 'application/json', + ), + 'body' => wp_json_encode( $args ), + ); + + $response = wp_remote_post( $this->oauth_url . '/api/start', $options ); + + // Check for errors. + if ( $response instanceof WP_Error ) { + wp_send_json_error( array( 'message' => $response->get_error_message() ) ); + } + + // Send the response to the front-end. + if ( 201 === $response['response']['code'] && ! empty( $response['body'] ) ) { + set_site_transient( 'mailchimp_sf_oauth_secret', $secret, 60 * 60 ); + $result = json_decode( $response['body'], true ); + wp_send_json_success( $result ); + } else { + if ( ! empty( $response['response'] ) ) { + $response = $response['response']; + } + wp_send_json_error( $response ); + } + } + + /** + * Finish the OAuth process. + * + * This function is called via AJAX. + * + * This function finishes the OAuth process by the sending + * a temporary token back to the OAuth server. + */ + public function finish_oauth_process() { + // Validate the nonce and permissions. + if ( + ! current_user_can( 'manage_options' ) || + ! isset( $_POST['nonce'] ) || + ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'mailchimp_sf_oauth_finish_nonce' ) + ) { + wp_send_json_error( array( 'message' => esc_html__( 'You do not have permission to perform this action.', 'mailchimp' ) ) ); + } + + $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; + $args = array( + 'domain' => site_url(), + 'secret' => get_site_transient( 'mailchimp_sf_oauth_secret' ), + 'token' => $token, + ); + + $options = array( + 'headers' => array( + 'Content-type' => 'application/json', + ), + 'body' => wp_json_encode( $args ), + ); + $response = wp_remote_post( $this->oauth_url . '/api/finish', $options ); + + // Check for errors. + if ( $response instanceof WP_Error ) { + wp_send_json_error( array( 'message' => $response->get_error_message() ) ); + } + + if ( 200 === $response['response']['code'] ) { + // Save the access token and data center. + $result = json_decode( $response['body'], true ); + if ( $result && ! empty( $result['access_token'] ) && ! empty( $result['data_center'] ) ) { + delete_site_transient( 'mailchimp_sf_oauth_secret' ); + + // Verify the token. + $verify = $this->verify_and_save_oauth_token( $result['access_token'], $result['data_center'] ); + + if ( is_wp_error( $verify ) ) { + // If there is an error, send it back to the front-end. + wp_send_json_error( array( 'message' => $verify->get_error_message() ) ); + } + + wp_send_json_success( true ); + } else { + wp_send_json_error( array( 'message' => esc_html__( 'Invalid response from the server.', 'mailchimp' ) ) ); + } + } else { + wp_send_json_error( $response ); + } + } + + /** + * Verify and save the OAuth token. + * + * @param string $access_token The token to verify. + * @param string $data_center The data center to verify. + * @return mixed + */ + public function verify_and_save_oauth_token( $access_token, $data_center ) { + try { + $api = new MailChimp_API( $access_token, $data_center ); + } catch ( Exception $e ) { + $msg = $e->getMessage(); + return new WP_Error( 'mailchimp-sf-invalid-token', $msg ); + } + + $user = $api->get( '' ); + if ( is_wp_error( $user ) ) { + return $user; + } + + // Might as well set this data if we have it already. + $valid_roles = array( 'owner', 'admin', 'manager' ); + if ( isset( $user['role'] ) && in_array( $user['role'], $valid_roles, true ) ) { + $data_encryption = new Mailchimp_Data_Encryption(); + + // Clean up the old data. + delete_option( 'mailchimp_sf_access_token' ); + delete_option( 'mailchimp_sf_auth_error' ); + delete_option( 'mc_datacenter' ); + + update_option( 'mailchimp_sf_access_token', $data_encryption->encrypt( $access_token ) ); + update_option( 'mc_datacenter', sanitize_text_field( $data_center ) ); + update_option( 'mc_user', $this->sanitize_data( $user ) ); + return true; + + } else { + $msg = esc_html__( 'API Key must belong to "Owner", "Admin", or "Manager."', 'mailchimp' ); + return new WP_Error( 'mailchimp-sf-invalid-role', $msg ); + } + } + + /** + * Display admin notices. + * + * @since 1.6.0 + */ + public function admin_notices() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Display a deprecation notice if the user is using an API key to connect with Mailchimp. + if ( get_option( 'mc_api_key', '' ) && ! get_option( 'mailchimp_sf_access_token', '' ) && mailchimp_sf_should_display_form() ) { + ?> + <div class="notice notice-warning is-dismissible"> + <p> + <?php + $message = sprintf( + /* translators: Placeholders: %1$s - <a> tag, %2$s - </a> tag */ + __( 'Heads up! It looks like you\'re using an API key to connect with Mailchimp, which is now deprecated. Please log out and reconnect your Mailchimp account using the new OAuth authentication by clicking the "Connect Account" button on the %1$splugin settings%2$s page.', 'mailchimp' ), + '<a href="' . esc_url( admin_url( 'admin.php?page=mailchimp_sf_options' ) ) . '">', + '</a>' + ); + + echo wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ); + ?> + </p> + </div> + <?php + } + + if ( + ! get_option( 'mailchimp_sf_auth_error', false ) || + ! get_option( 'mailchimp_sf_access_token', '' ) + ) { + return; + } + + // Display a notice if the access token is invalid/revoked. + ?> + <div class="notice notice-error is-dismissible"> + <p> + <?php + $message = sprintf( + /* translators: Placeholders: %1$s - <a> tag, %2$s - </a> tag */ + __( 'Heads up! There may be a problem with your connection to Mailchimp. Please %1$sre-connect%2$s your Mailchimp account to fix the issue.', 'mailchimp' ), + '<a href="' . esc_url( admin_url( 'admin.php?page=mailchimp_sf_options' ) ) . '">', + '</a>' + ); + + echo wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ); + ?> + </p> + </div> + <?php + } + + /** + * Sanitize variables using sanitize_text_field. + * + * Arrays are sanitized recursively, non-scalar values are ignored. + * + * @param string|array $data Data to sanitize. + * @return string|array + */ + public function sanitize_data( $data ) { + if ( is_array( $data ) ) { + return array_map( array( $this, 'sanitize_data' ), $data ); + } else { + return is_scalar( $data ) ? sanitize_text_field( $data ) : $data; + } + } + + /** + * Enqueue scripts/styles for the Mailchimp admin page + * + * @param string $hook_suffix The current admin page. + * @return void + */ + public function enqueue_admin_page_scripts( $hook_suffix ) { + if ( 'toplevel_page_mailchimp_sf_options' !== $hook_suffix ) { + return; + } + + wp_enqueue_style( 'mailchimp_sf_admin_css', MCSF_URL . 'css/admin.css', array( 'wp-jquery-ui-dialog' ), true ); + wp_enqueue_script( 'showMe', MCSF_URL . 'js/hidecss.js', array( 'jquery' ), MCSF_VER, true ); + wp_enqueue_script( 'mailchimp_sf_admin', MCSF_URL . 'js/admin.js', array( 'jquery', 'jquery-ui-dialog' ), MCSF_VER, true ); + + wp_localize_script( + 'mailchimp_sf_admin', + 'mailchimp_sf_admin_params', + array( + 'ajax_url' => esc_url( admin_url( 'admin-ajax.php' ) ), + 'oauth_url' => esc_url( $this->oauth_url ), + 'oauth_start_nonce' => wp_create_nonce( 'mailchimp_sf_oauth_start_nonce' ), + 'oauth_finish_nonce' => wp_create_nonce( 'mailchimp_sf_oauth_finish_nonce' ), + 'oauth_window_name' => esc_html__( 'Mailchimp For WordPress OAuth', 'mailchimp' ), + 'generic_error' => esc_html__( 'An error occurred. Please try again.', 'mailchimp' ), + 'modal_title' => esc_html__( 'Login Popup is blocked!', 'mailchimp' ), + 'modal_button_try_again' => esc_html__( 'Try again', 'mailchimp' ), + 'modal_button_cancel' => esc_html__( 'No, cancel!', 'mailchimp' ), + ) + ); + } +} diff --git a/includes/class-mailchimp-data-encryption.php b/includes/class-mailchimp-data-encryption.php new file mode 100644 index 0000000..85fcb15 --- /dev/null +++ b/includes/class-mailchimp-data-encryption.php @@ -0,0 +1,147 @@ +<?php +/** + * Class responsible for encrypting and decrypting data. + * + * @package Mailchimp + */ + +// Exit if accessed directly. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Class Mailchimp_Data_Encryption + * + * @since 1.6.0 + */ +class Mailchimp_Data_Encryption { + + /** + * Key to use for encryption. + * + * @since 1.6.0 + * @var string + */ + private $key; + + /** + * Salt to use for encryption. + * + * @since 1.6.0 + * @var string + */ + private $salt; + + /** + * Constructor. + * + * @since 1.6.0 + */ + public function __construct() { + $this->key = $this->get_default_key(); + $this->salt = $this->get_default_salt(); + } + + /** + * Encrypts a value. + * + * If a user-based key is set, that is used. Otherwise the default key is used. + * + * @since 1.6.0 + * + * @param string $value Value to encrypt. + * @return string|bool Encrypted value, or false on failure. + */ + public function encrypt( $value ) { + if ( ! extension_loaded( 'openssl' ) ) { + return $value; + } + + $method = 'aes-256-ctr'; + $ivlen = openssl_cipher_iv_length( $method ); + $iv = openssl_random_pseudo_bytes( $ivlen ); + + $raw_value = openssl_encrypt( $value . $this->salt, $method, $this->key, 0, $iv ); + if ( ! $raw_value ) { + return false; + } + + return base64_encode( $iv . $raw_value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + } + + /** + * Decrypts a value. + * + * If a user-based key is set, that is used. Otherwise the default key is used. + * + * @since 1.6.0 + * + * @param string $raw_value Value to decrypt. + * @return string|bool Decrypted value, or false on failure. + */ + public function decrypt( $raw_value ) { + if ( ! extension_loaded( 'openssl' ) || ! is_string( $raw_value ) ) { + return $raw_value; + } + + $decoded_value = base64_decode( $raw_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + + if ( false === $decoded_value ) { + return $raw_value; + } + + $method = 'aes-256-ctr'; + $ivlen = openssl_cipher_iv_length( $method ); + $iv = substr( $decoded_value, 0, $ivlen ); + + $decoded_value = substr( $decoded_value, $ivlen ); + + $value = openssl_decrypt( $decoded_value, $method, $this->key, 0, $iv ); + if ( ! $value || substr( $value, - strlen( $this->salt ) ) !== $this->salt ) { + return false; + } + + return substr( $value, 0, - strlen( $this->salt ) ); + } + + /** + * Gets the default encryption key to use. + * + * @since 1.6.0 + * + * @return string Default (not user-based) encryption key. + */ + private function get_default_key() { + if ( defined( 'MAILCHIMP_SF_ENCRYPTION_KEY' ) && '' !== MAILCHIMP_SF_ENCRYPTION_KEY ) { + return MAILCHIMP_SF_ENCRYPTION_KEY; + } + + if ( defined( 'LOGGED_IN_KEY' ) && '' !== LOGGED_IN_KEY ) { + return LOGGED_IN_KEY; + } + + // Ideally this default is never used but we have it just in case. + return 'vJgwa_qf0u(k!uir[rB);g;DciNAKuX;+q&`A+z&m6kX3Y|$q.U3:Q>!$)6CA+=O'; + } + + /** + * Gets the default encryption salt to use. + * + * @since 1.6.0 + * + * @return string Encryption salt. + */ + private function get_default_salt() { + if ( defined( 'MAILCHIMP_SF_ENCRYPTION_SALT' ) && '' !== MAILCHIMP_SF_ENCRYPTION_SALT ) { + return MAILCHIMP_SF_ENCRYPTION_SALT; + } + + if ( defined( 'LOGGED_IN_SALT' ) && '' !== LOGGED_IN_SALT ) { + return LOGGED_IN_SALT; + } + + // Ideally this default is never used but we have it just in case. + return '|qhC}/w6q+$V`H>wM:AtNpg/{s)g<k{F:WMcvJJD[K6c_Kb1OEy^Yx7f|$Ovm+X|'; + } +} diff --git a/js/admin.js b/js/admin.js new file mode 100644 index 0000000..6e24563 --- /dev/null +++ b/js/admin.js @@ -0,0 +1,155 @@ +/* eslint-disable prefer-template, no-console */ +(function ($) { + const params = window.mailchimp_sf_admin_params || {}; + const spinner = '.mailchimp-sf-oauth-connect-wrapper .spinner'; + const errorSelector = '.mailchimp-sf-oauth-section .oauth-error'; + + /** + * Open Mailchimp OAuth popup. + * + * @param {string} token - Token from the Oauth service. + */ + function openMailchimpOauthPopup(token) { + const startUrl = params.oauth_url + '/auth/start/' + token; + const width = 800; + const height = 600; + const screenSizes = window.screen || { width: 1024, height: 768 }; + const left = (screenSizes.width - width) / 2; + const top = (screenSizes.height - height) / 4; + const windowOptions = + 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=' + + width + + ', height=' + + height + + ', top=' + + top + + ', left=' + + left + + ', domain=' + + params.oauth_url.replace('https://', ''); + + // Open Mailchimp OAuth popup. + const popup = window.open(startUrl, params.oauth_window_name, windowOptions); + + if (popup == null) { + // Show modal if popup is blocked. + $('#login-popup-blocked-modal').dialog({ + modal: true, + title: params.modal_title, + width: 480, + buttons: [ + { + text: params.modal_button_cancel, + class: 'button-secondary', + click() { + $(this).dialog('close'); + }, + }, + { + text: params.modal_button_try_again, + class: 'button-primary', + click() { + $(this).dialog('close'); + $(spinner).addClass('is-active'); + openMailchimpOauthPopup(token); + }, + style: 'margin-left: 10px;', + }, + ], + }); + $(spinner).removeClass('is-active'); + } else { + // Handle popup opened. + const oauthInterval = window.setInterval(function () { + if (popup.closed) { + // Clear interval. + window.clearInterval(oauthInterval); + + // Check status of OAuth connection. + const statusUrl = params.oauth_url + '/api/status/' + token; + $.post(statusUrl, function (statusData) { + if (statusData && statusData.status === 'accepted') { + const finishData = { + action: 'mailchimp_sf_oauth_finish', + nonce: params.oauth_finish_nonce, + token, + }; + + // Finish OAuth connection and save token. + $.post(params.ajax_url, finishData, function (finishResponse) { + if (finishResponse.success) { + // Token is saved in the database, reload the page to reflect the changes. + window.location.reload(); + } else { + console.log( + 'Error calling OAuth finish endpoint. Data:', + finishResponse, + ); + if (finishResponse.data && finishResponse.data.message) { + $(errorSelector).html(finishResponse.data.message); + } else { + $(errorSelector).html(params.generic_error); + } + $(errorSelector).show(); + } + $(spinner).removeClass('is-active'); + }).fail(function () { + console.error('Error calling OAuth finish endpoint.'); + $(errorSelector).html(params.generic_error); + $(errorSelector).show(); + $(spinner).removeClass('is-active'); + }); + } else { + console.log( + 'Error calling OAuth status endpoint. No credentials provided at login popup? Data:', + statusData, + ); + $(spinner).removeClass('is-active'); + } + }).fail(function () { + $(errorSelector).html(params.generic_error); + $(errorSelector).show(); + console.error('Error calling OAuth status endpoint.'); + $(spinner).removeClass('is-active'); + }); + } + }, 250); + } + } + + $(window).on('load', function () { + // Mailchimp OAuth connection. + $('#mailchimp_sf_oauth_connect').click(function () { + $(errorSelector).hide(); + $(errorSelector).html(''); + $(spinner).addClass('is-active'); + + $.post( + params.ajax_url, + { + action: 'mailchimp_sf_oauth_start', + nonce: params.oauth_start_nonce, + }, + function (response) { + if (response.success && response.data && response.data.token) { + // Open Mailchimp OAuth popup. + openMailchimpOauthPopup(response.data.token); + } else { + if (response.data && response.data.message) { + $(errorSelector).html(response.data.message); + } else { + $(errorSelector).html(params.generic_error); + } + $(errorSelector).show(); + $(spinner).removeClass('is-active'); + } + }, + ).fail(function () { + $(errorSelector).html(params.generic_error); + $(errorSelector).show(); + $(spinner).removeClass('is-active'); + }); + }); + }); + // eslint-disable-next-line no-undef +})(jQuery); diff --git a/lib/mailchimp/mailchimp.php b/lib/mailchimp/mailchimp.php index a310ba0..e3bb099 100644 --- a/lib/mailchimp/mailchimp.php +++ b/lib/mailchimp/mailchimp.php @@ -11,18 +11,18 @@ class MailChimp_API { /** - * The API key + * The access token. * * @var string */ - public $key; + public $access_token; /** * The API key * * @var string */ - public $api_key; + public $key; /** * The API url @@ -41,27 +41,35 @@ class MailChimp_API { /** * Initialize the class * - * @param string $api_key The API key. - * @throws Exception If no api key is set + * @param string $access_token Access token or API key. If data center is not provided, we'll assume that this is an API key. + * @param string $data_center The data center. If not provided, we'll assume the data center is in the API key itself. + * @throws Exception If no api key or access token is set */ - public function __construct( $api_key ) { - $api_key = trim( $api_key ); - if ( ! $api_key ) { + public function __construct( $access_token, $data_center = '' ) { + $access_token = trim( $access_token ); + if ( ! $access_token ) { throw new Exception( esc_html( sprintf( - /* translators: %s: api key */ - __( 'Invalid API Key: %s', 'mailchimp' ), - $api_key + /* translators: %s: access token */ + __( 'Invalid Access Token or API key: %s', 'mailchimp' ), + $access_token ) ) ); } - $this->key = $api_key; - $dc = explode( '-', $api_key ); - $this->datacenter = empty( $dc[1] ) ? 'us1' : $dc[1]; - $this->api_url = 'https://' . $this->datacenter . '.api.mailchimp.com/3.0/'; + // No data center provided, so we'll assume it's in the API key. + if ( ! $data_center ) { + $this->key = $access_token; + $dc = explode( '-', $access_token ); + $this->datacenter = empty( $dc[1] ) ? 'us1' : $dc[1]; + } else { + $this->access_token = $access_token; + $this->datacenter = $data_center; + } + + $this->api_url = 'https://' . $this->datacenter . '.api.mailchimp.com/3.0/'; } /** @@ -91,19 +99,38 @@ public function get( $endpoint, $count = 10, $fields = array() ) { $url .= "?{$query_params}"; } + $headers = array(); + // If we have an access token, use that, otherwise use the API key. + if ( $this->access_token ) { + $headers['Authorization'] = 'Bearer ' . $this->access_token; + } else { + $headers['Authorization'] = 'apikey ' . $this->key; + } + $args = array( - 'timeout' => 5, + 'timeout' => 10, 'redirection' => 5, 'httpversion' => '1.1', 'user-agent' => 'Mailchimp WordPress Plugin/' . get_bloginfo( 'url' ), - 'headers' => array( 'Authorization' => 'apikey ' . $this->key ), + 'headers' => $headers, ); $request = wp_remote_get( $url, $args ); + if ( is_wp_error( $request ) ) { + return $request; + } + if ( is_array( $request ) && 200 === $request['response']['code'] ) { + delete_option( 'mailchimp_sf_auth_error' ); return json_decode( $request['body'], true ); } elseif ( is_array( $request ) && $request['response']['code'] ) { + // Check if Access Token is invalid/revoked. + if ( in_array( $request['response']['code'], array( 401, 403 ), true ) ) { + update_option( 'mailchimp_sf_auth_error', true ); + return new WP_Error( 'mailchimp-auth-error', esc_html__( 'Authentication failed.', 'mailchimp' ) ); + } + $error = json_decode( $request['body'], true ); $error = new WP_Error( 'mailchimp-get-error', $error['detail'] ); return $error; @@ -123,24 +150,38 @@ public function get( $endpoint, $count = 10, $fields = array() ) { public function post( $endpoint, $body, $method = 'POST' ) { $url = $this->api_url . $endpoint; + $headers = array(); + // If we have an access token, use that, otherwise use the API key. + if ( $this->access_token ) { + $headers['Authorization'] = 'Bearer ' . $this->access_token; + } else { + $headers['Authorization'] = 'apikey ' . $this->key; + } + $args = array( 'method' => $method, - 'timeout' => 5, + 'timeout' => 10, 'redirection' => 5, 'httpversion' => '1.1', 'user-agent' => 'Mailchimp WordPress Plugin/' . get_bloginfo( 'url' ), - 'headers' => array( 'Authorization' => 'apikey ' . $this->key ), + 'headers' => $headers, 'body' => wp_json_encode( $body ), ); $request = wp_remote_post( $url, $args ); if ( is_array( $request ) && 200 === $request['response']['code'] ) { + delete_option( 'mailchimp_sf_auth_error' ); return json_decode( $request['body'], true ); } else { if ( is_wp_error( $request ) ) { return new WP_Error( 'mc-subscribe-error', $request->get_error_message() ); } + // Check if Access Token is invalid/revoked. + if ( is_array( $request ) && in_array( $request['response']['code'], array( 401, 403 ), true ) ) { + update_option( 'mailchimp_sf_auth_error', true ); + } + $body = json_decode( $request['body'], true ); $merges = get_option( 'mc_merge_vars' ); $field_name = ''; diff --git a/mailchimp.php b/mailchimp.php index a2a341a..99e93ca 100644 --- a/mailchimp.php +++ b/mailchimp.php @@ -49,6 +49,9 @@ require_once $path . 'lib/mailchimp/mailchimp.php'; } +// Encryption utility class. +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-data-encryption.php'; + // includes the widget code so it can be easily called either normally or via ajax require_once 'mailchimp_widget.php'; @@ -58,6 +61,11 @@ // Upgrade routines. require_once 'mailchimp_upgrade.php'; +// Init Admin functions. +require_once plugin_dir_path( __FILE__ ) . 'includes/class-mailchimp-admin.php'; +$admin = new Mailchimp_Admin(); +$admin->init(); + /** * Do the following plugin setup steps here * @@ -132,25 +140,6 @@ function mailchimp_sf_load_resources() { } } - -/** - * Loads resources for the Mailchimp admin page - * - * @param string $hook_suffix The current admin page. - * @return void - */ -function mailchimp_admin_page_scripts( $hook_suffix ) { - if ( 'toplevel_page_mailchimp_sf_options' !== $hook_suffix ) { - return; - } - - wp_enqueue_style( 'mailchimp_sf_admin_css', MCSF_URL . 'css/admin.css', array(), true ); - wp_enqueue_script( 'showMe', MCSF_URL . 'js/hidecss.js', array( 'jquery' ), MCSF_VER, true ); -} - -add_action( 'admin_enqueue_scripts', 'mailchimp_admin_page_scripts', 10, 1 ); - - /** * Loads jQuery Datepicker for the date-pick class **/ @@ -217,24 +206,6 @@ function mailchimp_sf_add_pages() { function mailchimp_sf_request_handler() { if ( isset( $_POST['mcsf_action'] ) ) { switch ( $_POST['mcsf_action'] ) { - case 'login': - $key = isset( $_POST['mailchimp_sf_api_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['mailchimp_sf_api_key'] ) ) ) : ''; - - try { - $api = new MailChimp_API( $key ); - } catch ( Exception $e ) { - $msg = '<strong class="error_msg">' . $e->getMessage() . '</strong>'; - mailchimp_sf_global_msg( $msg ); - break; - } - - $key = mailchimp_sf_verify_key( $api ); - if ( is_wp_error( $key ) ) { - $msg = '<strong class="error_msg">' . $key->get_error_message() . '</strong>'; - mailchimp_sf_global_msg( $msg ); - } - - break; case 'logout': // Check capability & Verify nonce if ( @@ -246,7 +217,7 @@ function mailchimp_sf_request_handler() { } // erase auth information - $options = array( 'mc_api_key', 'mc_sopresto_user', 'mc_sopresto_public_key', 'mc_sopresto_secret_key' ); + $options = array( 'mc_api_key', 'mailchimp_sf_access_token', 'mc_datacenter', 'mailchimp_sf_auth_error', 'mc_sopresto_user', 'mc_sopresto_public_key', 'mc_sopresto_secret_key' ); mailchimp_sf_delete_options( $options ); break; case 'change_form_settings': @@ -381,6 +352,14 @@ function mailchimp_sf_auth_nonce_salt() { * @return MailChimp_API | false */ function mailchimp_sf_get_api() { + // Check for the access token first. + $access_token = mailchimp_sf_get_access_token(); + $data_center = get_option( 'mc_datacenter' ); + if ( ! empty( $access_token ) && ! empty( $data_center ) ) { + return new MailChimp_API( $access_token, $data_center ); + } + + // Check for the API key if the access token is not available. $key = get_option( 'mc_api_key' ); if ( $key ) { return new MailChimp_API( $key ); @@ -1223,7 +1202,7 @@ function mailchimp_sf_verify_key( $api ) { // Might as well set this data if we have it already. $valid_roles = array( 'owner', 'admin', 'manager' ); - if ( in_array( $user['role'], $valid_roles, true ) ) { + if ( isset( $user['role'] ) && in_array( $user['role'], $valid_roles, true ) ) { update_option( 'mc_api_key', $api->key ); update_option( 'mc_user', $user ); update_option( 'mc_datacenter', $api->datacenter ); @@ -1393,3 +1372,36 @@ function mailchimp_sf_create_nonce( $action = -1 ) { return substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 ); } + +/** + * Get Mailchimp Access Token. + * + * @since 1.6.0 + * @return string|bool + */ +function mailchimp_sf_get_access_token() { + $access_token = get_option( 'mailchimp_sf_access_token' ); + if ( empty( $access_token ) ) { + return false; + } + + $data_encryption = new Mailchimp_Data_Encryption(); + $access_token = $data_encryption->decrypt( $access_token ); + + // If decryption fails, display notice to user to re-authenticate. + if ( false === $access_token ) { + update_option( 'mailchimp_sf_auth_error', true ); + } + + return $access_token; +} + +/** + * Should display Mailchimp Signup form. + * + * @since 1.6.0 + * @return bool + */ +function mailchimp_sf_should_display_form() { + return mailchimp_sf_get_api() && ! get_option( 'mailchimp_sf_auth_error' ); +} diff --git a/mailchimp_widget.php b/mailchimp_widget.php index 965509b..d2d129c 100644 --- a/mailchimp_widget.php +++ b/mailchimp_widget.php @@ -12,6 +12,10 @@ * @return void */ function mailchimp_sf_signup_form( $args = array() ) { + // Check if we should display the form. + if ( ! mailchimp_sf_should_display_form() ) { + return; + } $before_title = isset( $args['before_title'] ) ? $args['before_title'] : ''; $after_title = isset( $args['after_title'] ) ? $args['after_title'] : ''; @@ -83,9 +87,10 @@ function mailchimp_sf_signup_form( $args = array() ) { line-height: 1.4em; margin-bottom: 0.75em; } + .mc_custom_border_hdr, #mc_subheader { line-height: 1.25em; - margin: 18px 0; + margin-bottom: 18px; } .mc_merge_var { margin-bottom: 1.0em; diff --git a/package-lock.json b/package-lock.json index fd5aea9..8cc551d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,10 @@ "@wordpress/server-side-render": "^5.2.0" }, "devDependencies": { - "@10up/cypress-wp-utils": "^0.3.0", + "@10up/cypress-wp-utils": "^0.4.0", "@wordpress/env": "^10.2.0", "10up-toolkit": "^6.2.0", - "cypress": "^13.12.0", + "cypress": "^13.13.2", "cypress-mochawesome-reporter": "^3.8.2", "mochawesome-json-to-md": "^1.3.5" } @@ -42,9 +42,9 @@ } }, "node_modules/@10up/cypress-wp-utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@10up/cypress-wp-utils/-/cypress-wp-utils-0.3.0.tgz", - "integrity": "sha512-iMjvca50TerMCY9M9vL0FIE+80ye5YohaQp3XvhgUgQdc4LS51X2fH+lhdb0uRmBTiUQcISazEvWGJxV7DeTbw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@10up/cypress-wp-utils/-/cypress-wp-utils-0.4.0.tgz", + "integrity": "sha512-7cNELIX6ml5V9JEU83iEyQ6dkZ77ImdR5HKjUP4oyArQogPVcFPUnokU7GInH8DicqXbESrrkxZ0IfnNtNWh+A==", "dev": true, "engines": { "node": ">=12.0" @@ -9912,13 +9912,13 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.12.0.tgz", - "integrity": "sha512-udzS2JilmI9ApO/UuqurEwOvThclin5ntz7K0BtnHBs+tg2Bl9QShLISXpSEMDv/u8b6mqdoAdyKeZiSqKWL8g==", + "version": "13.13.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.13.2.tgz", + "integrity": "sha512-PvJQU33933NvS1StfzEb8/mu2kMy4dABwCF+yd5Bi7Qly1HOVf+Bufrygee/tlmty/6j5lX+KIi8j9Q3JUMbhA==", "dev": true, "hasInstallScript": true, "dependencies": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.1", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -9957,7 +9957,7 @@ "request-progress": "^3.0.0", "semver": "^7.5.3", "supports-color": "^8.1.1", - "tmp": "~0.2.1", + "tmp": "~0.2.3", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, diff --git a/package.json b/package.json index 9d09323..865d02a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "scripts": { "cypress:open": "cypress open --config-file tests/cypress/config.js --e2e --browser chrome", - "cypress:run": "cypress run --config-file tests/cypress/config.js", + "cypress:run": "cypress run --config-file tests/cypress/config.js --e2e --browser chrome", "env": "wp-env", "env:start": "wp-env start", "env:stop": "wp-env stop", @@ -31,10 +31,10 @@ "build": "10up-toolkit build" }, "devDependencies": { - "@10up/cypress-wp-utils": "^0.3.0", + "@10up/cypress-wp-utils": "^0.4.0", "@wordpress/env": "^10.2.0", "10up-toolkit": "^6.2.0", - "cypress": "^13.12.0", + "cypress": "^13.13.2", "cypress-mochawesome-reporter": "^3.8.2", "mochawesome-json-to-md": "^1.3.5" }, diff --git a/readme.txt b/readme.txt index 86929e8..59fd58a 100644 --- a/readme.txt +++ b/readme.txt @@ -16,6 +16,21 @@ After installation, you’ll log in with your API key, select your Mailchimp lis WordPress.com compatibility is limited to Business tier users only. [How to add a signup form if you have a WordPress.com site](https://mailchimp.com/help/ways-to-add-a-signup-form-in-wordpress/). +=== Access Token Encryption === + +Starting in version 1.6.0, authentication has changed to use OAuth. As part of this process, we retrieve an access token that can be used to make API requests. To provide a high-level of security, this access token is encrypted before being stored in the WordPress database. In order to ensure this access token can be decrypted when used, the plugin relies on certain security constants that should remain unchanged. + +With no additional configuration, we use the standard `LOGGED_IN_KEY` and `LOGGED_IN_SALT` constants that are normally set in your site's `wp-config.php` file. Some sites make use of security plugins that rotate these constants on a periodic basis. When this happens, we won't be able to decrypt the access token and you’ll need to reconnect your Mailchimp account to generate a new access token. + +To prevent such issues, it is recommended to define two additional constants in your site's `wp-config.php` file: `MAILCHIMP_SF_ENCRYPTION_KEY` and `MAILCHIMP_SF_ENCRYPTION_SALT`. These constants should consist of a combination of characters, preferably at least 32 characters long. Once set, these values should not be changed. For strong values, you can copy some of the values from [here](https://api.wordpress.org/secret-key/1.1/salt/) and use them. You'll end up with additional code like the following in your `wp-config.php` file: + +` +define( 'MAILCHIMP_SF_ENCRYPTION_KEY', 'put your unique phrase here' ); +define( 'MAILCHIMP_SF_ENCRYPTION_SALT', 'put your unique phrase here' ); +` + +If these constants are added after you've already authenticated with Mailchimp, you will need to reconnect your account. To avoid this, you can copy the values from `LOGGED_IN_KEY` and `LOGGED_IN_SALT` (if they exist) to `MAILCHIMP_SF_ENCRYPTION_KEY` and `MAILCHIMP_SF_ENCRYPTION_SALT` respectively. + == Frequently Asked Questions == = Can I have multiple forms on one page? = diff --git a/tests/bin/set-core-version.js b/tests/bin/set-core-version.js index f320701..abe61c4 100755 --- a/tests/bin/set-core-version.js +++ b/tests/bin/set-core-version.js @@ -1,34 +1,46 @@ #!/usr/bin/env node const fs = require( 'fs' ); -const { exit } = require( 'process' ); -const path = `${ process.cwd() }/.wp-env.override.json`; +const path = `${ process.cwd() }/.wp-env.json`; -// eslint-disable-next-line import/no-dynamic-require -const config = fs.existsSync( path ) ? require( path ) : {}; +let config = fs.existsSync( path ) ? require( path ) : { plugins: [ '.' ] }; -const args = process.argv.slice( 2 ); +const args = {}; +process.argv + .slice(2, process.argv.length) + .forEach( arg => { + if (arg.slice(0,2) === '--') { + const param = arg.split('='); + const paramName = param[0].slice(2,param[0].length); + const paramValue = param.length > 1 ? param[1] : true; + args[paramName] = paramValue; + } + }); -if ( args.length === 0 ) exit( 0 ); +if ( ! args.core && ! args.plugins ) { + return; +} + +if ( 'latest' === args.core ) { + delete args.core; +} -if ( args[ 0 ] === 'latest' ) { - if ( fs.existsSync( path ) ) { - fs.unlinkSync( path ); - } - exit( 0 ); +if( Object.keys(args).length === 0 ) { + return; } -config.core = args[ 0 ]; +if ( args.plugins ) { + args.plugins = args.plugins.split(','); +} -// eslint-disable-next-line no-useless-escape -if ( ! config.core.match( /^WordPress\/WordPress\#/ ) ) { - config.core = `WordPress/WordPress#${ config.core }`; +config = { + ...config, + ...args, } try { - fs.writeFileSync( path, JSON.stringify( config ) ); + fs.writeFileSync( path, JSON.stringify( config ) ); } catch ( err ) { - // eslint-disable-next-line no-console - console.error( err ); + console.error( err ); } diff --git a/tests/cypress/config.js b/tests/cypress/config.js index b881418..0720819 100644 --- a/tests/cypress/config.js +++ b/tests/cypress/config.js @@ -25,6 +25,10 @@ module.exports = defineConfig( { supportFile: 'tests/cypress/support/index.js', defaultCommandTimeout: 20000, }, + retries: { + runMode: 2, + openMode: 0, + }, } ); /** diff --git a/tests/cypress/e2e/admin.test.js b/tests/cypress/e2e/admin.test.js index 1f9a545..65d5f4a 100644 --- a/tests/cypress/e2e/admin.test.js +++ b/tests/cypress/e2e/admin.test.js @@ -1,10 +1,22 @@ -describe( 'Admin can login and make sure plugin is activated', () => { - before( () => { +/* eslint-disable no-undef */ +describe('Admin can login and make sure plugin is activated', () => { + before(() => { cy.login(); - } ); + }); - it( 'Can deactivate and activate plugin?', () => { - cy.deactivatePlugin( 'mailchimp' ); - cy.activatePlugin( 'mailchimp' ); - } ); -} ); + it('Can deactivate and activate plugin?', () => { + cy.deactivatePlugin('mailchimp'); + cy.activatePlugin('mailchimp'); + }); + + it('Can see "Mailchimp" menu and Can visit "Mailchimp" settings page.', () => { + cy.visit('/wp-admin/'); + + // Check Mailchimp menu. + cy.get('#adminmenu li#toplevel_page_mailchimp_sf_options').contains('Mailchimp'); + + // Check Heading + cy.get('#adminmenu li#toplevel_page_mailchimp_sf_options').click(); + cy.get('#wpbody .mailchimp-header h1').contains('Mailchimp List Subscribe Form'); + }); +}); diff --git a/tests/cypress/e2e/connect.test.js b/tests/cypress/e2e/connect.test.js new file mode 100644 index 0000000..89c71bf --- /dev/null +++ b/tests/cypress/e2e/connect.test.js @@ -0,0 +1,43 @@ +/* eslint-disable no-undef */ +describe('Admin can connect to "Mailchimp" Account', () => { + before(() => { + cy.login(); + }); + + it('Can connect to "Mailchimp" using OAuth flow.', () => { + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + + // Logout if already connected. + cy.get('body').then(($body) => { + if ($body.find('input[value="Logout"]').length > 0) { + cy.get('input[value="Logout"]').click(); + } + }); + + // Check Mailchimp menu. + cy.get('#mailchimp_sf_oauth_connect').should('exist'); + + // Enable popup capture. + cy.capturePopup(); + + cy.get('#mailchimp_sf_oauth_connect').click(); + cy.wait(6000); + + cy.popup() + .find('input#username') + .clear() + .type(Cypress.env('MAILCHIMP_USERNAME'), { force: true }); + cy.popup() + .find('input#password') + .clear() + .type(Cypress.env('MAILCHIMP_PASSWORD'), { force: true }); + cy.popup().find('button[type="submit"]').click({ force: true }); + cy.wait(10000); // Not a best practice, but did not find a better way to handle this. + + cy.popup().find('input#submitButton').click({ force: true }); + cy.wait(10000); // Not a best practice, but did not find a better way to handle this. + + cy.get('.mc-user h3').contains('Logged in as: '); + cy.get('input[value="Logout"]').should('exist'); + }); +}); diff --git a/tests/cypress/e2e/settings.test.js b/tests/cypress/e2e/settings.test.js new file mode 100644 index 0000000..ceb4406 --- /dev/null +++ b/tests/cypress/e2e/settings.test.js @@ -0,0 +1,205 @@ +/* eslint-disable no-undef */ +describe('Admin can update plugin settings', () => { + let shortcodePostURL = '/mailchimp-signup-form-shortcode'; + let blockPostPostURL = '/mailchimp-signup-form-block'; + + before(() => { + cy.login(); + }); + + it('Admin can see list and save it', () => { + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + + cy.get('.mc-h2').contains('Your Lists'); + cy.get('#mc_list_id').select('10up'); + cy.get('input[value="Update List"]').click(); + cy.get('#mc-message .success_msg b').contains('Success!'); + }); + + it('Admin can create a Signup form using the shortcode', () => { + const postTitle = 'Mailchimp signup form - shortcode'; + const beforeSave = () => { + cy.insertBlock('core/shortcode').then((id) => { + cy.getBlockEditor() + .find(`#${id} .blocks-shortcode__textarea`) + .clear() + .type('[mailchimpsf_form]'); + }); + }; + cy.createPost({ title: postTitle, content: '', beforeSave }).then((post) => { + if (post) { + shortcodePostURL = `/?p=${post.id}`; + cy.visit(shortcodePostURL); + cy.get('#mc_signup').should('exist'); + cy.get('#mc_mv_EMAIL').should('exist'); + cy.get('#mc_signup_submit').should('exist'); + cy.get('#mc_signup_submit').click(); + cy.get('.mc_error_msg').should('exist'); + cy.get('.mc_error_msg').contains(': This value should not be blank.'); + } + }); + }); + + it('Admin can create a Signup form using Mailchimp block', () => { + const postTitle = 'Mailchimp signup form - Block'; + const beforeSave = () => { + cy.insertBlock('mailchimp/mailchimp', 'Mailchimp List Subscribe Form'); + cy.wait(500); + }; + cy.createPost({ title: postTitle, content: '', beforeSave }).then((postBlock) => { + if (postBlock) { + blockPostPostURL = `/?p=${postBlock.id}`; + cy.visit(blockPostPostURL); + cy.get('#mc_signup').should('exist'); + cy.get('#mc_mv_EMAIL').should('exist'); + cy.get('#mc_signup_submit').should('exist'); + cy.get('#mc_signup_submit').click(); + cy.get('.mc_error_msg').should('exist'); + cy.get('.mc_error_msg').contains(': This value should not be blank.'); + } + }); + }); + + it('Admin can set content options for signup form', () => { + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + + // Set content options + const header = 'Subscribe to our newsletter'; + const subHeader = + 'Join our mailing list to receive the latest news and updates from our team.'; + const button = 'Subscribe Now'; + cy.get('#mc_header_content').clear().type(header); + cy.get('#mc_subheader_content').clear().type(subHeader); + cy.get('#mc_submit_text').clear().type(button); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify content options + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('.mc_custom_border_hdr').contains(header); + cy.get('#mc_subheader').contains(subHeader); + cy.get('#mc_signup_submit').contains(button); + }); + }); + + it('Admin can remove mailchimp CSS', () => { + // Remove mailchimp CSS. + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_nuke_all_styles').check(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_subheader').should('not.have.css', 'margin-bottom', '18px'); + }); + + // Enable mailchimp CSS. + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_nuke_all_styles').uncheck(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_subheader').should('have.css', 'margin-bottom', '18px'); + }); + }); + + it('Admin can set custom styling on signup form', () => { + // Enable custom styling and set values. + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_custom_style').check(); + cy.get('#mc_form_border_width').clear().type('10'); + cy.get('#mc_form_border_color').clear().type('000000'); + cy.get('#mc_form_text_color').clear().type('FF0000'); + cy.get('#mc_form_background').clear().type('00FF00'); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_signup form').should('have.css', 'border-width', '10px'); + cy.get('#mc_signup form').should('have.css', 'border-color', 'rgb(0, 0, 0)'); + cy.get('#mc_signup form').should('have.css', 'color', 'rgb(255, 0, 0)'); + cy.get('#mc_signup form').should('have.css', 'background-color', 'rgb(0, 255, 0)'); + }); + + // Reset + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_custom_style').uncheck(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + }); + + it('Admin can set Merge Fields Included settings', () => { + // Remove mailchimp CSS. + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_mv_FNAME').uncheck(); + cy.get('#mc_mv_LNAME').uncheck(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_mv_FNAME').should('not.exist'); + cy.get('#mc_mv_LNAME').should('not.exist'); + }); + + // Reset + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_mv_FNAME').check(); + cy.get('#mc_mv_LNAME').check(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_mv_FNAME').should('exist'); + cy.get('#mc_mv_LNAME').should('exist'); + }); + }); + + it('Admin can set list options settings', () => { + // Remove mailchimp CSS. + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_use_javascript').uncheck(); + cy.get('#mc_use_datepicker').uncheck(); + cy.get('#mc_use_unsub_link').check(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + // Verify + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_submit_type').should('have.value', 'html'); + cy.get('#mc_mv_BIRTHDAY').should('not.have.class', 'hasDatepicker'); + cy.get('#mc_mv_BIRTHDAY').click(); + cy.get('#ui-datepicker-div').should('not.exist'); + cy.get('#mc_unsub_link').should('exist'); + }); + + // Reset + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mc_use_javascript').check(); + cy.get('#mc_use_datepicker').check(); + cy.get('#mc_use_unsub_link').uncheck(); + cy.get('input[value="Update Subscribe Form Settings"]').first().click(); + + [shortcodePostURL, blockPostPostURL].forEach((url) => { + cy.visit(url); + cy.get('#mc_submit_type').should('have.value', 'js'); + cy.get('#mc_mv_BIRTHDAY').should('have.class', 'hasDatepicker'); + cy.get('#mc_mv_BIRTHDAY').click(); + cy.get('#ui-datepicker-div').should('exist'); + cy.get('#mc_unsub_link').should('not.exist'); + }); + }); + + it('Admin can logout', () => { + cy.visit('/wp-admin/admin.php?page=mailchimp_sf_options'); + cy.get('#mailchimp_sf_oauth_connect').should('not.exist'); + cy.get('input[value="Logout"]').click(); + + // connect to "Mailchimp" Account button should be visible. + cy.get('#mailchimp_sf_oauth_connect').should('exist'); + }); +}); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 119ab03..8d89823 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -1,3 +1,4 @@ +/* eslint-disable no-undef */ // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -23,3 +24,27 @@ // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) + +const state = {}; + +/** + * Intercepts calls to window.open() to keep a reference to the new window + */ +Cypress.Commands.add('capturePopup', () => { + cy.window().then((win) => { + const { open } = win; + cy.stub(win, 'open').callsFake((...params) => { + // Capture the reference to the popup + state.popup = open(...params); + return state.popup; + }); + }); +}); + +/** + * Returns a wrapped body of a captured popup + */ +Cypress.Commands.add('popup', () => { + const popup = Cypress.$(state.popup.document); + return cy.wrap(popup.contents().find('body')); +}); diff --git a/tests/cypress/support/index.js b/tests/cypress/support/index.js index d8895b6..5652037 100644 --- a/tests/cypress/support/index.js +++ b/tests/cypress/support/index.js @@ -15,6 +15,9 @@ import '@10up/cypress-wp-utils'; +// Import commands.js using ES2015 syntax: +import './commands'; + beforeEach( () => { cy.session( 'login', cy.login, { cacheAcrossSpecs: true, diff --git a/views/css/frontend.php b/views/css/frontend.php index dd8ee09..2bb4b89 100644 --- a/views/css/frontend.php +++ b/views/css/frontend.php @@ -75,7 +75,7 @@ padding-left: 0; } ul.mc_list li { - font-size: 12px; + font-size: 14px; } #ui-datepicker-div .ui-datepicker-year { display: none; diff --git a/views/setup_page.php b/views/setup_page.php index 9929753..63f3677 100644 --- a/views/setup_page.php +++ b/views/setup_page.php @@ -7,7 +7,7 @@ ?> <div class="wrap"> - + <hr class="wp-header-end"> <div class="mailchimp-header"> <svg xmlns="http://www.w3.org/2000/svg" aria-label="<?php esc_attr_e( 'Mailchimp Freddie', 'mailchimp' ); ?>" width="40" height="40" fill="none" viewBox="0 0 40 40"> <title><?php esc_html_e( 'Mailchimp Freddie', 'mailchimp' ); ?></title> @@ -31,21 +31,19 @@ } // If we don't have an API Key, do a login form -if ( ! $user || ! get_option( 'mc_api_key' ) ) { +if ( ! $user || ( ! get_option( 'mc_api_key' ) && ! mailchimp_sf_get_access_token() ) ) { ?> <div> <h3 class="mc-h2"><?php esc_html_e( 'Log In', 'mailchimp' ); ?></h3> - <p class="mc-p" style="width: 40%;"> + <p class="mc-p"><?php esc_html_e( 'To get started, we\'ll need to connect your Mailchimp account.', 'mailchimp' ); ?></p> + <p class="mc-p" style="max-width: var(--mailchimp-max-width);"> <?php echo wp_kses( __( - 'To get started, we\'ll need to access your Mailchimp account with an <a href="http://kb.mailchimp.com/integrations/api-integrations/about-api-keys">API Key</a>. Paste your Mailchimp API key, and click <strong>Connect</strong> to continue.', + 'Please click the <strong>Connect Account</strong> button to connect this WordPress site with your Mailchimp account. Once you complete the Mailchimp login in the pop-up window that appears, this page will refresh to show the Mailchimp List Subscribe Form settings.', 'mailchimp' ), [ - 'a' => [ - 'href' => [], - ], 'strong' => [], ] ); @@ -60,21 +58,22 @@ ); ?> </p> - <div class="mc-section"> + <div class="mc-section mailchimp-sf-oauth-section"> <table class="widefat mc-widefat mc-api"> - <form method="POST" action=""> <tr valign="top"> <th scope="row" class="mailchimp-connect"><?php esc_html_e( 'Connect to Mailchimp', 'mailchimp' ); ?></th> <td> - <input type="hidden" name="mcsf_action" value="login"/> - <input type="password" name="mailchimp_sf_api_key" placeholder="<?php esc_attr_e( 'API Key', 'mailchimp' ); ?>"> - </td> - <td> - <input class="button mc-submit" type="submit" value="<?php esc_attr_e( 'Connect', 'mailchimp' ); ?>"> + <div class="mailchimp-sf-oauth-connect-wrapper"> + <span class="spinner"></span> + <button class="button" id="mailchimp_sf_oauth_connect" href="#"><?php esc_html_e( 'Connect Account', 'mailchimp' ); ?></button> + </div> </td> </tr> - </form> </table> + <p class="oauth-error error_msg" style="display:none;"></p> + <div id="login-popup-blocked-modal" style="display:none;"> + <p><?php esc_html_e( 'Please allow your browser to show popups for this page.', 'mailchimp' ); ?></p> + </div> </div> </div> @@ -128,6 +127,23 @@ <?php // we *could* support paging, but few users have that many lists (and shouldn't) $lists = $api->get( 'lists', 100, array( 'fields' => 'lists.id,lists.name,lists.email_type_option' ) ); + + if ( is_wp_error( $lists ) ) { + ?> + <div class="error_msg"> + <?php + printf( + /* translators: %s: error message */ + esc_html__( 'Uh-oh, we couldn\'t get your lists from Mailchimp! Error: %s', 'mailchimp' ), + esc_html( $lists->get_error_message() ) + ); + ?> + </div> + </div> <!-- Close parent div as well. --> + <?php + return; + } + $lists = $lists['lists']; if ( count( $lists ) === 0 ) {