diff --git a/app/assets/javascripts/discourse/app/components/login-modal.js b/app/assets/javascripts/discourse/app/components/login-modal.js deleted file mode 100644 index c6d0f938a9f..00000000000 --- a/app/assets/javascripts/discourse/app/components/login-modal.js +++ /dev/null @@ -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(); - } - }); - }); - }, -}); diff --git a/app/assets/javascripts/discourse/app/components/modal/login.hbs b/app/assets/javascripts/discourse/app/components/modal/login.hbs new file mode 100644 index 00000000000..c831d96cfb0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login.hbs @@ -0,0 +1,73 @@ + + <:body> + + + {{#if this.site.mobileView}} + + {{#if this.showLoginButtons}} + + {{/if}} + {{/if}} + + {{#if this.canLoginLocal}} +
+ {{#if this.site.desktopView}} + + {{/if}} + + +
+ {{/if}} + + {{#if (and this.showLoginButtons this.site.desktopView)}} + {{#unless this.canLoginLocal}} + + {{/unless}} + + {{/if}} + +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/login.js b/app/assets/javascripts/discourse/app/components/modal/login.js new file mode 100644 index 00000000000..b04a7a13bfb --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login.js @@ -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); + } +} diff --git a/app/assets/javascripts/discourse/app/components/modal/login/footer.hbs b/app/assets/javascripts/discourse/app/components/modal/login/footer.hbs new file mode 100644 index 00000000000..c0f82514f0e --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login/footer.hbs @@ -0,0 +1,28 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs new file mode 100644 index 00000000000..b0ba7ed72e6 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.hbs @@ -0,0 +1,98 @@ +
+
+
+ + + {{#if @canLoginLocalWithEmail}} + + {{i18n "email_login.login_link"}} + + {{/if}} +
+
+ + + +
+ {{d-icon "exclamation-triangle"}} + {{i18n "login.caps_lock_warning"}}
+
+
+ + {{#if @showSecurityKey}} + + {{else}} + + {{/if}} + +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.js b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.js new file mode 100644 index 00000000000..3674182db0c --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login/local-login-form.js @@ -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"); + } + ); + } +} diff --git a/app/assets/javascripts/discourse/app/components/modal/login/welcome-header.hbs b/app/assets/javascripts/discourse/app/components/modal/login/welcome-header.hbs new file mode 100644 index 00000000000..085bb3a5cf6 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/modal/login/welcome-header.hbs @@ -0,0 +1,9 @@ +
+

{{i18n "login.header_title"}}

+ +

{{i18n "login.subheader_title"}}

+ +
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/controllers/create-account.js b/app/assets/javascripts/discourse/app/controllers/create-account.js index 0d75cc8495c..a4cfff30537 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-account.js +++ b/app/assets/javascripts/discourse/app/controllers/create-account.js @@ -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() { diff --git a/app/assets/javascripts/discourse/app/controllers/login.js b/app/assets/javascripts/discourse/app/controllers/login.js deleted file mode 100644 index c5d193b219b..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/login.js +++ /dev/null @@ -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", - }); - }); - }, -}); diff --git a/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js b/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js index b650bf4d4f5..f5a5d8e0cb0 100644 --- a/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js +++ b/app/assets/javascripts/discourse/app/instance-initializers/auth-complete.js @@ -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", + }); + }); + } }); }); } diff --git a/app/assets/javascripts/discourse/app/routes/application.js b/app/assets/javascripts/discourse/app/routes/application.js index 01afa81f3a2..78fd3083612 100644 --- a/app/assets/javascripts/discourse/app/routes/application.js +++ b/app/assets/javascripts/discourse/app/routes/application.js @@ -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); + } } }, diff --git a/app/assets/javascripts/discourse/app/services/modal.js b/app/assets/javascripts/discourse/app/services/modal.js index cf5e35d6a3b..13810f147ba 100644 --- a/app/assets/javascripts/discourse/app/services/modal.js +++ b/app/assets/javascripts/discourse/app/services/modal.js @@ -20,7 +20,6 @@ const KNOWN_LEGACY_MODALS = [ "create-invite", "grant-badge", "group-default-notifications", - "login", "raw-email", "reject-reason-reviewable", "reorder-categories", diff --git a/app/assets/javascripts/discourse/app/templates/mobile/modal/login.hbs b/app/assets/javascripts/discourse/app/templates/mobile/modal/login.hbs deleted file mode 100644 index fb64d2024fe..00000000000 --- a/app/assets/javascripts/discourse/app/templates/mobile/modal/login.hbs +++ /dev/null @@ -1,128 +0,0 @@ - - - - - {{#if this.showLoginButtons}} - - {{/if}} - - {{#if this.canLoginLocal}} -
-
-
- - - {{#if this.showLoginWithEmailLink}} - - {{i18n "email_login.login_link"}} - - {{/if}} -
-
- - - -
-
- - {{#if this.showSecurityKey}} - - {{else}} - - {{/if}} - -
- {{/if}} - -
- - - - - -
{{this.alert}}
-
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/modal/login.hbs b/app/assets/javascripts/discourse/app/templates/modal/login.hbs deleted file mode 100644 index 9916a8a797d..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/login.hbs +++ /dev/null @@ -1,164 +0,0 @@ - - - - - {{#if this.canLoginLocal}} - - {{/if}} - {{#if this.showLoginButtons}} - {{#if this.noLoginLocal}} - - {{/if}} - - - {{/if}} - - - - -
{{this.alert}}
-
\ No newline at end of file diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-account-from-login-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-account-from-login-test.js new file mode 100644 index 00000000000..571a9a50803 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/create-account-from-login-test.js @@ -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"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/login-with-email-test.js b/app/assets/javascripts/discourse/tests/acceptance/login-with-email-test.js index 540bbbb47b8..f78a7082a27 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/login-with-email-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/login-with-email-test.js @@ -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", }), diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js deleted file mode 100644 index 492dc97f171..00000000000 --- a/app/assets/javascripts/discourse/tests/acceptance/modal-legacy-test.js +++ /dev/null @@ -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`` - ); - - class ModernModal extends Component {} - setComponentTemplate( - hbs``, - 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" - ); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js new file mode 100644 index 00000000000..c93014c718e --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/modal/login-test.js @@ -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(); + }); +});