Commit 95bb2d86 authored by Vitaly Slobodin's avatar Vitaly Slobodin Committed by James Lopez

Implement Sign In / Sign Up form for trials

This commit introduces a new Sign In / Sign Up form for trials.
It behaves almost like the original with some exceptions:
- removed ability to sign in via social networks;
- removed captcha on the sign in form;
- field 'name' replaced by two new fields 'first_name' and 'last_name';
parent 64294330
import '~/pages/sessions/index';
import LengthValidator from '~/pages/sessions/new/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer';
import UsernameSuggester from './username_suggester';
document.addEventListener('DOMContentLoaded', () => {
new UsernameValidator(); // eslint-disable-line no-new
new LengthValidator(); // eslint-disable-line no-new
new SigninTabsMemoizer(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
new UsernameSuggester('new_user_username', ['new_user_first_name', 'new_user_last_name']); // eslint-disable-line no-new
});
import { debounce } from 'underscore';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import Flash from '~/flash';
const USERNAME_SUGGEST_DEBOUNCE_TIME = 300;
export default class UsernameSuggester {
/**
* Creates an instance of UsernameSuggester.
* @param {HTMLElement} targetElement target input element id for suggested username
* @param {HTMLElement[]} sourceElementsIds array of HTML input element ids used for generating username
*/
constructor(targetElement, sourceElementsIds = []) {
if (!targetElement) {
throw new Error(__("Required argument 'targetElement' is missing"));
}
this.usernameElement = document.getElementById(targetElement);
if (!this.usernameElement) {
throw new Error(__('The target element is missing.'));
}
this.apiPath = this.usernameElement.dataset.apiPath;
if (!this.apiPath) {
throw new Error(__('The API path was not specified.'));
}
this.sourceElements = sourceElementsIds.map(id => document.getElementById(id)).filter(Boolean);
this.isLoading = false;
this.debouncedSuggestWrapper = debounce(
this.suggestUsername.bind(this),
USERNAME_SUGGEST_DEBOUNCE_TIME,
);
this.bindEvents();
this.cleanupWrapper = this.cleanup.bind(this);
window.addEventListener('beforeunload', this.cleanupWrapper);
}
bindEvents() {
this.sourceElements.forEach(sourceElement => {
sourceElement.addEventListener('change', this.debouncedSuggestWrapper);
});
}
suggestUsername() {
if (this.isLoading) {
return;
}
const name = this.joinSources();
if (!name) {
return;
}
axios
.get(this.apiPath, { params: { name } })
.then(({ data }) => {
this.usernameElement.value = data.username;
})
.catch(() => {
Flash(__('An error occurred while generating a username. Please try again.'));
})
.finally(() => {
this.isLoading = false;
});
}
/**
* Joins values from HTML input elements to a string separated by `_` (underscore).
*/
joinSources() {
return this.sourceElements
.map(el => el.value)
.filter(Boolean)
.join('_');
}
cleanup() {
window.removeEventListener('beforeunload', this.cleanupWrapper);
this.sourceElements.forEach(sourceElement =>
sourceElement.removeEventListener('change', this.debouncedSuggestWrapper),
);
}
}
.trial-page {
@extend .login-page;
}
......@@ -3,6 +3,8 @@
class TrialRegistrationsController < RegistrationsController
extend ::Gitlab::Utils::Override
layout 'trial'
before_action :check_if_gl_com
before_action :check_if_improved_trials_enabled
before_action :set_redirect_url, only: [:new]
......
!!! 5
%html{ lang: "en" }
= render "layouts/head"
%body.ui-indigo.trial-page.application.navless
%body.ui-indigo.login-page.application.navless
= render "layouts/header/logo_with_title"
= render "layouts/broadcast"
.container.navless-container
.content
.container.navless-container.pt-0
.content.mw-460.mx-auto
= render "layouts/flash"
= yield
- if form_based_providers.any?
- if crowd_enabled?
.login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) }
.login-body
= render 'devise/sessions/new_crowd'
= render_if_exists 'devise/sessions/new_kerberos_tab'
- @ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
= render 'devise/sessions/new_ldap', server: server
= render_if_exists 'devise/sessions/new_smartcard'
- if password_authentication_enabled_for_web?
.login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'signin_form'
- elsif password_authentication_enabled_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'signin_form'
= form_for(User.new, url: session_path(:user), html: { id: 'user', class: 'gl-show-field-errors', 'aria-live' => 'assertive'}) do |f|
.form-group
= f.label _('Username or email'), for: 'user_login', class: 'label-bold'
= f.text_field :login, class: 'form-control top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' }
.form-group
= f.label :password, for: 'user_password', class: 'label-bold'
= f.password_field :password, class: 'form-control bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.remember-me.gl-py-4
%label{ for: 'user_remember_me' }
= f.check_box :remember_me, class: 'remember-me-checkbox'
%span
= _('Remember me')
.float-right
- if unconfirmed_email?
= link_to _('Resend confirmation email'), new_user_confirmation_path
- else
= link_to _('Forgot your password?'), new_password_path(:user)
.submit-container.move-submit-down
= f.submit _('Continue'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' }
- max_name_length = 128
- max_username_length = 255
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
.login-body
= form_for(User.new, as: :new_user, url: trial_registrations_path, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: User.new
- if Feature.enabled?(:invisible_captcha)
= invisible_captcha
.name.form-row
.col.form-group
= f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
= f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|First Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.")
.col.form-group
= f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
= f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_name_length, :max_length_message => s_("SignUp|Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, for: 'new_user_username', class: 'label-bold'
= f.text_field :username, class: 'form-control middle js-block-emoji js-validate-length js-validate-username', :data => { :max_length => max_username_length, :api_path => suggestion_path, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.hide= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...')
.form-group
= f.label :email, for: 'new_user_email', class: 'label-bold'
= f.email_field :email, class: 'form-control middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, for: 'new_user_password', class: 'label-bold'
= f.password_field :password, class: 'form-control bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
.form-group
= check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' }
= label_tag :terms_opt_in, for: 'terms_opt_in', class: 'form-check-label' do
- terms_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: terms_path }
= _("I accept the %{terms_link_start}Terms of Service and Privacy Policy%{terms_link_end}").html_safe % { terms_link_start: terms_link_start, terms_link_end: '</a>'.html_safe }
.form-group
= check_box_tag :email_opted_in, '1', false, data: { qa_selector: 'new_user_email_opted_in_checkbox' }
= label_tag :email_opted_in, for: 'email_opted_in', class: 'form-check-label' do
= _("I'd like to receive updates via email about GitLab")
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
= f.submit _("Continue"), class: "btn-register btn", data: { qa_selector: 'new_user_register_button' }
- page_title _('Start a Free Trial')
%h2.center.py-6
= _('Start a Free Trial')
%div
- if form_based_providers.any?
= render 'devise/shared/tabs_ldap'
- else
= render 'devise/shared/tabs_normal'
.tab-content
- if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled?
= render 'signin_box'
-# Signup only makes sense if you can also sign-in
- if allow_signup?
= render 'signup_box'
-# Show a message if none of the mechanisms above are enabled
- if !password_authentication_enabled_for_web? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
= _('No authentication methods configured.')
---
title: Sign in / sign up step for trial
merge_request: 15289
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe 'Trial Sign In' do
let(:user) { create(:user) }
describe 'on GitLab.com' do
before do
stub_feature_flags(improved_trial_signup: true)
allow(Gitlab).to receive(:com?).and_return(true).at_least(:once)
end
it 'logs the user in' do
visit(new_trial_registration_path)
within('div#login-pane') do
fill_in 'user_login', with: user.email
fill_in 'user_password', with: '12345678'
click_button 'Continue'
end
expect(current_path).to eq(new_trial_path)
end
end
describe 'not on GitLab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(false).at_least(:once)
end
it 'returns 404' do
visit(new_trial_registration_path)
expect(status_code).to eq(404)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Trial Sign Up', :js do
let(:user_attrs) { attributes_for(:user, first_name: 'GitLab', last_name: 'GitLab') }
describe 'on GitLab.com' do
before do
stub_feature_flags(invisible_captcha: false)
stub_feature_flags(improved_trial_signup: true)
allow(Gitlab).to receive(:com?).and_return(true).at_least(:once)
end
context 'with the unavailable username' do
let(:existing_user) { create(:user) }
it 'shows the error about existing username' do
visit(new_trial_registration_path)
click_on 'Register'
within('div#register-pane') do
fill_in 'new_user_username', with: existing_user.username
end
expect(page).to have_content('Username is already taken.')
end
end
context 'entering' do
using RSpec::Parameterized::TableSyntax
where(:case_name, :first_name, :last_name, :suggested_username) do
'first name' | 'foobar' | nil | 'foobar'
'last name' | nil | 'foobar' | 'foobar'
'first name and last name' | 'foo' | 'bar' | 'foo_bar'
end
with_them do
it 'suggests the username' do
visit(new_trial_registration_path)
click_on 'Register'
within('div#register-pane') do
fill_in 'new_user_first_name', with: first_name if first_name
fill_in 'new_user_last_name', with: last_name if last_name
end
find('body').click
expect(page).to have_field('new_user_username', with: suggested_username)
end
end
end
end
end
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { setHTMLFixture } from 'helpers/fixtures';
import UsernameSuggester from 'ee/pages/trial_registrations/new/username_suggester';
describe('UsernameSuggester', () => {
let axiosMock;
let suggester;
let firstName;
let lastName;
let username;
const usernameEndPoint = '/-/username';
const expectedUsername = 'foo_bar';
const setupSuggester = (dstElement, srcElementIds) => {
suggester = new UsernameSuggester(dstElement, srcElementIds);
};
beforeEach(() => {
setHTMLFixture(`
<div class="flash-container"></div>
<input type="text" id="first_name">
<input type="text" id="last_name">
<input type="text" id="username" data-api-path="${usernameEndPoint}">
`);
firstName = document.getElementById('first_name');
lastName = document.getElementById('last_name');
username = document.getElementById('username');
});
describe('constructor', () => {
it('sets isLoading to false', () => {
setupSuggester('username', ['first_name']);
expect(suggester.isLoading).toBe(false);
});
it(`sets the apiPath to ${usernameEndPoint}`, () => {
setupSuggester('username', ['first_name']);
expect(suggester.apiPath).toBe(usernameEndPoint);
});
it('throws an error if target element is missing', () => {
expect(() => {
setupSuggester('id_with_that_id_does_not_exist', ['first_name']);
}).toThrow('The target element is missing.');
});
it('throws an error if api path is missing', () => {
setHTMLFixture(`
<input type="text" id="first_name">
<input type="text" id="last_name">
<input type="text" id="username">
`);
expect(() => {
setupSuggester('username', ['first_name']);
}).toThrow('The API path was not specified.');
});
it('throws an error when no arguments were provided', () => {
expect(() => {
setupSuggester();
}).toThrow("Required argument 'targetElement' is missing");
});
});
describe('joinSources', () => {
it('does not add `_` (underscore) with the only input specified', () => {
setupSuggester('username', ['first_name']);
firstName.value = 'foo';
const name = suggester.joinSources();
expect(name).toBe('foo');
});
it('joins values from multiple inputs specified by `_` (underscore)', () => {
setupSuggester('username', ['first_name', 'last_name']);
firstName.value = 'foo';
lastName.value = 'bar';
const name = suggester.joinSources();
expect(name).toBe(expectedUsername);
});
it('returns an empty string if 0 inputs specified', () => {
setupSuggester('username', []);
const name = suggester.joinSources();
expect(name).toBe('');
});
});
describe('suggestUsername', () => {
beforeEach(() => {
axiosMock = new MockAdapter(axios);
setupSuggester('username', ['first_name', 'last_name']);
});
afterEach(() => {
axiosMock.restore();
});
it('does not suggests username if suggester is already running', () => {
suggester.isLoading = true;
expect(axiosMock.history.get.length).toBe(0);
expect(username).toHaveValue('');
});
it('suggests username successfully', () => {
axiosMock
.onGet(usernameEndPoint, { param: { name: expectedUsername } })
.reply(200, { username: expectedUsername });
expect(suggester.isLoading).toBe(false);
firstName.value = 'foo';
lastName.value = 'bar';
suggester.suggestUsername();
setImmediate(() => {
expect(axiosMock.history.get.length).toBe(1);
expect(suggester.isLoading).toBe(false);
expect(username).toHaveValue(expectedUsername);
});
});
it('shows a flash message if request fails', done => {
axiosMock.onGet(usernameEndPoint).replyOnce(500);
expect(suggester.isLoading).toBe(false);
firstName.value = 'foo';
lastName.value = 'bar';
suggester.suggestUsername();
setImmediate(() => {
expect(axiosMock.history.get.length).toBe(1);
expect(suggester.isLoading).toBe(false);
expect(username).toHaveValue('');
expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
'An error occurred while generating a username. Please try again.',
);
done();
});
});
});
});
......@@ -1453,6 +1453,9 @@ msgstr ""
msgid "An error occurred while fetching this tab."
msgstr ""
msgid "An error occurred while generating a username. Please try again."
msgstr ""
msgid "An error occurred while getting projects"
msgstr ""
......@@ -6735,6 +6738,9 @@ msgstr ""
msgid "First day of the week"
msgstr ""
msgid "First name"
msgstr ""
msgid "Fixed date"
msgstr ""
......@@ -8018,6 +8024,9 @@ msgstr ""
msgid "However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation."
msgstr ""
msgid "I accept the %{terms_link_start}Terms of Service and Privacy Policy%{terms_link_end}"
msgstr ""
msgid "I accept the %{terms_link}"
msgstr ""
......@@ -8030,6 +8039,9 @@ msgstr ""
msgid "I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}"
msgstr ""
msgid "I'd like to receive updates via email about GitLab"
msgstr ""
msgid "ID"
msgstr ""
......@@ -8916,6 +8928,9 @@ msgstr ""
msgid "Last edited by %{name}"
msgstr ""
msgid "Last name"
msgstr ""
msgid "Last reply by"
msgstr ""
......@@ -10214,6 +10229,9 @@ msgstr ""
msgid "No application_settings found"
msgstr ""
msgid "No authentication methods configured."
msgstr ""
msgid "No available namespaces to fork the project."
msgstr ""
......@@ -12714,6 +12732,9 @@ msgstr ""
msgid "Releases mark specific points in a project's development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API."
msgstr ""
msgid "Remember me"
msgstr ""
msgid "Remind later"
msgstr ""
......@@ -13056,6 +13077,9 @@ msgstr ""
msgid "Require users to prove ownership of custom domains"
msgstr ""
msgid "Required argument 'targetElement' is missing"
msgstr ""
msgid "Requires approval from %{names}."
msgid_plural "Requires %{count} more approvals from %{names}."
msgstr[0] ""
......@@ -14213,6 +14237,12 @@ msgstr ""
msgid "Sign-up restrictions"
msgstr ""
msgid "SignUp|First Name is too long (maximum is %{max_length} characters)."
msgstr ""
msgid "SignUp|Last Name is too long (maximum is %{max_length} characters)."
msgstr ""
msgid "SignUp|Name is too long (maximum is %{max_length} characters)."
msgstr ""
......@@ -14657,6 +14687,9 @@ msgstr ""
msgid "Start a %{new_merge_request} with these changes"
msgstr ""
msgid "Start a Free Trial"
msgstr ""
msgid "Start a new discussion..."
msgstr ""
......@@ -15238,6 +15271,9 @@ msgid_plural "The %{type} contains the following errors:"
msgstr[0] ""
msgstr[1] ""
msgid "The API path was not specified."
msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
......@@ -15469,6 +15505,9 @@ msgstr ""
msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time."
msgstr ""
msgid "The target element is missing."
msgstr ""
msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running."
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment