DEV: Migrate `create-account` to the new modal API (#24098)

plugin/theme-breaking changes:

1. `controller:create-account` is gone (use `component:modal/create-account` in modifyClass, **if** absolutely necessary)
2. `create-account-body` css class is gone (target `.d-modal.create-account` or any of the inner classes: `.modal-outer-container`, `.modal-middle-container`, `.modal-inner-container`, or `.modal-body`)
This commit is contained in:
Jarek Radosz 2023-10-30 11:01:09 +01:00 committed by GitHub
parent 0a4b1b655d
commit 351cbab1a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 960 additions and 1028 deletions

View File

@ -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);
}
}
},
});

View File

@ -0,0 +1,291 @@
<DModal
{{on "keydown" this.actionOnEnter}}
{{on "click" this.selectKitFocus}}
@closeModal={{@closeModal}}
@bodyClass={{this.modalBodyClasses}}
@flash={{this.flash}}
@flashType="error"
aria-labelledby="create-account-title"
class="create-account"
>
<:body>
<PluginOutlet
@name="create-account-before-modal-body"
@connectorTagName="div"
/>
<div
class={{concat-class
"create-account-form"
this.model.authOptions.auth_provider
}}
>
<div class="login-welcome-header" id="create-account-title">
<h1 class="login-title">{{i18n "create_account.header_title"}}</h1>
<img src={{this.wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n "create_account.subheader_title"}}</p>
<PluginOutlet
@name="create-account-header-bottom"
@outletArgs={{hash showLogin=(route-action "showLogin")}}
/>
</div>
{{#if this.showCreateForm}}
<div class="login-form">
<form>
{{#if this.associateHtml}}
<div class="input-group create-account-associate-link">
<span>{{html-safe this.associateHtml}}</span>
</div>
{{/if}}
<div class="input-group create-account-email">
<Input
{{on "focusout" (action "checkEmailAvailability")}}
@type="email"
@value={{this.model.accountEmail}}
disabled={{this.emailDisabled}}
autofocus="autofocus"
aria-describedby="account-email-validation"
aria-invalid={{this.emailValidation.failed}}
name="email"
id="new-account-email"
class={{value-entered this.model.accountEmail}}
/>
<label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<InputTip
@validation={{this.emailValidation}}
@id="account-email-validation"
/>
<span class="more-info">{{i18n "user.email.instructions"}}</span>
</div>
<div class="input-group create-account__username">
<Input
@value={{this.model.accountUsername}}
disabled={{this.usernameDisabled}}
maxlength={{this.maxUsernameLength}}
aria-describedby="username-validation"
aria-invalid={{this.usernameValidation.failed}}
autocomplete="off"
name="username"
id="new-account-username"
class={{value-entered this.model.accountUsername}}
/>
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<InputTip
@validation={{this.usernameValidation}}
@id="username-validation"
/>
<span class="more-info">
{{i18n "user.username.instructions"}}
</span>
</div>
<div class="input-group create-account__fullname">
{{#if this.fullnameRequired}}
<TextField
@disabled={{this.nameDisabled}}
@value={{this.model.accountName}}
@id="new-account-name"
@class={{value-entered this.model.accountName}}
aria-describedby="fullname-validation"
aria-invalid={{this.nameValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}}
{{#if this.siteSettings.full_name_required}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
{{/if}}
</label>
<InputTip
@validation={{this.nameValidation}}
@id="fullname-validation"
/>
<span class="more-info">{{this.nameInstructions}}</span>
{{/if}}
</div>
<PluginOutlet
@name="create-account-before-password"
@outletArgs={{hash
accountName=this.model.accountName
accountUsername=this.model.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
authOptions=this.model.authOptions
}}
/>
<div class="input-group create-account__password">
{{#if this.passwordRequired}}
<PasswordField
@value={{this.accountPassword}}
@class={{value-entered this.accountPassword}}
@type={{if this.maskPassword "password" "text"}}
@autocomplete="current-password"
@capsLockOn={{this.capsLockOn}}
aria-describedby="password-validation"
aria-invalid={{this.passwordValidation.failed}}
id="new-account-password"
/>
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<div class="create-account__password-info">
<div class="create-account__password-tip-validation">
<InputTip
@validation={{this.passwordValidation}}
@id="password-validation"
/>
<span class="more-info">{{this.passwordInstructions}}</span>
<div
class={{concat-class
"caps-lock-warning"
(unless this.capsLockOn "hidden")
}}
>
{{d-icon "exclamation-triangle"}}
{{i18n "login.caps_lock_warning"}}
</div>
</div>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
</div>
{{/if}}
<div class="password-confirmation">
<label for="new-account-password-confirmation">
{{i18n "user.password_confirmation.title"}}
</label>
<HoneypotInput
@id="new-account-confirmation"
@autocomplete="new-password"
@value={{this.accountHoneypot}}
/>
<Input
@value={{this.accountChallenge}}
id="new-account-challenge"
/>
</div>
</div>
{{#if this.requireInviteCode}}
<div class="input-group create-account__invite-code">
<Input
@value={{this.inviteCode}}
id="inviteCode"
class={{value-entered this.inviteCode}}
/>
<label class="alt-placeholder" for="invite-code">
{{i18n "user.invite_code.title"}}
</label>
<span class="more-info">
{{i18n "user.invite_code.instructions"}}
</span>
</div>
{{/if}}
<PluginOutlet
@name="create-account-after-password"
@outletArgs={{hash
accountName=this.model.accountName
accountUsername=this.model.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>
{{#if this.userFields}}
<div class="user-fields">
{{#each this.userFields as |f|}}
<div class="input-group">
{{! adding the value-entered class here to
be able to detect if the user field has a value
entered }}
<UserField
{{on "focus" this.userInputFocus}}
{{on "focusout" this.userInputFocusOut}}
@field={{f.field}}
@value={{f.value}}
@class={{value-entered f.value}}
@validation={{f.validation}}
/>
</div>
{{/each}}
</div>
{{/if}}
<PluginOutlet
@name="create-account-after-user-fields"
@outletArgs={{hash
accountName=this.model.accountName
accountUsername=this.model.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>
</form>
</div>
<div class="modal-footer">
<div class="disclaimer">
{{html-safe this.disclaimerHtml}}
</div>
<DButton
@action={{action "createAccount"}}
@disabled={{this.submitDisabled}}
@label="create_account.title"
@isLoading={{this.formSubmitted}}
class="btn-large btn-primary"
/>
{{#unless this.hasAuthOptions}}
<DButton
@action={{route-action "showLogin"}}
@disabled={{this.formSubmitted}}
@label="log_in"
id="login-link"
class="btn-large"
/>
{{/unless}}
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{/if}}
{{#if this.showExternalLoginButtons}}
<div class="create-account-login-buttons">
<LoginButtons @externalLogin={{action "externalLogin"}} />
</div>
{{/if}}
{{#if this.model.skipConfirmation}}
{{loading-spinner size="large"}}
{{/if}}
</div>
</:body>
</DModal>

View File

@ -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();
}
}

View File

@ -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();
},
},
}
);

View File

@ -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(),

View File

@ -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,
},
})
);
}
});
});

View File

@ -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 });
}
}
},

View File

@ -16,7 +16,6 @@ const KNOWN_LEGACY_MODALS = [
"avatar-selector",
"change-owner",
"change-post-notice",
"create-account",
"create-invite-bulk",
"create-invite",
"grant-badge",

View File

@ -1,290 +0,0 @@
<CreateAccount
@email={{this.accountEmail}}
@disabled={{this.submitDisabled}}
@action={{action "createAccount"}}
>
{{#unless this.complete}}
<span>
<PluginOutlet
@name="create-account-before-modal-body"
@connectorTagName="div"
/>
</span>
<DModalBody
@class={{this.modalBodyClasses}}
@preventModalAlertHiding={{true}}
>
<div class="create-account-form {{this.authOptions.auth_provider}}">
<div class="login-welcome-header" id="create-account-title">
<h1 class="login-title">{{i18n "create_account.header_title"}}</h1>
<img src={{this.wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n
"create_account.subheader_title"
}}</p>
<PluginOutlet
@name="create-account-header-bottom"
@outletArgs={{hash showLogin=(route-action "showLogin")}}
/>
</div>
{{#if this.showCreateForm}}
<div class="login-form">
<form>
{{#if this.associateHtml}}
<div class="input-group create-account-associate-link">
<span>{{html-safe this.associateHtml}}</span>
</div>
{{/if}}
<div class="input-group create-account-email">
<Input
@type="email"
disabled={{this.emailDisabled}}
@value={{this.accountEmail}}
id="new-account-email"
name="email"
class={{value-entered this.accountEmail}}
autofocus="autofocus"
{{on "focusout" (action "checkEmailAvailability")}}
aria-describedby="account-email-validation"
aria-invalid={{this.emailValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<InputTip
@validation={{this.emailValidation}}
@id="account-email-validation"
/>
<span class="more-info">{{i18n
"user.email.instructions"
}}</span>
</div>
<div class="input-group create-account__username">
<Input
@value={{this.accountUsername}}
disabled={{this.usernameDisabled}}
class={{value-entered this.accountUsername}}
id="new-account-username"
name="username"
maxlength={{this.maxUsernameLength}}
aria-describedby="username-validation"
aria-invalid={{this.usernameValidation.failed}}
autocomplete="off"
/>
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<InputTip
@validation={{this.usernameValidation}}
@id="username-validation"
/>
<span class="more-info">{{i18n
"user.username.instructions"
}}</span>
</div>
<div class="input-group create-account__fullname">
{{#if this.fullnameRequired}}
<TextField
@disabled={{this.nameDisabled}}
@value={{this.accountName}}
@id="new-account-name"
@class={{value-entered this.accountName}}
aria-describedby="fullname-validation"
aria-invalid={{this.nameValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}}
{{#if this.siteSettings.full_name_required}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
{{/if}}
</label>
<InputTip
@validation={{this.nameValidation}}
@id="fullname-validation"
/>
<span class="more-info">{{this.nameInstructions}}</span>
{{/if}}
</div>
<PluginOutlet
@name="create-account-before-password"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
authOptions=this.authOptions
}}
/>
<div class="input-group create-account__password">
{{#if this.passwordRequired}}
<PasswordField
@value={{this.accountPassword}}
@class={{value-entered this.accountPassword}}
@type={{if this.maskPassword "password" "text"}}
id="new-account-password"
@autocomplete="current-password"
@capsLockOn={{this.capsLockOn}}
aria-describedby="password-validation"
aria-invalid={{this.passwordValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}
{{~#if this.userFields~}}
<span class="required">*</span>
{{/if}}
</label>
<div class="create-account__password-info">
<div class="create-account__password-tip-validation">
<InputTip
@validation={{this.passwordValidation}}
@id="password-validation"
/>
<span
class="more-info"
>{{this.passwordInstructions}}</span>
<div
class="caps-lock-warning
{{unless this.capsLockOn 'hidden'}}"
>
{{d-icon "exclamation-triangle"}}
{{i18n "login.caps_lock_warning"}}
</div>
</div>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
</div>
{{/if}}
<div class="password-confirmation">
<label for="new-account-password-confirmation">{{i18n
"user.password_confirmation.title"
}}</label>
<HoneypotInput
@id="new-account-confirmation"
@autocomplete="new-password"
@value={{this.accountHoneypot}}
/>
<Input
@value={{this.accountChallenge}}
id="new-account-challenge"
/>
</div>
</div>
{{#if this.requireInviteCode}}
<div class="input-group create-account__invite-code">
<Input
@value={{this.inviteCode}}
class={{value-entered this.inviteCode}}
id="inviteCode"
/>
<label class="alt-placeholder" for="invite-code">{{i18n
"user.invite_code.title"
}}</label>
<span class="more-info">{{i18n
"user.invite_code.instructions"
}}</span>
</div>
{{/if}}
<PluginOutlet
@name="create-account-after-password"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>
{{#if this.userFields}}
<div class="user-fields">
{{#each this.userFields as |f|}}
<div class="input-group">
{{! adding the value-entered class here to
be able to detect if the user field has a value
entered }}
<UserField
@field={{f.field}}
@value={{f.value}}
@class={{value-entered f.value}}
@validation={{f.validation}}
/>
</div>
{{/each}}
</div>
{{/if}}
<PluginOutlet
@name="create-account-after-user-fields"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>
</form>
</div>
<div class="modal-footer">
<div class="disclaimer">
{{html-safe this.disclaimerHtml}}
</div>
<DButton
@action={{action "createAccount"}}
@disabled={{this.submitDisabled}}
@label="create_account.title"
@isLoading={{this.formSubmitted}}
class="btn-large btn-primary"
/>
{{#unless this.hasAuthOptions}}
<DButton
@action={{route-action "showLogin"}}
@disabled={{this.formSubmitted}}
@label="log_in"
id="login-link"
class="btn-large"
/>
{{/unless}}
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{/if}}
{{#if this.site.desktopView}}
{{#unless this.hasAuthOptions}}
<div class="create-account-login-buttons">
<LoginButtons @externalLogin={{action "externalLogin"}} />
</div>
{{/unless}}
{{/if}}
{{#if this.skipConfirmation}}
{{loading-spinner size="large"}}
{{/if}}
</div>
</DModalBody>
{{/unless}}
</CreateAccount>

View File

@ -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");
});
});

View File

@ -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");
});
});

View File

@ -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");
});
});

View File

@ -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"
);
});

View File

@ -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;

View File

@ -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%;

View File

@ -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;