diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb368ebf0..3705919b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,6 +51,7 @@ jobs: - 'EndToEnd/integrations/woocommerce' - 'EndToEnd/landing-pages' - 'EndToEnd/products' + - 'EndToEnd/restrict-content/blocks-shortcodes' - 'EndToEnd/restrict-content/general' - 'EndToEnd/restrict-content/post-types' - 'EndToEnd/tags' diff --git a/includes/blocks/class-convertkit-block-member-content-login.php b/includes/blocks/class-convertkit-block-member-content-login.php new file mode 100644 index 000000000..8c5878dac --- /dev/null +++ b/includes/blocks/class-convertkit-block-member-content-login.php @@ -0,0 +1,402 @@ + $this->get_title(), + 'description' => __( 'Displays a login form, so subscribers can log in to view Member Content.', 'convertkit' ), + 'icon' => $this->get_icon(), + 'category' => 'convertkit', + 'keywords' => array( + __( 'ConvertKit', 'convertkit' ), + __( 'Kit', 'convertkit' ), + __( 'Member Content', 'convertkit' ), + __( 'Login', 'convertkit' ), + ), + + // Function to call when rendering as a block or a shortcode on the frontend web site. + 'render_callback' => array( $this, 'render' ), + + // Shortcode: TinyMCE / QuickTags Modal Width and Height. + 'modal' => array( + 'width' => 500, + 'height' => 305, + ), + + // Shortcode: Include a closing [/shortcode] tag when using TinyMCE or QuickTag Modals. + 'shortcode_include_closing_tag' => false, + + // Gutenberg: Block Icon in Editor. + 'gutenberg_icon' => convertkit_get_file_contents( CONVERTKIT_PLUGIN_PATH . '/resources/backend/images/block-icon-login.svg' ), + + // Help descriptions, displayed when no API key / resources exist and this block/shortcode is added. + 'no_access_token' => array( + 'notice' => __( 'Not connected to Kit.', 'convertkit' ), + 'link' => convertkit_get_setup_wizard_plugin_link(), + 'link_text' => __( 'Click here to connect your Kit account.', 'convertkit' ), + 'instruction_text' => __( 'Connect your Kit account at Settings > Kit, and then refresh this page.', 'convertkit' ), + ), + + // Whether an Access Token exists in the Plugin. + 'has_access_token' => $settings->has_access_and_refresh_token(), + 'has_resources' => true, + ); + + } + + /** + * Returns this block's Attributes + * + * @since 3.4.2 + */ + public function get_attributes() { + + return array( + // Block attributes. + 'logged_in_text' => array( + 'type' => 'string', + 'default' => $this->get_default_value( 'logged_in_text' ), + ), + 'logout_button_label' => array( + 'type' => 'string', + 'default' => $this->get_default_value( 'logout_button_label' ), + ), + + // The below are built in Gutenberg attributes registered in get_supports(). + + // get_supports() style, color and typography attributes. + 'style' => array( + 'type' => 'object', + ), + 'backgroundColor' => array( + 'type' => 'string', + ), + 'textColor' => array( + 'type' => 'string', + ), + 'fontSize' => array( + 'type' => 'string', + ), + + // Always required for Gutenberg. + 'is_gutenberg_example' => array( + 'type' => 'boolean', + 'default' => false, + ), + ); + + } + + /** + * Returns this block's supported built-in Attributes. + * + * @since 3.4.2 + * + * @return array Supports + */ + public function get_supports() { + + return array( + 'className' => true, + 'color' => array( + 'background' => true, + 'text' => true, + + // Don't apply styles to the block editor's div element. + // This ensures what's rendered in the Gutenberg editor matches the frontend output for styling. + // See: https://github.com/WordPress/gutenberg/issues/32417. + '__experimentalSkipSerialization' => true, + ), + 'typography' => array( + 'fontSize' => true, + 'lineHeight' => true, + ), + 'spacing' => array( + 'margin' => true, + 'padding' => true, + ), + ); + + } + + /** + * Returns this block's Fields + * + * @since 3.4.2 + * + * @return bool|array + */ + public function get_fields() { + + // The login form's text and labels are defined at Settings > Kit > Member Content, + // so that they match the login form displayed on Member Content. Only the logged in + // text and log out button label are defined here, as no equivalent settings exist. + return array( + 'logged_in_text' => array( + 'label' => __( 'Logged In Text', 'convertkit' ), + 'type' => 'text', + 'description' => __( 'The text to display when the subscriber is logged in.', 'convertkit' ), + ), + 'logout_button_label' => array( + 'label' => __( 'Log Out Button Text', 'convertkit' ), + 'type' => 'text', + 'description' => __( 'The text to display for the button to log the subscriber out.', 'convertkit' ), + ), + + // These fields will only display on the shortcode, and are deliberately not registered in get_attributes(), + // because Gutenberg will register its own color pickers for background and text. + 'background_color' => array( + 'label' => __( 'Background color', 'convertkit' ), + 'type' => 'color', + ), + 'text_color' => array( + 'label' => __( 'Text color', 'convertkit' ), + 'type' => 'color', + ), + ); + + } + + /** + * Returns this block's UI panels / sections. + * + * @since 3.4.2 + * + * @return bool|array + */ + public function get_panels() { + + return array( + 'general' => array( + 'label' => __( 'General', 'convertkit' ), + 'fields' => array( + 'logged_in_text', + 'logout_button_label', + 'background_color', + 'text_color', + ), + ), + ); + + } + + /** + * Returns this block's Default Values + * + * @since 3.4.2 + * + * @return array + */ + public function get_default_values() { + + return array( + 'logged_in_text' => __( 'You are logged in.', 'convertkit' ), + 'logout_button_label' => __( 'Log out', 'convertkit' ), + 'background_color' => '', + 'text_color' => '', + + // Built-in Gutenberg block attributes. + 'backgroundColor' => '', + 'textColor' => '', + 'fontSize' => '', + 'style' => array( + 'visualizers' => array( + 'padding' => array( + 'top' => '', + 'bottom' => '', + 'left' => '', + 'right' => '', + ), + ), + ), + ); + + } + + /** + * Returns the block's output, based on the supplied configuration attributes. + * + * @since 3.4.2 + * + * @param array $atts Block / Shortcode / Page Builder Module Attributes. + * @return string + */ + public function render( $atts ) { + + // Parse attributes, defining fallback defaults if required + // and moving some attributes (such as Gutenberg's styles), if defined. + $atts = $this->sanitize_and_declare_atts( $atts ); + + // Setup Settings classes. + $settings = new ConvertKit_Settings(); + $restrict_content_settings = new ConvertKit_Settings_Restrict_Content(); + + // Bail if the Plugin Access Token has not been configured. + if ( ! $settings->has_access_and_refresh_token() ) { + if ( $settings->debug_enabled() ) { + return ''; + } + + return ''; + } + + // Define variables for the views. + $output_restrict_content = WP_ConvertKit()->get_class( 'output_restrict_content' ); + $post_id = get_the_ID(); + $css_classes = $this->get_css_classes(); + $css_styles = $this->get_css_styles( $atts ); + + // Enqueue CSS and JS. + $output_restrict_content->enqueue_scripts_and_styles(); + + // If the subscriber is logged in, output the log out button. + $subscriber = new ConvertKit_Subscriber(); + $subscriber_id = $subscriber->get_subscriber_id(); + if ( ! is_wp_error( $subscriber_id ) && $subscriber_id ) { + $logout_url = add_query_arg( + array( + 'convertkit_logout' => 1, + '_wpnonce' => wp_create_nonce( 'convertkit_member_content_logout' ), + ), + get_permalink( $post_id ) + ); + + ob_start(); + include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/member-content-logged-in.php'; + return trim( ob_get_clean() ); + } + + // If the subscriber submitted their email address, output the code form. + if ( $output_restrict_content->token !== false ) { + ob_start(); + include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/code.php'; + return trim( ob_get_clean() ); + } + + // Output. + ob_start(); + include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/member-content-login.php'; + $html = trim( ob_get_clean() ); + + /** + * Filter the block's content immediately before it is output. + * + * @since 3.4.2 + * + * @param string $html Kit Member Content Login HTML. + * @param array $atts Block Attributes. + */ + $html = apply_filters( 'convertkit_block_member_content_login_render', $html, $atts ); + + return $html; + + } + +} diff --git a/includes/blocks/v2/login/block.json b/includes/blocks/v2/login/block.json new file mode 100644 index 000000000..78634fd00 --- /dev/null +++ b/includes/blocks/v2/login/block.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "convertkit/login", + "title": "Kit Member Content Login", + "category": "kit", + "description": "Displays a login form, so subscribers can log in to view Member Content.", + "keywords": [ "convertkit", "kit", "member content", "login" ], + "textdomain": "convertkit", + "attributes": { + "logged_in_text": { + "type": "string" + }, + "logout_button_label": { + "type": "string" + }, + "style": { + "type": "object" + }, + "backgroundColor": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "fontSize": { + "type": "string" + }, + "is_gutenberg_example": { + "type": "boolean", + "default": false + } + }, + "supports": { + "className": true, + "color": { + "background": true, + "text": true + }, + "typography": { + "fontSize": true, + "lineHeight": true + }, + "spacing": { + "margin": true, + "padding": true + } + }, + "editorScript": "convertkit-gutenberg" +} diff --git a/includes/blocks/v3/login/block.json b/includes/blocks/v3/login/block.json new file mode 100644 index 000000000..544bc374d --- /dev/null +++ b/includes/blocks/v3/login/block.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "convertkit/login", + "title": "Kit Member Content Login", + "category": "kit", + "description": "Displays a login form, so subscribers can log in to view Member Content.", + "keywords": [ "convertkit", "kit", "member content", "login" ], + "textdomain": "convertkit", + "attributes": { + "logged_in_text": { + "type": "string" + }, + "logout_button_label": { + "type": "string" + }, + "style": { + "type": "object" + }, + "backgroundColor": { + "type": "string" + }, + "textColor": { + "type": "string" + }, + "fontSize": { + "type": "string" + }, + "is_gutenberg_example": { + "type": "boolean", + "default": false + } + }, + "supports": { + "className": true, + "color": { + "background": true, + "text": true + }, + "typography": { + "fontSize": true, + "lineHeight": true + }, + "spacing": { + "margin": true, + "padding": true + } + }, + "editorScript": "convertkit-gutenberg" +} diff --git a/includes/class-convertkit-output-restrict-content.php b/includes/class-convertkit-output-restrict-content.php index 1a6412059..146b4140f 100644 --- a/includes/class-convertkit-output-restrict-content.php +++ b/includes/class-convertkit-output-restrict-content.php @@ -98,6 +98,15 @@ class ConvertKit_Output_Restrict_Content { */ public $token = false; + /** + * Whether the login modal has been output. + * + * @since 3.4.2 + * + * @var bool + */ + public $login_modal_output = false; + /** * Constructor. Registers actions and filters to possibly limit output of a Page/Post/CPT's * content on the frontend site. @@ -109,6 +118,7 @@ public function __construct() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'init', array( $this, 'initialize_classes' ), 2 ); add_action( 'init', array( $this, 'maybe_run_subscriber_authentication' ), 3 ); + add_action( 'wp', array( $this, 'maybe_run_subscriber_logout' ), 3 ); add_action( 'wp', array( $this, 'maybe_run_subscriber_verification' ), 4 ); add_action( 'wp', array( $this, 'register_content_filter' ), 5 ); add_filter( 'get_previous_post_where', array( $this, 'maybe_change_previous_post_where_clause' ), 10, 5 ); @@ -155,9 +165,10 @@ public function register_routes() { 'sanitize_callback' => 'absint', ), - // Resource Type: Validate resource type is included in the request and is a string. + // Resource Type: Validate resource type is a string, if included in the request. + // It's not included when logging in using the Member Content Login block. 'convertkit_resource_type' => array( - 'required' => true, + 'required' => false, 'validate_callback' => function ( $param ) { return is_string( $param ); @@ -166,9 +177,10 @@ public function register_routes() { 'sanitize_callback' => 'sanitize_text_field', ), - // Resource ID: Validate resource ID is included in the request and is an integer. + // Resource ID: Validate resource ID is an integer, if included in the request. + // It's not included when logging in using the Member Content Login block. 'convertkit_resource_id' => array( - 'required' => true, + 'required' => false, 'validate_callback' => function ( $param ) { return is_numeric( $param ); @@ -176,6 +188,30 @@ public function register_routes() { }, 'sanitize_callback' => 'absint', ), + + // Spam protection response, if a spam protection provider is enabled. + 'spam_protection_response' => array( + 'required' => false, + 'validate_callback' => function ( $param ) { + + return is_string( $param ); + + }, + 'sanitize_callback' => 'sanitize_text_field', + ), + + // Whether to display the heading above the login form. + // It's not displayed by the Member Content Login block, as it refers to + // reading the Member Content the subscriber is logging in to view. + 'display_heading' => array( + 'required' => false, + 'default' => true, + 'validate_callback' => function ( $param ) { + + return is_bool( $param ); + + }, + ), ), 'callback' => function ( $request ) { @@ -189,6 +225,24 @@ public function register_routes() { $resource_type = $request->get_param( 'convertkit_resource_type' ); $resource_id = $request->get_param( 'convertkit_resource_id' ); + // Check spam protection (reCAPTCHA or Cloudflare Turnstile, depending on Plugin settings). + $result = $output_restrict_content->verify_spam_protection( $request->get_param( 'spam_protection_response' ) ); + + // If spam protection failed, build the email form view with the error message. + if ( is_wp_error( $result ) ) { + $output_restrict_content->error = $result; + + ob_start(); + include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/' . ( $request->get_param( 'display_heading' ) ? 'login-modal-content-email.php' : 'login-email.php' ); + $output = trim( ob_get_clean() ); + return rest_ensure_response( + array( + 'success' => false, + 'data' => $output, + ) + ); + } + // Run subscriber authentication. $result = $output_restrict_content->subscriber_authentication_send_code( $email, @@ -202,7 +256,7 @@ public function register_routes() { // Build email form view to return for output with error message. ob_start(); - include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/login-modal-content-email.php'; + include CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/' . ( $request->get_param( 'display_heading' ) ? 'login-modal-content-email.php' : 'login-email.php' ); $output = trim( ob_get_clean() ); return rest_ensure_response( array( @@ -365,16 +419,10 @@ public function maybe_run_subscriber_authentication() { return; } - // Bail if the expected email, resource type, resource ID or Post ID are missing from the request. + // Bail if the expected email or Post ID are missing from the request. if ( ! array_key_exists( 'convertkit_email', $_REQUEST ) ) { return; } - if ( ! array_key_exists( 'convertkit_resource_type', $_REQUEST ) ) { - return; - } - if ( ! array_key_exists( 'convertkit_resource_id', $_REQUEST ) ) { - return; - } if ( ! array_key_exists( 'convertkit_post_id', $_REQUEST ) ) { return; } @@ -386,8 +434,8 @@ public function maybe_run_subscriber_authentication() { // Sanitize inputs. $email = sanitize_text_field( wp_unslash( $_REQUEST['convertkit_email'] ) ); - $this->resource_type = sanitize_text_field( wp_unslash( $_REQUEST['convertkit_resource_type'] ) ); - $this->resource_id = absint( $_REQUEST['convertkit_resource_id'] ); + $this->resource_type = ( array_key_exists( 'convertkit_resource_type', $_REQUEST ) ? sanitize_text_field( wp_unslash( $_REQUEST['convertkit_resource_type'] ) ) : '' ); + $this->resource_id = ( array_key_exists( 'convertkit_resource_id', $_REQUEST ) ? absint( $_REQUEST['convertkit_resource_id'] ) : 0 ); $this->post_id = absint( $_REQUEST['convertkit_post_id'] ); // If Restrict Content is by tag, tag the subscriber. @@ -410,6 +458,15 @@ public function maybe_run_subscriber_authentication() { $this->error = $result; return; } + } else { + // Check spam protection (reCAPTCHA or Cloudflare Turnstile, depending on Plugin settings). + $spam_check = $this->verify_spam_protection(); + + // Bail if spam protection failed. + if ( is_wp_error( $spam_check ) ) { + $this->error = $spam_check; + return; + } } // Run subscriber authentication. @@ -487,6 +544,131 @@ public function maybe_run_subscriber_verification() { } + /** + * Logs the subscriber out by deleting their subscriber ID cookie, when the + * log out button is clicked in the Member Content Login block. + * + * @since 3.4.2 + */ + public function maybe_run_subscriber_logout() { + + // Bail if no logout request was made. + if ( ! array_key_exists( 'convertkit_logout', $_REQUEST ) ) { + return; + } + + // Bail if no nonce was specified. + if ( ! array_key_exists( '_wpnonce', $_REQUEST ) ) { + return; + } + + // Bail if the nonce failed validation. + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_wpnonce'] ), 'convertkit_member_content_logout' ) ) { + return; + } + + // Delete the subscriber ID cookie. + $subscriber = new ConvertKit_Subscriber(); + $subscriber->forget(); + + // Reload the Post, so the login form displays. + wp_safe_redirect( $this->get_url( get_the_ID(), true ) ); + exit(); + + } + + /** + * Verifies the spam protection response for the login form, using the spam + * protection provider enabled in the Plugin's settings. + * + * @since 3.4.2 + * + * @param bool|string $response Spam protection response, if supplied by a REST API request. + * @return bool|WP_Error + */ + public function verify_spam_protection( $response = false ) { + + $spam_protection = new ConvertKit_Spam_Protection(); + $provider = $spam_protection->get_active_provider(); + + // Return true if no spam protection provider is enabled. + if ( $provider === false ) { + return true; + } + + // Verify the response included in the REST API request. + if ( ! empty( $response ) ) { + return $provider->verify( $response, 'convertkit_member_content_login' ); + } + + // Verify the response included in the form submission. + return $spam_protection->verify( 'convertkit_member_content_login' ); + + } + + /** + * Enqueues the CSS and JS required by the login form and modal. + * + * @since 3.4.2 + */ + public function enqueue_scripts_and_styles() { + + // Only load styles if the Disable CSS option is off. + if ( ! $this->settings->css_disabled() ) { + convertkit_enqueue_frontend_css(); + } + + // Bail if scripts are disabled. + if ( $this->settings->scripts_disabled() ) { + return; + } + + // Enqueue scripts. + convertkit_enqueue_frontend_js(); + + // Define variables. + wp_localize_script( + 'convertkit-js', + 'convertkit_restrict_content', + array( + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'subscriber_authentication_url' => rest_url( 'kit/v1/restrict-content/subscriber-authentication' ), + 'subscriber_verification_url' => rest_url( 'kit/v1/restrict-content/subscriber-verification' ), + 'debug' => $this->settings->debug_enabled(), + ) + ); + + } + + /** + * Outputs the login modal in the footer, ensuring it is only output once + * when a Post contains multiple Member Content Login blocks. + * + * @since 3.4.2 + * + * @param int $post_id Post ID. + * @param bool|int $resource_id Resource ID. + * @param bool|string $resource_type Resource Type. + */ + public function output_login_modal( $post_id, $resource_id = 0, $resource_type = '' ) { + + if ( $this->login_modal_output ) { + return; + } + + $this->login_modal_output = true; + + add_action( + 'wp_footer', + function () use ( $post_id, $resource_id, $resource_type ) { + + include_once CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/login-modal.php'; + + } + ); + + } + /** * Sends an email to the subscriber with a code and link to authenticate they have access to the email address submitted. * @@ -1345,29 +1527,8 @@ private function get_excerpt( $post_id ) { */ private function get_call_to_action( $post_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter - // Only load styles if the Disable CSS option is off. - if ( ! $this->settings->css_disabled() ) { - // Enqueue styles. - convertkit_enqueue_frontend_css(); - } - - // Only load scripts if the Disable Scripts option is off. - if ( ! $this->settings->scripts_disabled() ) { - // Enqueue scripts. - convertkit_enqueue_frontend_js(); - - // Define variables. - wp_localize_script( - 'convertkit-js', - 'convertkit_restrict_content', - array( - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'subscriber_authentication_url' => rest_url( 'kit/v1/restrict-content/subscriber-authentication' ), - 'subscriber_verification_url' => rest_url( 'kit/v1/restrict-content/subscriber-verification' ), - 'debug' => $this->settings->debug_enabled(), - ) - ); - } + // Enqueue CSS and JS. + $this->enqueue_scripts_and_styles(); // Output code form if this request is after the user entered their email address, // which means we're going through the authentication flow. @@ -1403,14 +1564,7 @@ private function get_call_to_action( $post_id ) { // phpcs:ignore Generic.CodeAn // If scripts are enabled, output the email login form in a modal, which will be displayed // when the 'log in' link is clicked. if ( ! $this->settings->scripts_disabled() ) { - add_action( - 'wp_footer', - function () use ( $post_id, $resource_id, $resource_type ) { - - include_once CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/login-modal.php'; - - } - ); + $this->output_login_modal( $post_id, $resource_id, $resource_type ); } // Output. @@ -1433,14 +1587,7 @@ function () use ( $post_id, $resource_id, $resource_type ) { // If scripts are enabled, output the email login form in a modal, which will be displayed // when the 'log in' link is clicked. if ( ! $this->settings->scripts_disabled() ) { - add_action( - 'wp_footer', - function () use ( $post_id, $resource_id, $resource_type ) { - - include_once CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/login-modal.php'; - - } - ); + $this->output_login_modal( $post_id, $resource_id, $resource_type ); } // Output. @@ -1456,14 +1603,7 @@ function () use ( $post_id, $resource_id, $resource_type ) { // If scripts are enabled, output the email login form in a modal, which will be displayed // when the 'log in' link is clicked. if ( ! $this->settings->scripts_disabled() ) { - add_action( - 'wp_footer', - function () use ( $post_id, $resource_id, $resource_type ) { - - include_once CONVERTKIT_PLUGIN_PATH . '/views/frontend/restrict-content/login-modal.php'; - - } - ); + $this->output_login_modal( $post_id, $resource_id, $resource_type ); } // Enqueue the active spam protection provider's client-side script. diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 1005927f4..45ba92f10 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -195,6 +195,7 @@ private function initialize_global() { $this->classes['blocks_convertkit_form_builder_field_email'] = new ConvertKit_Block_Form_Builder_Field_Email(); $this->classes['blocks_convertkit_form_builder_field_name'] = new ConvertKit_Block_Form_Builder_Field_Name(); $this->classes['blocks_convertkit_form_builder_field_custom'] = new ConvertKit_Block_Form_Builder_Field_Custom(); + $this->classes['blocks_convertkit_member_content_login'] = new ConvertKit_Block_Member_Content_Login(); $this->classes['blocks_convertkit_product'] = new ConvertKit_Block_Product(); $this->classes['block_formatter_form_link'] = new ConvertKit_Block_Formatter_Form_Link(); $this->classes['block_formatter_product_link'] = new ConvertKit_Block_Formatter_Product_Link(); diff --git a/resources/backend/images/block-icon-login.svg b/resources/backend/images/block-icon-login.svg new file mode 100644 index 000000000..83505c64a --- /dev/null +++ b/resources/backend/images/block-icon-login.svg @@ -0,0 +1,5 @@ + diff --git a/resources/backend/images/login.svg b/resources/backend/images/login.svg new file mode 100644 index 000000000..7390ac6d5 --- /dev/null +++ b/resources/backend/images/login.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/backend/js/tinymce-login.js b/resources/backend/js/tinymce-login.js new file mode 100644 index 000000000..9791da68c --- /dev/null +++ b/resources/backend/js/tinymce-login.js @@ -0,0 +1,9 @@ +/** + * Registers the TinyMCE Member Content Login Button. + * + * @since 3.4.2 + * + * @author ConvertKit + */ + +convertKitTinyMCERegisterPlugin(convertkit_shortcodes.login); diff --git a/resources/frontend/js/convertkit.js b/resources/frontend/js/convertkit.js index bb82a5dd5..1cfba916d 100644 --- a/resources/frontend/js/convertkit.js +++ b/resources/frontend/js/convertkit.js @@ -83,8 +83,9 @@ function convertKitRecaptchaFormSubmit(token) { // Get the parent form of the submit button. const form = submitButton.closest('form'); - // Submit the form. - form.submit(); + // Submit the form, using requestSubmit() so any submit event listeners are honored + // e.g. the Member Content login form, which submits using AJAX. + form.requestSubmit(); } // Scope the function to the window object as webpack will wrap everything in a closure, @@ -117,8 +118,9 @@ function convertKitTurnstileFormSubmit(token) { return; } - // Submit the form. - form.submit(); + // Submit the form, using requestSubmit() so any submit event listeners are honored + // e.g. the Member Content login form, which submits using AJAX. + form.requestSubmit(); } // Scope the function to the window object as webpack will wrap everything in a closure, diff --git a/resources/frontend/js/restrict-content.js b/resources/frontend/js/restrict-content.js index 9ec1442e7..cc1f340d1 100644 --- a/resources/frontend/js/restrict-content.js +++ b/resources/frontend/js/restrict-content.js @@ -56,8 +56,12 @@ document.addEventListener('DOMContentLoaded', function () { * @param {Event} e Form submission event. */ function convertKitRestrictContentFormSubmit(e) { + // Determine where the response should be displayed; either the embedded login + // form, or the modal. + const container = convertKitRestrictContentContainer(e.target); + // Disable inputs. - document + container .querySelectorAll( 'input[type="text"], input[type="email"], input[type="submit"]' ) @@ -66,13 +70,11 @@ function convertKitRestrictContentFormSubmit(e) { }); // Show loading overlay. - document.querySelector( - '#convertkit-restrict-content-modal-loading' - ).style.display = 'block'; + convertKitRestrictContentLoading(true); // Determine if this is the email or code submission. const isCodeSubmission = - document.querySelector('input#convertkit-subscriber-code') !== null; + container.querySelector('input#convertkit-subscriber-code') !== null; if (isCodeSubmission) { // Code submission. @@ -80,7 +82,8 @@ function convertKitRestrictContentFormSubmit(e) { convertkit_restrict_content.nonce, e.target.querySelector('input[name="subscriber_code"]').value, e.target.querySelector('input[name="token"]').value, - e.target.querySelector('input[name="convertkit_post_id"]').value + e.target.querySelector('input[name="convertkit_post_id"]').value, + container ); return false; @@ -92,8 +95,75 @@ function convertKitRestrictContentFormSubmit(e) { e.target.querySelector('input[name="convertkit_email"]').value, e.target.querySelector('input[name="convertkit_resource_type"]').value, e.target.querySelector('input[name="convertkit_resource_id"]').value, - e.target.querySelector('input[name="convertkit_post_id"]').value + e.target.querySelector('input[name="convertkit_post_id"]').value, + convertKitRestrictContentSpamProtectionResponse(e.target), + container + ); +} + +/** + * Returns whether the heading should be displayed above the login form, which is + * the case when the modal is used. + * + * @since 3.4.2 + * + * @param {Object} container Container element. + * @return {boolean} Display the heading. + */ +function convertKitRestrictContentDisplayHeading(container) { + return container.id === 'convertkit-restrict-content-modal-content'; +} + +/** + * Returns the element to display the response in for the given form, which is + * either the embedded login form's container, or the modal's content. + * + * @since 3.4.2 + * + * @param {Object} form Form element. + * @return {Object} Container element. + */ +function convertKitRestrictContentContainer(form) { + return ( + form.closest('.convertkit-restrict-content-content') || + document.querySelector('#convertkit-restrict-content-modal-content') + ); +} + +/** + * Shows or hides the modal's loading overlay, if the modal is used. + * + * @since 3.4.2 + * + * @param {boolean} display Display the loading overlay. + */ +function convertKitRestrictContentLoading(display) { + const loading = document.querySelector( + '#convertkit-restrict-content-modal-loading' ); + + if (loading === null) { + return; + } + + loading.style.display = display ? 'block' : 'none'; +} + +/** + * Returns the spam protection response for the given form, if a spam protection + * provider is enabled. + * + * @since 3.4.2 + * + * @param {Object} form Form element. + * @return {string} Spam protection response. + */ +function convertKitRestrictContentSpamProtectionResponse(form) { + const field = form.querySelector( + '[name="g-recaptcha-response"], [name="cf-turnstile-response"]' + ); + + return field === null ? '' : field.value; } /** @@ -134,18 +204,22 @@ function convertKitRestrictContentCloseModal() { * * @since 2.3.8 * - * @param {string} nonce WordPress nonce. - * @param {string} email Email address. - * @param {string} resource_type Resource Type (form|tag|product). - * @param {string} resource_id Resource ID (Kit Form,Tag or Product ID). - * @param {number} post_id WordPress Post ID being viewed / accessed. + * @param {string} nonce WordPress nonce. + * @param {string} email Email address. + * @param {string} resource_type Resource Type (form|tag|product). + * @param {string} resource_id Resource ID (Kit Form,Tag or Product ID). + * @param {number} post_id WordPress Post ID being viewed / accessed. + * @param {string} spam_protection_response Spam protection response. + * @param {Object} container Element to display the response in. */ function convertKitRestrictContentSubscriberAuthenticationSendCode( nonce, email, resource_type, resource_id, - post_id + post_id, + spam_protection_response, + container ) { fetch(convertkit_restrict_content.subscriber_authentication_url, { method: 'POST', @@ -158,6 +232,8 @@ function convertKitRestrictContentSubscriberAuthenticationSendCode( convertkit_resource_type: resource_type, convertkit_resource_id: resource_id, convertkit_post_id: post_id, + spam_protection_response, + display_heading: convertKitRestrictContentDisplayHeading(container), }), }) .then(function (response) { @@ -174,22 +250,16 @@ function convertKitRestrictContentSubscriberAuthenticationSendCode( // Output error message if the response contains a code. if (typeof result.code !== 'undefined') { - document.querySelector( - '#convertkit-restrict-content-modal-content' - ).innerHTML = result.message; + container.innerHTML = result.message; } else { // Output response, which will be either: // - the email form view, with an error message e.g. invalid email, // - the code form view, where the user can enter the OTP. - document.querySelector( - '#convertkit-restrict-content-modal-content' - ).innerHTML = result.data; + container.innerHTML = result.data; } // Hide loading overlay. - document.querySelector( - '#convertkit-restrict-content-modal-loading' - ).style.display = 'none'; + convertKitRestrictContentLoading(false); // Re-bind OTP listener. convertKitRestrictContentOTPField(); @@ -213,12 +283,14 @@ function convertKitRestrictContentSubscriberAuthenticationSendCode( * @param {string} subscriber_code OTP Subscriber Code. * @param {string} token Subscriber Token. * @param {number} post_id WordPress Post ID being viewed / accessed. + * @param {Object} container Element to display the response in. */ function convertKitRestrictContentSubscriberVerification( nonce, subscriber_code, token, - post_id + post_id, + container ) { fetch(convertkit_restrict_content.subscriber_verification_url, { method: 'POST', @@ -244,16 +316,12 @@ function convertKitRestrictContentSubscriberVerification( console.log(result); } - // If the entered code is invalid, show the response in the modal. + // If the entered code is invalid, show the response. if (!result.success) { - document.querySelector( - '#convertkit-restrict-content-modal-content' - ).innerHTML = result.data; + container.innerHTML = result.data; // Hide loading overlay. - document.querySelector( - '#convertkit-restrict-content-modal-loading' - ).style.display = 'none'; + convertKitRestrictContentLoading(false); // Re-bind OTP listener. convertKitRestrictContentOTPField(); diff --git a/resources/frontend/scss/_restrict-content.scss b/resources/frontend/scss/_restrict-content.scss index a5533253e..ed8eb1c01 100644 --- a/resources/frontend/scss/_restrict-content.scss +++ b/resources/frontend/scss/_restrict-content.scss @@ -314,3 +314,13 @@ form.convertkit-restrict-content-form { padding: 40px; } } + +/* Member Content Login block and shortcode */ +#convertkit-restrict-content.convertkit-login { + + /* Honor the block's text color, which the ID selectors above would otherwise override. */ + p, + small { + color: inherit; + } +} diff --git a/tests/EndToEnd/restrict-content/blocks-shortcodes/PageBlockMemberContentLoginCest.php b/tests/EndToEnd/restrict-content/blocks-shortcodes/PageBlockMemberContentLoginCest.php new file mode 100644 index 000000000..2c8b9cbbc --- /dev/null +++ b/tests/EndToEnd/restrict-content/blocks-shortcodes/PageBlockMemberContentLoginCest.php @@ -0,0 +1,130 @@ +activateKitPlugin($I); + } + + /** + * Test the Member Content Login block's login flow, entering an invalid email + * address, a valid email address and an invalid code. + * + * @since 3.4.2 + * + * @param EndToEndTester $I Tester. + */ + public function testMemberContentLoginBlock(EndToEndTester $I) + { + // Setup Plugin and Resources. + $I->setupKitPluginNoDefaultForms($I); + $I->setupKitPluginResources($I); + + // Add a Page using the Gutenberg editor. + $I->addGutenbergPage( + $I, + title: 'Kit: Page: Member Content Login: Block: Login' + ); + + // Configure metabox's Form setting = None, ensuring we only test the block in Gutenberg. + $I->configurePluginSidebarSettings( + $I, + form: 'None' + ); + + // Add block to Page. + $I->addGutenbergBlock( + $I, + blockName: 'Kit Member Content Login', + blockProgrammaticName: 'convertkit-login' + ); + + // Publish and view the Page on the frontend site. + $I->publishAndViewGutenbergPage($I); + + // Confirm the login form is displayed, and no modal is used. + $I->seeElementInDOM('input#convertkit_email'); + $I->see('email you a magic code to log you in without a password'); + $I->dontSeeElementInDOM('a.convertkit-restrict-content-modal-open'); + $I->dontSeeElementInDOM('#convertkit-restrict-content-modal'); + + // Confirm the Member Content heading and text are not displayed. + $I->dontSee('Log in to read this post'); + $I->dontSee('Already subscribed?'); + + // Get the default Member Content settings, which define the expected text. + $settings = $I->getRestrictedContentDefaultSettings(); + + // Log in as a Kit subscriber who does not exist in Kit. + $I->loginToRestrictContentWithEmail($I, 'fail@kit.com'); + + // Confirm an inline error message is displayed. + $I->seeRestrictContentError($I, 'invalid: Email address is invalid'); + + // Confirm the Member Content heading is not displayed with the error. + $I->dontSee('Log in to read this post'); + + // Log in as a Kit subscriber. + $I->loginToRestrictContentWithEmail($I, $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']); + + // Confirm that the subscriber code form displays. + $I->seeRestrictContentSubscriberCode($I, $settings['email_check_heading'], $settings['email_check_text']); + + // Enter an invalid code. + $I->submitRestrictContentSubscriberCodeModal($I, '999999'); + + // Confirm an inline error message is displayed. + $I->seeRestrictContentError($I, 'The entered code is invalid. Please try again, or click the link sent in the email.'); + + // Test that the restricted content displays when a valid signed subscriber ID is used, + // as if we entered the code sent in the email. + $I->setRestrictContentCookie($I, $_ENV['CONVERTKIT_API_SIGNED_SUBSCRIBER_ID']); + $I->reloadPage(); + + // Confirm the logged in text and log out button are displayed. + $I->waitForElementVisible('a.convertkit-restrict-content-logout'); + $I->see('You are logged in'); + + // Log out. + $I->click('a.convertkit-restrict-content-logout'); + + // Confirm the login form is displayed, and the subscriber is logged out. + $I->waitForElementVisible('input#convertkit_email'); + $I->waitForElementNotVisible('a.convertkit-restrict-content-logout'); + $I->dontSee('You are logged in'); + $I->dontSeeCookie('ck_subscriber_id'); + } + + /** + * Deactivate and reset Plugin(s) after each test, if the test passes. + * We don't use _after, as this would provide a screenshot of the Plugin + * deactivation and not the true test error. + * + * @since 3.4.2 + * + * @param EndToEndTester $I Tester. + */ + public function _passed(EndToEndTester $I) + { + $I->clearRestrictContentCookie($I); + $I->deactivateKitPlugin($I); + $I->resetKitPlugin($I); + } +} diff --git a/tests/EndToEnd/restrict-content/blocks-shortcodes/PageShortcodeMemberContentLoginCest.php b/tests/EndToEnd/restrict-content/blocks-shortcodes/PageShortcodeMemberContentLoginCest.php new file mode 100644 index 000000000..91928095e --- /dev/null +++ b/tests/EndToEnd/restrict-content/blocks-shortcodes/PageShortcodeMemberContentLoginCest.php @@ -0,0 +1,168 @@ +activateKitPlugin($I); + } + + /** + * Test the [convertkit_login] shortcode works using the Classic Editor + * (TinyMCE / Visual). + * + * @since 3.4.2 + * + * @param EndToEndTester $I Tester. + */ + public function testMemberContentLoginShortcodeInVisualEditor(EndToEndTester $I) + { + // Setup Plugin and Resources. + $I->setupKitPluginNoDefaultForms($I); + $I->setupKitPluginResources($I); + + // Add a Page using the Classic Editor. + $I->addClassicEditorPage( + $I, + title: 'Kit: Page: Member Content Login: Shortcode: Visual Editor' + ); + + // Add shortcode to Page, defining the logged in text and log out button label. + $I->addVisualEditorShortcode( + $I, + shortcodeName: 'Kit Member Content Login', + shortcodeConfiguration: [ + 'logged_in_text' => [ 'input', 'You are signed in' ], + 'logout_button_label' => [ 'input', 'Sign out' ], + ], + expectedShortcodeOutput: '[convertkit_login logged_in_text="You are signed in" logout_button_label="Sign out"]' + ); + + // Publish and view the Page on the frontend site. + $I->publishAndViewClassicEditorPage($I); + + // Confirm the login form is displayed, and no modal is used. + $I->seeElementInDOM('input#convertkit_email'); + $I->see('email you a magic code to log you in without a password'); + $I->dontSeeElementInDOM('a.convertkit-restrict-content-modal-open'); + $I->dontSeeElementInDOM('#convertkit-restrict-content-modal'); + + // Confirm the Member Content heading and text are not displayed. + $I->dontSee('Log in to read this post'); + $I->dontSee('Already subscribed?'); + + // Get the default Member Content settings, which define the expected text. + $settings = $I->getRestrictedContentDefaultSettings(); + + // Log in as a Kit subscriber who does not exist in Kit. + $I->loginToRestrictContentWithEmail($I, 'fail@kit.com'); + + // Confirm an inline error message is displayed. + $I->seeRestrictContentError($I, 'invalid: Email address is invalid'); + + // Confirm the Member Content heading is not displayed with the error. + $I->dontSee('Log in to read this post'); + + // Log in as a Kit subscriber. + $I->loginToRestrictContentWithEmail($I, $_ENV['CONVERTKIT_API_SUBSCRIBER_EMAIL']); + + // Confirm that the subscriber code form displays. + $I->seeRestrictContentSubscriberCode($I, $settings['email_check_heading'], $settings['email_check_text']); + + // Enter an invalid code. + $I->submitRestrictContentSubscriberCodeModal($I, '999999'); + + // Confirm an inline error message is displayed. + $I->seeRestrictContentError($I, 'The entered code is invalid. Please try again, or click the link sent in the email.'); + + // Test that the restricted content displays when a valid signed subscriber ID is used, + // as if we entered the code sent in the email. + $I->setRestrictContentCookie($I, $_ENV['CONVERTKIT_API_SIGNED_SUBSCRIBER_ID']); + $I->reloadPage(); + + // Confirm the logged in text and log out button are displayed. + $I->waitForElementVisible('a.convertkit-restrict-content-logout'); + $I->see('You are signed in'); + + // Log out. + $I->click('a.convertkit-restrict-content-logout'); + + // Confirm the login form is displayed, and the subscriber is logged out. + $I->waitForElementVisible('input#convertkit_email'); + $I->waitForElementNotVisible('a.convertkit-restrict-content-logout'); + $I->dontSee('You are signed in'); + $I->dontSeeCookie('ck_subscriber_id'); + } + + /** + * Test the [convertkit_login] shortcode works using the Text Editor. + * + * @since 3.4.2 + * + * @param EndToEndTester $I Tester. + */ + public function testMemberContentLoginShortcodeInTextEditor(EndToEndTester $I) + { + // Setup Plugin and Resources. + $I->setupKitPluginNoDefaultForms($I); + $I->setupKitPluginResources($I); + + // Add a Page using the Classic Editor. + $I->addClassicEditorPage( + $I, + title: 'Kit: Page: Member Content Login: Shortcode: Text Editor' + ); + + // Add shortcode to Page, defining the logged in text and log out button label. + $I->addTextEditorShortcode( + $I, + shortcodeProgrammaticName: 'convertkit-login', + shortcodeConfiguration: [ + 'logged_in_text' => [ 'input', 'You are signed in' ], + 'logout_button_label' => [ 'input', 'Sign out' ], + ], + expectedShortcodeOutput: '[convertkit_login logged_in_text="You are signed in" logout_button_label="Sign out"]' + ); + + // Publish and view the Page on the frontend site. + $I->publishAndViewClassicEditorPage($I); + + // Confirm that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Confirm the login form is displayed. + $I->seeElementInDOM('input#convertkit_email'); + } + + /** + * Deactivate and reset Plugin(s) after each test, if the test passes. + * We don't use _after, as this would provide a screenshot of the Plugin + * deactivation and not the true test error. + * + * @since 3.4.2 + * + * @param EndToEndTester $I Tester. + */ + public function _passed(EndToEndTester $I) + { + $I->clearRestrictContentCookie($I); + $I->deactivateKitPlugin($I); + $I->resetKitPlugin($I); + } +} diff --git a/views/frontend/restrict-content/login-email.php b/views/frontend/restrict-content/login-email.php index 9d3a80b66..1e00550f2 100644 --- a/views/frontend/restrict-content/login-email.php +++ b/views/frontend/restrict-content/login-email.php @@ -8,11 +8,31 @@ * @author ConvertKit */ +// Resource type and ID aren't defined when the Member Content Login block is used. +$resource_type = ( isset( $resource_type ) ? $resource_type : '' ); +$resource_id = ( isset( $resource_id ) ? $resource_id : 0 ); ?>