mirror of
https://github.com/discourse/discourse.git
synced 2025-01-19 08:12:46 +08:00
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:
parent
7a34ea7953
commit
bb2d1f8703
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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>
|
302
app/assets/javascripts/discourse/app/components/modal/login.js
Normal file
302
app/assets/javascripts/discourse/app/components/modal/login.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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");
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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() {
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -20,7 +20,6 @@ const KNOWN_LEGACY_MODALS = [
|
|||
"create-invite",
|
||||
"grant-badge",
|
||||
"group-default-notifications",
|
||||
"login",
|
||||
"raw-email",
|
||||
"reject-reason-reviewable",
|
||||
"reorder-categories",
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user