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,
} from "discourse-common/utils/decorators";
import { A } from "@ember/array";
import EmberObject from "@ember/object";
import EmberObject, { action } from "@ember/object";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
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 { emailValid } from "discourse/lib/utilities";
import { findAll } from "discourse/models/login-method";
import discourseDebounce from "discourse-common/lib/debounce";
import getURL from "discourse-common/lib/get-url";
import { isEmpty } from "@ember/utils";
import { notEmpty } from "@ember/object/computed";
@ -58,6 +59,8 @@ export default Controller.extend(
accountEmail: "",
accountUsername: "",
accountPassword: "",
serverAccountEmail: null,
serverEmailValidation: null,
authOptions: null,
complete: false,
formSubmitted: false,
@ -130,13 +133,27 @@ export default Controller.extend(
},
// Check the email address
@discourseComputed("accountEmail", "rejectedEmails.[]")
emailValidation(email, rejectedEmails) {
@discourseComputed(
"serverAccountEmail",
"serverEmailValidation",
"accountEmail",
"rejectedEmails.[]"
)
emailValidation(
serverAccountEmail,
serverEmailValidation,
email,
rejectedEmails
) {
const failedAttrs = {
failed: true,
element: document.querySelector("#new-account-email"),
};
if (serverAccountEmail === email && serverEmailValidation) {
return serverEmailValidation;
}
// If blank, fail without a reason
if (isEmpty(email)) {
return EmberObject.create(
@ -146,7 +163,7 @@ export default Controller.extend(
);
}
if (rejectedEmails.includes(email)) {
if (rejectedEmails.includes(email) || !emailValid(email)) {
return EmberObject.create(
Object.assign(failedAttrs, {
reason: I18n.t("user.email.invalid"),
@ -168,18 +185,48 @@ export default Controller.extend(
});
}
if (emailValid(email)) {
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.ok"),
});
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.ok"),
});
},
@action
checkEmailAvailability() {
if (
!this.emailValidation.ok ||
this.serverAccountEmail === this.accountEmail
) {
return;
}
return EmberObject.create(
Object.assign(failedAttrs, {
reason: I18n.t("user.email.invalid"),
return User.checkEmail(this.accountEmail)
.then((result) => {
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(
@ -220,7 +267,7 @@ export default Controller.extend(
// If email is valid and username has not been entered yet,
// or email and username were filled automatically by 3rd parth auth,
// 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"),
fetchExistingUsername() {
discourseDebounce(
this,
function () {
User.checkUsername(null, this.accountEmail).then((result) => {
if (
result.suggestion &&
(isEmpty(this.accountUsername) ||
this.accountUsername === this.get("authOptions.username"))
) {
this.setProperties({
accountUsername: result.suggestion,
prefilledUsername: result.suggestion,
});
}
User.checkUsername(null, this.accountEmail).then((result) => {
if (
result.suggestion &&
(isEmpty(this.accountUsername) ||
this.accountUsername === this.get("authOptions.username"))
) {
this.setProperties({
accountUsername: result.suggestion,
prefilledUsername: result.suggestion,
});
},
500
);
}
});
},
@observes("accountUsername")
@ -55,7 +49,7 @@ export default Mixin.create({
let result = this.basicUsernameValidation(accountUsername);
if (result.shouldCheck) {
this.checkUsernameAvailability();
discourseDebounce(this, this.checkUsernameAvailability, 500);
}
this.set("usernameValidation", result);
},
@ -84,43 +78,37 @@ export default Mixin.create({
},
checkUsernameAvailability() {
discourseDebounce(
this,
function () {
return User.checkUsername(this.accountUsername, this.accountEmail).then(
(result) => {
this.set("isDeveloper", false);
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 User.checkUsername(this.accountUsername, this.accountEmail).then(
(result) => {
this.set("isDeveloper", false);
if (result.available) {
if (result.is_developer) {
this.set("isDeveloper", true);
}
);
},
500
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"),
})
);
}
}
}
);
},
});

View File

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

View File

@ -25,7 +25,7 @@
{{#if emailValidated}}
<span class="value">{{accountEmail}}</span>
{{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">
{{i18n "user.email.title"}}
{{~#if userFields~}}

View File

@ -449,6 +449,10 @@ export function applyDefaultHandlers(pretender) {
return response({ available: true });
});
pretender.get("/u/check_email", function () {
return response({ success: "OK" });
});
pretender.post("/u", () => response({ success: true }));
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
skip_before_action :verify_authenticity_token, only: [:create]
skip_before_action :redirect_to_login_if_required, only: [:check_username,
:check_email,
:create,
:account_created,
:activate_account,
@ -522,6 +523,24 @@ class UsersController < ApplicationController
render json: checker.check_username(username, email)
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
params[:for_user_id] ? User.find(params[:for_user_id]) : current_user
end

View File

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

View File

@ -1584,6 +1584,30 @@ describe UsersController do
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
it 'fails for anonymous users' do
user = Fabricate(:user)