2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Remove full_page_login setting (#32189)

We are making this the only option for our login/signup
pages on April 29th, 2025, per

https://meta.discourse.org/t/introducing-our-new-fullscreen-signup-and-login-pages/340401.

This commit removes the `full_page_login` setting and any logic
around it, as well as deleting the old login and signup modals,
and removing leftover problem checks and settings from the database.
This commit is contained in:
Martin Brennan 2025-04-29 18:40:40 +10:00 committed by GitHub
parent 19626bc75a
commit ed1e0e30f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 57 additions and 3075 deletions

View file

@ -1,898 +0,0 @@
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
import Component, { Input } from "@ember/component";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import EmberObject, { action } from "@ember/object";
import { dependentKeyCompat } from "@ember/object/compat";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { observes } from "@ember-decorators/object";
import { Promise } from "rsvp";
import { and, not } from "truth-helpers";
import DModal from "discourse/components/d-modal";
import FullnameInput from "discourse/components/fullname-input";
import HoneypotInput from "discourse/components/honeypot-input";
import InputTip from "discourse/components/input-tip";
import LoginButtons from "discourse/components/login-buttons";
import PasswordField from "discourse/components/password-field";
import PluginOutlet from "discourse/components/plugin-outlet";
import SignupPageCta from "discourse/components/signup-page-cta";
import SignupProgressBar from "discourse/components/signup-progress-bar";
import TogglePasswordMask from "discourse/components/toggle-password-mask";
import UserField from "discourse/components/user-field";
import WelcomeHeader from "discourse/components/welcome-header";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import loadingSpinner from "discourse/helpers/loading-spinner";
import routeAction from "discourse/helpers/route-action";
import valueEntered from "discourse/helpers/value-entered";
import { ajax } from "discourse/lib/ajax";
import { setting } from "discourse/lib/computed";
import cookie, { removeCookie } from "discourse/lib/cookie";
import discourseDebounce from "discourse/lib/debounce";
import discourseComputed, { bind } from "discourse/lib/decorators";
import NameValidationHelper from "discourse/lib/name-validation-helper";
import PasswordValidationHelper from "discourse/lib/password-validation-helper";
import { userPath } from "discourse/lib/url";
import UserFieldsValidationHelper from "discourse/lib/user-fields-validation-helper";
import UsernameValidationHelper from "discourse/lib/username-validation-helper";
import { emailValid } from "discourse/lib/utilities";
import { findAll } from "discourse/models/login-method";
import User from "discourse/models/user";
import { i18n } from "discourse-i18n";

export default class CreateAccount extends Component {
@service site;
@service siteSettings;
@service login;

@tracked isDeveloper = false;
@tracked accountName = this.model.accountName;
@tracked accountEmail = this.model.accountEmail;
@tracked accountUsername = this.model.accountUsername;
@tracked accountPassword = this.model.accountPassword;
@tracked authOptions = this.model.authOptions;
@tracked skipConfirmation = this.model.skipConfirmation;
accountChallenge = 0;
accountHoneypot = 0;
formSubmitted = false;
rejectedEmails = A();
prefilledUsername = null;
maskPassword = true;
emailValidationVisible = false;
nameValidationHelper = new NameValidationHelper(this);
usernameValidationHelper = new UsernameValidationHelper({
getAccountEmail: () => this.accountEmail,
getAccountUsername: () => this.accountUsername,
getPrefilledUsername: () => this.prefilledUsername,
getAuthOptionsUsername: () => this.authOptions?.username,
getForceValidationReason: () => this.forceValidationReason,
siteSettings: this.siteSettings,
isInvalid: () => this.isDestroying || this.isDestroyed,
updateIsDeveloper: (isDeveloper) => (this.isDeveloper = isDeveloper),
updateUsernames: (username) => {
this.accountUsername = username;
this.prefilledUsername = username;
},
});
passwordValidationHelper = new PasswordValidationHelper(this);
userFieldsValidationHelper = new UserFieldsValidationHelper({
getUserFields: () => this.site.get("user_fields"),
getAccountPassword: () => this.accountPassword,
showValidationOnInit: false,
});

@setting("enable_local_logins") canCreateLocal;
@setting("require_invite_code") requireInviteCode;

init() {
super.init(...arguments);

if (cookie("email")) {
this.accountEmail = cookie("email");
}

this.fetchConfirmationValue();

if (this.model.skipConfirmation) {
this.performAccountCreation().finally(
() => (this.skipConfirmation = false)
);
}
}

@dependentKeyCompat
get userFields() {
return this.userFieldsValidationHelper.userFields;
}

@dependentKeyCompat
get userFieldsValidation() {
return this.userFieldsValidationHelper.userFieldsValidation;
}

@action
setAccountUsername(event) {
this.accountUsername = event.target.value;
}

@dependentKeyCompat
get usernameValidation() {
return this.usernameValidationHelper.usernameValidation;
}

get passwordValidation() {
return this.passwordValidationHelper.passwordValidation;
}

get nameTitle() {
return this.nameValidationHelper.nameTitle;
}

get nameValidation() {
return this.nameValidationHelper.nameValidation;
}

@dependentKeyCompat
get hasAuthOptions() {
return !isEmpty(this.authOptions);
}

@dependentKeyCompat
get forceValidationReason() {
return this.nameValidationHelper.forceValidationReason;
}

@bind
actionOnEnter(event) {
if (!this.submitDisabled && event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
this.createAccount();
return false;
}
}

@bind
selectKitFocus(event) {
const target = document.getElementById(event.target.getAttribute("for"));
if (target?.classList.contains("select-kit")) {
event.preventDefault();
target.querySelector(".select-kit-header").click();
}
}

get showCreateForm() {
return (
(this.hasAuthOptions || this.canCreateLocal) && !this.skipConfirmation
);
}

@discourseComputed("site.desktopView", "hasAuthOptions")
showExternalLoginButtons(desktopView, hasAuthOptions) {
return desktopView && !hasAuthOptions;
}

@discourseComputed("formSubmitted")
submitDisabled() {
return this.formSubmitted;
}

@discourseComputed("userFields", "hasAtLeastOneLoginButton", "hasAuthOptions")
modalBodyClasses(userFields, hasAtLeastOneLoginButton, hasAuthOptions) {
const classes = [];
if (userFields) {
classes.push("has-user-fields");
}
if (hasAtLeastOneLoginButton && !hasAuthOptions) {
classes.push("has-alt-auth");
}
if (!this.canCreateLocal) {
classes.push("no-local-logins");
}
return classes.join(" ");
}

get usernameDisabled() {
return this.authOptions && !this.authOptions.can_edit_username;
}

get nameDisabled() {
return this.authOptions && !this.authOptions.can_edit_name;
}

@discourseComputed
showFullname() {
return this.site.full_name_visible_in_signup;
}

@discourseComputed
fullnameRequired() {
return this.site.full_name_required_for_signup;
}

@discourseComputed(
"emailValidation.ok",
"emailValidation.reason",
"emailValidationVisible"
)
showEmailValidation(
emailValidationOk,
emailValidationReason,
emailValidationVisible
) {
return (
emailValidationOk || (emailValidationReason && emailValidationVisible)
);
}

get showPasswordValidation() {
return this.passwordValidation.ok || this.passwordValidation.reason;
}

get showUsernameInstructions() {
return (
this.siteSettings.show_signup_form_username_instructions &&
!this.usernameValidation.reason
);
}

get passwordRequired() {
return isEmpty(this.authOptions?.auth_provider);
}

@discourseComputed
disclaimerHtml() {
if (this.site.tos_url && this.site.privacy_policy_url) {
return i18n("create_account.disclaimer", {
tos_link: this.site.tos_url,
privacy_link: this.site.privacy_policy_url,
});
}
}

// Check the email address
@discourseComputed(
"serverAccountEmail",
"serverEmailValidation",
"accountEmail",
"rejectedEmails.[]",
"forceValidationReason"
)
emailValidation(
serverAccountEmail,
serverEmailValidation,
email,
rejectedEmails,
forceValidationReason
) {
const failedAttrs = {
failed: true,
ok: false,
element: document.querySelector("#new-account-email"),
};

if (serverAccountEmail === email && serverEmailValidation) {
return serverEmailValidation;
}

// If blank, fail without a reason
if (isEmpty(email)) {
return EmberObject.create(
Object.assign(failedAttrs, {
message: i18n("user.email.required"),
reason: forceValidationReason ? i18n("user.email.required") : null,
})
);
}

if (rejectedEmails.includes(email) || !emailValid(email)) {
return EmberObject.create(
Object.assign(failedAttrs, {
reason: i18n("user.email.invalid"),
})
);
}

if (this.authOptions?.email === email && this.authOptions?.email_valid) {
return EmberObject.create({
ok: true,
reason: i18n("user.email.authenticated", {
provider: this.authProviderDisplayName(
this.authOptions?.auth_provider
),
}),
});
}

return EmberObject.create({
ok: true,
reason: i18n("user.email.ok"),
});
}

@action
checkEmailAvailability() {
this.set("emailValidationVisible", Boolean(this.emailValidation.reason));

if (
!this.emailValidation.ok ||
this.serverAccountEmail === this.accountEmail
) {
return;
}

return User.checkEmail(this.accountEmail)
.then((result) => {
if (this.isDestroying || this.isDestroyed) {
return;
}

if (result.failed) {
this.setProperties({
serverAccountEmail: this.accountEmail,
serverEmailValidation: EmberObject.create({
failed: true,
element: document.querySelector("#new-account-email"),
reason: result.errors[0],
}),
});
} else {
this.setProperties({
serverAccountEmail: this.accountEmail,
serverEmailValidation: EmberObject.create({
ok: true,
reason: i18n("user.email.ok"),
}),
});
}
})
.catch(() => {
this.setProperties({
serverAccountEmail: null,
serverEmailValidation: null,
});
});
}

get emailDisabled() {
return (
this.authOptions?.email === this.accountEmail &&
this.authOptions?.email_valid
);
}

authProviderDisplayName(providerName) {
const matchingProvider = findAll().find((provider) => {
return provider.name === providerName;
});
return matchingProvider ? matchingProvider.get("prettyName") : providerName;
}

@observes("emailValidation", "accountEmail")
prefillUsername() {
if (this.prefilledUsername) {
// If username field has been filled automatically, and email field just changed,
// then remove the username.
if (this.accountUsername === this.prefilledUsername) {
this.accountUsername = "";
}
this.set("prefilledUsername", null);
}
if (
this.get("emailValidation.ok") &&
(isEmpty(this.accountUsername) || this.authOptions?.email)
) {
// If email is valid and username has not been entered yet,
// or email and username were filled automatically by 3rd party auth,
// then look for a registered username that matches the email.
discourseDebounce(
this,
() => this.usernameValidationHelper.fetchExistingUsername(),
500
);
}
}

// Determines whether at least one login button is enabled
@discourseComputed
hasAtLeastOneLoginButton() {
return findAll().length > 0;
}

fetchConfirmationValue() {
if (this._challengeDate === undefined && this._hpPromise) {
// Request already in progress
return this._hpPromise;
}

this._hpPromise = ajax("/session/hp.json")
.then((json) => {
if (this.isDestroying || this.isDestroyed) {
return;
}

this._challengeDate = new Date();
// remove 30 seconds for jitter, make sure this works for at least
// 30 seconds so we don't have hard loops
this._challengeExpiry = parseInt(json.expires_in, 10) - 30;
if (this._challengeExpiry < 30) {
this._challengeExpiry = 30;
}

this.setProperties({
accountHoneypot: json.value,
accountChallenge: json.challenge.split("").reverse().join(""),
});
})
.finally(() => (this._hpPromise = undefined));

return this._hpPromise;
}

performAccountCreation() {
if (
!this._challengeDate ||
new Date() - this._challengeDate > 1000 * this._challengeExpiry
) {
return this.fetchConfirmationValue().then(() =>
this.performAccountCreation()
);
}

const attrs = {
accountName: this.accountName,
accountEmail: this.accountEmail,
accountPassword: this.accountPassword,
accountUsername: this.accountUsername,
accountChallenge: this.accountChallenge,
inviteCode: this.inviteCode,
accountPasswordConfirm: this.accountHoneypot,
};

const destinationUrl = this.authOptions?.destination_url;

if (!isEmpty(destinationUrl)) {
cookie("destination_url", destinationUrl, { path: "/" });
}

// Add the userFields to the data
if (!isEmpty(this.userFields)) {
attrs.userFields = {};
this.userFields.forEach((f) => (attrs.userFields[f.field.id] = f.value));
}

this.set("formSubmitted", true);
return User.createAccount(attrs).then(
(result) => {
if (this.isDestroying || this.isDestroyed) {
return;
}

this.isDeveloper = false;
if (result.success) {
// invalidate honeypot
this._challengeExpiry = 1;

// Trigger the browser's password manager using the hidden static login form:
const hiddenLoginForm = document.querySelector("#hidden-login-form");
if (hiddenLoginForm) {
hiddenLoginForm.querySelector("input[name=username]").value =
attrs.accountUsername;
hiddenLoginForm.querySelector("input[name=password]").value =
attrs.accountPassword;
hiddenLoginForm.querySelector("input[name=redirect]").value =
userPath("account-created");
hiddenLoginForm.submit();
}
return new Promise(() => {}); // This will never resolve, the page will reload instead
} else {
this.set("flash", result.message || i18n("create_account.failed"));
if (result.is_developer) {
this.isDeveloper = true;
}
if (
result.errors &&
result.errors.email &&
result.errors.email.length > 0 &&
result.values
) {
this.rejectedEmails.pushObject(result.values.email);
}
if (result.errors?.["user_password.password"]?.length > 0) {
this.passwordValidationHelper.rejectedPasswords.push(
attrs.accountPassword
);
}
this.set("formSubmitted", false);
removeCookie("destination_url");
}
},
() => {
this.set("formSubmitted", false);
removeCookie("destination_url");
return this.set("flash", i18n("create_account.failed"));
}
);
}

get associateHtml() {
const url = this.authOptions?.associate_url;
if (!url) {
return;
}
return i18n("create_account.associate", {
associate_link: url,
provider: i18n(`login.${this.authOptions.auth_provider}.name`),
});
}

@action
scrollInputIntoView(event) {
event.target.scrollIntoView({
behavior: "smooth",
block: "center",
});
}

@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
}

@action
externalLogin(provider) {
// we will automatically redirect to the external auth service
this.login.externalLogin(provider, { signup: true });
}

@action
createAccount() {
this.set("flash", "");
this.nameValidationHelper.forceValidationReason = true;
this.userFieldsValidationHelper.validationVisible = true;
this.set("emailValidationVisible", true);

const validation = [
this.emailValidation,
this.usernameValidation,
this.nameValidation,
this.passwordValidation,
this.userFieldsValidation,
].find((v) => v.failed);

if (validation) {
const element = validation.element;
if (element) {
if (element.tagName === "DIV") {
if (element.scrollIntoView) {
element.scrollIntoView();
}
element.click();
} else {
element.focus();
}
}

return;
}

this.userFieldsValidationHelper.validationVisible = false;
this.nameValidationHelper.forceValidationReason = false;
this.performAccountCreation();
}

<template>
{{! template-lint-disable no-duplicate-id }}
<DModal
class="create-account -large"
{{on "keydown" this.actionOnEnter}}
{{on "click" this.selectKitFocus}}
@closeModal={{@closeModal}}
@bodyClass={{this.modalBodyClasses}}
@flash={{this.flash}}
@flashType="error"
aria-labelledby="create-account-title"
>
<:body>
<PluginOutlet
@name="create-account-before-modal-body"
@connectorTagName="div"
/>

<div
class={{concatClass
(if this.site.desktopView "login-left-side")
this.authOptions?.auth_provider
}}
>
<SignupProgressBar @step="signup" />
<WelcomeHeader
id="create-account-title"
@header={{i18n "create_account.header_title"}}
>
<PluginOutlet
@name="create-account-header-bottom"
@outletArgs={{hash showLogin=(routeAction "showLogin")}}
/>
</WelcomeHeader>
{{#if this.showCreateForm}}
<form id="login-form">
{{#if this.associateHtml}}
<div class="input-group create-account-associate-link">
<span>{{htmlSafe this.associateHtml}}</span>
</div>
{{/if}}
<div class="input-group create-account-email">
<Input
{{on "focusout" this.checkEmailAvailability}}
{{on "focusin" this.scrollInputIntoView}}
@type="email"
@value={{this.accountEmail}}
disabled={{this.emailDisabled}}
autofocus="autofocus"
aria-describedby="account-email-validation account-email-validation-more-info"
aria-invalid={{this.emailValidation.failed}}
name="email"
id="new-account-email"
class={{valueEntered this.accountEmail}}
/>
<label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}}
</label>
{{#if this.showEmailValidation}}
<InputTip
@validation={{this.emailValidation}}
id="account-email-validation"
/>
{{else}}
<span
class="more-info"
id="account-email-validation-more-info"
>
{{#if
this.siteSettings.show_signup_form_email_instructions
}}
{{i18n "user.email.instructions"}}
{{/if}}
</span>
{{/if}}
</div>

<div class="input-group create-account__username">
<input
{{on "focusin" this.scrollInputIntoView}}
{{on "input" this.setAccountUsername}}
type="text"
value={{this.accountUsername}}
disabled={{this.usernameDisabled}}
maxlength={{this.maxUsernameLength}}
aria-describedby="username-validation username-validation-more-info"
aria-invalid={{this.usernameValidation.failed}}
autocomplete="off"
name="username"
id="new-account-username"
class={{valueEntered this.accountUsername}}
/>
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
</label>

{{#if this.showUsernameInstructions}}
<span class="more-info" id="username-validation-more-info">
{{i18n "user.username.instructions"}}
</span>

{{else}}
<InputTip
@validation={{this.usernameValidation}}
id="username-validation"
/>
{{/if}}
</div>

{{#if (and this.showFullname this.fullnameRequired)}}
<FullnameInput
@nameValidation={{this.nameValidation}}
@nameTitle={{this.nameTitle}}
@accountName={{this.accountName}}
@nameDisabled={{this.nameDisabled}}
@onFocusIn={{this.scrollInputIntoView}}
class="input-group create-account__fullname required"
/>
{{/if}}

<PluginOutlet
@name="create-account-before-password"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
authOptions=this.authOptions
}}
/>

<div class="input-group create-account__password">
{{#if this.passwordRequired}}
<PasswordField
{{on "focusin" this.scrollInputIntoView}}
@value={{this.accountPassword}}
@capsLockOn={{this.capsLockOn}}
type={{if this.maskPassword "password" "text"}}
autocomplete="current-password"
aria-describedby="password-validation password-validation-more-info"
aria-invalid={{this.passwordValidation.failed}}
id="new-account-password"
class={{valueEntered this.accountPassword}}
/>
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}
</label>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
<div class="create-account__password-info">
<div class="create-account__password-tip-validation">
{{#if this.showPasswordValidation}}
<InputTip
@validation={{this.passwordValidation}}
id="password-validation"
/>
{{else if
this.siteSettings.show_signup_form_password_instructions
}}
<span
class="more-info"
id="password-validation-more-info"
>
{{this.passwordValidationHelper.passwordInstructions}}
</span>
{{/if}}
<div
class={{concatClass
"caps-lock-warning"
(unless this.capsLockOn "hidden")
}}
>
{{icon "triangle-exclamation"}}
{{i18n "login.caps_lock_warning"}}
</div>
</div>
</div>
{{/if}}

<div class="password-confirmation">
<label for="new-account-password-confirmation">
{{i18n "user.password_confirmation.title"}}
</label>
<HoneypotInput
@id="new-account-confirmation"
@autocomplete="new-password"
@value={{this.accountHoneypot}}
/>
<Input
@value={{this.accountChallenge}}
id="new-account-challenge"
/>
</div>
</div>

{{#if this.requireInviteCode}}
<div class="input-group create-account__invite-code">
<Input
{{on "focusin" this.scrollInputIntoView}}
@value={{this.inviteCode}}
id="inviteCode"
class={{valueEntered this.inviteCode}}
/>
<label class="alt-placeholder" for="invite-code">
{{i18n "user.invite_code.title"}}
</label>
<span class="more-info">
{{i18n "user.invite_code.instructions"}}
</span>
</div>
{{/if}}

<PluginOutlet
@name="create-account-after-password"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>

{{#if (and this.showFullname (not this.fullnameRequired))}}
<FullnameInput
@nameValidation={{this.nameValidation}}
@nameTitle={{this.nameTitle}}
@accountName={{this.accountName}}
@nameDisabled={{this.nameDisabled}}
@onFocusIn={{this.scrollInputIntoView}}
class="input-group create-account__fullname"
/>
{{/if}}

{{#if this.userFields}}
<div class="user-fields">
{{#each this.userFields as |f|}}
<div class="input-group">
<UserField
{{on "focusin" this.scrollInputIntoView}}
@field={{f.field}}
@value={{f.value}}
@validation={{f.validation}}
class={{valueEntered f.value}}
/>
</div>
{{/each}}
</div>
{{/if}}

<PluginOutlet
@name="create-account-after-user-fields"
@outletArgs={{hash
accountName=this.accountName
accountUsername=this.accountUsername
accountPassword=this.accountPassword
userFields=this.userFields
}}
/>
</form>

{{#if this.site.desktopView}}
<div class="d-modal__footer">
<SignupPageCta
@formSubmitted={{this.formSubmitted}}
@hasAuthOptions={{this.hasAuthOptions}}
@createAccount={{this.createAccount}}
@submitDisabled={{this.submitDisabled}}
@disclaimerHtml={{this.disclaimerHtml}}
/>
</div>

<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
{{/if}}
{{/if}}

{{#if this.skipConfirmation}}
{{loadingSpinner size="large"}}
{{/if}}
</div>

{{#if this.hasAtLeastOneLoginButton}}
{{#if this.site.mobileView}}
<div class="login-or-separator"><span>
{{i18n "login.or"}}</span></div>{{/if}}
<div class="login-right-side">
<LoginButtons
@externalLogin={{this.externalLogin}}
@context="create-account"
/>
</div>
{{/if}}
</:body>

<:footer>
{{#if (and this.showCreateForm this.site.mobileView)}}
<SignupPageCta
@formSubmitted={{this.formSubmitted}}
@hasAuthOptions={{this.hasAuthOptions}}
@createAccount={{this.createAccount}}
@submitDisabled={{this.submitDisabled}}
@disclaimerHtml={{this.disclaimerHtml}}
/>
{{/if}}
</:footer>
</DModal>
</template>
}

View file

@ -1,526 +0,0 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { and } from "truth-helpers";
import DModal from "discourse/components/d-modal";
import LocalLoginForm from "discourse/components/local-login-form";
import LoginButtons from "discourse/components/login-buttons";
import LoginPageCta from "discourse/components/login-page-cta";
import PluginOutlet from "discourse/components/plugin-outlet";
import WelcomeHeader from "discourse/components/welcome-header";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import cookie, { removeCookie } from "discourse/lib/cookie";
import escape from "discourse/lib/escape";
import getURL from "discourse/lib/get-url";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { areCookiesEnabled } from "discourse/lib/utilities";
import {
getPasskeyCredential,
isWebauthnSupported,
} from "discourse/lib/webauthn";
import { findAll } from "discourse/models/login-method";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
import { i18n } from "discourse-i18n";
import ForgotPassword from "./forgot-password";

export default class Login extends Component {
@service capabilities;
@service dialog;
@service siteSettings;
@service site;
@service login;
@service modal;

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

get awaitingApproval() {
return (
this.args.model.awaitingApproval &&
!this.canLoginLocal &&
!this.canLoginLocalWithEmail
);
}

get loginDisabled() {
return this.loggingIn || this.loggedIn;
}

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 canUsePasskeys() {
return (
this.siteSettings.enable_local_logins &&
this.siteSettings.enable_passkeys &&
isWebauthnSupported()
);
}

get hasAtLeastOneLoginButton() {
return findAll().length > 0 || this.canUsePasskeys;
}

get hasNoLoginOptions() {
return !this.hasAtLeastOneLoginButton && !this.canLoginLocal;
}

get loginButtonLabel() {
return this.loggingIn ? "login.logging_in" : "login.title";
}

get showSignupLink() {
return this.args.model.canSignUp && !this.showSecondFactor;
}

get adminLoginPath() {
return getURL("/u/admin-login");
}

@action
async passkeyLogin(mediation = "optional") {
try {
const publicKeyCredential = await getPasskeyCredential(
(e) => this.dialog.alert(e),
mediation,
this.capabilities.isFirefox
);

if (publicKeyCredential) {
const authResult = await ajax("/session/passkey/auth.json", {
type: "POST",
data: { publicKeyCredential },
});

if (authResult && !authResult.error) {
const destinationUrl = cookie("destination_url");
const ssoDestinationUrl = cookie("sso_destination_url");

if (ssoDestinationUrl) {
removeCookie("sso_destination_url");
window.location.assign(ssoDestinationUrl);
} else if (destinationUrl) {
removeCookie("destination_url");
window.location.assign(destinationUrl);
} else if (this.args.model.referrerTopicUrl) {
window.location.assign(this.args.model.referrerTopicUrl);
} else {
window.location.reload();
}
} else {
this.dialog.alert(authResult.error);
}
}
} catch (e) {
popupAjaxError(e);
}
}

@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 triggerLogin() {
if (this.loginDisabled) {
return;
}

if (isEmpty(this.loginName) || isEmpty(this.loginPassword)) {
this.flash = i18n("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 if (result.reason === "expired") {
this.flash = htmlSafe(
i18n("login.password_expired", {
reset_url: getURL("/password-reset"),
})
);
this.flashType = "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 if (this.args.model.referrerTopicUrl) {
applyHiddenFormInputValue(
this.args.model.referrerTopicUrl,
"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("login.rate_limit");
this.flashType = "error";
} else if (
e.jqXHR &&
e.jqXHR.status === 503 &&
e.jqXHR.responseJSON.error_type === "read_only"
) {
this.flash = i18n("read_only_mode.login_disabled");
this.flashType = "error";
} else if (!areCookiesEnabled()) {
this.flash = i18n("login.cookies_error");
this.flashType = "error";
} else {
this.flash = i18n("login.error");
this.flashType = "error";
}
this.loggingIn = false;
}
}

@action
externalLoginAction(loginMethod) {
if (this.loginDisabled) {
return;
}
this.login.externalLogin(loginMethod, {
signup: false,
setLoggingIn: (value) => (this.loggingIn = value),
});
}

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

@action
interceptResetLink(event) {
if (
!wantsNewWindow(event) &&
event.target.href &&
new URL(event.target.href).pathname === getURL("/password-reset")
) {
event.preventDefault();
event.stopPropagation();
this.modal.show(ForgotPassword, {
model: {
emailOrUsername: this.loginName,
},
});
}
}

<template>
<DModal
class="login-modal -large"
@bodyClass={{this.modalBodyClasses}}
@closeModal={{@closeModal}}
@flash={{this.flash}}
@flashType={{this.flashType}}
{{didInsert this.preloadLogin}}
{{on "click" this.interceptResetLink}}
>
<:body>
<PluginOutlet @name="login-before-modal-body" @connectorTagName="div" />

{{#if this.hasNoLoginOptions}}
<div class={{if this.site.desktopView "login-left-side"}}>
<div class="login-welcome-header no-login-methods-configured">
<h1 class="login-title">{{i18n
"login.no_login_methods.title"
}}</h1>
<img />
<p class="login-subheader">
{{htmlSafe
(i18n
"login.no_login_methods.description"
(hash adminLoginPath=this.adminLoginPath)
)
}}
</p>
</div>
</div>
{{else}}
{{#if this.site.mobileView}}
<WelcomeHeader @header={{i18n "login.header_title"}}>
<PluginOutlet
@name="login-header-bottom"
@outletArgs={{hash createAccount=this.createAccount}}
/>
</WelcomeHeader>
{{#if this.showLoginButtons}}
<LoginButtons
@externalLogin={{this.externalLoginAction}}
@passkeyLogin={{this.passkeyLogin}}
@context="login"
/>
{{/if}}
{{/if}}

{{#if this.canLoginLocal}}
<div class={{if this.site.desktopView "login-left-side"}}>
{{#if this.site.desktopView}}
<WelcomeHeader @header={{i18n "login.header_title"}}>
<PluginOutlet
@name="login-header-bottom"
@outletArgs={{hash createAccount=this.createAccount}}
/>
</WelcomeHeader>
{{/if}}
<LocalLoginForm
@loginName={{this.loginName}}
@loginNameChanged={{this.loginNameChanged}}
@canLoginLocalWithEmail={{this.canLoginLocalWithEmail}}
@canUsePasskeys={{this.canUsePasskeys}}
@passkeyLogin={{this.passkeyLogin}}
@loginPassword={{this.loginPassword}}
@secondFactorMethod={{this.secondFactorMethod}}
@secondFactorToken={{this.secondFactorToken}}
@backupEnabled={{this.backupEnabled}}
@totpEnabled={{this.totpEnabled}}
@securityKeyAllowedCredentialIds={{this.securityKeyAllowedCredentialIds}}
@securityKeyChallenge={{this.securityKeyChallenge}}
@showSecurityKey={{this.showSecurityKey}}
@otherMethodAllowed={{this.otherMethodAllowed}}
@showSecondFactor={{this.showSecondFactor}}
@handleForgotPassword={{this.handleForgotPassword}}
@login={{this.triggerLogin}}
@flashChanged={{this.flashChanged}}
@flashTypeChanged={{this.flashTypeChanged}}
@securityKeyCredentialChanged={{this.securityKeyCredentialChanged}}
/>
{{#if this.site.desktopView}}
<div class="d-modal__footer">
<LoginPageCta
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.triggerLogin}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
@showSecondFactor={{this.showSecondFactor}}
/>
</div>
{{/if}}
</div>
{{/if}}

{{#if (and this.showLoginButtons this.site.desktopView)}}
{{#unless this.canLoginLocal}}
<div class="login-left-side">
<WelcomeHeader @header={{i18n "login.header_title"}} />
</div>
{{/unless}}
{{#if this.hasAtLeastOneLoginButton}}
<div class="login-right-side">
<LoginButtons
@externalLogin={{this.externalLoginAction}}
@passkeyLogin={{this.passkeyLogin}}
@context="login"
/>
</div>
{{/if}}
{{/if}}
{{/if}}
</:body>

<:footer>
{{#if this.site.mobileView}}
{{#unless this.hasNoLoginOptions}}
<LoginPageCta
@canLoginLocal={{this.canLoginLocal}}
@showSecurityKey={{this.showSecurityKey}}
@login={{this.triggerLogin}}
@loginButtonLabel={{this.loginButtonLabel}}
@loginDisabled={{this.loginDisabled}}
@showSignupLink={{this.showSignupLink}}
@createAccount={{this.createAccount}}
@loggingIn={{this.loggingIn}}
@showSecondFactor={{this.showSecondFactor}}
/>
{{/unless}}
{{/if}}
</:footer>
</DModal>
</template>
}

View file

@ -118,9 +118,7 @@ export default class LoginPageController extends Controller {


get shouldTriggerRouteAction() { get shouldTriggerRouteAction() {
return ( return (
!this.siteSettings.full_page_login || this.siteSettings.enable_discourse_connect || this.singleExternalLogin
this.siteSettings.enable_discourse_connect ||
this.singleExternalLogin
); );
} }



View file

@ -1,8 +1,6 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { next } from "@ember/runloop"; import { next } from "@ember/runloop";
import { htmlSafe } from "@ember/template"; import { htmlSafe } from "@ember/template";
import CreateAccount from "discourse/components/modal/create-account";
import LoginModal from "discourse/components/modal/login";
import cookie, { removeCookie } from "discourse/lib/cookie"; import cookie, { removeCookie } from "discourse/lib/cookie";
import DiscourseUrl from "discourse/lib/url"; import DiscourseUrl from "discourse/lib/url";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
@ -52,11 +50,9 @@ export default {
.lookup("controller:invites-show") .lookup("controller:invites-show")
.authenticationComplete(options); .authenticationComplete(options);
} else { } else {
const modal = owner.lookup("service:modal");
const siteSettings = owner.lookup("service:site-settings"); const siteSettings = owner.lookup("service:site-settings");


const loginError = (errorMsg, className, properties, callback) => { const loginError = (errorMsg, className, properties, callback) => {
const applicationRoute = owner.lookup("route:application");
const applicationController = owner.lookup( const applicationController = owner.lookup(
"controller:application" "controller:application"
); );
@ -69,23 +65,12 @@ export default {
...properties, ...properties,
}; };


if (siteSettings.full_page_login) { router.transitionTo("login").then((login) => {
router.transitionTo("login").then((login) => { Object.keys(loginProps || {}).forEach((key) => {
Object.keys(loginProps || {}).forEach((key) => { login.controller.set(key, loginProps[key]);
login.controller.set(key, loginProps[key]);
});
}); });
} else { });
modal.show(LoginModal, {
model: {
showNotActivated: (props) =>
applicationRoute.send("showNotActivated", props),
showCreateAccount: (props) =>
applicationRoute.send("showCreateAccount", props),
...loginProps,
},
});
}
next(() => callback?.()); next(() => callback?.());
}; };


@ -139,16 +124,12 @@ export default {
skipConfirmation: siteSettings.auth_skip_create_confirm, skipConfirmation: siteSettings.auth_skip_create_confirm,
}; };


if (siteSettings.full_page_login) { router.transitionTo("signup").then((signup) => {
router.transitionTo("signup").then((signup) => { const signupController =
const signupController = signup.controller || owner.lookup("controller:signup");
signup.controller || owner.lookup("controller:signup"); Object.assign(signupController, createAccountProps);
Object.assign(signupController, createAccountProps); signupController.handleSkipConfirmation();
signupController.handleSkipConfirmation(); });
});
} else {
modal.show(CreateAccount, { model: createAccountProps });
}
}); });
} }
}); });

View file

@ -1,8 +1,6 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import CreateAccount from "discourse/components/modal/create-account";
import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help"; import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help";
import LoginModal from "discourse/components/modal/login";
import NotActivatedModal from "discourse/components/modal/not-activated"; import NotActivatedModal from "discourse/components/modal/not-activated";
import { RouteException } from "discourse/controllers/exception"; import { RouteException } from "discourse/controllers/exception";
import { setting } from "discourse/lib/computed"; import { setting } from "discourse/lib/computed";
@ -270,24 +268,13 @@ export default class ApplicationRoute extends DiscourseRoute {
} else { } else {
if (this.login.isOnlyOneExternalLoginMethod) { if (this.login.isOnlyOneExternalLoginMethod) {
this.login.singleExternalLogin(); this.login.singleExternalLogin();
} else if (this.siteSettings.full_page_login) { } else {
this.router.transitionTo("login").then((login) => { this.router.transitionTo("login").then((login) => {
login.controller.set("canSignUp", this.controller.canSignUp); login.controller.set("canSignUp", this.controller.canSignUp);
if (this.siteSettings.login_required) { if (this.siteSettings.login_required) {
login.controller.set("showLogin", true); login.controller.set("showLogin", true);
} }
}); });
} else {
this.modal.show(LoginModal, {
model: {
showNotActivated: (props) => this.send("showNotActivated", props),
showCreateAccount: (props) => this.send("showCreateAccount", props),
canSignUp: this.controller.canSignUp,
referrerTopicUrl: DiscourseURL.isInternalTopic(document.referrer)
? document.referrer
: null,
},
});
} }
} }
} }
@ -300,14 +287,12 @@ export default class ApplicationRoute extends DiscourseRoute {
if (this.login.isOnlyOneExternalLoginMethod) { if (this.login.isOnlyOneExternalLoginMethod) {
// we will automatically redirect to the external auth service // we will automatically redirect to the external auth service
this.login.singleExternalLogin({ signup: true }); this.login.singleExternalLogin({ signup: true });
} else if (this.siteSettings.full_page_login) { } else {
this.router.transitionTo("signup").then((signup) => { this.router.transitionTo("signup").then((signup) => {
Object.keys(createAccountProps || {}).forEach((key) => { Object.keys(createAccountProps || {}).forEach((key) => {
signup.controller.set(key, createAccountProps[key]); signup.controller.set(key, createAccountProps[key]);
}); });
}); });
} else {
this.modal.show(CreateAccount, { model: createAccountProps });
} }
} }
} }

View file

@ -24,15 +24,9 @@ export default class LoginRoute extends DiscourseRoute {
) { ) {
this.login.singleExternalLogin(); this.login.singleExternalLogin();
} }
} else if ( } else if (this.login.isOnlyOneExternalLoginMethod) {
this.login.isOnlyOneExternalLoginMethod &&
this.siteSettings.full_page_login
) {
this.login.singleExternalLogin(); this.login.singleExternalLogin();
} else if ( } else if (this.siteSettings.enable_discourse_connect) {
!this.siteSettings.full_page_login ||
this.siteSettings.enable_discourse_connect
) {
this.router this.router
.replaceWith(`/${defaultHomepage()}`) .replaceWith(`/${defaultHomepage()}`)
.followRedirects() .followRedirects()

View file

@ -33,16 +33,15 @@ export default class SignupRoute extends DiscourseRoute {
@action @action
async showCreateAccount() { async showCreateAccount() {
const { canSignUp } = this.controllerFor("application"); const { canSignUp } = this.controllerFor("application");
if (canSignUp && this.siteSettings.full_page_login) { if (!canSignUp) {
return; const route = await this.router
} .replaceWith(
const route = await this.router this.siteSettings.login_required ? "login" : "discovery.latest"
.replaceWith( )
this.siteSettings.login_required ? "login" : "discovery.latest" .followRedirects();
) if (canSignUp) {
.followRedirects(); next(() => route.send("showCreateAccount"));
if (canSignUp) { }
next(() => route.send("showCreateAccount"));
} }
} }
} }

View file

@ -29,12 +29,7 @@ export default RouteTemplate(
{{loadingSpinner}} {{loadingSpinner}}
{{else}} {{else}}
{{#if {{#if
(and (or @controller.showLogin (not @controller.siteSettings.login_required))
@controller.siteSettings.full_page_login
(or
@controller.showLogin (not @controller.siteSettings.login_required)
)
)
}} }}
{{! Show the full page login form }} {{! Show the full page login form }}
<div class="login-fullpage"> <div class="login-fullpage">

View file

@ -1,32 +0,0 @@
import { currentRouteName, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";

acceptance("Login redirect - anonymous", function (needs) {
needs.settings({ full_page_login: false });

test("redirects login to default homepage", async function (assert) {
await visit("/login");
assert.strictEqual(
currentRouteName(),
"discovery.latest",
"it works when latest is the homepage"
);
});
});

acceptance("Login redirect - categories default", function (needs) {
needs.settings({
top_menu: "categories|latest|top|hot",
full_page_login: false,
});

test("when site setting is categories", async function (assert) {
await visit("/login");
assert.strictEqual(
currentRouteName(),
"discovery.categories",
"it works when categories is the homepage"
);
});
});

View file

@ -2,27 +2,8 @@ import { click, currentRouteName, visit } from "@ember/test-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers"; import { acceptance } from "discourse/tests/helpers/qunit-helpers";


acceptance("Login Required", function (needs) {
needs.settings({ login_required: true, full_page_login: false });

test("redirect", async function (assert) {
await visit("/latest");
assert.strictEqual(
currentRouteName(),
"login",
"it redirects them to login"
);

await click(".login-button");
assert.dom(".login-modal").exists("login modal is shown");

await click(".d-modal__header .modal-close");
assert.dom(".login-modal").doesNotExist("closes the login modal");
});
});

acceptance("Login Required - Full page login", function (needs) { acceptance("Login Required - Full page login", function (needs) {
needs.settings({ login_required: true, full_page_login: true }); needs.settings({ login_required: true });


test("page", async function (assert) { test("page", async function (assert) {
await visit("/"); await visit("/");

View file

@ -1,142 +0,0 @@
import { click, fillIn, tab, visit } from "@ember/test-helpers";
import { test } from "qunit";
import sinon from "sinon";
import { acceptance, chromeTest } from "discourse/tests/helpers/qunit-helpers";
import { i18n } from "discourse-i18n";

acceptance("Modal - Login", function (needs) {
needs.settings({
full_page_login: false,
});

chromeTest("You can tab to the login button", async function (assert) {
await visit("/");
await click("header .login-button");
// you have to press the tab key thrice to get to the login button
await tab({ unRestrainTabIndex: true });
await tab({ unRestrainTabIndex: true });
await tab({ unRestrainTabIndex: true });
assert.dom(".d-modal__footer #login-button").isFocused();
});
});

acceptance("Modal - Login - With 2FA", function (needs) {
needs.settings({
enable_local_logins_via_email: true,
full_page_login: false,
});

needs.pretender((server, helper) => {
server.post(`/session`, () =>
helper.response({
error: i18n("login.invalid_second_factor_code"),
multiple_second_factor_methods: false,
security_key_enabled: false,
totp_enabled: true,
})
);
});

test("You can tab to 2FA login button", async function (assert) {
await visit("/");
await click("header .login-button");

await fillIn("#login-account-name", "isaac@discourse.org");
await fillIn("#login-account-password", "password");
await click("#login-button");

assert.dom("#login-second-factor").isFocused();
await tab();
assert.dom("#login-button").isFocused();
});
});

acceptance("Login - With Passkeys enabled", function () {
test("Includes passkeys button and conditional UI", async function (assert) {
await visit("/");
await click("header .login-button");

assert.dom(".passkey-login-button").exists();

assert
.dom("#login-account-name")
.hasAttribute("autocomplete", "username webauthn");
});
});

acceptance("Modal - Login - With Passkeys disabled", function (needs) {
needs.settings({
enable_passkeys: false,
});

test("Excludes passkeys button and conditional UI", async function (assert) {
await visit("/");
await click("header .login-button");

assert.dom(".passkey-login-button").doesNotExist();
assert.dom("#login-account-name").hasAttribute("autocomplete", "username");
});
});

acceptance("Login - Passkeys on mobile", function (needs) {
needs.mobileView();

test("Includes passkeys button and conditional UI", async function (assert) {
await visit("/");
await click("header .login-button");

sinon.stub(navigator.credentials, "get").callsFake(function () {
return Promise.reject(new Error("credentials.get got called"));
});

assert
.dom("#login-account-name")
.hasAttribute("autocomplete", "username webauthn");

await click(".passkey-login-button");

// clicking the button triggers credentials.get
// but we can't really test that in frontend so an error is returned
assert.dom(".dialog-body").exists();
});
});

acceptance("Login - With no way to login", function (needs) {
needs.settings({
enable_local_logins: false,
enable_facebook_logins: false,
});
needs.site({ auth_providers: [] });

test("Displays a helpful message", async function (assert) {
await visit("/");
await click("header .login-button");

assert.dom("#login-account-name").doesNotExist();
assert.dom("#login-button").doesNotExist();
assert.dom(".no-login-methods-configured").exists();
});
});

acceptance("Login button", function () {
test("with custom event on webview", async function (assert) {
const capabilities = this.container.lookup("service:capabilities");
sinon.stub(capabilities, "isAppWebview").value(true);

window.ReactNativeWebView = {
postMessage: () => {},
};

const webviewSpy = sinon.spy(window.ReactNativeWebView, "postMessage");

await visit("/");
await click("header .login-button");

assert.true(
webviewSpy.withArgs('{"showLogin":true}').calledOnce,
"triggers postmessage event"
);

delete window.ReactNativeWebView;
});
});

View file

@ -1,42 +0,0 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { i18n } from "discourse-i18n";

const TOKEN = "sometoken";

acceptance("Login with email and 2FA", function (needs) {
needs.settings({
enable_local_logins_via_email: true,
});

needs.pretender((server, helper) => {
server.post("/u/email-login", () =>
helper.response({
success: "OK",
user_found: true,
})
);

server.get(`/session/email-login/${TOKEN}.json`, () =>
helper.response({
token: TOKEN,
can_login: true,
token_email: "blah@example.com",
security_key_required: true,
second_factor_required: true,
})
);
});

test("You can switch from security key to 2FA", async function (assert) {
await visit("/");
await click("header .login-button");
await fillIn("#login-account-name", "blah@example.com");
await click("#email-login-link");
await visit(`/session/email-login/${TOKEN}`);
await click(".toggle-second-factor-method");

assert.dom("#second-factor").containsText(i18n("user.second_factor.title"));
});
});

View file

@ -1,31 +0,0 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { i18n } from "discourse-i18n";

acceptance("Login with email - hide email address taken", function (needs) {
needs.settings({
enable_local_logins_via_email: true,
hide_email_address_taken: true,
});

needs.pretender((server, helper) => {
server.post("/u/email-login", () => {
return helper.response({ success: "OK" });
});
});

test("with hide_email_address_taken enabled", async function (assert) {
await visit("/");
await click("header .login-button");
await fillIn("#login-account-name", "someuser@example.com");
await click("#email-login-link");

assert.dom(".alert-success").hasHtml(
i18n("email_login.complete_email_found", {
email: "someuser@example.com",
}),
"displays the success message for any email address"
);
});
});

View file

@ -1,24 +0,0 @@
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";

acceptance("Login with email - no social logins", function (needs) {
needs.settings({ enable_local_logins_via_email: true });
needs.pretender((server, helper) => {
server.post("/u/email-login", () => helper.response({ success: "OK" }));
});

test("with login with email enabled", async function (assert) {
await visit("/");
await click("header .login-button");

assert.dom("#email-login-link").exists();
});

test("with login with email disabled", async function (assert) {
await visit("/");
await click("header .login-button");

assert.dom(".login-buttons").doesNotExist();
});
});

View file

@ -1,23 +0,0 @@
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";

acceptance("Login with email disabled", function (needs) {
needs.settings({
enable_local_logins_via_email: false,
enable_facebook_logins: true,
});

test("with email button", async function (assert) {
await visit("/");
await click("header .login-button");

assert
.dom(".btn-social.facebook")
.exists("it displays the facebook login button");

assert
.dom("#email-login-link")
.doesNotExist("it displays the login with email button");
});
});

View file

@ -1,113 +0,0 @@
import { click, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import sinon from "sinon";
import DiscourseURL from "discourse/lib/url";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { i18n } from "discourse-i18n";

const TOKEN = "sometoken";

acceptance("Login with email", function (needs) {
needs.settings({
enable_local_logins_via_email: true,
enable_facebook_logins: true,
});

let userFound = false;
needs.pretender((server, helper) => {
server.post("/u/email-login", () =>
helper.response({ success: "OK", user_found: userFound })
);

server.get(`/session/email-login/${TOKEN}.json`, () =>
helper.response({
token: TOKEN,
can_login: true,
token_email: "blah@example.com",
})
);

server.post(`/session/email-login/${TOKEN}`, () =>
helper.response({
success: true,
})
);
});

test("with email button", async function (assert) {
await visit("/");
await click("header .login-button");

assert
.dom(".btn-social.facebook")
.exists("it displays the facebook login button");

assert
.dom("#email-login-link")
.exists("it displays the login with email button");

await fillIn("#login-account-name", "someuser");
await click("#email-login-link");

assert.dom(".alert-error").hasHtml(
i18n("email_login.complete_username_not_found", {
username: "someuser",
}),
"displays an error for an invalid username"
);

await fillIn("#login-account-name", "someuser@gmail.com");
await click("#email-login-link");

assert.dom(".alert-error").hasHtml(
i18n("email_login.complete_email_not_found", {
email: "someuser@gmail.com",
}),
"displays an error for an invalid email"
);

await fillIn("#login-account-name", "someuser");

userFound = true;

await click("#email-login-link");

assert
.dom(".alert-success")
.hasHtml(
i18n("email_login.complete_username_found", { username: "someuser" }),
"displays a success message for a valid username"
);

await visit("/");
await click("header .login-button");
await fillIn("#login-account-name", "someuser@gmail.com");
await click("#email-login-link");

assert.dom(".alert-success").hasHtml(
i18n("email_login.complete_email_found", {
email: "someuser@gmail.com",
}),
"displays a success message for a valid email"
);

userFound = false;
});

test("finish login UI", async function (assert) {
await visit(`/session/email-login/${TOKEN}`);
sinon.stub(DiscourseURL, "redirectTo");
await click(".email-login .btn-primary");
assert.true(DiscourseURL.redirectTo.calledWith("/"), "redirects to home");
});

test("finish login UI - safe mode", async function (assert) {
await visit(`/session/email-login/${TOKEN}?safe_mode=no_themes,no_plugins`);
sinon.stub(DiscourseURL, "redirectTo");
await click(".email-login .btn-primary");
assert.true(
DiscourseURL.redirectTo.calledWith("/?safe_mode=no_themes%2Cno_plugins"),
"redirects to home with safe mode"
);
});
});

View file

@ -41,17 +41,6 @@ acceptance("Static pages", function () {
assert.dom(".body-page").exists("The content is present"); assert.dom(".body-page").exists("The content is present");
}); });


test("Login redirect", async function (assert) {
this.siteSettings.full_page_login = false;
await visit("/login");

assert.strictEqual(
currentRouteName(),
"discovery.latest",
"it redirects to /latest"
);
});

test("Login-required page", async function (assert) { test("Login-required page", async function (assert) {
this.siteSettings.login_required = true; this.siteSettings.login_required = true;
await visit("/login"); await visit("/login");
@ -61,24 +50,4 @@ acceptance("Static pages", function () {
assert.dom(".sign-up-button").exists(); assert.dom(".sign-up-button").exists();
assert.dom(".login-button").exists(); assert.dom(".login-button").exists();
}); });

test("Signup redirect", async function (assert) {
this.siteSettings.full_page_login = false;
await visit("/signup");

assert.strictEqual(
currentRouteName(),
"discovery.latest",
"it redirects to /latest"
);
});

test("Signup redirect with login_required", async function (assert) {
this.siteSettings.full_page_login = false;
this.siteSettings.login_required = true;
await visit("/signup");

assert.strictEqual(currentRouteName(), "login");
assert.dom(".body-page").exists("The content is present");
});
}); });

View file

@ -1,113 +0,0 @@
import { getOwner } from "@ember/owner";
import { settled } from "@ember/test-helpers";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { i18n } from "discourse-i18n";

module("Unit | Component | create-account", function (hooks) {
setupTest(hooks);

test("basicUsernameValidation", function (assert) {
const testInvalidUsername = (username, expectedReason) => {
const component = this.owner
.factoryFor("component:modal/create-account")
.create({ model: { accountUsername: username } });

const validation =
component.usernameValidationHelper.basicUsernameValidation(username);
assert.true(validation.failed, `username should be invalid: ${username}`);
assert.strictEqual(
validation.reason,
expectedReason,
`username validation reason: ${username}, ${expectedReason}`
);
};

testInvalidUsername("", null);
testInvalidUsername("x", i18n("user.username.too_short"));
testInvalidUsername(
"123456789012345678901",
i18n("user.username.too_long")
);

const component = this.owner
.factoryFor("component:modal/create-account")
.create({ model: { accountUsername: "porkchops" } });
component.set("prefilledUsername", "porkchops");

const validation =
component.usernameValidationHelper.basicUsernameValidation("porkchops");
assert.true(validation.ok, "Prefilled username is valid");
assert.strictEqual(
validation.reason,
i18n("user.username.prefilled"),
"Prefilled username is valid"
);
});

test("passwordValidation", async function (assert) {
const component = this.owner
.factoryFor("component:modal/create-account")
.create({
model: {
accountEmail: "pork@chops.com",
accountUsername: "porkchops123",
},
});

component.set("prefilledUsername", "porkchops123");
component.set("accountPassword", "b4fcdae11f9167");
assert.true(component.passwordValidation.ok, "Password is ok");
assert.strictEqual(
component.passwordValidation.reason,
i18n("user.password.ok"),
"Password is valid"
);

const testInvalidPassword = (password, expectedReason) => {
component.set("accountPassword", password);

assert.true(
component.passwordValidation.failed,
`password should be invalid: ${password}`
);
assert.strictEqual(
component.passwordValidation.reason,
expectedReason,
`password validation reason: ${password}, ${expectedReason}`
);
};

const siteSettings = getOwner(this).lookup("service:site-settings");
testInvalidPassword("", null);
testInvalidPassword(
"x",
i18n("user.password.too_short", {
count: siteSettings.min_password_length,
})
);
testInvalidPassword("porkchops123", i18n("user.password.same_as_username"));
testInvalidPassword("pork@chops.com", i18n("user.password.same_as_email"));

// Wait for username check request to finish
await settled();
});

test("authProviderDisplayName", function (assert) {
const component = this.owner
.factoryFor("component:modal/create-account")
.create({ model: {} });

assert.strictEqual(
component.authProviderDisplayName("facebook"),
i18n("login.facebook.name"),
"provider name is translated correctly"
);

assert.strictEqual(
component.authProviderDisplayName("does-not-exist"),
"does-not-exist",
"provider name falls back if not found"
);
});
});

View file

@ -1,3 +1,2 @@
@import "login-modal";
@import "modal-overrides"; @import "modal-overrides";
@import "user-status"; @import "user-status";

View file

@ -1,446 +0,0 @@
.d-modal.login-modal,
.d-modal.create-account {
&:not(:has(.login-right-side)) .d-modal__container {
max-width: 500px;
}

&.awaiting-approval {
display: none;
}

.d-modal {
&__container {
position: relative;
width: 100%;
}

&__header {
border-bottom: none;
padding: 0;
position: absolute;
top: 0.75em;
right: 0.75em;
z-index: z("max");
}

&__body {
display: flex;
gap: 2rem;
padding: 0;
}

&__footer {
flex-wrap: nowrap;
padding: 0;
border: 0;

.inline-spinner {
display: inline-flex;
}
}
}

.login-left-side {
box-sizing: border-box;
width: 100%;
padding: 3rem;
overflow: auto;
}

.login-right-side {
background: var(--tertiary-or-tertiary-low);
padding: 3.5rem 3rem;
max-width: 16em;
}

.btn-social-title {
@include ellipsis;
}

#login-buttons {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1rem;
height: 100%;
white-space: nowrap;
}

#login-form {
margin: 2em 0 1em 0;
display: flex;
flex-direction: column;

.create-account-associate-link {
order: 1;
}
}

.tip {
font-size: var(--font-down-1);
min-height: 1.4em;
display: block;

&.bad {
color: var(--danger);
}
}

.toggle-password-mask span {
font-size: var(--font-down-1-rem);
}

.more-info,
.instructions {
font-size: var(--font-down-1);
color: var(--primary-medium);
overflow-wrap: anywhere;
}

.caps-lock-warning {
color: var(--danger);
font-size: var(--font-down-1);
font-weight: bold;
margin-top: 0.5em;
}

#modal-alert {
box-sizing: border-box;
display: inline-block;

// if you want to use flexbox here make sure child elements like <b> don't cause line breaks
padding: 1em 3.5em 1em 1em; // large right padding to make sure long text isn't under the close button
width: 100%;
max-width: 100%;
margin-bottom: 0;
min-height: 35px;

&:empty {
min-height: 0;
padding: 0;
overflow: hidden;
display: inline;
}
}

.login-page-cta,
.signup-page-cta {
width: 100%;
display: flex;
flex-direction: column;

&__disclaimer {
color: var(--primary-medium);
margin-bottom: 1rem;
}

&__buttons {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;

button {
font-size: var(--font-0) !important;
width: 100%;
}
}

&__existing-account,
&__no-account-yet {
color: var(--primary-medium);
font-size: var(--font-down);
margin-bottom: 0.5rem;
text-align: center;
width: 100%;

&::before {
content: " ";
display: block;
height: 1px;
width: 100%;
background-color: var(--primary-low);
margin-block: 1rem;
}
}
}

.desktop-view & {
@media screen and (width <= 767px) {
// important to maintain narrow desktop widths
// for auth modals in Discourse Hub on iPad
.d-modal__header {
right: 0;
top: 0;
}

.has-alt-auth {
flex-direction: column;
overflow: auto;
gap: 0;

.d-modal__footer,
.btn-social {
font-size: var(--font-down-1);
}

.login-left-side {
overflow: unset;
padding: 1em;
}

.login-right-side {
padding: 1em;
max-width: unset;
}

#login-form {
margin: 1.5em 0;
}

.signup-progress-bar {
display: none;
}
}
}
}
}

.invites-show {
#account-email-validation:not(:has(svg)) {
display: none;
}
}

.d-modal.create-account {
.d-modal {
&__container {
width: 100%;
}

&__footer {
flex-direction: column;
align-items: flex-start;
}

&__footer-buttons {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;

.already-have-account {
color: var(--primary-medium);
margin-bottom: 0.5em;
}

button,
hr {
width: 100%;
}
}
}

.disclaimer {
color: var(--primary-medium);
margin-bottom: 0.5em;
}

.create-account__password-info {
display: flex;
justify-content: space-between;

.create-account__password-tip-validation {
display: flex;
}
}
}

// Login Form Styles
.login-modal,
.create-account,
.invites-show {
#login-form,
.login-form,
.invite-form {
.input-group {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 1em;

input,
.select-kit-header {
padding: 0.75em 0.77em;
min-width: 250px;
margin-bottom: 0.25em;
width: 100%;
}

input:focus {
outline: none;
border: 1px solid var(--tertiary);
box-shadow: 0 0 0 2px rgba(var(--tertiary-rgb), 0.25);
}

input:disabled {
background-color: var(--primary-low);
}

span.more-info {
color: var(--primary-medium);
min-height: 1.4em; // prevents height increase due to tips
overflow-wrap: anywhere;
}

label.alt-placeholder,
.user-field.text label.control-label,
.user-field.dropdown label.control-label,
.user-field.multiselect label.control-label {
color: var(--primary-medium);
font-size: 16px;
font-weight: normal;
position: absolute;
pointer-events: none;
left: 1em;
top: 13px;
box-shadow: 0 0 0 0 rgba(var(--tertiary-rgb), 0);
transition: 0.2s ease all;
}

.user-field.text label.control-label,
.user-field.dropdown label.control-label,
.user-field.multiselect label.control-label {
z-index: 999;
top: -8px;
left: calc(1em - 0.25em);
background-color: var(--secondary);
padding: 0 0.25em 0 0.25em;
font-size: $font-down-1;
}

.user-field.text label.control-label {
top: 13px;
}

.user-field.text:focus-within,
.user-field.dropdown:focus-within,
.user-field.multiselect:focus-within {
z-index: 1000; // ensures the active input is always on top of sibling input labels
}

input:focus + label.alt-placeholder,
input.value-entered + label.alt-placeholder {
top: -8px;
left: calc(1em - 0.25em);
background-color: var(--secondary);
padding: 0 0.25em 0 0.25em;
font-size: var(--font-down-1);
}

input.alt-placeholder:invalid {
color: var(--primary);
}

.user-field.dropdown,
.user-field.multiselect {
.more-info,
.instructions {
opacity: 1;
}
}

#email-login-link {
transition: opacity 0.5s;

&.no-login-filled {
opacity: 0;
visibility: hidden;
}
}

#email-login-link,
.login__password-links {
font-size: var(--font-down-1);
display: flex;
justify-content: space-between;
}

.tip:not(:empty) + label.more-info {
display: none;
}
}

#second-factor {
input {
width: 100%;
padding: 0.75em 0.5em;
min-width: 250px;
box-shadow: none;
}

input:focus {
outline: none;
border: 1px solid var(--tertiary);
box-shadow: 0 0 0 2px rgba(var(--tertiary-rgb), 0.25);
}
}

// user fields input groups will
// be styled differently
.user-fields .input-group {
.user-field {
&.text {
&.value-entered label.alt-placeholder.control-label,
input:focus + label.alt-placeholder.control-label {
top: -8px;
left: calc(1em - 0.25em);
background-color: var(--secondary);
padding: 0 0.25em 0 0.25em;
font-size: 14px;
color: var(--primary-medium);
}

label.alt-placeholder.control-label {
color: var(--primary-medium);
font-size: 16px;
position: absolute;
pointer-events: none;
transition: 0.2s ease all;
max-width: calc(100% - 2em);
white-space: nowrap;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
}
}

details:not(.has-selection) span.name,
details:not(.has-selection) span.formatted-selection {
color: var(--primary-medium);
}

.select-kit-row span.name {
color: var(--primary);
}

.select-kit.combo-box.is-expanded summary {
outline: none;
border: 1px solid var(--tertiary);
box-shadow: 0 0 0 2px rgba(var(--tertiary-rgb), 0.25);
}

.controls .checkbox-label {
display: flex;
align-items: center;

input[type="checkbox"].ember-checkbox {
width: 1em !important;
min-width: unset;
margin-block: 0;
}
}
}
}
}
}

View file

@ -1,7 +1,6 @@
@import "discourse"; @import "discourse";
@import "invite-signup"; @import "invite-signup";
@import "list-controls"; @import "list-controls";
@import "login-modal";
@import "login-signup-page"; @import "login-signup-page";
@import "menu-panel"; @import "menu-panel";
@import "modal"; @import "modal";

View file

@ -1,292 +0,0 @@
.static-login {
#main-outlet-wrapper,
#main-outlet {
padding: 0;
}

.contents.body-page {
margin: 0;
}
}

.login-welcome {
gap: 2em;
height: 100svh;
width: 100svw;
box-sizing: border-box;

.body-page-button-container {
display: flex;
flex-direction: column;
gap: 1em;
width: 100%;
}
}

.d-modal.login-modal .d-modal__body {
margin-top: 3.75rem;
}

.d-modal.login-modal,
.d-modal.create-account {
padding-bottom: var(--safe-area-inset-bottom);

.d-modal {
&__container {
height: 100svh;
max-height: unset;
width: 100%;
}

&__body {
flex-direction: column;
gap: unset;
padding-inline: 0.5rem;
padding-bottom: 1em;
outline: none;
}

&__footer {
padding: 1em 1.5rem;
border-top: 1px solid var(--primary-low);
}

&__footer-buttons {
gap: 0.5em;
}
}

.login-title {
font-size: var(--font-up-4);
}

.close {
font-size: var(--font-up-3);
}

.login-welcome-header {
padding: 1rem;
}

.login-right-side {
padding: 1rem 0 0;
background: unset;
max-width: unset;
}

.login-or-separator {
border-top: 1px solid var(--primary-low);
position: relative;
margin-block: 0.75rem;

span {
transform: translate(-50%, -50%);
position: absolute;
left: 50%;
top: 50%;
background: var(--secondary);
padding-inline: 0.5rem;
color: var(--primary-medium);
font-size: var(--font-down-1-rem);
text-transform: uppercase;
}
}

#login-buttons {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
padding: 0 1rem;
gap: 0.25em;
margin-bottom: 1rem;
height: unset;

.btn {
margin: 0;
padding-block: 0.65rem;
border: 1px solid var(--primary-low);
flex-grow: 1;
font-size: var(--font-down-1);
white-space: nowrap;
min-width: calc(50% - 0.25em);
}
}

.login-page-cta {
&__buttons {
.login-page-cta__login {
width: 100%;
margin-bottom: 0.5rem;
}
}

&__signup {
background: none !important;
font-size: var(--font-down);
padding: 0;
}
}

.signup-page-cta {
&__buttons {
.signup-page-cta__signup {
width: 100%;
margin-bottom: 0.5rem;
}
}

&__login {
background: none !important;
font-size: var(--font-down);
padding: 0;
}
}

.login-page-cta,
.signup-page-cta {
&__existing-account,
&__no-account-yet {
width: unset;
margin-bottom: 0;

&::before {
display: none;
}
}

&__buttons {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}

button {
width: fit-content;
}
}

#login-form,
.login-form {
margin: 0;
padding: 1rem 1rem 0;

.input-group {
&:not(:last-child) {
margin-bottom: 0.75em;
}

input:focus + label,
input.value-entered + label.alt-placeholder {
top: -10px;
}

input.alt-placeholder:invalid {
color: var(--primary);
}

label.more-info {
color: var(--primary-medium);
}

.more-info,
.tip,
.instructions {
font-size: var(--font-down-1-rem);
}
}

.user-fields {
display: contents;
}
}

.caps-lock-warning {
display: none;
}
}

.login-modal,
.create-account,
.invites-show {
#login-form,
.login-form,
.invite-form {
.input-group {
label.alt-placeholder,
.user-field.text label.control-label {
top: 11px;
}

div.user-field:not(.dropdown)
.controls
input:focus
+ label.alt-placeholder.control-label,
div.user-field.value-entered:not(.dropdown)
.controls
label.alt-placeholder.control-label {
top: -10px;
}

.user-field.dropdown label.control-label,
.user-field.multiselect label.control-label {
top: -10px;
}
}
}
}

.account-created,
.activate-account {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
max-width: unset;
margin: 0;
padding: 0.75em 1rem;
box-shadow: none;

.activate-new-email {
width: 100%;
height: 2.5em;
}

.activation-controls {
flex-direction: column;
gap: 1em;
margin-top: auto;
margin-bottom: 0.75em;

button {
height: 2.5em;
}
}

.activate-account-button {
margin-top: auto;
margin-bottom: 0.75em;
width: 100%;
height: 2.5em;
}

.account-activated {
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;

.tada-image {
margin: 0;
}

.continue-button {
margin-top: auto;
margin-bottom: 0.75em;
width: 100%;
height: 2.5em;
}
}
}

View file

@ -66,7 +66,6 @@ class ProblemCheck
ProblemCheck::FacebookConfig, ProblemCheck::FacebookConfig,
ProblemCheck::FailingEmails, ProblemCheck::FailingEmails,
ProblemCheck::ForceHttps, ProblemCheck::ForceHttps,
ProblemCheck::FullPageLoginCheck,
ProblemCheck::GithubConfig, ProblemCheck::GithubConfig,
ProblemCheck::GoogleAnalyticsVersion, ProblemCheck::GoogleAnalyticsVersion,
ProblemCheck::GoogleOauth2Config, ProblemCheck::GoogleOauth2Config,

View file

@ -1,19 +0,0 @@
# frozen_string_literal: true

class ProblemCheck::FullPageLoginCheck < ProblemCheck
self.priority = "low"

def call
if full_page_login_disabled?
return problem(override_key: "dashboard.problem.full_page_login_check")
end

no_problem
end

private

def full_page_login_disabled?
SiteSetting.full_page_login == false
end
end

View file

@ -1736,7 +1736,6 @@ en:
category_style_deprecated: "Your Discourse is currently using a deprecated category style which will be removed before the final beta release of Discourse 3.2. Please refer to <a href='https://meta.discourse.org/t/282441'>Moving to a Single Category Style Site Setting</a> for instructions on how to keep your selected category style." category_style_deprecated: "Your Discourse is currently using a deprecated category style which will be removed before the final beta release of Discourse 3.2. Please refer to <a href='https://meta.discourse.org/t/282441'>Moving to a Single Category Style Site Setting</a> for instructions on how to keep your selected category style."
maxmind_db_configuration: 'The server has been configured to use MaxMind databases for reverse IP lookups but a valid MaxMind account ID has not been configured which may result in MaxMind databases failing to download in the future. <a href="https://meta.discourse.org/t/configure-maxmind-for-reverse-ip-lookups/173941" target="_blank">See this guide to learn more</a>.' maxmind_db_configuration: 'The server has been configured to use MaxMind databases for reverse IP lookups but a valid MaxMind account ID has not been configured which may result in MaxMind databases failing to download in the future. <a href="https://meta.discourse.org/t/configure-maxmind-for-reverse-ip-lookups/173941" target="_blank">See this guide to learn more</a>.'
admin_sidebar_deprecation: "The old admin layout is deprecated in favour of the new <a href='https://meta.discourse.org/t/-/289281'>sidebar layout</a> and will be removed in the next release. You can <a href='%{base_path}/admin/config/navigation?filter=admin%20sidebar'>configure</a> the new sidebar layout now to opt in before that." admin_sidebar_deprecation: "The old admin layout is deprecated in favour of the new <a href='https://meta.discourse.org/t/-/289281'>sidebar layout</a> and will be removed in the next release. You can <a href='%{base_path}/admin/config/navigation?filter=admin%20sidebar'>configure</a> the new sidebar layout now to opt in before that."
full_page_login_check: "Your site has disabled the full page login setting. This setting is deprecated and will be removed on <strong>29 April 2025</strong>. Your site will be updated to use full page signup and login screens on that date. <a href='https://meta.discourse.org/t/introducing-our-new-fullscreen-signup-and-login-pages/340401'>Learn more</a>."
back_from_logster_text: "Back to site" back_from_logster_text: "Back to site"


site_settings: site_settings:
@ -1905,7 +1904,6 @@ en:
pending_users_reminder_delay_minutes: "Notify moderators if new users have been waiting for approval for longer than this many minutes. Set to -1 to disable notifications." pending_users_reminder_delay_minutes: "Notify moderators if new users have been waiting for approval for longer than this many minutes. Set to -1 to disable notifications."
persistent_sessions: "Users will remain logged in when the web browser is closed" persistent_sessions: "Users will remain logged in when the web browser is closed"
maximum_session_age: "User will remain logged in for n hours since last visit" maximum_session_age: "User will remain logged in for n hours since last visit"
full_page_login: "Show the login and signup forms in a full page (when unchecked, users will see the forms in a modal). "
show_signup_form_email_instructions: Show email instructions on the signup form. show_signup_form_email_instructions: Show email instructions on the signup form.
show_signup_form_username_instructions: Show username instructions on the signup form. show_signup_form_username_instructions: Show username instructions on the signup form.
show_signup_form_full_name_instructions: Show full name instructions on the signup form. show_signup_form_full_name_instructions: Show full name instructions on the signup form.

View file

@ -723,9 +723,6 @@ login:
default: 1440 default: 1440
min: 1 min: 1
max: 175200 max: 175200
full_page_login:
default: true
client: true
show_signup_form_email_instructions: show_signup_form_email_instructions:
client: true client: true
default: true default: true

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class RemoveFullPageLoginProblemCheckTrackers < ActiveRecord::Migration[7.2]
def up
execute(<<~SQL)
DELETE FROM problem_check_trackers WHERE identifier = 'full_page_login_check';
SQL
end

def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class DeleteFullPageLoginSetting < ActiveRecord::Migration[7.2]
def up
execute "DELETE FROM site_settings WHERE name = 'full_page_login'"
end

def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1,19 +0,0 @@
# frozen_string_literal: true

RSpec.describe ProblemCheck::FullPageLoginCheck do
let(:check) { described_class.new }

describe "#call" do
context "when full_page_login is enabled" do
before { SiteSetting.full_page_login = true }

it { expect(check).to be_chill_about_it }
end

context "when full_page_login is enabled" do
before { SiteSetting.full_page_login = false }

it { expect(check).to have_a_problem.with_priority("low") }
end
end
end

View file

@ -10,16 +10,16 @@ RSpec.shared_examples_for "having working core features" do |skip_examples: []|


if skip_examples.exclude?(:login) if skip_examples.exclude?(:login)
describe "Login" do describe "Login" do
let(:login_form) { PageObjects::Modals::Login.new } let(:login_form) { PageObjects::Pages::Login.new }


before { EmailToken.confirm(Fabricate(:email_token, user: active_user).token) } before { EmailToken.confirm(Fabricate(:email_token, user: active_user).token) }


it "logs in" do it "logs in" do
visit("/") visit("/")
login_form login_form.open
.open login_form.fill_username(active_user.username)
.fill(username: active_user.username, password: "secure_password") login_form.fill_password("secure_password")
.click_login login_form.click_login
expect(page).to have_css(".current-user", visible: true) expect(page).to have_css(".current-user", visible: true)
end end



View file

@ -85,7 +85,6 @@ describe "Changing email", type: :system do
end end


it "does not require login to confirm email change" do it "does not require login to confirm email change" do
SiteSetting.full_page_login = false
second_factor = Fabricate(:user_second_factor_totp, user: user) second_factor = Fabricate(:user_second_factor_totp, user: user)
sign_in user sign_in user



View file

@ -360,12 +360,10 @@ end


describe "Login", type: :system do describe "Login", type: :system do
context "when fullpage desktop" do context "when fullpage desktop" do
before { SiteSetting.full_page_login = true }
include_examples "login scenarios", PageObjects::Pages::Login.new include_examples "login scenarios", PageObjects::Pages::Login.new
end end


context "when fullpage mobile", mobile: true do context "when fullpage mobile", mobile: true do
before { SiteSetting.full_page_login = true }
include_examples "login scenarios", PageObjects::Pages::Login.new include_examples "login scenarios", PageObjects::Pages::Login.new
end end
end end

View file

@ -1,75 +0,0 @@
# frozen_string_literal: true

module PageObjects
module Modals
class Login < PageObjects::Modals::Base
def open?
super && has_css?(".login-modal")
end

def closed?
super && has_no_css?(".login-modal")
end

def open
visit("/login")
self
end

def open_from_header
find(".login-button").click
end

def click(selector)
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_css(".d-modal:not(.is-animating)")
end
find(selector).click
end

def open_signup
click("#new-account-link")
end

def click_login
click("#login-button")
end

def email_login_link
click("#email-login-link")
end

def forgot_password
click("#forgot-password-link")
end

def fill_input(selector, text)
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_css(".d-modal:not(.is-animating)")
end
find(selector).fill_in(with: text)
self
end

def fill_username(username)
fill_input("#login-account-name", username)
self
end

def fill_password(password)
fill_input("#login-account-password", password)
self
end

def fill(username: nil, password: nil)
fill_username(username) if username
fill_password(password) if password
self
end

def click_social_button(provider)
click(".btn-social.#{provider}")
end
end
end
end

View file

@ -1,126 +0,0 @@
# frozen_string_literal: true

module PageObjects
module Modals
class Signup < PageObjects::Modals::Base
def open?
super && has_css?(".modal.create-account")
end

def closed?
super && has_no_css?(".modal.create-account")
end

def open
visit("/signup")
self
end

def open_from_header
find(".sign-up-button").click
end

def click(selector)
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_css(".d-modal:not(.is-animating)")
end
find(selector).click
end

def open_login
click("#login-link")
end

def click_create_account
click(".modal.create-account .btn-primary")
end

def has_password_input?
has_css?("#new-account-password")
end

def has_no_password_input?
has_no_css?("#new-account-password")
end

def has_name_input?
has_css?("#new-account-name")
end

def has_no_name_input?
has_no_css?("#new-account-name")
end

def fill_input(selector, text)
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_css(".d-modal:not(.is-animating)")
end
find(selector).fill_in(with: text)
end

def fill_email(email)
fill_input("#new-account-email", email)
self
end

def fill_username(username)
fill_input("#new-account-username", username)
self
end

def fill_name(name)
fill_input("#new-account-name", name)
self
end

def fill_password(password)
fill_input("#new-account-password", password)
self
end

def fill_code(code)
fill_input("#inviteCode", code)
self
end

def fill_custom_field(name, value)
find(".user-field-#{name.downcase} input").fill_in(with: value)
self
end

def has_valid_email?
find(".create-account-email").has_css?("#account-email-validation.good")
end

def has_valid_username?
find(".create-account__username").has_css?("#username-validation.good")
end

def has_valid_password?
find(".create-account__password").has_css?("#password-validation.good")
end

def has_valid_fields?
has_valid_email?
has_valid_username?
has_valid_password?
end

def has_disabled_email?
find(".create-account-email").has_css?("input[disabled]")
end

def has_disabled_name?
find(".create-account__fullname").has_css?("input[disabled]")
end

def has_disabled_username?
find(".create-account__username").has_css?("input[disabled]")
end

def click_social_button(provider)
click(".btn-social.#{provider}")
end
end
end
end

View file

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true


shared_examples "signup scenarios" do |signup_page_object, login_page_object| shared_examples "signup scenarios" do |signup_page_object, login_page_object|
let(:login_form) { login_page_object }
let(:signup_form) { signup_page_object } let(:signup_form) { signup_page_object }
let(:login_form) { login_page_object }
let(:invite_form) { PageObjects::Pages::InviteForm.new } let(:invite_form) { PageObjects::Pages::InviteForm.new }
let(:activate_account) { PageObjects::Pages::ActivateAccount.new } let(:activate_account) { PageObjects::Pages::ActivateAccount.new }
let(:invite) { Fabricate(:invite, email: "johndoe@example.com") } let(:invite) { Fabricate(:invite, email: "johndoe@example.com") }
@ -285,14 +285,8 @@ shared_examples "signup scenarios" do |signup_page_object, login_page_object|


expect(page).to have_css(".invited-by .user-info[data-username='#{inviter.username}']") expect(page).to have_css(".invited-by .user-info[data-username='#{inviter.username}']")
find(".invitation-cta__sign-in").click find(".invitation-cta__sign-in").click

expect(page).to have_css("#login-form")
if SiteSetting.full_page_login page.go_back
expect(page).to have_css("#login-form")
page.go_back
else
find(".d-modal .modal-close").click
end

expect(page).to have_css(".invited-by .user-info[data-username='#{inviter.username}']") expect(page).to have_css(".invited-by .user-info[data-username='#{inviter.username}']")
end end


@ -350,14 +344,12 @@ end


describe "Signup", type: :system do describe "Signup", type: :system do
context "when fullpage desktop" do context "when fullpage desktop" do
before { SiteSetting.full_page_login = true }
include_examples "signup scenarios", include_examples "signup scenarios",
PageObjects::Pages::Signup.new, PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new PageObjects::Pages::Login.new
end end


context "when fullpage mobile", mobile: true do context "when fullpage mobile", mobile: true do
before { SiteSetting.full_page_login = true }
include_examples "signup scenarios", include_examples "signup scenarios",
PageObjects::Pages::Signup.new, PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new PageObjects::Pages::Login.new

View file

@ -454,14 +454,12 @@ describe "Social authentication", type: :system do
before { SiteSetting.full_name_requirement = "optional_at_signup" } before { SiteSetting.full_name_requirement = "optional_at_signup" }


context "when fullpage desktop" do context "when fullpage desktop" do
before { SiteSetting.full_page_login = true }
include_examples "social authentication scenarios", include_examples "social authentication scenarios",
PageObjects::Pages::Signup.new, PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new PageObjects::Pages::Login.new
end end


context "when fullpage mobile", mobile: true do context "when fullpage mobile", mobile: true do
before { SiteSetting.full_page_login = true }
include_examples "social authentication scenarios", include_examples "social authentication scenarios",
PageObjects::Pages::Signup.new, PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new PageObjects::Pages::Login.new