FEATURE: Check email availability in signup form (#12328)

* FEATURE: Check email availability on focus out

* FIX: Properly debounce username availability
This commit is contained in:
Bianca Nenciu 2021-03-22 17:46:03 +02:00 committed by GitHub
parent 4fb2d397a4
commit ec7415ff49
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 156 additions and 69 deletions

View File

@ -5,7 +5,7 @@ import discourseComputed, {
on, on,
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import { A } from "@ember/array"; import { A } from "@ember/array";
import EmberObject from "@ember/object"; import EmberObject, { action } from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality"; import ModalFunctionality from "discourse/mixins/modal-functionality";
import NameValidation from "discourse/mixins/name-validation"; import NameValidation from "discourse/mixins/name-validation";
@ -17,6 +17,7 @@ import UsernameValidation from "discourse/mixins/username-validation";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { emailValid } from "discourse/lib/utilities"; import { emailValid } from "discourse/lib/utilities";
import { findAll } from "discourse/models/login-method"; import { findAll } from "discourse/models/login-method";
import discourseDebounce from "discourse-common/lib/debounce";
import getURL from "discourse-common/lib/get-url"; import getURL from "discourse-common/lib/get-url";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { notEmpty } from "@ember/object/computed"; import { notEmpty } from "@ember/object/computed";
@ -58,6 +59,8 @@ export default Controller.extend(
accountEmail: "", accountEmail: "",
accountUsername: "", accountUsername: "",
accountPassword: "", accountPassword: "",
serverAccountEmail: null,
serverEmailValidation: null,
authOptions: null, authOptions: null,
complete: false, complete: false,
formSubmitted: false, formSubmitted: false,
@ -130,13 +133,27 @@ export default Controller.extend(
}, },
// Check the email address // Check the email address
@discourseComputed("accountEmail", "rejectedEmails.[]") @discourseComputed(
emailValidation(email, rejectedEmails) { "serverAccountEmail",
"serverEmailValidation",
"accountEmail",
"rejectedEmails.[]"
)
emailValidation(
serverAccountEmail,
serverEmailValidation,
email,
rejectedEmails
) {
const failedAttrs = { const failedAttrs = {
failed: true, failed: true,
element: document.querySelector("#new-account-email"), element: document.querySelector("#new-account-email"),
}; };
if (serverAccountEmail === email && serverEmailValidation) {
return serverEmailValidation;
}
// If blank, fail without a reason // If blank, fail without a reason
if (isEmpty(email)) { if (isEmpty(email)) {
return EmberObject.create( return EmberObject.create(
@ -146,7 +163,7 @@ export default Controller.extend(
); );
} }
if (rejectedEmails.includes(email)) { if (rejectedEmails.includes(email) || !emailValid(email)) {
return EmberObject.create( return EmberObject.create(
Object.assign(failedAttrs, { Object.assign(failedAttrs, {
reason: I18n.t("user.email.invalid"), reason: I18n.t("user.email.invalid"),
@ -168,18 +185,48 @@ export default Controller.extend(
}); });
} }
if (emailValid(email)) { return EmberObject.create({
return EmberObject.create({ ok: true,
ok: true, reason: I18n.t("user.email.ok"),
reason: I18n.t("user.email.ok"), });
}); },
@action
checkEmailAvailability() {
if (
!this.emailValidation.ok ||
this.serverAccountEmail === this.accountEmail
) {
return;
} }
return EmberObject.create( return User.checkEmail(this.accountEmail)
Object.assign(failedAttrs, { .then((result) => {
reason: I18n.t("user.email.invalid"), if (result.failed) {
this.setProperties({
serverAccountEmail: this.accountEmail,
serverEmailValidation: EmberObject.create({
failed: true,
element: document.querySelector("#new-account-email"),
reason: result.errors[0],
}),
});
} else {
this.setProperties({
serverAccountEmail: this.accountEmail,
serverEmailValidation: EmberObject.create({
ok: true,
reason: I18n.t("user.email.ok"),
}),
});
}
}) })
); .catch(() => {
this.setProperties({
serverAccountEmail: null,
serverEmailValidation: null,
});
});
}, },
@discourseComputed( @discourseComputed(
@ -220,7 +267,7 @@ export default Controller.extend(
// If email is valid and username has not been entered yet, // If email is valid and username has not been entered yet,
// or email and username were filled automatically by 3rd parth auth, // or email and username were filled automatically by 3rd parth auth,
// then look for a registered username that matches the email. // then look for a registered username that matches the email.
this.fetchExistingUsername(); discourseDebounce(this, this.fetchExistingUsername, 500);
} }
}, },

View File

@ -29,24 +29,18 @@ export default Mixin.create({
minUsernameLength: setting("min_username_length"), minUsernameLength: setting("min_username_length"),
fetchExistingUsername() { fetchExistingUsername() {
discourseDebounce( User.checkUsername(null, this.accountEmail).then((result) => {
this, if (
function () { result.suggestion &&
User.checkUsername(null, this.accountEmail).then((result) => { (isEmpty(this.accountUsername) ||
if ( this.accountUsername === this.get("authOptions.username"))
result.suggestion && ) {
(isEmpty(this.accountUsername) || this.setProperties({
this.accountUsername === this.get("authOptions.username")) accountUsername: result.suggestion,
) { prefilledUsername: result.suggestion,
this.setProperties({
accountUsername: result.suggestion,
prefilledUsername: result.suggestion,
});
}
}); });
}, }
500 });
);
}, },
@observes("accountUsername") @observes("accountUsername")
@ -55,7 +49,7 @@ export default Mixin.create({
let result = this.basicUsernameValidation(accountUsername); let result = this.basicUsernameValidation(accountUsername);
if (result.shouldCheck) { if (result.shouldCheck) {
this.checkUsernameAvailability(); discourseDebounce(this, this.checkUsernameAvailability, 500);
} }
this.set("usernameValidation", result); this.set("usernameValidation", result);
}, },
@ -84,43 +78,37 @@ export default Mixin.create({
}, },
checkUsernameAvailability() { checkUsernameAvailability() {
discourseDebounce( return User.checkUsername(this.accountUsername, this.accountEmail).then(
this, (result) => {
function () { this.set("isDeveloper", false);
return User.checkUsername(this.accountUsername, this.accountEmail).then( if (result.available) {
(result) => { if (result.is_developer) {
this.set("isDeveloper", false); this.set("isDeveloper", true);
if (result.available) {
if (result.is_developer) {
this.set("isDeveloper", true);
}
return this.set(
"usernameValidation",
validResult({ reason: I18n.t("user.username.available") })
);
} else {
if (result.suggestion) {
return this.set(
"usernameValidation",
failedResult({
reason: I18n.t("user.username.not_available", result),
})
);
} else {
return this.set(
"usernameValidation",
failedResult({
reason: result.errors
? result.errors.join(" ")
: I18n.t("user.username.not_available_no_suggestion"),
})
);
}
}
} }
); return this.set(
}, "usernameValidation",
500 validResult({ reason: I18n.t("user.username.available") })
);
} else {
if (result.suggestion) {
return this.set(
"usernameValidation",
failedResult({
reason: I18n.t("user.username.not_available", result),
})
);
} else {
return this.set(
"usernameValidation",
failedResult({
reason: result.errors
? result.errors.join(" ")
: I18n.t("user.username.not_available_no_suggestion"),
})
);
}
}
}
); );
}, },
}); });

View File

@ -1044,6 +1044,10 @@ User.reopenClass(Singleton, {
}); });
}, },
checkEmail(email) {
return ajax(userPath("check_email"), { data: { email } });
},
groupStats(stats) { groupStats(stats) {
const responses = UserActionStat.create({ const responses = UserActionStat.create({
count: 0, count: 0,

View File

@ -25,7 +25,7 @@
{{#if emailValidated}} {{#if emailValidated}}
<span class="value">{{accountEmail}}</span> <span class="value">{{accountEmail}}</span>
{{else}} {{else}}
{{input type="email" value=accountEmail id="new-account-email" name="email" class=(value-entered accountEmail) autofocus="autofocus"}} {{input type="email" value=accountEmail id="new-account-email" name="email" class=(value-entered accountEmail) autofocus="autofocus" focusOut=(action "checkEmailAvailability")}}
<label class="alt-placeholder" for="new-account-email"> <label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}} {{i18n "user.email.title"}}
{{~#if userFields~}} {{~#if userFields~}}

View File

@ -449,6 +449,10 @@ export function applyDefaultHandlers(pretender) {
return response({ available: true }); return response({ available: true });
}); });
pretender.get("/u/check_email", function () {
return response({ success: "OK" });
});
pretender.post("/u", () => response({ success: true })); pretender.post("/u", () => response({ success: true }));
pretender.get("/login.html", () => [200, {}, "LOGIN PAGE"]); pretender.get("/login.html", () => [200, {}, "LOGIN PAGE"]);

View File

@ -34,6 +34,7 @@ class UsersController < ApplicationController
# once that happens you can't log in with social # once that happens you can't log in with social
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
skip_before_action :redirect_to_login_if_required, only: [:check_username, skip_before_action :redirect_to_login_if_required, only: [:check_username,
:check_email,
:create, :create,
:account_created, :account_created,
:activate_account, :activate_account,
@ -522,6 +523,24 @@ class UsersController < ApplicationController
render json: checker.check_username(username, email) render json: checker.check_username(username, email)
end end
def check_email
RateLimiter.new(nil, "check-email-#{request.remote_ip}", 10, 1.minute).performed!
if SiteSetting.hide_email_address_taken?
return render json: success_json
end
user_email = UserEmail.new(email: params[:email])
if user_email.valid?
render json: success_json
else
render json: failed_json.merge(errors: user_email.errors.full_messages)
end
rescue RateLimiter::LimitExceeded
render json: success_json
end
def user_from_params_or_current_user def user_from_params_or_current_user
params[:for_user_id] ? User.find(params[:for_user_id]) : current_user params[:for_user_id] ? User.find(params[:for_user_id]) : current_user
end end

View File

@ -388,6 +388,7 @@ Discourse::Application.routes.draw do
resources :users, except: [:index, :new, :show, :update, :destroy], path: root_path do resources :users, except: [:index, :new, :show, :update, :destroy], path: root_path do
collection do collection do
get "check_username" get "check_username"
get "check_email"
get "is_local_username" get "is_local_username"
end end
end end

View File

@ -1584,6 +1584,30 @@ describe UsersController do
end end
end end
describe '#check_email' do
it 'returns success if hide_email_address_taken is true' do
SiteSetting.hide_email_address_taken = true
get "/u/check_email.json", params: { email: user.email }
expect(response.parsed_body["success"]).to be_present
end
it 'returns failure if email is not valid' do
get "/u/check_email.json", params: { email: "invalid" }
expect(response.parsed_body["failed"]).to be_present
end
it 'returns failure if email exists' do
get "/u/check_email.json", params: { email: user.email }
expect(response.parsed_body["failed"]).to be_present
end
it 'returns success if email does not exists' do
get "/u/check_email.json", params: { email: "available@example.com" }
expect(response.parsed_body["success"]).to be_present
end
end
describe '#invited' do describe '#invited' do
it 'fails for anonymous users' do it 'fails for anonymous users' do
user = Fabricate(:user) user = Fabricate(:user)