DEV: Convert login modal to component-based API (#23093)

# Desktop
##### Before
<img width="865" alt="Screenshot 2023-08-17 at 1 32 02 PM" src="https://github.com/discourse/discourse/assets/50783505/1691ae34-8cc3-4deb-bee0-748851a43f6c">

##### After
<img width="818" alt="Screenshot 2023-08-17 at 1 34 13 PM" src="https://github.com/discourse/discourse/assets/50783505/0dcc6d95-270f-44a1-8582-5f7bf89e7e2c">

# Mobile
##### Before
<img width="364" alt="Screenshot 2023-08-17 at 1 28 20 PM" src="https://github.com/discourse/discourse/assets/50783505/6758b7f9-da65-464e-b289-d43177218026">

##### After
<img width="365" alt="Screenshot 2023-08-17 at 1 28 33 PM" src="https://github.com/discourse/discourse/assets/50783505/f32f28d3-a48c-485f-91eb-dc6bcaf8a2e1">

# Changes Made
- I took the liberty to hide the password <kbd>Show</kbd> / <kbd>Hide</kbd> toggle when no password present.
##### Before
<img width="237" alt="Screenshot 2023-08-15 at 4 46 16 PM" src="https://github.com/discourse/discourse/assets/50783505/dfa46535-27ea-4756-8cb0-2c1108505ec7">
<img width="240" alt="Screenshot 2023-08-15 at 4 43 03 PM" src="https://github.com/discourse/discourse/assets/50783505/b1b9bacd-8b11-4fb5-89ce-53135417193f">
<img width="244" alt="Screenshot 2023-08-15 at 4 42 58 PM" src="https://github.com/discourse/discourse/assets/50783505/88f3176b-fc25-4d0b-8193-967bf898f113">

##### After
<img width="263" alt="Screenshot 2023-08-15 at 4 45 47 PM" src="https://github.com/discourse/discourse/assets/50783505/48241693-5b0b-4c21-8a06-e14262ede79c">
<img width="268" alt="Screenshot 2023-08-15 at 4 45 50 PM" src="https://github.com/discourse/discourse/assets/50783505/3c2c4aeb-6fde-45c5-8e45-2879ecb7ead2">
<img width="221" alt="Screenshot 2023-08-15 at 4 45 39 PM" src="https://github.com/discourse/discourse/assets/50783505/94406f13-6b20-484c-831e-1b828600cccf">
This commit is contained in:
Isaac Janzen 2023-09-05 12:01:39 -05:00 committed by GitHub
parent 7a34ea7953
commit bb2d1f8703
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 842 additions and 1075 deletions

View File

@ -1,30 +0,0 @@
import Component from "@ember/component";
import cookie from "discourse/lib/cookie";
import { schedule } from "@ember/runloop";
export default Component.extend({
didInsertElement() {
this._super(...arguments);
const prefillUsername = $("#hidden-login-form input[name=username]").val();
if (prefillUsername) {
this.set("loginName", prefillUsername);
this.set(
"loginPassword",
$("#hidden-login-form input[name=password]").val()
);
} else if (cookie("email")) {
this.set("loginName", cookie("email"));
}
schedule("afterRender", () => {
$(
"#login-account-password, #login-account-name, #login-second-factor"
).keydown((e) => {
if (e.key === "Enter") {
this.action();
}
});
});
},
});

View File

@ -0,0 +1,73 @@
<DModal
class="login-modal"
@bodyClass={{this.modalBodyClasses}}
@closeModal={{@closeModal}}
@flash={{this.flash}}
@flashType={{this.flashType}}
{{did-insert this.preloadLogin}}
>
<:body>
<PluginOutlet @name="login-before-modal-body" @connectorTagName="div" />
{{#if this.site.mobileView}}
<Modal::Login::WelcomeHeader
@wavingHandURL={{this.wavingHandURL}}
@createAccount={{this.createAccount}}
/>
{{#if this.showLoginButtons}}
<LoginButtons @externalLogin={{this.externalLogin}} />
{{/if}}
{{/if}}
{{#if this.canLoginLocal}}
<div class={{if this.site.desktopView "login-left-side"}}>
{{#if this.site.desktopView}}
<Modal::Login::WelcomeHeader
@wavingHandURL={{this.wavingHandURL}}
@createAccount={{this.createAccount}}
/>
{{/if}}
<Modal::Login::LocalLoginForm
@loginName={{this.loginName}}
@loginNameChanged={{this.loginNameChanged}}
@canLoginLocalWithEmail={{this.canLoginLocalWithEmail}}
@loginPassword={{this.loginPassword}}
@secondFactorMethod={{this.secondFactorMethod}}
@secondFactorToken={{this.secondFactorToken}}
@backupEnabled={{this.backupEnabled}}
@securityKeyAllowedCredentialIds={{this.securityKeyAllowedCredentialIds}}
@securityKeyChallenge={{this.securityKeyChallenge}}
@showSecurityKey={{this.showSecurityKey}}
@otherMethodAllowed={{this.otherMethodAllowed}}
@showSecondFactor={{this.showSecondFactor}}
@handleForgotPassword={{this.handleForgotPassword}}
@login={{this.login}}
@flashChanged={{this.flashChanged}}
@flashTypeChanged={{this.flashTypeChanged}}
@securityKeyCredentialChanged={{this.securityKeyCredentialChanged}}
/>
<Modal::Login::Footer
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.login}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
/>
</div>
{{/if}}
{{#if (and this.showLoginButtons this.site.desktopView)}}
{{#unless this.canLoginLocal}}
<div class="login-left-side">
<Modal::Login::WelcomeHeader @wavingHandURL={{this.wavingHandURL}} />
</div>
{{/unless}}
<div class="login-right-side">
<LoginButtons @externalLogin={{this.externalLogin}} />
</div>
{{/if}}
</:body>
</DModal>

View File

@ -0,0 +1,302 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { ajax } from "discourse/lib/ajax";
import { findAll } from "discourse/models/login-method";
import { areCookiesEnabled } from "discourse/lib/utilities";
import { wavingHandURL } from "discourse/lib/waving-hand-url";
import { schedule } from "@ember/runloop";
import cookie, { removeCookie } from "discourse/lib/cookie";
import { isEmpty } from "@ember/utils";
import I18n from "I18n";
import { escape } from "pretty-text/sanitizer";
export default class Login extends Component {
@service dialog;
@service siteSettings;
@service site;
@tracked loggingIn = false;
@tracked loggedIn = false;
@tracked showLoginButtons = true;
@tracked showSecondFactor = false;
@tracked loginPassword = "";
@tracked loginName = "";
@tracked flash = this.args.model?.flash;
@tracked flashType = this.args.model?.flashType;
@tracked canLoginLocal = this.siteSettings.enable_local_logins;
@tracked
canLoginLocalWithEmail = this.siteSettings.enable_local_logins_via_email;
@tracked secondFactorMethod = SECOND_FACTOR_METHODS.TOTP;
@tracked securityKeyCredential;
@tracked otherMethodAllowed;
@tracked secondFactorRequired;
@tracked backupEnabled;
@tracked totpEnabled;
@tracked showSecurityKey;
@tracked securityKeyChallenge;
@tracked securityKeyAllowedCredentialIds;
@tracked secondFactorToken;
constructor() {
super(...arguments);
if (this.args.model?.isExternalLogin) {
this.externalLogin(this.args.model.externalLoginMethod, {
signup: this.args.model.signup,
});
}
}
get awaitingApproval() {
return (
this.args.model?.awaitingApproval &&
!this.canLoginLocal &&
!this.canLoginLocalWithEmail
);
}
get loginDisabled() {
return this.loggingIn || this.loggedIn;
}
get wavingHandURL() {
return wavingHandURL();
}
get modalBodyClasses() {
const classes = ["login-modal-body"];
if (this.awaitingApproval) {
classes.push("awaiting-approval");
}
if (
this.hasAtLeastOneLoginButton &&
!this.showSecondFactor &&
!this.showSecurityKey
) {
classes.push("has-alt-auth");
}
if (!this.canLoginLocal) {
classes.push("no-local-login");
}
if (this.showSecondFactor || this.showSecurityKey) {
classes.push("second-factor");
}
return classes.join(" ");
}
get hasAtLeastOneLoginButton() {
return findAll().length > 0;
}
get loginButtonLabel() {
return this.loggingIn ? "login.logging_in" : "login.title";
}
get showSignupLink() {
return this.args.model.canSignUp && !this.loggingIn;
}
@action
preloadLogin() {
const prefillUsername = document.querySelector(
"#hidden-login-form input[name=username]"
)?.value;
if (prefillUsername) {
this.loginName = prefillUsername;
this.loginPassword = document.querySelector(
"#hidden-login-form input[name=password]"
).value;
} else if (cookie("email")) {
this.loginName = cookie("email");
}
}
@action
securityKeyCredentialChanged(value) {
this.securityKeyCredential = value;
}
@action
flashChanged(value) {
this.flash = value;
}
@action
flashTypeChanged(value) {
this.flashType = value;
}
@action
loginNameChanged(event) {
this.loginName = event.target.value;
}
@action
async login() {
if (this.loginDisabled) {
return;
}
if (isEmpty(this.loginName) || isEmpty(this.loginPassword)) {
this.flash = I18n.t("login.blank_username_or_password");
this.flashType = "error";
return;
}
try {
this.loggingIn = true;
const result = await ajax("/session", {
type: "POST",
data: {
login: this.loginName,
password: this.loginPassword,
second_factor_token:
this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod,
timezone: moment.tz.guess(),
},
});
if (result && result.error) {
this.loggingIn = false;
this.flash = null;
if (
(result.security_key_enabled || result.totp_enabled) &&
!this.secondFactorRequired
) {
this.otherMethodAllowed = result.multiple_second_factor_methods;
this.secondFactorRequired = true;
this.showLoginButtons = false;
this.backupEnabled = result.backup_enabled;
this.totpEnabled = result.totp_enabled;
this.showSecondFactor = result.totp_enabled;
this.showSecurityKey = result.security_key_enabled;
this.secondFactorMethod = result.security_key_enabled
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP;
this.securityKeyChallenge = result.challenge;
this.securityKeyAllowedCredentialIds = result.allowed_credential_ids;
// only need to focus the 2FA input for TOTP
if (!this.showSecurityKey) {
schedule("afterRender", () =>
document
.getElementById("second-factor")
.querySelector("input")
.focus()
);
}
return;
} else if (result.reason === "not_activated") {
this.args.model.showNotActivated({
username: this.loginName,
sentTo: escape(result.sent_to_email),
currentEmail: escape(result.current_email),
});
} else if (result.reason === "suspended") {
this.args.closeModal();
this.dialog.alert(result.error);
} else {
this.flash = result.error;
this.flashType = "error";
}
} else {
this.loggedIn = true;
// Trigger the browser's password manager using the hidden static login form:
const hiddenLoginForm = document.getElementById("hidden-login-form");
const applyHiddenFormInputValue = (value, key) => {
if (!hiddenLoginForm) {
return;
}
hiddenLoginForm.querySelector(`input[name=${key}]`).value = value;
};
const destinationUrl = cookie("destination_url");
const ssoDestinationUrl = cookie("sso_destination_url");
applyHiddenFormInputValue(this.loginName, "username");
applyHiddenFormInputValue(this.loginPassword, "password");
if (ssoDestinationUrl) {
removeCookie("sso_destination_url");
window.location.assign(ssoDestinationUrl);
return;
} else if (destinationUrl) {
// redirect client to the original URL
removeCookie("destination_url");
applyHiddenFormInputValue(destinationUrl, "redirect");
} else {
applyHiddenFormInputValue(window.location.href, "redirect");
}
if (hiddenLoginForm) {
if (
navigator.userAgent.match(/(iPad|iPhone|iPod)/g) &&
navigator.userAgent.match(/Safari/g)
) {
// In case of Safari on iOS do not submit hidden login form
window.location.href = hiddenLoginForm.querySelector(
"input[name=redirect]"
).value;
} else {
hiddenLoginForm.submit();
}
}
return;
}
} catch (e) {
// Failed to login
if (e.jqXHR && e.jqXHR.status === 429) {
this.flash = I18n.t("login.rate_limit");
this.flashType = "error";
} else if (
e.jqXHR &&
e.jqXHR.status === 503 &&
e.jqXHR.responseJSON.error_type === "read_only"
) {
this.flash = I18n.t("read_only_mode.login_disabled");
this.flashType = "error";
} else if (!areCookiesEnabled()) {
this.flash = I18n.t("login.cookies_error");
this.flashType = "error";
} else {
this.flash = I18n.t("login.error");
this.flashType = "error";
}
this.loggingIn = false;
}
}
@action
async externalLogin(loginMethod, { signup = false } = {}) {
if (this.loginDisabled) {
return;
}
try {
this.loggingIn = true;
await loginMethod.doLogin({ signup });
this.args.closeModal();
} catch {
this.loggingIn = false;
}
}
@action
createAccount() {
let createAccountProps = {};
if (this.loginName && this.loginName.indexOf("@") > 0) {
createAccountProps.accountEmail = this.loginName;
createAccountProps.accountUsername = null;
} else {
createAccountProps.accountUsername = this.loginName;
createAccountProps.accountEmail = null;
}
this.args.model.showCreateAccount(createAccountProps);
}
}

View File

@ -0,0 +1,28 @@
<div class="modal-footer">
{{#if @canLoginLocal}}
{{#unless @showSecurityKey}}
<DButton
@action={{@login}}
id="login-button"
@form="login-form"
@icon="unlock"
@label={{@loginButtonLabel}}
@disabled={{@loginDisabled}}
class="btn btn-large btn-primary"
@tabindex="2"
/>
{{/unless}}
{{#if @showSignupLink}}
<DButton
class="btn-large"
id="new-account-link"
@action={{@createAccount}}
@label="create_account.title"
@tabindex="3"
/>
{{/if}}
{{/if}}
<ConditionalLoadingSpinner @condition={{@loggingIn}} @size="small" />
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />
</div>

View File

@ -0,0 +1,98 @@
<form id="login-form" method="post">
<div id="credentials" class={{this.credentialsClass}}>
<div class="input-group">
<Input
@value={{@loginName}}
@type="email"
id="login-account-name"
class={{value-entered @loginName}}
autocomplete="username"
autocorrect="off"
autocapitalize="off"
disabled={{@showSecondFactor}}
autofocus="autofocus"
tabindex="1"
{{on "input" @loginNameChanged}}
{{on "keydown" this.loginOnEnter}}
/>
<label class="alt-placeholder" for="login-account-name">
{{i18n "login.email_placeholder"}}
</label>
{{#if @canLoginLocalWithEmail}}
<a
href
class={{if @loginName "" "no-login-filled"}}
tabindex="3"
id="email-login-link"
{{on "click" this.emailLogin}}
>
{{i18n "email_login.login_link"}}
</a>
{{/if}}
</div>
<div class="input-group">
<PasswordField
@value={{@loginPassword}}
@type={{if this.maskPassword "password" "text"}}
class={{value-entered @loginPassword}}
id="login-account-password"
autocomplete="current-password"
maxlength="200"
@capsLockOn={{this.capsLockOn}}
disabled={{this.disableLoginFields}}
tabindex="1"
{{on "keydown" this.loginOnEnter}}
/>
<label class="alt-placeholder" for="login-account-password">
{{i18n "login.password"}}
</label>
<div class="login__password-links">
<a
href
id="forgot-password-link"
tabindex="3"
{{on "click" this.handleForgotPassword}}
>
{{i18n "forgot_password.action"}}
</a>
{{#if @loginPassword}}
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
tabindex="3"
/>
{{/if}}
</div>
<div class="caps-lock-warning {{unless this.capsLockOn 'hidden'}}">
{{d-icon "exclamation-triangle"}}
{{i18n "login.caps_lock_warning"}}</div>
</div>
</div>
<SecondFactorForm
@secondFactorMethod={{@secondFactorMethod}}
@secondFactorToken={{@secondFactorToken}}
@class={{this.secondFactorClass}}
@backupEnabled={{@backupEnabled}}
@isLogin={{true}}
>
{{#if @showSecurityKey}}
<SecurityKeyForm
@allowedCredentialIds={{@securityKeyAllowedCredentialIds}}
@challenge={{@securityKeyChallenge}}
@showSecurityKey={{@showSecurityKey}}
@showSecondFactor={{@showSecondFactor}}
@secondFactorMethod={{@secondFactorMethod}}
@otherMethodAllowed={{@otherMethodAllowed}}
@action={{this.authenticateSecurityKey}}
/>
{{else}}
<SecondFactorInput
@value={{@secondFactorToken}}
@inputId="login-second-factor"
@secondFactorMethod={{@secondFactorMethod}}
@backupEnabled={{@backupEnabled}}
{{on "keydown" this.loginOnEnter}}
/>
{{/if}}
</SecondFactorForm>
</form>

View File

@ -0,0 +1,129 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { escapeExpression } from "discourse/lib/utilities";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
import getWebauthnCredential from "discourse/lib/webauthn";
import ForgotPassword from "discourse/components/modal/forgot-password";
export default class LocalLoginBody extends Component {
@service modal;
@tracked maskPassword = true;
@tracked processingEmailLink = false;
@tracked capsLockOn = false;
get credentialsClass() {
return this.args.showSecondFactor || this.args.showSecurityKey
? "hidden"
: "";
}
get secondFactorClass() {
return this.args.showSecondFactor || this.args.showSecurityKey
? ""
: "hidden";
}
get disableLoginFields() {
return this.args.showSecondFactor || this.args.showSecurityKey;
}
@action
togglePasswordMask() {
this.maskPassword = !this.maskPassword;
}
@action
async emailLogin(event) {
event?.preventDefault();
if (this.processingEmailLink) {
return;
}
if (isEmpty(this.args.loginName)) {
this.args.flashChanged(I18n.t("login.blank_username"));
this.args.flashTypeChanged("info");
return;
}
try {
this.processingEmailLink = true;
const data = await ajax("/u/email-login", {
data: { login: this.args.loginName.trim() },
type: "POST",
});
const loginName = escapeExpression(this.args.loginName);
const isEmail = loginName.match(/@/);
const key = isEmail
? "email_login.complete_email"
: "email_login.complete_username";
if (data.user_found === false) {
this.args.flashChanged(
htmlSafe(
I18n.t(`${key}_not_found`, {
email: loginName,
username: loginName,
})
)
);
this.args.flashTypeChanged("error");
} else {
const postfix = data.hide_taken ? "" : "_found";
this.args.flashChanged(
htmlSafe(
I18n.t(`${key}${postfix}`, {
email: loginName,
username: loginName,
})
)
);
this.args.flashTypeChanged("success");
}
} catch (e) {
popupAjaxError(e);
} finally {
this.processingEmailLink = false;
}
}
@action
loginOnEnter(event) {
if (event.key === "Enter") {
this.args.login();
}
}
@action
handleForgotPassword(event) {
event?.preventDefault();
this.modal.show(ForgotPassword, {
model: {
emailOrUsername: this.args.loginName,
},
});
}
@action
authenticateSecurityKey() {
getWebauthnCredential(
this.args.securityKeyChallenge,
this.args.securityKeyAllowedCredentialIds,
(credentialData) => {
this.args.securityKeyCredentialChanged(credentialData);
this.args.login();
},
(error) => {
this.args.flashChanged(error);
this.args.flashTypeChanged("error");
}
);
}
}

View File

@ -0,0 +1,9 @@
<div class="login-welcome-header">
<h1 class="login-title">{{i18n "login.header_title"}}</h1>
<img src={{@wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n "login.subheader_title"}}</p>
<PluginOutlet
@name="login-header-bottom"
@outletArgs={{hash createAccount=@createAccount}}
/>
</div>

View File

@ -1,4 +1,4 @@
import Controller, { inject as controller } from "@ember/controller";
import Controller from "@ember/controller";
import cookie, { removeCookie } from "discourse/lib/cookie";
import discourseComputed, {
observes,
@ -23,6 +23,8 @@ import { notEmpty } from "@ember/object/computed";
import { setting } from "discourse/lib/computed";
import { userPath } from "discourse/lib/url";
import { wavingHandURL } from "discourse/lib/waving-hand-url";
import { inject as service } from "@ember/service";
import LoginModal from "discourse/components/modal/login";
export default Controller.extend(
ModalFunctionality,
@ -31,7 +33,7 @@ export default Controller.extend(
NameValidation,
UserFieldsValidation,
{
login: controller(),
modal: service(),
complete: false,
accountChallenge: 0,
@ -52,27 +54,6 @@ export default Controller.extend(
return (hasAuthOptions || canCreateLocal) && !skipConfirmation;
},
resetForm() {
// We wrap the fields in a structure so we can assign a value
this.setProperties({
accountName: "",
accountEmail: "",
accountUsername: "",
accountPassword: "",
serverAccountEmail: null,
serverEmailValidation: null,
authOptions: null,
complete: false,
formSubmitted: false,
rejectedEmails: [],
rejectedPasswords: [],
prefilledUsername: null,
isDeveloper: false,
maskPassword: true,
});
this._createUserFields();
},
@discourseComputed("formSubmitted")
submitDisabled() {
if (this.formSubmitted) {
@ -444,7 +425,14 @@ export default Controller.extend(
actions: {
externalLogin(provider) {
this.login.send("externalLogin", provider, { signup: true });
// we will automatically redirect to the external auth service
this.modal.show(LoginModal, {
model: {
isExternalLogin: true,
externalLoginMethod: provider,
signup: true,
},
});
},
createAccount() {

View File

@ -1,466 +0,0 @@
import Controller, { inject as controller } from "@ember/controller";
import { alias, not, or, readOnly } from "@ember/object/computed";
import { areCookiesEnabled, escapeExpression } from "discourse/lib/utilities";
import cookie, { removeCookie } from "discourse/lib/cookie";
import { next, schedule } from "@ember/runloop";
import EmberObject, { action } from "@ember/object";
import I18n from "I18n";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
import { escape } from "pretty-text/sanitizer";
import { flashAjaxError } from "discourse/lib/ajax-error";
import { findAll } from "discourse/models/login-method";
import getURL from "discourse-common/lib/get-url";
import { getWebauthnCredential } from "discourse/lib/webauthn";
import { isEmpty } from "@ember/utils";
import { setting } from "discourse/lib/computed";
import showModal from "discourse/lib/show-modal";
import { wavingHandURL } from "discourse/lib/waving-hand-url";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import ForgotPassword from "discourse/components/modal/forgot-password";
// This is happening outside of the app via popup
const AuthErrors = [
"requires_invite",
"awaiting_approval",
"awaiting_activation",
"admin_not_allowed_from_ip_address",
"not_allowed_from_ip_address",
];
export default Controller.extend(ModalFunctionality, {
createAccount: controller(),
application: controller(),
dialog: service(),
loggingIn: false,
loggedIn: false,
processingEmailLink: false,
showLoginButtons: true,
showSecondFactor: false,
awaitingApproval: false,
maskPassword: true,
canLoginLocal: setting("enable_local_logins"),
canLoginLocalWithEmail: setting("enable_local_logins_via_email"),
loginRequired: alias("application.loginRequired"),
secondFactorMethod: SECOND_FACTOR_METHODS.TOTP,
noLoginLocal: not("canLoginLocal"),
resetForm() {
this.setProperties({
loggingIn: false,
loggedIn: false,
secondFactorRequired: false,
showSecondFactor: false,
showSecurityKey: false,
showLoginButtons: true,
awaitingApproval: false,
maskPassword: true,
});
},
@discourseComputed("showSecondFactor", "showSecurityKey")
credentialsClass(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey ? "hidden" : "";
},
@discourseComputed()
wavingHandURL: () => wavingHandURL(),
@discourseComputed("showSecondFactor", "showSecurityKey")
secondFactorClass(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey ? "" : "hidden";
},
@discourseComputed(
"awaitingApproval",
"hasAtLeastOneLoginButton",
"showSecondFactor",
"canLoginLocal",
"showSecurityKey"
)
modalBodyClasses(
awaitingApproval,
hasAtLeastOneLoginButton,
showSecondFactor,
canLoginLocal,
showSecurityKey
) {
const classes = ["login-modal-body"];
if (awaitingApproval) {
classes.push("awaiting-approval");
}
if (hasAtLeastOneLoginButton && !showSecondFactor && !showSecurityKey) {
classes.push("has-alt-auth");
}
if (!canLoginLocal) {
classes.push("no-local-login");
}
if (showSecondFactor || showSecurityKey) {
classes.push("second-factor");
}
return classes.join(" ");
},
@discourseComputed("showSecondFactor", "showSecurityKey")
disableLoginFields(showSecondFactor, showSecurityKey) {
return showSecondFactor || showSecurityKey;
},
@discourseComputed()
hasAtLeastOneLoginButton() {
return findAll().length > 0;
},
@discourseComputed("loggingIn")
loginButtonLabel(loggingIn) {
return loggingIn ? "login.logging_in" : "login.title";
},
loginDisabled: or("loggingIn", "loggedIn"),
@discourseComputed("loggingIn", "application.canSignUp")
showSignupLink(loggingIn, canSignUp) {
return canSignUp && !loggingIn;
},
showSpinner: readOnly("loggingIn"),
@discourseComputed("canLoginLocalWithEmail")
showLoginWithEmailLink(canLoginLocalWithEmail) {
return canLoginLocalWithEmail;
},
@action
emailLogin(event) {
event?.preventDefault();
if (this.processingEmailLink) {
return;
}
if (isEmpty(this.loginName)) {
this.flash(I18n.t("login.blank_username"), "info");
return;
}
this.set("processingEmailLink", true);
ajax("/u/email-login", {
data: { login: this.loginName.trim() },
type: "POST",
})
.then((data) => {
const loginName = escapeExpression(this.loginName);
const isEmail = loginName.match(/@/);
let key = isEmail
? "email_login.complete_email"
: "email_login.complete_username";
if (data.user_found === false) {
this.flash(
htmlSafe(
I18n.t(`${key}_not_found`, {
email: loginName,
username: loginName,
})
),
"error"
);
} else {
let postfix = data.hide_taken ? "" : "_found";
this.flash(
htmlSafe(
I18n.t(`${key}${postfix}`, {
email: loginName,
username: loginName,
})
)
);
}
})
.catch(flashAjaxError(this))
.finally(() => this.set("processingEmailLink", false));
},
@action
handleForgotPassword(event) {
event?.preventDefault();
this.modal.show(ForgotPassword, {
model: {
emailOrUsername: this.loginName,
},
});
},
@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
},
actions: {
forgotPassword() {
this.handleForgotPassword();
},
login() {
if (this.loginDisabled) {
return;
}
if (isEmpty(this.loginName) || isEmpty(this.loginPassword)) {
this.flash(I18n.t("login.blank_username_or_password"), "error");
return;
}
this.set("loggingIn", true);
ajax("/session", {
type: "POST",
data: {
login: this.loginName,
password: this.loginPassword,
second_factor_token:
this.securityKeyCredential || this.secondFactorToken,
second_factor_method: this.secondFactorMethod,
timezone: moment.tz.guess(),
},
}).then(
(result) => {
// Successful login
if (result && result.error) {
this.set("loggingIn", false);
this.clearFlash();
if (
(result.security_key_enabled || result.totp_enabled) &&
!this.secondFactorRequired
) {
this.setProperties({
otherMethodAllowed: result.multiple_second_factor_methods,
secondFactorRequired: true,
showLoginButtons: false,
backupEnabled: result.backup_enabled,
totpEnabled: result.totp_enabled,
showSecondFactor: result.totp_enabled,
showSecurityKey: result.security_key_enabled,
secondFactorMethod: result.security_key_enabled
? SECOND_FACTOR_METHODS.SECURITY_KEY
: SECOND_FACTOR_METHODS.TOTP,
securityKeyChallenge: result.challenge,
securityKeyAllowedCredentialIds: result.allowed_credential_ids,
});
// only need to focus the 2FA input for TOTP
if (!this.showSecurityKey) {
schedule("afterRender", () =>
document
.getElementById("second-factor")
.querySelector("input")
.focus()
);
}
return;
} else if (result.reason === "not_activated") {
this.send("showNotActivated", {
username: this.loginName,
sentTo: escape(result.sent_to_email),
currentEmail: escape(result.current_email),
});
} else if (result.reason === "suspended") {
this.send("closeModal");
this.dialog.alert(result.error);
} else {
this.flash(result.error, "error");
}
} else {
this.set("loggedIn", true);
// Trigger the browser's password manager using the hidden static login form:
const hiddenLoginForm =
document.getElementById("hidden-login-form");
const applyHiddenFormInputValue = (value, key) => {
if (!hiddenLoginForm) {
return;
}
hiddenLoginForm.querySelector(`input[name=${key}]`).value = value;
};
const destinationUrl = cookie("destination_url");
const ssoDestinationUrl = cookie("sso_destination_url");
applyHiddenFormInputValue(this.loginName, "username");
applyHiddenFormInputValue(this.loginPassword, "password");
if (ssoDestinationUrl) {
removeCookie("sso_destination_url");
window.location.assign(ssoDestinationUrl);
return;
} else if (destinationUrl) {
// redirect client to the original URL
removeCookie("destination_url");
applyHiddenFormInputValue(destinationUrl, "redirect");
} else {
applyHiddenFormInputValue(window.location.href, "redirect");
}
if (hiddenLoginForm) {
if (
navigator.userAgent.match(/(iPad|iPhone|iPod)/g) &&
navigator.userAgent.match(/Safari/g)
) {
// In case of Safari on iOS do not submit hidden login form
window.location.href = hiddenLoginForm.querySelector(
"input[name=redirect]"
).value;
} else {
hiddenLoginForm.submit();
}
}
return;
}
},
(e) => {
// Failed to login
if (e.jqXHR && e.jqXHR.status === 429) {
this.flash(I18n.t("login.rate_limit"), "error");
} else if (
e.jqXHR &&
e.jqXHR.status === 503 &&
e.jqXHR.responseJSON.error_type === "read_only"
) {
this.flash(I18n.t("read_only_mode.login_disabled"), "error");
} else if (!areCookiesEnabled()) {
this.flash(I18n.t("login.cookies_error"), "error");
} else {
this.flash(I18n.t("login.error"), "error");
}
this.set("loggingIn", false);
}
);
return false;
},
externalLogin(loginMethod, { signup = false } = {}) {
if (this.loginDisabled) {
return;
}
this.set("loggingIn", true);
loginMethod.doLogin({ signup }).catch(() => this.set("loggingIn", false));
},
createAccount() {
const createAccountController = this.createAccount;
if (createAccountController) {
createAccountController.resetForm();
const loginName = this.loginName;
if (loginName && loginName.indexOf("@") > 0) {
createAccountController.set("accountEmail", loginName);
} else {
createAccountController.set("accountUsername", loginName);
}
}
this.send("showCreateAccount");
},
authenticateSecurityKey() {
getWebauthnCredential(
this.securityKeyChallenge,
this.securityKeyAllowedCredentialIds,
(credentialData) => {
this.set("securityKeyCredential", credentialData);
this.send("login");
},
(errorMessage) => {
this.flash(errorMessage, "error");
}
);
},
},
authenticationComplete(options) {
const loginError = (errorMsg, className, callback) => {
showModal("login");
next(() => {
if (callback) {
callback();
}
this.flash(errorMsg, className || "success");
});
};
if (
options.awaiting_approval &&
!this.canLoginLocal &&
!this.canLoginLocalWithEmail
) {
this.set("awaitingApproval", true);
}
if (options.omniauth_disallow_totp) {
return loginError(I18n.t("login.omniauth_disallow_totp"), "error", () => {
this.setProperties({
loginName: options.email,
showLoginButtons: false,
});
document.getElementById("login-account-password").focus();
});
}
for (let i = 0; i < AuthErrors.length; i++) {
const cond = AuthErrors[i];
if (options[cond]) {
return loginError(I18n.t(`login.${cond}`));
}
}
if (options.suspended) {
return loginError(options.suspended_message, "error");
}
// Reload the page if we're authenticated
if (options.authenticated) {
const destinationUrl =
cookie("destination_url") || options.destination_url;
if (destinationUrl) {
// redirect client to the original URL
removeCookie("destination_url");
window.location.href = destinationUrl;
} else if (window.location.pathname === getURL("/login")) {
window.location = getURL("/");
} else {
window.location.reload();
}
return;
}
const skipConfirmation = this.siteSettings.auth_skip_create_confirm;
const createAccountController = this.createAccount;
createAccountController.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",
});
});
},
});

View File

@ -1,4 +1,19 @@
import { next } from "@ember/runloop";
import cookie, { removeCookie } from "discourse/lib/cookie";
import { getURL } from "discourse/lib/url";
import EmberObject from "@ember/object";
import showModal from "discourse/lib/show-modal";
import I18n from "I18n";
import LoginModal from "discourse/components/modal/login";
// This is happening outside of the app via popup
const AuthErrors = [
"requires_invite",
"awaiting_approval",
"awaiting_activation",
"admin_not_allowed_from_ip_address",
"not_allowed_from_ip_address",
];
export default {
after: "inject-objects",
@ -13,14 +28,93 @@ export default {
if (lastAuthResult) {
const router = owner.lookup("router:main");
router.one("didTransition", () => {
const controllerName =
router.currentPath === "invites.show" ? "invites-show" : "login";
next(() => {
let controller = owner.lookup(`controller:${controllerName}`);
controller.authenticationComplete(JSON.parse(lastAuthResult));
if (router.currentPath === "invites.show") {
owner
.lookup("controller:invites-show")
.authenticationComplete(JSON.parse(lastAuthResult));
} else {
const options = JSON.parse(lastAuthResult);
const modal = owner.lookup("service:modal");
const siteSettings = owner.lookup("service:site-settings");
const loginError = (errorMsg, className, properties, callback) => {
const applicationRouter = owner.lookup("route:application");
const applicationController = owner.lookup(
"controller:application"
);
modal.show(LoginModal, {
model: {
showNotActivated: (props) =>
applicationRouter.send("showNotActivated", props),
showCreateAccount: (props) =>
applicationRouter.send("showCreateAccount", props),
canSignUp: applicationController.canSignUp,
flash: errorMsg,
flashType: className || "success",
awaitingApproval: options.awaiting_approval,
...properties,
},
});
next(() => callback?.());
};
if (options.omniauth_disallow_totp) {
return loginError(
I18n.t("login.omniauth_disallow_totp"),
"error",
{
loginName: options.email,
showLoginButtons: false,
},
() => document.getElementById("login-account-password").focus()
);
}
for (let i = 0; i < AuthErrors.length; i++) {
const cond = AuthErrors[i];
if (options[cond]) {
return loginError(I18n.t(`login.${cond}`));
}
}
if (options.suspended) {
return loginError(options.suspended_message, "error");
}
// Reload the page if we're authenticated
if (options.authenticated) {
const destinationUrl =
cookie("destination_url") || options.destination_url;
if (destinationUrl) {
// redirect client to the original URL
removeCookie("destination_url");
window.location.href = destinationUrl;
} else if (window.location.pathname === getURL("/login")) {
window.location = getURL("/");
} else {
window.location.reload();
}
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",
});
});
}
});
});
}

View File

@ -17,16 +17,7 @@ import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts
import NotActivatedModal from "../components/modal/not-activated";
import ForgotPassword from "discourse/components/modal/forgot-password";
import deprecated from "discourse-common/lib/deprecated";
function unlessReadOnly(method, message) {
return function () {
if (this.site.isReadOnly) {
this.dialog.alert(message);
} else {
this[method]();
}
};
}
import LoginModal from "discourse/components/modal/login";
function unlessStrictlyReadOnly(method, message) {
return function () {
@ -47,6 +38,18 @@ const ApplicationRoute = DiscourseRoute.extend({
modal: service(),
loadingSlider: service(),
router: service(),
siteSettings: service(),
get includeExternalLoginMethods() {
return (
!this.siteSettings.enable_local_logins &&
this.externalLoginMethods.length === 1
);
},
get externalLoginMethods() {
return findAll();
},
@action
loading(transition) {
@ -143,10 +146,13 @@ const ApplicationRoute = DiscourseRoute.extend({
I18n.t("read_only_mode.login_disabled")
),
showCreateAccount: unlessReadOnly(
"handleShowCreateAccount",
I18n.t("read_only_mode.login_disabled")
),
showCreateAccount(createAccountProps = {}) {
if (this.site.isReadOnly) {
this.dialog.alert(I18n.t("read_only_mode.login_disabled"));
} else {
this.handleShowCreateAccount(createAccountProps);
}
},
showForgotPassword() {
this.modal.show(ForgotPassword);
@ -227,45 +233,41 @@ const ApplicationRoute = DiscourseRoute.extend({
const returnPath = encodeURIComponent(window.location.pathname);
window.location = getURL("/session/sso?return_path=" + returnPath);
} else {
this._autoLogin("login", {
notAuto: () => getOwner(this).lookup("controller:login").resetForm(),
this.modal.show(LoginModal, {
model: {
...(this.includeExternalLoginMethods && {
isExternalLogin: true,
externalLoginMethod: this.externalLoginMethods[0],
}),
showNotActivated: (props) => this.send("showNotActivated", props),
showCreateAccount: (props) => this.send("showCreateAccount", props),
canSignUp: this.controller.canSignUp,
},
});
}
},
handleShowCreateAccount() {
handleShowCreateAccount(createAccountProps) {
if (this.siteSettings.enable_discourse_connect) {
const returnPath = encodeURIComponent(window.location.pathname);
window.location = getURL("/session/sso?return_path=" + returnPath);
} else {
this._autoLogin("create-account", {
modalClass: "create-account",
signup: true,
titleAriaElementId: "create-account-title",
});
}
},
_autoLogin(
modal,
{
modalClass = undefined,
notAuto = null,
signup = false,
titleAriaElementId = null,
} = {}
) {
const methods = findAll();
if (!this.siteSettings.enable_local_logins && methods.length === 1) {
getOwner(this)
.lookup("controller:login")
.send("externalLogin", methods[0], {
signup,
if (this.includeExternalLoginMethods) {
// we will automatically redirect to the external auth service
this.modal.show(LoginModal, {
model: {
isExternalLogin: true,
externalLoginMethod: this.externalLoginMethods[0],
signup: true,
},
});
} else {
showModal(modal, { modalClass, titleAriaElementId });
notAuto?.();
} else {
const createAccount = showModal("create-account", {
modalClass: "create-account",
titleAriaElementId: "create-account-title",
});
createAccount.setProperties(createAccountProps);
}
}
},

View File

@ -20,7 +20,6 @@ const KNOWN_LEGACY_MODALS = [
"create-invite",
"grant-badge",
"group-default-notifications",
"login",
"raw-email",
"reject-reason-reviewable",
"reorder-categories",

View File

@ -1,128 +0,0 @@
<LoginModal
@loginName={{this.loginName}}
@loginPassword={{this.loginPassword}}
@secondFactorToken={{this.secondFactorToken}}
@action={{action "login"}}
>
<PluginOutlet @name="login-before-modal-body" @connectorTagName="div" />
<DModalBody @class={{this.modalBodyClasses}}>
<div class="login-welcome-header">
<h1 class="login-title">{{i18n "login.header_title"}}</h1>
<img src={{this.wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n "login.subheader_title"}}</p>
<PluginOutlet
@name="login-header-bottom"
@outletArgs={{hash createAccount=(action "createAccount")}}
/>
</div>
{{#if this.showLoginButtons}}
<LoginButtons @externalLogin={{action "externalLogin"}} />
{{/if}}
{{#if this.canLoginLocal}}
<form id="login-form" method="post">
<div id="credentials" class={{this.credentialsClass}}>
<div class="input-group">
<Input
@value={{this.loginName}}
class={{value-entered this.loginName}}
@type="email"
id="login-account-name"
autocorrect="off"
autocapitalize="off"
disabled={{this.showSecondFactor}}
autofocus="autofocus"
/>
<label class="alt-placeholder" for="login-account-name">{{i18n
"login.email_placeholder"
}}</label>
{{#if this.showLoginWithEmailLink}}
<a href id="email-login-link" {{on "click" this.emailLogin}}>
{{i18n "email_login.login_link"}}
</a>
{{/if}}
</div>
<div class="input-group">
<Input
@value={{this.loginPassword}}
class={{value-entered this.loginPassword}}
@type={{if this.maskPassword "password" "text"}}
id="login-account-password"
maxlength="200"
disabled={{this.showSecondFactor}}
/>
<label class="alt-placeholder" for="login-account-password">{{i18n
"login.password"
}}</label>
<div class="login__password-links">
<a
href
id="forgot-password-link"
{{on "click" this.handleForgotPassword}}
>{{i18n "forgot_password.action"}}</a>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
</div>
</div>
</div>
<SecondFactorForm
@secondFactorMethod={{this.secondFactorMethod}}
@secondFactorToken={{this.secondFactorToken}}
@class={{this.secondFactorClass}}
@backupEnabled={{this.backupEnabled}}
@isLogin={{true}}
>
{{#if this.showSecurityKey}}
<SecurityKeyForm
@allowedCredentialIds={{this.securityKeyAllowedCredentialIds}}
@challenge={{this.securityKeyChallenge}}
@showSecurityKey={{this.showSecurityKey}}
@showSecondFactor={{this.showSecondFactor}}
@secondFactorMethod={{this.secondFactorMethod}}
@otherMethodAllowed={{this.otherMethodAllowed}}
@action={{action "authenticateSecurityKey"}}
/>
{{else}}
<SecondFactorInput
@value={{this.secondFactorToken}}
@inputId="login-second-factor"
@secondFactorMethod={{this.secondFactorMethod}}
@backupEnabled={{this.backupEnabled}}
/>
{{/if}}
</SecondFactorForm>
</form>
{{/if}}
</DModalBody>
<div class="modal-footer">
{{#if this.canLoginLocal}}
{{#unless this.showSecurityKey}}
<DButton
@action={{action "login"}}
@id="login-button"
@icon="unlock"
@label={{this.loginButtonLabel}}
@disabled={{this.loginDisabled}}
@class="btn-large btn-primary"
/>
{{/unless}}
{{#if this.showSignupLink}}
<DButton
@class="btn-large"
@id="new-account-link"
@action={{route-action "showCreateAccount"}}
@label="create_account.title"
/>
{{/if}}
{{/if}}
</div>
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />
<div class={{this.alertClass}} id="login-alert">{{this.alert}}</div>
</LoginModal>

View File

@ -1,164 +0,0 @@
<LoginModal
@loginName={{this.loginName}}
@loginPassword={{this.loginPassword}}
@secondFactorToken={{this.secondFactorToken}}
@action={{action "login"}}
>
<PluginOutlet @name="login-before-modal-body" @connectorTagName="div" />
<DModalBody @class={{this.modalBodyClasses}}>
{{#if this.canLoginLocal}}
<div class="login-left-side">
<div class="login-welcome-header">
<h1 class="login-title">{{i18n "login.header_title"}}</h1>
<img src={{this.wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n "login.subheader_title"}}</p>
<PluginOutlet
@name="login-header-bottom"
@outletArgs={{hash createAccount=(action "createAccount")}}
/>
</div>
<form id="login-form" method="post">
<div id="credentials" class={{this.credentialsClass}}>
<div class="input-group">
<Input
@value={{this.loginName}}
@type="email"
id="login-account-name"
class={{value-entered this.loginName}}
autocomplete="username"
autocorrect="off"
autocapitalize="off"
disabled={{this.showSecondFactor}}
autofocus="autofocus"
tabindex="1"
/>
<label class="alt-placeholder" for="login-account-name">{{i18n
"login.email_placeholder"
}}</label>
{{#if this.showLoginWithEmailLink}}
<a
href
class={{if this.loginName "" "no-login-filled"}}
tabindex="3"
id="email-login-link"
{{on "click" this.emailLogin}}
>
{{i18n "email_login.login_link"}}
</a>
{{/if}}
</div>
<div class="input-group">
<PasswordField
@value={{this.loginPassword}}
@type={{if this.maskPassword "password" "text"}}
class={{value-entered this.loginPassword}}
id="login-account-password"
autocomplete="current-password"
maxlength="200"
@capsLockOn={{this.capsLockOn}}
disabled={{this.disableLoginFields}}
tabindex="1"
/>
<label class="alt-placeholder" for="login-account-password">{{i18n
"login.password"
}}</label>
<div class="login__password-links">
<a
href
id="forgot-password-link"
tabindex="3"
{{on "click" this.handleForgotPassword}}
>{{i18n "forgot_password.action"}}</a>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
tabindex="3"
/>
</div>
<div
class="caps-lock-warning {{unless this.capsLockOn 'hidden'}}"
>{{d-icon "exclamation-triangle"}}
{{i18n "login.caps_lock_warning"}}</div>
</div>
</div>
<SecondFactorForm
@secondFactorMethod={{this.secondFactorMethod}}
@secondFactorToken={{this.secondFactorToken}}
@class={{this.secondFactorClass}}
@backupEnabled={{this.backupEnabled}}
@isLogin={{true}}
>
{{#if this.showSecurityKey}}
<SecurityKeyForm
@allowedCredentialIds={{this.securityKeyAllowedCredentialIds}}
@challenge={{this.securityKeyChallenge}}
@showSecurityKey={{this.showSecurityKey}}
@showSecondFactor={{this.showSecondFactor}}
@secondFactorMethod={{this.secondFactorMethod}}
@otherMethodAllowed={{this.otherMethodAllowed}}
@action={{action "authenticateSecurityKey"}}
/>
{{else}}
<SecondFactorInput
@value={{this.secondFactorToken}}
@inputId="login-second-factor"
@secondFactorMethod={{this.secondFactorMethod}}
@backupEnabled={{this.backupEnabled}}
/>
{{/if}}
</SecondFactorForm>
</form>
<div class="modal-footer">
{{#if this.canLoginLocal}}
{{#unless this.showSecurityKey}}
<DButton
@action={{action "login"}}
@id="login-button"
@form="login-form"
@icon="unlock"
@label={{this.loginButtonLabel}}
@disabled={{this.loginDisabled}}
@class="btn btn-large btn-primary"
@tabindex="2"
/>
{{/unless}}
{{#if this.showSignupLink}}
<DButton
@class="btn-large"
@id="new-account-link"
@action={{action "createAccount"}}
@label="create_account.title"
@tabindex="3"
/>
{{/if}}
{{/if}}
<ConditionalLoadingSpinner
@condition={{this.showSpinner}}
@size="small"
/>
</div>
</div>
{{/if}}
{{#if this.showLoginButtons}}
{{#if this.noLoginLocal}}
<div class="login-left-side">
<div class="login-welcome-header">
<h1 class="login-title">{{i18n "login.header_title"}}</h1>
<img src={{this.wavingHandURL}} alt="" class="waving-hand" />
<p class="login-subheader">{{i18n "login.subheader_title"}}</p>
</div>
</div>
{{/if}}
<div class="login-right-side">
<LoginButtons @externalLogin={{action "externalLogin"}} />
</div>
{{/if}}
</DModalBody>
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />
<div class={{this.alertClass}} id="login-alert">{{this.alert}}</div>
</LoginModal>

View File

@ -0,0 +1,29 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
acceptance("Create Account Fields - From Login Form", function () {
test("autofills email field with login form value", async function (assert) {
await visit("/");
await click("header .login-button");
await fillIn("#login-account-name", "isaac@foo.com");
await click(".modal-footer #new-account-link");
assert.dom("#new-account-username").hasText("");
assert
.dom("#new-account-email")
.hasValue("isaac@foo.com", "email is autofilled");
});
test("autofills username field with login form value", async function (assert) {
await visit("/");
await click("header .login-button");
await fillIn("#login-account-name", "isaac");
await click(".modal-footer #new-account-link");
assert.dom("#new-account-email").hasText("");
assert
.dom("#new-account-username")
.hasValue("isaac", "username is autofilled");
});
});

View File

@ -56,7 +56,7 @@ acceptance("Login with email", function (needs) {
await click("#email-login-link");
assert.strictEqual(
query(".alert-error").innerHTML.trim(),
query("#modal-alert").innerHTML.trim(),
I18n.t("email_login.complete_username_not_found", {
username: "someuser",
}),
@ -67,7 +67,7 @@ acceptance("Login with email", function (needs) {
await click("#email-login-link");
assert.strictEqual(
query(".alert-error").innerHTML.trim(),
query("#modal-alert").innerHTML.trim(),
I18n.t("email_login.complete_email_not_found", {
email: "someuser@gmail.com",
}),

View File

@ -1,210 +0,0 @@
import {
acceptance,
count,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { click, settled, triggerKeyEvent, visit } from "@ember/test-helpers";
import { test } from "qunit";
import I18n from "I18n";
import { hbs } from "ember-cli-htmlbars";
import showModal from "discourse/lib/show-modal";
import { registerTemporaryModule } from "../helpers/temporary-module-helper";
import { getOwner } from "discourse-common/lib/get-owner";
import { withSilencedDeprecations } from "discourse-common/lib/deprecated";
import Component from "@glimmer/component";
import { setComponentTemplate } from "@glimmer/manager";
function silencedShowModal() {
return withSilencedDeprecations("discourse.modal-controllers", () =>
showModal(...arguments)
);
}
acceptance("Legacy Modal", function (needs) {
let _translations;
needs.hooks.beforeEach(() => {
_translations = I18n.translations;
I18n.translations = {
en: {
js: {
test_title: "Test title",
},
},
};
});
needs.hooks.afterEach(() => {
I18n.translations = _translations;
});
test("modal", async function (assert) {
await visit("/");
assert.ok(!exists(".d-modal:visible"), "there is no modal at first");
await click(".login-button");
assert.strictEqual(count(".d-modal:visible"), 1, "modal should appear");
const service = getOwner(this).lookup("service:modal");
assert.strictEqual(service.name, "login");
await click(".modal-outer-container");
assert.ok(
!exists(".d-modal:visible"),
"modal should disappear when you click outside"
);
assert.strictEqual(service.name, null);
await click(".login-button");
assert.strictEqual(count(".d-modal:visible"), 1, "modal should reappear");
await triggerKeyEvent("#main-outlet", "keydown", "Escape");
assert.ok(!exists(".d-modal:visible"), "ESC should close the modal");
registerTemporaryModule(
"discourse/templates/modal/not-dismissable",
hbs`{{#d-modal-body title="" class="" dismissable=false}}test{{/d-modal-body}}`
);
silencedShowModal("not-dismissable", {});
await settled();
assert.strictEqual(count(".d-modal:visible"), 1, "modal should appear");
await click(".modal-outer-container");
assert.strictEqual(
count(".d-modal:visible"),
1,
"modal should not disappear when you click outside"
);
await triggerKeyEvent("#main-outlet", "keyup", "Escape");
assert.strictEqual(
count(".d-modal:visible"),
1,
"ESC should not close the modal"
);
});
test("rawTitle in modal panels", async function (assert) {
registerTemporaryModule(
"discourse/templates/modal/test-raw-title-panels",
hbs``
);
const panels = [
{ id: "test1", rawTitle: "Test 1" },
{ id: "test2", rawTitle: "Test 2" },
];
await visit("/");
silencedShowModal("test-raw-title-panels", { panels });
await settled();
assert.strictEqual(
query(".d-modal .modal-tab:first-child").innerText.trim(),
"Test 1",
"it should display the raw title"
);
});
test("modal title", async function (assert) {
registerTemporaryModule("discourse/templates/modal/test-title", hbs``);
registerTemporaryModule(
"discourse/templates/modal/test-title-with-body",
hbs`{{#d-modal-body}}test{{/d-modal-body}}`
);
await visit("/");
silencedShowModal("test-title", { title: "test_title" });
await settled();
assert.strictEqual(
query(".d-modal .title").innerText.trim(),
"Test title",
"it should display the title"
);
await click(".d-modal .close");
silencedShowModal("test-title-with-body", { title: "test_title" });
await settled();
assert.strictEqual(
query(".d-modal .title").innerText.trim(),
"Test title",
"it should display the title when used with d-modal-body"
);
await click(".d-modal .close");
silencedShowModal("test-title");
await settled();
assert.ok(
!exists(".d-modal .title"),
"it should not re-use the previous title"
);
});
test("opening legacy modal while modern modal is open", async function (assert) {
registerTemporaryModule(
"discourse/templates/modal/legacy-modal",
hbs`<DModalBody @rawTitle="legacy modal title" />`
);
class ModernModal extends Component {}
setComponentTemplate(
hbs`<DModal @title="modern modal title" />`,
ModernModal
);
await visit("/");
const modalService = getOwner(this).lookup("service:modal");
modalService.show(ModernModal);
await settled();
assert.dom(".d-modal .title").hasText("modern modal title");
silencedShowModal("legacy-modal");
await settled();
assert.dom(".d-modal .title").hasText("legacy modal title");
});
});
acceptance("Modal Keyboard Events", function (needs) {
needs.user();
test("modal-keyboard-events", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".toggle-admin-menu");
await click(".admin-topic-timer-update button");
await triggerKeyEvent(".d-modal", "keydown", "Enter");
assert.strictEqual(
count("#modal-alert:visible"),
1,
"hitting Enter triggers modal action"
);
assert.strictEqual(
count(".d-modal:visible"),
1,
"hitting Enter does not dismiss modal due to alert error"
);
assert.ok(exists(".d-modal:visible"), "modal should be visible");
await triggerKeyEvent("#main-outlet", "keydown", "Escape");
assert.ok(!exists(".d-modal:visible"), "ESC should close the modal");
await click(".topic-body button.reply");
await click(".d-editor-button-bar .btn.link");
await triggerKeyEvent(".d-modal", "keydown", "Enter");
assert.ok(
!exists(".d-modal:visible"),
"modal should disappear on hitting Enter"
);
});
});

View File

@ -0,0 +1,14 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { click, tab, visit } from "@ember/test-helpers";
acceptance("Modal - Login", function () {
test("You can tab to the login button", async function (assert) {
await visit("/");
await click("header .login-button");
// you have to press the tab key twice to get to the login button
await tab({ unRestrainTabIndex: true });
await tab({ unRestrainTabIndex: true });
assert.dom(".modal-footer #login-button").isFocused();
});
});