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