Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: reader registration on donate #1655

Merged
merged 22 commits into from
May 27, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions includes/class-newspack.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/class-api.php';
include_once NEWSPACK_ABSPATH . 'includes/class-profile.php';
include_once NEWSPACK_ABSPATH . 'includes/class-analytics.php';
include_once NEWSPACK_ABSPATH . 'includes/class-reader-activation.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-revenue/class-stripe-connection.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-revenue/class-woocommerce-connection.php';
include_once NEWSPACK_ABSPATH . 'includes/reader-revenue/class-reader-revenue-emails.php';
Expand Down
323 changes: 323 additions & 0 deletions includes/class-reader-activation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
<?php
/**
* Reader Activation.
*
* @package Newspack
*/

namespace Newspack;

defined( 'ABSPATH' ) || exit;

/**
* Reader Activation Class.
*/
final class Reader_Activation {

const AUTH_INTENTION_COOKIE = 'np_auth_intention';

/**
* Reader user meta keys.
*/
const READER = 'np_reader';
const EMAIL_VERIFIED = 'np_reader_email_verified';

/**
* Initialize hooks.
*/
public static function init() {
if ( self::is_enabled() ) {
\add_action( 'clear_auth_cookie', [ __CLASS__, 'clear_auth_intention_cookie' ] );
adekbadek marked this conversation as resolved.
Show resolved Hide resolved
\add_action( 'set_auth_cookie', [ __CLASS__, 'clear_auth_intention_cookie' ] );
\add_filter( 'login_form_defaults', [ __CLASS__, 'add_auth_intention_to_login_form' ], 20 );
\add_action( 'resetpass_form', [ __CLASS__, 'set_reader_verified' ] );
\add_action( 'password_reset', [ __CLASS__, 'set_reader_verified' ] );
}
}

/**
* Whether reader activation is enabled.
*
* @return bool True if reader activation is enabled.
*/
public static function is_enabled() {
$is_enabled = defined( 'NEWSPACK_EXPERIMENTAL_READER_ACTIVATION' ) && NEWSPACK_EXPERIMENTAL_READER_ACTIVATION;

/**
* Filters whether reader activation is enabled.
*
* @param bool $is_enabled Whether reader activation is enabled.
*/
return \apply_filters( 'newspack_reader_activation_enabled', $is_enabled );
}

/**
* Add auth intention email to login form defaults.
*
* @param array $defaults Login form defaults.
*
* @return array
*/
public static function add_auth_intention_to_login_form( $defaults ) {
$email = self::get_auth_intention();
if ( ! empty( $email ) ) {
$defaults['label_username'] = __( 'Email address', 'newspack' );
$defaults['value_username'] = $email;
adekbadek marked this conversation as resolved.
Show resolved Hide resolved
}
return $defaults;
}

/**
* Clear the auth intention cookie.
*/
public static function clear_auth_intention_cookie() {
/** This filter is documented in wp-includes/pluggable.php */
if ( ! apply_filters( 'send_auth_cookies', true ) ) {
return;
}

// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.cookies_setcookie
setcookie( self::AUTH_INTENTION_COOKIE, ' ', time() - YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN );
}

/**
* Set the auth intention cookie.
*
* @param string $email Email address.
*/
public static function set_auth_intention_cookie( $email ) {
/** This filter is documented in wp-includes/pluggable.php */
if ( ! apply_filters( 'send_auth_cookies', true ) ) {
return;
}

/**
* Filters the duration of the auth intention cookie expiration period.
*
* @param int $length Duration of the expiration period in seconds.
* @param string $email Email address.
*/
$expire = time() + \apply_filters( 'newspack_auth_intention_expiration', 30 * DAY_IN_SECONDS, $email );
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.cookies_setcookie
setcookie( self::AUTH_INTENTION_COOKIE, $email, $expire, COOKIEPATH, COOKIE_DOMAIN, true );
}

/**
* Get the auth intention.
*
* @return string|null Email address or null if not set.
*/
public static function get_auth_intention() {
adekbadek marked this conversation as resolved.
Show resolved Hide resolved
$email_address = null;
if ( isset( $_COOKIE[ self::AUTH_INTENTION_COOKIE ] ) ) {
// phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE
$email_address = \sanitize_email( $_COOKIE[ self::AUTH_INTENTION_COOKIE ] );
}
/**
* Filters the session auth intention email address.
*
* @param string|null $email_address Email address or null if not set.
*/
return \apply_filters( 'newspack_auth_intention', $email_address );
}

/**
* Whether the user is a reader.
*
* @param \WP_User $user User object.
*
* @return bool Whether the user is a reader.
*/
public static function is_user_reader( $user ) {
$is_reader = (bool) \get_user_meta( $user->ID, self::READER, true );

if ( false === $is_reader ) {
/**
* Filters the roles that can determine if a user is a reader.
*
* @param string[] $roles Array of user roles.
*/
$reader_roles = \apply_filters( 'newspack_reader_user_roles', [ 'subscriber', 'customer' ] );
if ( ! empty( $reader_roles ) ) {
$user_data = \get_userdata( $user->ID );
$is_reader = ! empty( array_intersect( $reader_roles, $user_data->roles ) );
}
}

/**
* Filters whether the user is a reader.
*
* @param bool $is_reader Whether the user is a reader.
* @param \WP_User $user User object.
*/
return \apply_filters( 'newspack_is_user_reader', $is_reader, $user );
}

/**
* Verify email address of a reader given the user.
*
* @param \WP_User $user User object.
*
* @return bool Whether the email address was verified.
*/
public static function set_reader_verified( $user ) {
if ( ! $user ) {
return false;
}

/** Should not verify email if user is not a reader. */
if ( ! self::is_user_reader( $user ) ) {
return false;
}

$verified = \get_user_meta( $user->ID, self::EMAIL_VERIFIED, true );
if ( $verified ) {
return true;
}

\update_user_meta( $user->ID, self::EMAIL_VERIFIED, true );

return true;
}

/**
* Check if current reader has its email verified.
*
* @param \WP_User $user User object.
*
* @return bool|null Whether the email address is verified, null if invalid user.
*/
public static function is_reader_verified( $user ) {
if ( ! $user ) {
return null;
}

/** Should not verify email if user is not a reader. */
if ( ! self::is_user_reader( $user ) ) {
return null;
}

return (bool) \get_user_meta( $user->ID, self::EMAIL_VERIFIED, true );
}

/**
* Authenticate a reader session given its user ID.
*
* Warning: this method will only verify if the user is a reader in order to
* authenticate. It will not check for any credentials.
*
* @param int $user_id User ID.
*
* @return \WP_User|\WP_Error The authenticated reader or WP_Error if authentication failed.
*/
private static function set_current_reader( $user_id ) {
$user_id = \absint( $user_id );
if ( empty( $user_id ) ) {
return new \WP_Error( 'newspack_authenticate_invalid_user_id', __( 'Invalid user id.', 'newspack' ) );
}

$user = \get_user_by( 'id', $user_id );
if ( ! $user || \is_wp_error( $user ) || ! self::is_user_reader( $user ) ) {
return new \WP_Error( 'newspack_authenticate_invalid_user', __( 'Invalid user.', 'newspack' ) );
}

\wp_clear_auth_cookie();
\wp_set_current_user( $user->ID );
\wp_set_auth_cookie( $user->ID, true );
\do_action( 'wp_login', $user->user_login, $user );

return $user;
}

/**
* Register a reader given its email.
*
* Due to authentication or auth intention, this method should be used
* preferably on POST or API requests to avoid issues with caching.
*
* @param string $email Email address.
* @param string $display_name Reader display name to be used on account creation.
* @param bool $authenticate Whether to authenticate after registering. Default to true.
*
* @return int|false|\WP_Error The created user ID in case of registration, false if the user already exists, or a WP_Error object.
*/
public static function register_reader( $email, $display_name = '', $authenticate = true ) {
if ( ! self::is_enabled() ) {
return new \WP_Error( 'newspack_register_reader_disabled', __( 'Registration is disabled.', 'newspack' ) );
}

if ( \is_user_logged_in() ) {
return new \WP_Error( 'newspack_register_reader_logged_in', __( 'Cannot register while logged in.', 'newspack' ) );
}

$email = \sanitize_email( $email );

if ( empty( $email ) ) {
return new \WP_Error( 'newspack_register_reader_empty_email', __( 'Please enter a valid email address.', 'newspack' ) );
}

self::set_auth_intention_cookie( $email );

$existing_user = \get_user_by( 'email', $email );
if ( \is_wp_error( $existing_user ) ) {
return $existing_user;
}

$user_id = false;

if ( ! $existing_user ) {
/**
* Create new reader.
*/
if ( empty( $display_name ) ) {
$display_name = explode( '@', $email, 2 )[0];
}

$random_password = \wp_generate_password();

if ( function_exists( '\wc_create_new_customer' ) ) {
/**
* Create WooCommerce Customer if possible.
*
* Email notification for WooCommerce is handled by the plugin.
*/
$user_id = \wc_create_new_customer( $email, $email, $random_password, [ 'display_name' => $display_name ] );
adekbadek marked this conversation as resolved.
Show resolved Hide resolved
} else {
$user_id = \wp_insert_user(
[
'user_login' => $email,
adekbadek marked this conversation as resolved.
Show resolved Hide resolved
'user_email' => $email,
'user_pass' => $random_password,
'display_name' => $display_name,
]
);
\wp_new_user_notification( $user_id, null, 'user' );
}

if ( \is_wp_error( $user_id ) ) {
return $user_id;
}

/** Add default reader related meta. */
\update_user_meta( $user_id, self::READER, true );
\update_user_meta( $user_id, self::EMAIL_VERIFIED, false );

if ( $authenticate ) {
self::set_current_reader( $user_id );
}
}

/**
* Action after registering and authenticating a reader.
*
* @param string $email Email address.
* @param bool $authenticate Whether to authenticate after registering.
* @param false|int $user_id The created user id.
* @param false|\WP_User $existing_user The existing user object.
*/
\do_action( 'newspack_registered_reader', $email, $authenticate, $user_id, $existing_user );

return $user_id;
}
}
Reader_Activation::init();
9 changes: 9 additions & 0 deletions includes/reader-revenue/class-stripe-connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,15 @@ public static function handle_donation( $config ) {
$payment_metadata = $config['payment_metadata'];
$payment_method_id = $config['payment_method_id'];

if ( ! isset( $client_metadata['userId'] ) && Reader_Activation::is_enabled() ) {
$user_id = Reader_Activation::register_reader( $email_address, $full_name, true, false );
if ( \is_wp_error( $user_id ) ) {
return $user_id;
} elseif ( false !== $user_id ) {
$client_metadata['userId'] = $user_id;
}
}

$customer = self::upsert_customer(
[
'email' => $email_address,
Expand Down
28 changes: 19 additions & 9 deletions includes/reader-revenue/class-woocommerce-connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,26 @@ public static function set_up_membership( $email_address, $full_name, $frequency
}
}
if ( $should_create_account ) {
Logger::log( 'This order will result in a membership, creating account for user.' );
$user_login = sanitize_title( $full_name );
$user_id = wc_create_new_customer( $email_address, $user_login, '', [ 'display_name' => $full_name ] );
if ( is_wp_error( $user_id ) ) {
return $user_id;
if ( Reader_Activation::is_enabled() ) {
$user_id = Reader_Activation::register_reader( $email_address, $full_name );
if ( is_wp_error( $user_id ) ) {
return $user_id;
}
if ( ! absint( $user_id ) ) {
$user_id = null;
}
} else {
Logger::log( 'This order will result in a membership, creating account for user.' );
$user_login = sanitize_title( $full_name );
$user_id = wc_create_new_customer( $email_address, $user_login, '', [ 'display_name' => $full_name ] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user creation is duplicated – here and in the Reader Activation module. The module should expose a method for handling user creation, and the is_enabled only used for the reader-activation-flow specific tasks: currently adding the meta fields. Or, the user creation should be delegated to the main Newspack class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't call duplicated, the original intended behavior is preserved while reader activation is experimental.

WP core already provides a straightforward API for generic user creation that should be used if not making use of reader activation logic. I don't see why reader activation would provide methods outside of its concept.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about a method that would either wp_insert_user or wc_create_new_customer – but on a second thought it should not be used here because at this point we only wanna handle customer creation, not user creation. I was overthinking it.

Still, I think it'd be more readable if Reader_Activation - in this code block - only set metadata on the created user, not take over the user creation and flow. Something like:

$user_id = wc_create_new_customerif ( Reader_Activation::is_enabled() ) {
  Reader_Activation::setup_reader( $user_id );
}
wp_set_current_user …

But it's not a blocker.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A "reader" is a fairly flexible concept:

https://github.com/Automattic/newspack-plugin/blob/feat/reader-activation-donate-block/includes/class-reader-activation.php#L131-L154

Currently any subscriber or customer is eligible to use the reader-related tools, including register_reader( $email ). What happens when we call setup_reader( $user_id )?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify on why we have the user meta to also determine the same thing: the user roles that determine a reader are filterable. If by any chance this is filtered to an empty array, we must still be able to know readers that were created through this flow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when we call setup_reader( $user_id )?

The meta fields are set up. Actually, it can be register_reader – it can be called with an existing user ID.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting idea, we should probably do it in a separate method to not necessarily trigger all the side effects of a reader registration if we just want to set the user as a reader.

If you don't mind, I think we could work on that on a separate PR, once we have more practical use on other cases.

if ( is_wp_error( $user_id ) ) {
return $user_id;
}

// Log the new user in.
wp_set_current_user( $user_id, $user_login );
wp_set_auth_cookie( $user_id );
}

// Log the new user in.
wp_set_current_user( $user_id, $user_login );
wp_set_auth_cookie( $user_id );
return $user_id;
}
}
Expand Down
Loading