diff --git a/app/assets/javascripts/discourse/app/components/create-account.js b/app/assets/javascripts/discourse/app/components/create-account.js deleted file mode 100644 index a5f8ce393f1..00000000000 --- a/app/assets/javascripts/discourse/app/components/create-account.js +++ /dev/null @@ -1,91 +0,0 @@ -import Component from "@ember/component"; -import cookie from "discourse/lib/cookie"; -import { bind } from "discourse-common/utils/decorators"; - -export default Component.extend({ - classNames: ["create-account-body"], - - // used for animating the label inside of inputs - userInputFocus(event) { - const userField = event.target.parentElement.parentElement; - if (!userField.classList.contains("value-entered")) { - userField.classList.toggle("value-entered"); - } - }, - - // used for animating the label inside of inputs - userInputFocusOut(event) { - const userField = event.target.parentElement.parentElement; - if ( - event.target.value.length === 0 && - userField.classList.contains("value-entered") - ) { - userField.classList.toggle("value-entered"); - } - }, - - @bind - actionOnEnter(event) { - if (!this.disabled && event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - this.action(); - return false; - } - }, - - @bind - selectKitFocus(event) { - const target = document.getElementById(event.target.getAttribute("for")); - if (target?.classList.contains("select-kit")) { - event.preventDefault(); - target.querySelector(".select-kit-header").click(); - } - }, - - didInsertElement() { - this._super(...arguments); - - if (cookie("email")) { - this.set("email", cookie("email")); - } - - let userTextFields = document.getElementsByClassName("user-fields")[0]; - - if (userTextFields) { - userTextFields = - userTextFields.getElementsByClassName("ember-text-field"); - } - - if (userTextFields) { - for (let element of userTextFields) { - element.addEventListener("focus", this.userInputFocus); - element.addEventListener("focusout", this.userInputFocusOut); - } - } - - this.element.addEventListener("keydown", this.actionOnEnter); - this.element.addEventListener("click", this.selectKitFocus); - }, - - willDestroyElement() { - this._super(...arguments); - - this.element.removeEventListener("keydown", this.actionOnEnter); - this.element.removeEventListener("click", this.selectKitFocus); - - let userTextFields = document.getElementsByClassName("user-fields")[0]; - - if (userTextFields) { - userTextFields = - userTextFields.getElementsByClassName("ember-text-field"); - } - - if (userTextFields) { - for (let element of userTextFields) { - element.removeEventListener("focus", this.userInputFocus); - element.removeEventListener("focusout", this.userInputFocusOut); - } - } - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/modal/create-account.hbs b/app/assets/javascripts/discourse/app/components/modal/create-account.hbs new file mode 100644 index 00000000000..53828095520 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/create-account.hbs @@ -0,0 +1,291 @@ + + <:body> + + +
+ + + {{#if this.showCreateForm}} + + + + + + {{/if}} + + {{#if this.showExternalLoginButtons}} + + {{/if}} + + {{#if this.model.skipConfirmation}} + {{loading-spinner size="large"}} + {{/if}} +
+ +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/create-account.js b/app/assets/javascripts/discourse/app/components/modal/create-account.js new file mode 100644 index 00000000000..68cb890eb73 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/create-account.js @@ -0,0 +1,522 @@ +import { A } from "@ember/array"; +import Component from "@ember/component"; +import EmberObject, { action } from "@ember/object"; +import { alias, notEmpty } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; +import { isEmpty } from "@ember/utils"; +import $ from "jquery"; +import { Promise } from "rsvp"; +import LoginModal from "discourse/components/modal/login"; +import { ajax } from "discourse/lib/ajax"; +import { setting } from "discourse/lib/computed"; +import cookie, { removeCookie } from "discourse/lib/cookie"; +import { userPath } from "discourse/lib/url"; +import { emailValid } from "discourse/lib/utilities"; +import { wavingHandURL } from "discourse/lib/waving-hand-url"; +import NameValidation from "discourse/mixins/name-validation"; +import PasswordValidation from "discourse/mixins/password-validation"; +import UserFieldsValidation from "discourse/mixins/user-fields-validation"; +import UsernameValidation from "discourse/mixins/username-validation"; +import { findAll } from "discourse/models/login-method"; +import User from "discourse/models/user"; +import discourseDebounce from "discourse-common/lib/debounce"; +import discourseComputed, { + bind, + observes, +} from "discourse-common/utils/decorators"; +import I18n from "discourse-i18n"; + +export default class CreateAccount extends Component.extend( + PasswordValidation, + UsernameValidation, + NameValidation, + UserFieldsValidation +) { + @service modal; + @service site; + @service siteSettings; + + accountChallenge = 0; + accountHoneypot = 0; + formSubmitted = false; + rejectedEmails = A(); + prefilledUsername = null; + userFields = null; + isDeveloper = false; + maskPassword = true; + + @notEmpty("model.authOptions") hasAuthOptions; + @setting("enable_local_logins") canCreateLocal; + @setting("require_invite_code") requireInviteCode; + + // For UsernameValidation mixin + @alias("model.authOptions") authOptions; + @alias("model.accountEmail") accountEmail; + @alias("model.accountUsername") accountUsername; + + init() { + super.init(...arguments); + + if (cookie("email")) { + this.set("model.accountEmail", cookie("email")); + } + + this.fetchConfirmationValue(); + + if (this.model.skipConfirmation) { + this.performAccountCreation().finally(() => + this.set("model.skipConfirmation", false) + ); + } + } + + // used for animating the label inside of inputs + @bind + userInputFocus(event) { + const userField = event.target.parentElement.parentElement; + if (!userField.classList.contains("value-entered")) { + userField.classList.toggle("value-entered"); + } + } + + // used for animating the label inside of inputs + @bind + userInputFocusOut(event) { + const userField = event.target.parentElement.parentElement; + if ( + event.target.value.length === 0 && + userField.classList.contains("value-entered") + ) { + userField.classList.toggle("value-entered"); + } + } + + @bind + actionOnEnter(event) { + if (!this.submitDisabled && event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + this.createAccount(); + return false; + } + } + + @bind + selectKitFocus(event) { + const target = document.getElementById(event.target.getAttribute("for")); + if (target?.classList.contains("select-kit")) { + event.preventDefault(); + target.querySelector(".select-kit-header").click(); + } + } + + @discourseComputed( + "hasAuthOptions", + "canCreateLocal", + "model.skipConfirmation" + ) + showCreateForm(hasAuthOptions, canCreateLocal, skipConfirmation) { + return (hasAuthOptions || canCreateLocal) && !skipConfirmation; + } + + @discourseComputed("site.desktopView", "hasAuthOptions") + showExternalLoginButtons(desktopView, hasAuthOptions) { + return desktopView && !hasAuthOptions; + } + + @discourseComputed("formSubmitted") + submitDisabled() { + return this.formSubmitted; + } + + @discourseComputed() + wavingHandURL() { + return wavingHandURL(); + } + + @discourseComputed("userFields", "hasAtLeastOneLoginButton", "hasAuthOptions") + modalBodyClasses(userFields, hasAtLeastOneLoginButton, hasAuthOptions) { + const classes = []; + if (userFields) { + classes.push("has-user-fields"); + } + if (hasAtLeastOneLoginButton && !hasAuthOptions) { + classes.push("has-alt-auth"); + } + if (!this.canCreateLocal) { + classes.push("no-local-logins"); + } + return classes.join(" "); + } + + @discourseComputed("model.authOptions", "model.authOptions.can_edit_username") + usernameDisabled(authOptions, canEditUsername) { + return authOptions && !canEditUsername; + } + + @discourseComputed("model.authOptions", "model.authOptions.can_edit_name") + nameDisabled(authOptions, canEditName) { + return authOptions && !canEditName; + } + + @discourseComputed + fullnameRequired() { + return ( + this.siteSettings.full_name_required || this.siteSettings.enable_names + ); + } + + @discourseComputed("model.authOptions.auth_provider") + passwordRequired(authProvider) { + return isEmpty(authProvider); + } + + @discourseComputed + disclaimerHtml() { + if (this.site.tos_url && this.site.privacy_policy_url) { + return I18n.t("create_account.disclaimer", { + tos_link: this.site.tos_url, + privacy_link: this.site.privacy_policy_url, + }); + } + } + + // Check the email address + @discourseComputed( + "serverAccountEmail", + "serverEmailValidation", + "model.accountEmail", + "rejectedEmails.[]", + "forceValidationReason" + ) + emailValidation( + serverAccountEmail, + serverEmailValidation, + email, + rejectedEmails, + forceValidationReason + ) { + const failedAttrs = { + failed: true, + ok: false, + element: document.querySelector("#new-account-email"), + }; + + if (serverAccountEmail === email && serverEmailValidation) { + return serverEmailValidation; + } + + // If blank, fail without a reason + if (isEmpty(email)) { + return EmberObject.create( + Object.assign(failedAttrs, { + message: I18n.t("user.email.required"), + reason: forceValidationReason ? I18n.t("user.email.required") : null, + }) + ); + } + + if (rejectedEmails.includes(email) || !emailValid(email)) { + return EmberObject.create( + Object.assign(failedAttrs, { + reason: I18n.t("user.email.invalid"), + }) + ); + } + + if ( + this.get("model.authOptions.email") === email && + this.get("model.authOptions.email_valid") + ) { + return EmberObject.create({ + ok: true, + reason: I18n.t("user.email.authenticated", { + provider: this.authProviderDisplayName( + this.get("model.authOptions.auth_provider") + ), + }), + }); + } + + return EmberObject.create({ + ok: true, + reason: I18n.t("user.email.ok"), + }); + } + + @action + checkEmailAvailability() { + if ( + !this.emailValidation.ok || + this.serverAccountEmail === this.model.accountEmail + ) { + return; + } + + return User.checkEmail(this.model.accountEmail) + .then((result) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + if (result.failed) { + this.setProperties({ + serverAccountEmail: this.model.accountEmail, + serverEmailValidation: EmberObject.create({ + failed: true, + element: document.querySelector("#new-account-email"), + reason: result.errors[0], + }), + }); + } else { + this.setProperties({ + serverAccountEmail: this.model.accountEmail, + serverEmailValidation: EmberObject.create({ + ok: true, + reason: I18n.t("user.email.ok"), + }), + }); + } + }) + .catch(() => { + this.setProperties({ + serverAccountEmail: null, + serverEmailValidation: null, + }); + }); + } + + @discourseComputed( + "model.accountEmail", + "model.authOptions.email", + "model.authOptions.email_valid" + ) + emailDisabled() { + return ( + this.get("model.authOptions.email") === this.model.accountEmail && + this.get("model.authOptions.email_valid") + ); + } + + authProviderDisplayName(providerName) { + const matchingProvider = findAll().find((provider) => { + return provider.name === providerName; + }); + return matchingProvider ? matchingProvider.get("prettyName") : providerName; + } + + @observes("emailValidation", "model.accountEmail") + prefillUsername() { + if (this.prefilledUsername) { + // If username field has been filled automatically, and email field just changed, + // then remove the username. + if (this.model.accountUsername === this.prefilledUsername) { + this.set("model.accountUsername", ""); + } + this.set("prefilledUsername", null); + } + if ( + this.get("emailValidation.ok") && + (isEmpty(this.model.accountUsername) || + this.get("model.authOptions.email")) + ) { + // If email is valid and username has not been entered yet, + // or email and username were filled automatically by 3rd party auth, + // then look for a registered username that matches the email. + discourseDebounce(this, this.fetchExistingUsername, 500); + } + } + + // Determines whether at least one login button is enabled + @discourseComputed + hasAtLeastOneLoginButton() { + return findAll().length > 0; + } + + fetchConfirmationValue() { + if (this._challengeDate === undefined && this._hpPromise) { + // Request already in progress + return this._hpPromise; + } + + this._hpPromise = ajax("/session/hp.json") + .then((json) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this._challengeDate = new Date(); + // remove 30 seconds for jitter, make sure this works for at least + // 30 seconds so we don't have hard loops + this._challengeExpiry = parseInt(json.expires_in, 10) - 30; + if (this._challengeExpiry < 30) { + this._challengeExpiry = 30; + } + + this.setProperties({ + accountHoneypot: json.value, + accountChallenge: json.challenge.split("").reverse().join(""), + }); + }) + .finally(() => (this._hpPromise = undefined)); + + return this._hpPromise; + } + + performAccountCreation() { + if ( + !this._challengeDate || + new Date() - this._challengeDate > 1000 * this._challengeExpiry + ) { + return this.fetchConfirmationValue().then(() => + this.performAccountCreation() + ); + } + + const attrs = this.getProperties( + "model.accountName", + "model.accountEmail", + "accountPassword", + "model.accountUsername", + "accountChallenge", + "inviteCode" + ); + + attrs["accountPasswordConfirm"] = this.accountHoneypot; + + const userFields = this.userFields; + const destinationUrl = this.get("model.authOptions.destination_url"); + + if (!isEmpty(destinationUrl)) { + cookie("destination_url", destinationUrl, { path: "/" }); + } + + // Add the userFields to the data + if (!isEmpty(userFields)) { + attrs.userFields = {}; + userFields.forEach( + (f) => (attrs.userFields[f.get("field.id")] = f.get("value")) + ); + } + + this.set("formSubmitted", true); + return User.createAccount(attrs).then( + (result) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.set("isDeveloper", false); + if (result.success) { + // invalidate honeypot + this._challengeExpiry = 1; + + // Trigger the browser's password manager using the hidden static login form: + const $hidden_login_form = $("#hidden-login-form"); + $hidden_login_form + .find("input[name=username]") + .val(attrs.accountUsername); + $hidden_login_form + .find("input[name=password]") + .val(attrs.accountPassword); + $hidden_login_form + .find("input[name=redirect]") + .val(userPath("account-created")); + $hidden_login_form.submit(); + return new Promise(() => {}); // This will never resolve, the page will reload instead + } else { + this.set("flash", result.message || I18n.t("create_account.failed")); + if (result.is_developer) { + this.set("isDeveloper", true); + } + if ( + result.errors && + result.errors.email && + result.errors.email.length > 0 && + result.values + ) { + this.rejectedEmails.pushObject(result.values.email); + } + if ( + result.errors && + result.errors.password && + result.errors.password.length > 0 + ) { + this.rejectedPasswords.pushObject(attrs.accountPassword); + } + this.set("formSubmitted", false); + removeCookie("destination_url"); + } + }, + () => { + this.set("formSubmitted", false); + removeCookie("destination_url"); + return this.set("flash", I18n.t("create_account.failed")); + } + ); + } + + @discourseComputed( + "model.authOptions.associate_url", + "model.authOptions.auth_provider" + ) + associateHtml(url, provider) { + if (!url) { + return; + } + return I18n.t("create_account.associate", { + associate_link: url, + provider: I18n.t(`login.${provider}.name`), + }); + } + + @action + togglePasswordMask() { + this.toggleProperty("maskPassword"); + } + + @action + externalLogin(provider) { + // we will automatically redirect to the external auth service + this.modal.show(LoginModal, { + model: { + isExternalLogin: true, + externalLoginMethod: provider, + signup: true, + }, + }); + } + + @action + createAccount() { + this.set("flash", ""); + this.set("forceValidationReason", true); + + const validation = [ + this.emailValidation, + this.usernameValidation, + this.nameValidation, + this.passwordValidation, + this.userFieldsValidation, + ].find((v) => v.failed); + + if (validation) { + const element = validation.element; + if (element) { + if (element.tagName === "DIV") { + if (element.scrollIntoView) { + element.scrollIntoView(); + } + element.click(); + } else { + element.focus(); + } + } + + return; + } + + this.set("forceValidationReason", false); + this.performAccountCreation(); + } +} diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js deleted file mode 100644 index 8bd0d3ebc21..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/create-account.js +++ /dev/null @@ -1,472 +0,0 @@ -import { A } from "@ember/array"; -import Controller from "@ember/controller"; -import EmberObject, { action } from "@ember/object"; -import { notEmpty } from "@ember/object/computed"; -import { inject as service } from "@ember/service"; -import { isEmpty } from "@ember/utils"; -import $ from "jquery"; -import { Promise } from "rsvp"; -import LoginModal from "discourse/components/modal/login"; -import { ajax } from "discourse/lib/ajax"; -import { setting } from "discourse/lib/computed"; -import cookie, { removeCookie } from "discourse/lib/cookie"; -import { userPath } from "discourse/lib/url"; -import { emailValid } from "discourse/lib/utilities"; -import { wavingHandURL } from "discourse/lib/waving-hand-url"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import NameValidation from "discourse/mixins/name-validation"; -import PasswordValidation from "discourse/mixins/password-validation"; -import UserFieldsValidation from "discourse/mixins/user-fields-validation"; -import UsernameValidation from "discourse/mixins/username-validation"; -import { findAll } from "discourse/models/login-method"; -import User from "discourse/models/user"; -import discourseDebounce from "discourse-common/lib/debounce"; -import discourseComputed, { - observes, - on, -} from "discourse-common/utils/decorators"; -import I18n from "discourse-i18n"; - -export default Controller.extend( - ModalFunctionality, - PasswordValidation, - UsernameValidation, - NameValidation, - UserFieldsValidation, - { - modal: service(), - - complete: false, - accountChallenge: 0, - accountHoneypot: 0, - formSubmitted: false, - rejectedEmails: A(), - prefilledUsername: null, - userFields: null, - isDeveloper: false, - maskPassword: true, - - hasAuthOptions: notEmpty("authOptions"), - canCreateLocal: setting("enable_local_logins"), - requireInviteCode: setting("require_invite_code"), - - @discourseComputed("hasAuthOptions", "canCreateLocal", "skipConfirmation") - showCreateForm(hasAuthOptions, canCreateLocal, skipConfirmation) { - return (hasAuthOptions || canCreateLocal) && !skipConfirmation; - }, - - @discourseComputed("formSubmitted") - submitDisabled() { - if (this.formSubmitted) { - return true; - } - - return false; - }, - - @discourseComputed() - wavingHandURL: () => wavingHandURL(), - - @discourseComputed( - "userFields", - "hasAtLeastOneLoginButton", - "hasAuthOptions" - ) - modalBodyClasses(userFields, hasAtLeastOneLoginButton, hasAuthOptions) { - const classes = []; - if (userFields) { - classes.push("has-user-fields"); - } - if (hasAtLeastOneLoginButton && !hasAuthOptions) { - classes.push("has-alt-auth"); - } - if (!this.canCreateLocal) { - classes.push("no-local-logins"); - } - return classes.join(" "); - }, - - @discourseComputed("authOptions", "authOptions.can_edit_username") - usernameDisabled(authOptions, canEditUsername) { - return authOptions && !canEditUsername; - }, - - @discourseComputed("authOptions", "authOptions.can_edit_name") - nameDisabled(authOptions, canEditName) { - return authOptions && !canEditName; - }, - - @discourseComputed - fullnameRequired() { - return ( - this.siteSettings.full_name_required || this.siteSettings.enable_names - ); - }, - - @discourseComputed("authOptions.auth_provider") - passwordRequired(authProvider) { - return isEmpty(authProvider); - }, - - @discourseComputed - disclaimerHtml() { - if (this.site.tos_url && this.site.privacy_policy_url) { - return I18n.t("create_account.disclaimer", { - tos_link: this.site.tos_url, - privacy_link: this.site.privacy_policy_url, - }); - } - }, - - // Check the email address - @discourseComputed( - "serverAccountEmail", - "serverEmailValidation", - "accountEmail", - "rejectedEmails.[]", - "forceValidationReason" - ) - emailValidation( - serverAccountEmail, - serverEmailValidation, - email, - rejectedEmails, - forceValidationReason - ) { - const failedAttrs = { - failed: true, - ok: false, - element: document.querySelector("#new-account-email"), - }; - - if (serverAccountEmail === email && serverEmailValidation) { - return serverEmailValidation; - } - - // If blank, fail without a reason - if (isEmpty(email)) { - return EmberObject.create( - Object.assign(failedAttrs, { - message: I18n.t("user.email.required"), - reason: forceValidationReason - ? I18n.t("user.email.required") - : null, - }) - ); - } - - if (rejectedEmails.includes(email) || !emailValid(email)) { - return EmberObject.create( - Object.assign(failedAttrs, { - reason: I18n.t("user.email.invalid"), - }) - ); - } - - if ( - this.get("authOptions.email") === email && - this.get("authOptions.email_valid") - ) { - return EmberObject.create({ - ok: true, - reason: I18n.t("user.email.authenticated", { - provider: this.authProviderDisplayName( - this.get("authOptions.auth_provider") - ), - }), - }); - } - - return EmberObject.create({ - ok: true, - reason: I18n.t("user.email.ok"), - }); - }, - - @action - checkEmailAvailability() { - if ( - !this.emailValidation.ok || - this.serverAccountEmail === this.accountEmail - ) { - return; - } - - return User.checkEmail(this.accountEmail) - .then((result) => { - if (this.isDestroying || this.isDestroyed) { - return; - } - - 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( - "accountEmail", - "authOptions.email", - "authOptions.email_valid" - ) - emailDisabled() { - return ( - this.get("authOptions.email") === this.accountEmail && - this.get("authOptions.email_valid") - ); - }, - - authProviderDisplayName(providerName) { - const matchingProvider = findAll().find((provider) => { - return provider.name === providerName; - }); - return matchingProvider - ? matchingProvider.get("prettyName") - : providerName; - }, - - @observes("emailValidation", "accountEmail") - prefillUsername() { - if (this.prefilledUsername) { - // If username field has been filled automatically, and email field just changed, - // then remove the username. - if (this.accountUsername === this.prefilledUsername) { - this.set("accountUsername", ""); - } - this.set("prefilledUsername", null); - } - if ( - this.get("emailValidation.ok") && - (isEmpty(this.accountUsername) || this.get("authOptions.email")) - ) { - // If email is valid and username has not been entered yet, - // or email and username were filled automatically by 3rd party auth, - // then look for a registered username that matches the email. - discourseDebounce(this, this.fetchExistingUsername, 500); - } - }, - - // Determines whether at least one login button is enabled - @discourseComputed - hasAtLeastOneLoginButton() { - return findAll().length > 0; - }, - - @on("init") - fetchConfirmationValue() { - if (this._challengeDate === undefined && this._hpPromise) { - // Request already in progress - return this._hpPromise; - } - - this._hpPromise = ajax("/session/hp.json") - .then((json) => { - if (this.isDestroying || this.isDestroyed) { - return; - } - - this._challengeDate = new Date(); - // remove 30 seconds for jitter, make sure this works for at least - // 30 seconds so we don't have hard loops - this._challengeExpiry = parseInt(json.expires_in, 10) - 30; - if (this._challengeExpiry < 30) { - this._challengeExpiry = 30; - } - - this.setProperties({ - accountHoneypot: json.value, - accountChallenge: json.challenge.split("").reverse().join(""), - }); - }) - .finally(() => (this._hpPromise = undefined)); - - return this._hpPromise; - }, - - performAccountCreation() { - if ( - !this._challengeDate || - new Date() - this._challengeDate > 1000 * this._challengeExpiry - ) { - return this.fetchConfirmationValue().then(() => - this.performAccountCreation() - ); - } - - const attrs = this.getProperties( - "accountName", - "accountEmail", - "accountPassword", - "accountUsername", - "accountChallenge", - "inviteCode" - ); - - attrs["accountPasswordConfirm"] = this.accountHoneypot; - - const userFields = this.userFields; - const destinationUrl = this.get("authOptions.destination_url"); - - if (!isEmpty(destinationUrl)) { - cookie("destination_url", destinationUrl, { path: "/" }); - } - - // Add the userfields to the data - if (!isEmpty(userFields)) { - attrs.userFields = {}; - userFields.forEach( - (f) => (attrs.userFields[f.get("field.id")] = f.get("value")) - ); - } - - this.set("formSubmitted", true); - return User.createAccount(attrs).then( - (result) => { - if (this.isDestroying || this.isDestroyed) { - return; - } - - this.set("isDeveloper", false); - if (result.success) { - // invalidate honeypot - this._challengeExpiry = 1; - - // Trigger the browser's password manager using the hidden static login form: - const $hidden_login_form = $("#hidden-login-form"); - $hidden_login_form - .find("input[name=username]") - .val(attrs.accountUsername); - $hidden_login_form - .find("input[name=password]") - .val(attrs.accountPassword); - $hidden_login_form - .find("input[name=redirect]") - .val(userPath("account-created")); - $hidden_login_form.submit(); - return new Promise(() => {}); // This will never resolve, the page will reload instead - } else { - this.flash( - result.message || I18n.t("create_account.failed"), - "error" - ); - if (result.is_developer) { - this.set("isDeveloper", true); - } - if ( - result.errors && - result.errors.email && - result.errors.email.length > 0 && - result.values - ) { - this.rejectedEmails.pushObject(result.values.email); - } - if ( - result.errors && - result.errors.password && - result.errors.password.length > 0 - ) { - this.rejectedPasswords.pushObject(attrs.accountPassword); - } - this.set("formSubmitted", false); - removeCookie("destination_url"); - } - }, - () => { - this.set("formSubmitted", false); - removeCookie("destination_url"); - return this.flash(I18n.t("create_account.failed"), "error"); - } - ); - }, - - onShow() { - if (this.skipConfirmation) { - this.performAccountCreation().finally(() => - this.set("skipConfirmation", false) - ); - } - }, - - @discourseComputed("authOptions.associate_url", "authOptions.auth_provider") - associateHtml(url, provider) { - if (!url) { - return; - } - return I18n.t("create_account.associate", { - associate_link: url, - provider: I18n.t(`login.${provider}.name`), - }); - }, - - @action - togglePasswordMask() { - this.toggleProperty("maskPassword"); - }, - - actions: { - externalLogin(provider) { - // we will automatically redirect to the external auth service - this.modal.show(LoginModal, { - model: { - isExternalLogin: true, - externalLoginMethod: provider, - signup: true, - }, - }); - }, - - createAccount() { - this.clearFlash(); - - this.set("forceValidationReason", true); - const validation = [ - this.emailValidation, - this.usernameValidation, - this.nameValidation, - this.passwordValidation, - this.userFieldsValidation, - ].find((v) => v.failed); - - if (validation) { - const element = validation.element; - if (element) { - if (element.tagName === "DIV") { - if (element.scrollIntoView) { - element.scrollIntoView(); - } - element.click(); - } else { - element.focus(); - } - } - - return; - } - - this.set("forceValidationReason", false); - this.performAccountCreation(); - }, - }, - } -); diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js index 2df0b42b022..c0556074cee 100644 --- a/app/assets/javascripts/discourse/app/controllers/invites-show.js +++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js @@ -1,4 +1,4 @@ -import Controller, { inject as controller } from "@ember/controller"; +import Controller from "@ember/controller"; import EmberObject, { action } from "@ember/object"; import { alias, bool, not, readOnly } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; @@ -24,8 +24,6 @@ export default Controller.extend( { queryParams: ["t"], - createAccount: controller(), - invitedBy: readOnly("model.invited_by"), email: alias("model.email"), accountEmail: alias("email"), @@ -222,7 +220,7 @@ export default Controller.extend( } if (externalAuthEmail && externalAuthEmailValid) { - const provider = this.createAccount.authProviderDisplayName( + const provider = this.authProviderDisplayName( this.get("authOptions.auth_provider") ); @@ -263,6 +261,15 @@ export default Controller.extend( }); }, + authProviderDisplayName(providerName) { + const matchingProvider = findLoginMethods().find((provider) => { + return provider.name === providerName; + }); + return matchingProvider + ? matchingProvider.get("prettyName") + : providerName; + }, + @discourseComputed wavingHandURL: () => wavingHandURL(), diff --git a/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js b/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js index b9739224b30..cc52cae53b7 100644 --- a/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js +++ b/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js @@ -1,8 +1,8 @@ import EmberObject from "@ember/object"; import { next } from "@ember/runloop"; +import CreateAccount from "discourse/components/modal/create-account"; import LoginModal from "discourse/components/modal/login"; import cookie, { removeCookie } from "discourse/lib/cookie"; -import showModal from "discourse/lib/show-modal"; import DiscourseUrl from "discourse/lib/url"; import I18n from "discourse-i18n"; @@ -116,21 +116,17 @@ export default { return; } - const skipConfirmation = siteSettings.auth_skip_create_confirm; - owner.lookup("controller:createAccount").setProperties({ - accountEmail: options.email, - accountUsername: options.username, - accountName: options.name, - authOptions: EmberObject.create(options), - skipConfirmation, - }); - - next(() => { - showModal("create-account", { - modalClass: "create-account", - titleAriaElementId: "create-account-title", - }); - }); + next(() => + modal.show(CreateAccount, { + model: { + accountEmail: options.email, + accountUsername: options.username, + accountName: options.name, + authOptions: EmberObject.create(options), + skipConfirmation: siteSettings.auth_skip_create_confirm, + }, + }) + ); } }); }); diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js index 58278832c95..0187ace3700 100644 --- a/app/assets/javascripts/discourse/app/routes/application.js +++ b/app/assets/javascripts/discourse/app/routes/application.js @@ -1,5 +1,6 @@ import { action } from "@ember/object"; import { inject as service } from "@ember/service"; +import CreateAccount from "discourse/components/modal/create-account"; import ForgotPassword from "discourse/components/modal/forgot-password"; import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help"; import LoginModal from "discourse/components/modal/login"; @@ -7,7 +8,6 @@ import { setting } from "discourse/lib/computed"; import cookie from "discourse/lib/cookie"; import logout from "discourse/lib/logout"; import mobile from "discourse/lib/mobile"; -import showModal from "discourse/lib/show-modal"; import DiscourseURL from "discourse/lib/url"; import Category from "discourse/models/category"; import Composer from "discourse/models/composer"; @@ -258,11 +258,7 @@ const ApplicationRoute = DiscourseRoute.extend({ }, }); } else { - const createAccount = showModal("create-account", { - modalClass: "create-account", - titleAriaElementId: "create-account-title", - }); - createAccount.setProperties(createAccountProps); + this.modal.show(CreateAccount, { model: createAccountProps }); } } }, diff --git a/app/assets/javascripts/discourse/app/services/modal.js b/app/assets/javascripts/discourse/app/services/modal.js index abe7ae5bfef..d7ddc672c42 100644 --- a/app/assets/javascripts/discourse/app/services/modal.js +++ b/app/assets/javascripts/discourse/app/services/modal.js @@ -16,7 +16,6 @@ const KNOWN_LEGACY_MODALS = [ "avatar-selector", "change-owner", "change-post-notice", - "create-account", "create-invite-bulk", "create-invite", "grant-badge", diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs deleted file mode 100644 index 1b1c3068f56..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs +++ /dev/null @@ -1,290 +0,0 @@ - - {{#unless this.complete}} - - - - - - - {{/unless}} - \ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/acceptance/auth-complete-test.js b/app/assets/javascripts/discourse/tests/acceptance/auth-complete-test.js index 6d7e67d0d6b..db9c4a50dec 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/auth-complete-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/auth-complete-test.js @@ -1,10 +1,10 @@ import { currentRouteName, visit } from "@ember/test-helpers"; import { test } from "qunit"; import { withPluginApi } from "discourse/lib/plugin-api"; -import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; acceptance("Auth Complete", function (needs) { - needs.hooks.beforeEach(() => { + needs.hooks.beforeEach(function () { const node = document.createElement("meta"); node.dataset.authenticationData = JSON.stringify({ auth_provider: "test", @@ -14,10 +14,8 @@ acceptance("Auth Complete", function (needs) { document.querySelector("head").appendChild(node); }); - needs.hooks.afterEach(() => { - document - .querySelector("head") - .removeChild(document.getElementById("data-authentication")); + needs.hooks.afterEach(function () { + document.getElementById("data-authentication").remove(); }); test("when login not required", async function (assert) { @@ -29,10 +27,9 @@ acceptance("Auth Complete", function (needs) { "it stays on the homepage" ); - assert.ok( - exists("#discourse-modal div.create-account-body"), - "it shows the registration modal" - ); + assert + .dom(".d-modal.create-account") + .exists("it shows the registration modal"); }); test("when login required", async function (assert) { @@ -45,10 +42,9 @@ acceptance("Auth Complete", function (needs) { "it redirects to the login page" ); - assert.ok( - exists("#discourse-modal div.create-account-body"), - "it shows the registration modal" - ); + assert + .dom(".d-modal.create-account") + .exists("it shows the registration modal"); }); test("Callback added using addBeforeAuthCompleteCallback", async function (assert) { @@ -69,9 +65,8 @@ acceptance("Auth Complete", function (needs) { "The function added via API was run and it transitioned to 'discovery.categories' route" ); - assert.notOk( - exists("#discourse-modal div.create-account-body"), - "registration modal is not shown" - ); + assert + .dom(".d-modal.create-account") + .doesNotExist("registration modal is not shown"); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-account-external-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-account-external-test.js index 6decd473920..8db22b9aa43 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-account-external-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-account-external-test.js @@ -1,6 +1,6 @@ import { visit } from "@ember/test-helpers"; import { test } from "qunit"; -import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; function setupAuthData(data) { data = { @@ -18,68 +18,58 @@ function setupAuthData(data) { } acceptance("Create Account - external auth", function (needs) { - needs.hooks.beforeEach(() => { + needs.hooks.beforeEach(function () { setupAuthData(); }); - needs.hooks.afterEach(() => { - document - .querySelector("head") - .removeChild(document.getElementById("data-authentication")); + needs.hooks.afterEach(function () { + document.getElementById("data-authentication").remove(); }); test("when skip is disabled (default)", async function (assert) { await visit("/"); - assert.ok( - exists("#discourse-modal div.create-account-body"), - "it shows the registration modal" - ); + assert + .dom(".d-modal.create-account") + .exists("it shows the registration modal"); - assert.ok(exists("#new-account-username"), "it shows the fields"); + assert.dom("#new-account-username").exists("it shows the fields"); - assert.notOk( - exists(".create-account-associate-link"), - "it does not show the associate link" - ); + assert + .dom(".create-account-associate-link") + .doesNotExist("it does not show the associate link"); }); test("when skip is enabled", async function (assert) { this.siteSettings.auth_skip_create_confirm = true; await visit("/"); - assert.ok( - exists("#discourse-modal div.create-account-body"), - "it shows the registration modal" - ); + assert + .dom(".d-modal.create-account") + .exists("it shows the registration modal"); - assert.notOk( - exists("#new-account-username"), - "it does not show the fields" - ); + assert + .dom("#new-account-username") + .doesNotExist("it does not show the fields"); }); }); acceptance("Create account - with associate link", function (needs) { - needs.hooks.beforeEach(() => { + needs.hooks.beforeEach(function () { setupAuthData({ associate_url: "/associate/abcde" }); }); - needs.hooks.afterEach(() => { - document - .querySelector("head") - .removeChild(document.getElementById("data-authentication")); + needs.hooks.afterEach(function () { + document.getElementById("data-authentication").remove(); }); test("displays associate link when allowed", async function (assert) { await visit("/"); - assert.ok( - exists("#discourse-modal div.create-account-body"), - "it shows the registration modal" - ); - assert.ok(exists("#new-account-username"), "it shows the fields"); - assert.ok( - exists(".create-account-associate-link"), - "it shows the associate link" - ); + assert + .dom(".d-modal.create-account") + .exists("it shows the registration modal"); + assert.dom("#new-account-username").exists("it shows the fields"); + assert + .dom(".create-account-associate-link") + .exists("it shows the associate link"); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js index 17b9287343d..9351626d869 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js @@ -1,11 +1,7 @@ import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; import { test } from "qunit"; -import { - acceptance, - count, - exists, - query, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import I18n from "discourse-i18n"; acceptance("Create Account - User Fields", function (needs) { needs.site({ @@ -35,28 +31,25 @@ acceptance("Create Account - User Fields", function (needs) { await visit("/"); await click("header .sign-up-button"); - assert.ok(exists(".create-account"), "it shows the create account modal"); - assert.ok(exists(".user-field"), "it has at least one user field"); + assert.dom(".create-account").exists("it shows the create account modal"); + assert.dom(".user-field").exists("it has at least one user field"); await click(".modal-footer .btn-primary"); - assert.strictEqual( - query("#account-email-validation").innerText.trim(), - "Please enter an email address" - ); + assert + .dom("#account-email-validation") + .hasText(I18n.t("user.email.required")); await fillIn("#new-account-name", "Dr. Good Tuna"); await fillIn("#new-account-password", "cool password bro"); await fillIn("#new-account-email", "good.tuna@test.com"); await fillIn("#new-account-username", "goodtuna"); - assert.ok( - exists("#username-validation.good"), - "the username validation is good" - ); - assert.ok( - exists("#account-email-validation.good"), - "the email validation is good" - ); + assert + .dom("#username-validation.good") + .exists("the username validation is good"); + assert + .dom("#account-email-validation.good") + .exists("the email validation is good"); await click(".modal-footer .btn-primary"); await fillIn(".user-field input[type=text]:nth-of-type(1)", "Barky"); @@ -67,13 +60,11 @@ acceptance("Create Account - User Fields", function (needs) { test("can submit with enter", async function (assert) { await visit("/"); await click("header .sign-up-button"); - await triggerKeyEvent(".modal-footer .btn-primary", "keydown", "Enter"); + await triggerKeyEvent("#new-account-email", "keydown", "Enter"); - assert.strictEqual( - count("#modal-alert:visible"), - 1, - "hitting Enter triggers action" - ); + assert + .dom("#account-email-validation") + .hasText(I18n.t("user.email.required"), "hitting Enter triggers action"); }); test("shows validation error for user fields", async function (assert) { @@ -85,14 +76,12 @@ acceptance("Create Account - User Fields", function (needs) { await click(".modal-footer .btn-primary"); - assert.ok( - exists(".user-field-what-is-your-pets-name .tip.bad"), - "shows required field error" - ); + assert + .dom(".user-field-what-is-your-pets-name .tip.bad") + .exists("shows required field error"); - assert.ok( - exists(".user-field-whats-your-dad-like .tip.bad"), - "shows same as password error" - ); + assert + .dom(".user-field-whats-your-dad-like .tip.bad") + .exists("shows same as password error"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js b/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js similarity index 52% rename from app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js rename to app/assets/javascripts/discourse/tests/unit/components/create-account-test.js index 78fbda0d2b4..ea4b823a383 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/create-account-test.js +++ b/app/assets/javascripts/discourse/tests/unit/components/create-account-test.js @@ -3,20 +3,21 @@ import { setupTest } from "ember-qunit"; import { module, test } from "qunit"; import I18n from "discourse-i18n"; -module("Unit | Controller | create-account", function (hooks) { +module("Unit | Component | create-account", function (hooks) { setupTest(hooks); test("basicUsernameValidation", function (assert) { const testInvalidUsername = (username, expectedReason) => { - const controller = this.owner.lookup("controller:create-account"); - controller.set("accountUsername", username); + const component = this.owner + .factoryFor("component:modal/create-account") + .create({ model: { accountUsername: username } }); - let validation = controller.basicUsernameValidation(username); - assert.ok(validation.failed, "username should be invalid: " + username); + const validation = component.basicUsernameValidation(username); + assert.true(validation.failed, `username should be invalid: ${username}`); assert.strictEqual( validation.reason, expectedReason, - "username validation reason: " + username + ", " + expectedReason + `username validation reason: ${username}, ${expectedReason}` ); }; @@ -27,14 +28,13 @@ module("Unit | Controller | create-account", function (hooks) { I18n.t("user.username.too_long") ); - const controller = this.owner.lookup("controller:create-account"); - controller.setProperties({ - accountUsername: "porkchops", - prefilledUsername: "porkchops", - }); + const component = this.owner + .factoryFor("component:modal/create-account") + .create({ model: { accountUsername: "porkchops" } }); + component.set("prefilledUsername", "porkchops"); - let validation = controller.basicUsernameValidation("porkchops"); - assert.ok(validation.ok, "Prefilled username is valid"); + const validation = component.basicUsernameValidation("porkchops"); + assert.true(validation.ok, "Prefilled username is valid"); assert.strictEqual( validation.reason, I18n.t("user.username.prefilled"), @@ -43,35 +43,33 @@ module("Unit | Controller | create-account", function (hooks) { }); test("passwordValidation", async function (assert) { - const controller = this.owner.lookup("controller:create-account"); - - controller.set("authProvider", ""); - controller.set("accountEmail", "pork@chops.com"); - controller.set("accountUsername", "porkchops123"); - controller.set("prefilledUsername", "porkchops123"); - controller.set("accountPassword", "b4fcdae11f9167"); + const component = this.owner + .factoryFor("component:modal/create-account") + .create({ + model: { + accountEmail: "pork@chops.com", + accountUsername: "porkchops123", + }, + }); + component.set("prefilledUsername", "porkchops123"); + component.set("accountPassword", "b4fcdae11f9167"); + assert.true(component.passwordValidation.ok, "Password is ok"); assert.strictEqual( - controller.passwordValidation.ok, - true, - "Password is ok" - ); - assert.strictEqual( - controller.passwordValidation.reason, + component.passwordValidation.reason, I18n.t("user.password.ok"), "Password is valid" ); const testInvalidPassword = (password, expectedReason) => { - controller.set("accountPassword", password); + component.set("accountPassword", password); - assert.strictEqual( - controller.passwordValidation.failed, - true, + assert.true( + component.passwordValidation.failed, `password should be invalid: ${password}` ); assert.strictEqual( - controller.passwordValidation.reason, + component.passwordValidation.reason, expectedReason, `password validation reason: ${password}, ${expectedReason}` ); @@ -93,17 +91,19 @@ module("Unit | Controller | create-account", function (hooks) { }); test("authProviderDisplayName", function (assert) { - const controller = this.owner.lookup("controller:create-account"); + const component = this.owner + .factoryFor("component:modal/create-account") + .create({ model: {} }); assert.strictEqual( - controller.authProviderDisplayName("facebook"), + component.authProviderDisplayName("facebook"), I18n.t("login.facebook.name"), "provider name is translated correctly" ); assert.strictEqual( - controller.authProviderDisplayName("idontexist"), - "idontexist", + component.authProviderDisplayName("does-not-exist"), + "does-not-exist", "provider name falls back if not found" ); }); diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index eebd3369550..43c054a87b5 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -298,10 +298,11 @@ body.invite-page { } @media screen and (min-width: 701px) { - .create-account-body { + .modal-body { max-width: 40em; } } + .user-field { input[type="text"] { margin-bottom: 0; diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss index a64a5f29755..4e9fe4dc145 100644 --- a/app/assets/stylesheets/desktop/login.scss +++ b/app/assets/stylesheets/desktop/login.scss @@ -206,12 +206,11 @@ // create account // modal only .d-modal.create-account { - .create-account-body { - min-width: 100%; - } .modal-body { + min-width: 100%; overflow: hidden; } + .has-alt-auth .create-account-form { display: grid; grid-template-columns: 60% 40%; diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index 0ff8ce5bca3..614ff29490d 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -183,15 +183,27 @@ // create account // modal only -#discourse-modal .create-account .modal-body { - max-height: 60vh !important; - overflow: hidden; - @media screen and (max-height: 575px) { - max-height: 50vh !important; - } -} - .d-modal.create-account { + .modal-body { + max-height: 60vh !important; + overflow: hidden; + display: flex; + flex-direction: column; + + @media screen and (max-height: 575px) { + max-height: 50vh !important; + } + + #login-buttons { + border-bottom: 1px solid var(--primary-low); + } + + .login-form { + margin-bottom: 0; + padding-bottom: 0; + } + } + .create-account-form { overflow-y: auto; .login-welcome-header { @@ -205,18 +217,6 @@ } } -.create-account .modal-body { - display: flex; - flex-direction: column; - #login-buttons { - border-bottom: 1px solid var(--primary-low); - } - .login-form { - margin-bottom: 0; - padding-bottom: 0; - } -} - .create-account { .user-fields { display: flex;