diff --git a/modules/checkout/config/install/commerce_checkout.commerce_checkout_flow.default.yml b/modules/checkout/config/install/commerce_checkout.commerce_checkout_flow.default.yml index 7d5c31b25a..a36037b307 100644 --- a/modules/checkout/config/install/commerce_checkout.commerce_checkout_flow.default.yml +++ b/modules/checkout/config/install/commerce_checkout.commerce_checkout_flow.default.yml @@ -10,6 +10,7 @@ configuration: panes: login: allow_guest_checkout: true + show_registration_form: false step: login weight: 0 contact_information: diff --git a/modules/checkout/config/schema/commerce_checkout.schema.yml b/modules/checkout/config/schema/commerce_checkout.schema.yml index e7d1c75dc3..a038db03d7 100644 --- a/modules/checkout/config/schema/commerce_checkout.schema.yml +++ b/modules/checkout/config/schema/commerce_checkout.schema.yml @@ -73,6 +73,9 @@ commerce_checkout.commerce_checkout_pane.login: allow_guest_checkout: type: boolean label: 'Allow guest checkout' + show_registration_form: + type: boolean + label: 'Show registration form' commerce_checkout_pane_configuration: type: mapping diff --git a/modules/checkout/css/commerce_checkout.layout.css b/modules/checkout/css/commerce_checkout.layout.css index d7c9481368..e240028b15 100644 --- a/modules/checkout/css/commerce_checkout.layout.css +++ b/modules/checkout/css/commerce_checkout.layout.css @@ -11,7 +11,8 @@ box-sizing: border-box; } -.form-wrapper__returning-customer input:not([type="submit"]) { +.form-wrapper__returning-customer input:not([type="submit"]), +.form-wrapper__login-option input[type="email"] { width: 100%; } diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutPane/Login.php b/modules/checkout/src/Plugin/Commerce/CheckoutPane/Login.php index bf4bef293c..00beacca49 100644 --- a/modules/checkout/src/Plugin/Commerce/CheckoutPane/Login.php +++ b/modules/checkout/src/Plugin/Commerce/CheckoutPane/Login.php @@ -5,10 +5,12 @@ use Drupal\commerce\CredentialsCheckFloodInterface; use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\EntityFormBuilderInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Url; +use Drupal\Core\Link; use Drupal\user\UserAuthInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -76,17 +78,20 @@ class Login extends CheckoutPaneBase implements CheckoutPaneInterface, Container * The current user. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. + * @param \Drupal\Core\Entity\EntityFormBuilderInterface $entity_form_builder + * The entity form builder. * @param \Drupal\user\UserAuthInterface $user_auth * The user authentication object. * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack * The request stack. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, CredentialsCheckFloodInterface $credentials_check_flood, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, UserAuthInterface $user_auth, RequestStack $request_stack) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, CredentialsCheckFloodInterface $credentials_check_flood, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, EntityFormBuilderInterface $entity_form_builder, UserAuthInterface $user_auth, RequestStack $request_stack) { parent::__construct($configuration, $plugin_id, $plugin_definition, $checkout_flow); $this->credentialsCheckFlood = $credentials_check_flood; $this->currentUser = $current_user; $this->entityTypeManager = $entity_type_manager; + $this->entityFormBuilder = $entity_form_builder; $this->userAuth = $user_auth; $this->clientIp = $request_stack->getCurrentRequest()->getClientIp(); } @@ -103,6 +108,7 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('commerce.credentials_check_flood'), $container->get('current_user'), $container->get('entity_type.manager'), + $container->get('entity.form_builder'), $container->get('user.auth'), $container->get('request_stack') ); @@ -114,6 +120,7 @@ public static function create(ContainerInterface $container, array $configuratio public function defaultConfiguration() { return [ 'allow_guest_checkout' => TRUE, + 'allow_registration' => FALSE, ] + parent::defaultConfiguration(); } @@ -121,11 +128,12 @@ public function defaultConfiguration() { * {@inheritdoc} */ public function buildConfigurationSummary() { + $summary = $this->t('Login allowed'); if (!empty($this->configuration['allow_guest_checkout'])) { - $summary = $this->t('Guest checkout: Allowed'); + $summary .= '
' . $this->t('Guest checkout allowed'); } - else { - $summary = $this->t('Guest checkout: Not allowed'); + if (!empty($this->configuration['allow_registration'])) { + $summary .= '
' . $this->t('Registration allowed'); } return $summary; @@ -140,6 +148,22 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#type' => 'checkbox', '#title' => $this->t('Allow guest checkout'), '#default_value' => $this->configuration['allow_guest_checkout'], + '#states' => [ + 'visible' => [ + ':input[name="configuration[panes][login][configuration][allow_registration]"]' => ['checked' => FALSE], + ], + ], + ]; + + $form['allow_registration'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Allow registration'), + '#default_value' => $this->configuration['allow_registration'], + '#states' => [ + 'visible' => [ + ':input[name="configuration[panes][login][configuration][allow_guest_checkout]"]' => ['checked' => FALSE], + ], + ], ]; return $form; @@ -154,6 +178,7 @@ public function submitConfigurationForm(array &$form, FormStateInterface $form_s if (!$form_state->getErrors()) { $values = $form_state->getValue($form['#parents']); $this->configuration['allow_guest_checkout'] = !empty($values['allow_guest_checkout']); + $this->configuration['allow_registration'] = !empty($values['allow_registration']); } } @@ -197,12 +222,15 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, '#title' => $this->t('Password'), '#size' => 60, ]; - // @todo Add a "forgotten password" link. $pane_form['returning_customer']['submit'] = [ '#type' => 'submit', '#value' => $this->t('Log in'), '#op' => 'login', ]; + $pane_form['returning_customer']['forgot_password'] = [ + '#type' => 'markup', + '#markup' => Link::createFromRoute($this->t('Forgot password?'), 'user.pass')->toString(), + ]; $pane_form['guest'] = [ '#type' => 'fieldset', @@ -226,6 +254,35 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, '#op' => 'continue', ]; + $pane_form['register'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Create new account'), + '#access' => $this->configuration['allow_registration'], + '#attributes' => [ + 'class' => [ + 'form-wrapper__login-option', + 'form-wrapper__guest-checkout', + ], + ], + ]; + $pane_form['register']['mail'] = [ + '#type' => 'email', + '#title' => $this->t('Email address'), + '#description' => $this->t('A valid email address. All emails from the system will be sent to this address. The email address is not made public and will only be used if you wish to receive a new password or wish to receive certain news or notifications by email.'), + '#required' => FALSE, + ]; + $pane_form['register']['pass'] = [ + '#type' => 'password_confirm', + '#size' => 60, + '#description' => $this->t('Provide a password for the new account in both fields.'), + '#required' => FALSE, + ]; + $pane_form['register']['register'] = [ + '#type' => 'submit', + '#value' => $this->t('Create account and continue'), + '#op' => 'register', + ]; + return $pane_form; } @@ -234,40 +291,92 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, */ public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) { $triggering_element = $form_state->getTriggeringElement(); - if ($triggering_element['#op'] == 'continue') { - // No login in progress, nothing to validate. - return; - } - $name_element = $pane_form['returning_customer']['name']; - $values = $form_state->getValue($pane_form['#parents']); - $username = $values['returning_customer']['name']; - $password = trim($values['returning_customer']['password']); - if (empty($username) || empty($password)) { - $form_state->setErrorByName('name', $this->t('Unrecognized username or password.')); - return; - } - if (user_is_blocked($username)) { - $form_state->setError($name_element, $this->t('The username %name has not been activated or is blocked.', ['%name' => $username])); - return; - } - if (!$this->credentialsCheckFlood->isAllowedHost($this->clientIp)) { - $form_state->setErrorByName($name_element, $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or request a new password.', [':url' => Url::fromRoute('user.pass')])); - $this->credentialsCheckFlood->register($this->clientIp, $username); - return; - } - elseif (!$this->credentialsCheckFlood->isAllowedAccount($this->clientIp, $username)) { - $form_state->setErrorByName($name_element, $this->t('Too many failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.', [':url' => Url::fromRoute('user.pass')])); - $this->credentialsCheckFlood->register($this->clientIp, $username); - return; - } + switch ($triggering_element['#op']) { + case 'continue': + // No login in progress, nothing to validate. + return; - $uid = $this->userAuth->authenticate($username, $password); - if (!$uid) { - $this->credentialsCheckFlood->register($this->clientIp, $username); - $form_state->setErrorByName('name', $this->t('Unrecognized username or password.')); + case 'login': + $name_element = $pane_form['returning_customer']['name']; + $values = $form_state->getValue($pane_form['#parents']); + $username = $values['returning_customer']['name']; + $password = trim($values['returning_customer']['password']); + if (empty($username) || empty($password)) { + $form_state->setErrorByName('name', $this->t('Unrecognized username or password.')); + return; + } + if (user_is_blocked($username)) { + $form_state->setError($name_element, $this->t('The username %name has not been activated or is blocked.', ['%name' => $username])); + return; + } + if (!$this->credentialsCheckFlood->isAllowedHost($this->clientIp)) { + $form_state->setErrorByName($name_element, $this->t('Too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or request a new password.', [':url' => Url::fromRoute('user.pass')])); + $this->credentialsCheckFlood->register($this->clientIp, $username); + return; + } + elseif (!$this->credentialsCheckFlood->isAllowedAccount($this->clientIp, $username)) { + $form_state->setErrorByName($name_element, $this->t('Too many failed login attempts for this account. It is temporarily blocked. Try again later or request a new password.', [':url' => Url::fromRoute('user.pass')])); + $this->credentialsCheckFlood->register($this->clientIp, $username); + return; + } + + $uid = $this->userAuth->authenticate($username, $password); + if (!$uid) { + $this->credentialsCheckFlood->register($this->clientIp, $username); + $form_state->setErrorByName('name', $this->t('Unrecognized username or password.')); + } + $form_state->set('logged_in_uid', $uid); + break; + + case 'register': + $values = $form_state->getValue($pane_form['#parents']); + + // Basic validation to check if fields are filled in. + if (empty($values['register']['mail'])) { + $form_state->setErrorByName('mail', $this->t('Email is mandatory.')); + return; + } + if (empty($values['register']['pass'])) { + $form_state->setErrorByName('pass', $this->t('Password is mandatory.')); + return; + } + + // Advanced validation Make sure the account does not exist yet. And + // that the username is unused/valid. + $user_storage = $this->entityTypeManager->getStorage('user'); + if ($user_storage->loadByProperties(['mail' => $values['register']['mail']])) { + $form_state->setErrorByName('mail', $this->t('A user is already registered with this email.')); + return; + } + if ($user_storage->loadByProperties(['name' => $values['register']['mail']])) { + $form_state->setErrorByName('mail', $this->t('A user is already registered with this username, please contact support to resolve this issue.')); + return; + } + // Make sure the email would be a valid username. + if (user_validate_name($values['register']['mail'])) { + $form_state->setErrorByName('mail', $this->t('The email you have used contains bad characters.')); + return; + } + + // Create the new account. + $account = $this->entityTypeManager->getStorage('user')->create([]); + $account->setEmail($values['register']['mail']); + $account->setUsername($values['register']['mail']); + $account->setPassword($values['register']['pass']); + $account->enforceIsNew(); + $account->activate(); + $account->save(); + + // Login. + $form_state->set('logged_in_uid', $account->id()); + drupal_set_message($this->t('Registration successful. You can now continue the checkout.')); + break; + + default: + $form_state->setError($pane_form['returning_customer']['name'], $this->t('Invalid submission, please submit the form again.')); + break; } - $form_state->set('logged_in_uid', $uid); } /** @@ -275,12 +384,20 @@ public function validatePaneForm(array &$pane_form, FormStateInterface $form_sta */ public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) { $triggering_element = $form_state->getTriggeringElement(); - if ($triggering_element['#op'] == 'login') { - $storage = $this->entityTypeManager->getStorage('user'); - $account = $storage->load($form_state->get('logged_in_uid')); - user_login_finalize($account); - $this->order->setOwner($account); - $this->credentialsCheckFlood->clearAccount($this->clientIp, $account->getAccountName()); + + switch ($triggering_element['#op']) { + case 'login': + case 'register': + $storage = $this->entityTypeManager->getStorage('user'); + /** @var \Drupal\user\UserInterface $account */ + $account = $storage->load($form_state->get('logged_in_uid')); + user_login_finalize($account); + $this->order->setOwner($account); + $this->credentialsCheckFlood->clearAccount($this->clientIp, $account->getAccountName()); + break; + + default: + return; } $form_state->setRedirect('commerce_checkout.form', [ diff --git a/modules/checkout/tests/src/Functional/CheckoutOrderTest.php b/modules/checkout/tests/src/Functional/CheckoutOrderTest.php index e34b325d9d..5a59510a19 100644 --- a/modules/checkout/tests/src/Functional/CheckoutOrderTest.php +++ b/modules/checkout/tests/src/Functional/CheckoutOrderTest.php @@ -90,4 +90,72 @@ public function testGuestOrderCheckout() { $this->assertSession()->pageTextContains('Your order number is 1. You can view your order on your account page when logged in.'); } + /** + * Tests that you can register from the checkout pane. + */ + public function testRegisterOrderCheckout() { + // First we enable the checkout registration. + $config = \Drupal::configFactory()->getEditable('commerce_checkout.commerce_checkout_flow.default'); + $config->set('configuration.panes.login.allow_guest_checkout', FALSE); + $config->set('configuration.panes.login.allow_registration', TRUE); + $config->save(); + + $this->drupalLogout(); + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $cart_link = $this->getSession()->getPage()->findLink('your cart'); + $cart_link->click(); + $this->submitForm([], 'Checkout'); + $this->assertSession()->pageTextContains('Create new account'); + $this->submitForm([ + 'login[register][mail]' => 'guest@example.com', + 'login[register][pass][pass1]' => 'pass', + 'login[register][pass][pass2]' => 'pass', + ], 'Create account and continue'); + $this->assertSession()->pageTextContains('Registration successful. You can now continue the checkout.'); + $this->assertSession()->pageTextContains('Billing information'); + + // Test various validations. We first redo the same as above to emulate a + // double registration. + $this->drupalLogout(); + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $cart_link = $this->getSession()->getPage()->findLink('your cart'); + $cart_link->click(); + $this->submitForm([], 'Checkout'); + $this->assertSession()->pageTextContains('Create new account'); + + // Already used e-mail. + $this->submitForm([ + 'login[register][mail]' => 'guest@example.com', + 'login[register][pass][pass1]' => 'pass', + 'login[register][pass][pass2]' => 'pass', + ], 'Create account and continue'); + $this->assertSession()->pageTextContains('A user is already registered with this email.'); + + // Invalid characters. + $this->submitForm([ + 'login[register][mail]' => 'guest@#.com', + 'login[register][pass][pass1]' => 'pass', + 'login[register][pass][pass2]' => 'pass', + ], 'Create account and continue'); + $this->assertSession()->pageTextContains('The email you have used contains bad characters.'); + + // Empty e-mail. + $this->submitForm([ + 'login[register][mail]' => '', + 'login[register][pass][pass1]' => 'pass', + 'login[register][pass][pass2]' => 'pass', + ], 'Create account and continue'); + $this->assertSession()->pageTextContains('Email is mandatory.'); + + // Empty password. + $this->submitForm([ + 'login[register][mail]' => 'valid@example.com', + 'login[register][pass][pass1]' => '', + 'login[register][pass][pass2]' => '', + ], 'Create account and continue'); + $this->assertSession()->pageTextContains('Password is mandatory.'); + } + }