UX: Add option to use fullpages for login and signup (#29034)

This adds dedicated routes for /login and /signup, replacing the use of modals. Currently, this is behind the experimental_full_page_login feature flag. It also includes some small consistency fixes related to formatting, spacing, icons, and the loading of certain elements
This commit is contained in:
Jan Cernik 2024-10-15 11:10:54 -03:00 committed by GitHub
parent ec75c442db
commit 7e1cca87a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2568 additions and 283 deletions

View File

@ -1,6 +1,5 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
@ -10,6 +9,7 @@ import { modifier as modifierFn } from "ember-modifier";
import { and, not, or } from "truth-helpers";
import ConditionalInElement from "discourse/components/conditional-in-element";
import DButton from "discourse/components/d-button";
import FlashMessage from "discourse/components/flash-message";
import concatClass from "discourse/helpers/concat-class";
import element from "discourse/helpers/element";
import {
@ -27,8 +27,6 @@ export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut";
export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow";
export const CLOSE_INITIATED_BY_SWIPE_DOWN = "initiatedBySwipeDown";
const FLASH_TYPES = ["success", "error", "warning", "info"];
const SWIPE_VELOCITY_THRESHOLD = 0.4;
export default class DModal extends Component {
@ -210,13 +208,6 @@ export default class DModal extends Component {
this.closeModal(CLOSE_INITIATED_BY_BUTTON);
}
@action
validateFlashType(type) {
if (type && !FLASH_TYPES.includes(type)) {
throw `@flashType must be one of ${FLASH_TYPES.join(", ")}`;
}
}
// Could be optimised to remove classic component once RFC389 is implemented
// https://rfcs.emberjs.com/id/0389-dynamic-tag-names
@cached
@ -361,19 +352,12 @@ export default class DModal extends Component {
{{yield to="belowHeader"}}
{{this.validateFlashType @flashType}}
{{#if @flash}}
<div
id="modal-alert"
role="alert"
class={{concatClass
"alert"
(if @flashType (concat "alert-" @flashType))
}}
>
{{~@flash~}}
</div>
{{/if}}
<FlashMessage
id="modal-alert"
role="alert"
@flash={{@flash}}
@type={{@flashType}}
/>
<div
class={{concatClass "d-modal__body" @bodyClass}}

View File

@ -0,0 +1,21 @@
import Component from "@glimmer/component";
import concatClass from "discourse/helpers/concat-class";
const FLASH_TYPES = ["success", "error", "warning", "info"];
export default class FlashMessage extends Component {
get flashClass() {
if (this.args.type && !FLASH_TYPES.includes(this.args.type)) {
throw `@type must be one of ${FLASH_TYPES.join(", ")}`;
}
return this.args.type ? `alert-${this.args.type}` : null;
}
<template>
{{#if @flash}}
<div class={{concatClass "alert" this.flashClass}} ...attributes>
{{~@flash~}}
</div>
{{/if}}
</template>
}

View File

@ -37,6 +37,10 @@ export default class Icons extends Component {
// NOTE: In this scenario, we are forcing the sidebar on admin users,
// so we need to still show the hamburger menu to be able to
// access the legacy hamburger forum menu.
if (this.header.headerButtonsHidden.includes("menu")) {
return false;
}
if (
this.args.sidebarEnabled &&
this.sidebarState.adminSidebarAllowedWithLegacyNavigationMenu

View File

@ -50,7 +50,7 @@
<a
href
id="forgot-password-link"
tabindex="3"
tabindex="2"
{{on "click" this.handleForgotPassword}}
>
{{i18n "forgot_password.action"}}
@ -68,32 +68,33 @@
{{i18n "login.caps_lock_warning"}}</div>
</div>
</div>
<SecondFactorForm
@secondFactorMethod={{@secondFactorMethod}}
@secondFactorToken={{@secondFactorToken}}
@backupEnabled={{@backupEnabled}}
@totpEnabled={{@totpEnabled}}
@isLogin={{true}}
class={{this.secondFactorClass}}
>
{{#if @showSecurityKey}}
<SecurityKeyForm
@setShowSecurityKey={{fn (mut @showSecurityKey)}}
@setShowSecondFactor={{fn (mut @showSecondFactor)}}
@setSecondFactorMethod={{fn (mut @secondFactorMethod)}}
@backupEnabled={{@backupEnabled}}
@totpEnabled={{@totpEnabled}}
@otherMethodAllowed={{@otherMethodAllowed}}
@action={{this.authenticateSecurityKey}}
/>
{{else}}
<SecondFactorInput
{{on "keydown" this.loginOnEnter}}
{{on "input" (with-event-value (fn (mut @secondFactorToken)))}}
@secondFactorMethod={{@secondFactorMethod}}
value={{@secondFactorToken}}
id="login-second-factor"
/>
{{/if}}
</SecondFactorForm>
{{#if this.showSecondFactorForm}}
<SecondFactorForm
@secondFactorMethod={{@secondFactorMethod}}
@secondFactorToken={{@secondFactorToken}}
@backupEnabled={{@backupEnabled}}
@totpEnabled={{@totpEnabled}}
@isLogin={{true}}
>
{{#if @showSecurityKey}}
<SecurityKeyForm
@setShowSecurityKey={{fn (mut @showSecurityKey)}}
@setShowSecondFactor={{fn (mut @showSecondFactor)}}
@setSecondFactorMethod={{fn (mut @secondFactorMethod)}}
@backupEnabled={{@backupEnabled}}
@totpEnabled={{@totpEnabled}}
@otherMethodAllowed={{@otherMethodAllowed}}
@action={{this.authenticateSecurityKey}}
/>
{{else}}
<SecondFactorInput
{{on "keydown" this.loginOnEnter}}
{{on "input" (with-event-value (fn (mut @secondFactorToken)))}}
@secondFactorMethod={{@secondFactorMethod}}
value={{@secondFactorToken}}
id="login-second-factor"
/>
{{/if}}
</SecondFactorForm>
{{/if}}
</form>

View File

@ -11,7 +11,7 @@ import { escapeExpression } from "discourse/lib/utilities";
import { getWebauthnCredential } from "discourse/lib/webauthn";
import I18n from "discourse-i18n";
export default class LocalLoginBody extends Component {
export default class LocalLoginForm extends Component {
@service modal;
@tracked maskPassword = true;
@ -24,10 +24,8 @@ export default class LocalLoginBody extends Component {
: "";
}
get secondFactorClass() {
return this.args.showSecondFactor || this.args.showSecurityKey
? ""
: "hidden";
get showSecondFactorForm() {
return this.args.showSecondFactor || this.args.showSecurityKey;
}
get disableLoginFields() {

View File

@ -0,0 +1,41 @@
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import i18n from "discourse-common/helpers/i18n";
const LoginPageCta = <template>
<div class="login-page-cta">
<div class="login-page-cta__buttons">
{{#if @canLoginLocal}}
{{#unless @showSecurityKey}}
<DButton
@action={{@login}}
@disabled={{@loginDisabled}}
@isLoading={{@loggingIn}}
@label={{@loginButtonLabel}}
id="login-button"
form="login-form"
class="btn-large btn-primary login-page-cta__login"
tabindex={{unless @showSecondFactor "2"}}
/>
{{/unless}}
{{#if @showSignupLink}}
<span class="signup-page-cta__no-account-yet">
{{i18n "create_account.no_account_yet"}}
</span>
<DButton
@action={{@createAccount}}
@disabled={{@loggingIn}}
@label="create_account.title"
class="btn-large btn-flat login-page-cta__signup"
id="new-account-link"
tabindex="3"
/>
{{/if}}
{{/if}}
</div>
<PluginOutlet @name="login-after-modal-footer" @connectorTagName="div" />
</div>
</template>;
export default LoginPageCta;

View File

@ -58,7 +58,7 @@
/>
</WelcomeHeader>
{{/if}}
<Modal::Login::LocalLoginForm
<LocalLoginForm
@loginName={{this.loginName}}
@loginNameChanged={{this.loginNameChanged}}
@canLoginLocalWithEmail={{this.canLoginLocalWithEmail}}

View File

@ -333,7 +333,7 @@ export default class Login extends Component {
}
@action
async externalLoginAction(loginMethod) {
externalLoginAction(loginMethod) {
if (this.loginDisabled) {
return;
}

View File

@ -1,7 +1,12 @@
import Component from "@glimmer/component";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { SECOND_FACTOR_METHODS } from "discourse/models/user";
export default class SecondFactorInput extends Component {
_focusInput(input) {
input.focus();
}
get isTotp() {
return this.args.secondFactorMethod === SECOND_FACTOR_METHODS.TOTP;
}
@ -45,6 +50,7 @@ export default class SecondFactorInput extends Component {
autofocus="autofocus"
class="second-factor-token-input"
...attributes
{{didInsert this._focusInput}}
/>
</template>
}

View File

@ -0,0 +1,41 @@
import { htmlSafe } from "@ember/template";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import routeAction from "discourse/helpers/route-action";
import i18n from "discourse-common/helpers/i18n";
const SignupPageCta = <template>
<div class="signup-page-cta">
{{#if @disclaimerHtml}}
<div class="signup-page-cta__disclaimer">
{{htmlSafe @disclaimerHtml}}
</div>
{{/if}}
<div class="signup-page-cta__buttons">
<DButton
@action={{@createAccount}}
@disabled={{@submitDisabled}}
@isLoading={{@formSubmitted}}
@label="create_account.title"
class="btn-large btn-primary signup-page-cta__signup"
/>
{{#unless @hasAuthOptions}}
<span class="signup-page-cta__existing-account">
{{i18n "create_account.already_have_account"}}
</span>
<DButton
@action={{routeAction "showLogin"}}
@disabled={{@formSubmitted}}
@label="log_in"
class="btn-large btn-flat signup-page-cta__login"
/>
{{/unless}}
</div>
</div>
<PluginOutlet
@name="create-account-after-modal-footer"
@connectorTagName="div"
/>
</template>;
export default SignupPageCta;

View File

@ -1,5 +0,0 @@
import Controller, { inject as controller } from "@ember/controller";
export default class LoginPageController extends Controller {
@controller application;
}

View File

@ -0,0 +1,405 @@
import { tracked } from "@glimmer/tracking";
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import ForgotPassword from "discourse/components/modal/forgot-password";
import NotActivatedModal from "discourse/components/modal/not-activated";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import cookie, { removeCookie } from "discourse/lib/cookie";
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 escape from "discourse-common/lib/escape";
import getURL from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
export default class LoginPageController extends Controller {
@service siteSettings;
@service router;
@service capabilities;
@service dialog;
@service site;
@service login;
@service modal;
@controller application;
@tracked loggingIn = false;
@tracked loggedIn = false;
@tracked showLoginButtons = true;
@tracked showSecondFactor = false;
@tracked loginPassword = "";
@tracked loginName = "";
@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;
@tracked flash;
@tracked flashType;
get isAwaitingApproval() {
return (
this.awaitingApproval &&
!this.canLoginLocal &&
!this.canLoginLocalWithEmail
);
}
get loginDisabled() {
return this.loggingIn || this.loggedIn;
}
get bodyClasses() {
const classes = ["login-body"];
if (this.isAwaitingApproval) {
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.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) {
let authResult;
try {
authResult = await ajax("/session/passkey/auth.json", {
type: "POST",
data: { publicKeyCredential },
});
} catch (e) {
popupAjaxError(e);
return;
}
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 {
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
showCreateAccount(createAccountProps = {}) {
if (this.site.isReadOnly) {
this.dialog.alert(I18n.t("read_only_mode.login_disabled"));
} else {
this.handleShowCreateAccount(createAccountProps);
}
}
handleShowCreateAccount(createAccountProps) {
if (this.siteSettings.enable_discourse_connect) {
const returnPath = encodeURIComponent(window.location.pathname);
window.location = getURL("/session/sso?return_path=" + returnPath);
} else {
if (this.isOnlyOneExternalLoginMethod) {
// we will automatically redirect to the external auth service
this.login.externalLogin(this.externalLoginMethods[0], {
signup: true,
});
} else {
this.router.transitionTo("signup").then((login) => {
Object.keys(createAccountProps || {}).forEach((key) => {
login.controller.set(key, createAccountProps[key]);
});
});
}
}
}
@action
showNotActivated(props) {
this.modal.show(NotActivatedModal, { model: props });
}
@action
async triggerLogin() {
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;
return;
} else if (result.reason === "not_activated") {
this.showNotActivated({
username: this.loginName,
sentTo: escape(result.sent_to_email),
currentEmail: escape(result.current_email),
});
} else if (result.reason === "suspended") {
this.dialog.alert(result.error);
} else if (result.reason === "expired") {
this.flash = htmlSafe(
I18n.t("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 {
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
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.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,
},
});
}
}
}

View File

@ -0,0 +1,469 @@
import { A } from "@ember/array";
import Controller from "@ember/controller";
import EmberObject, { action } from "@ember/object";
import { notEmpty } from "@ember/object/computed";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { observes } from "@ember-decorators/object";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { setting } from "discourse/lib/computed";
import cookie, { removeCookie } from "discourse/lib/cookie";
import { userPath } from "discourse/lib/url";
import { emailValid } from "discourse/lib/utilities";
import NameValidation from "discourse/mixins/name-validation";
import PasswordValidation from "discourse/mixins/password-validation";
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
import UsernameValidation from "discourse/mixins/username-validation";
import { findAll } from "discourse/models/login-method";
import User from "discourse/models/user";
import discourseDebounce from "discourse-common/lib/debounce";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
export default class SignupPageController extends Controller.extend(
PasswordValidation,
UsernameValidation,
NameValidation,
UserFieldsValidation
) {
@service site;
@service siteSettings;
@service login;
accountChallenge = 0;
accountHoneypot = 0;
formSubmitted = false;
rejectedEmails = A();
prefilledUsername = null;
userFields = null;
isDeveloper = false;
maskPassword = true;
@notEmpty("authOptions") hasAuthOptions;
@setting("enable_local_logins") canCreateLocal;
@setting("require_invite_code") requireInviteCode;
init() {
super.init(...arguments);
if (cookie("email")) {
this.set("accountEmail", cookie("email"));
}
this.fetchConfirmationValue();
if (this.skipConfirmation) {
this.performAccountCreation().finally(() =>
this.set("skipConfirmation", false)
);
}
}
@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();
}
}
@discourseComputed("hasAuthOptions", "canCreateLocal", "skipConfirmation")
showCreateForm(hasAuthOptions, canCreateLocal, skipConfirmation) {
return (hasAuthOptions || canCreateLocal) && !skipConfirmation;
}
@discourseComputed("site.desktopView", "hasAuthOptions")
showExternalLoginButtons(desktopView, hasAuthOptions) {
return desktopView && !hasAuthOptions;
}
@discourseComputed("formSubmitted")
submitDisabled() {
return this.formSubmitted;
}
@discourseComputed("userFields", "hasAtLeastOneLoginButton", "hasAuthOptions")
bodyClasses(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(" ");
}
@discourseComputed("authOptions", "authOptions.can_edit_username")
usernameDisabled(authOptions, canEditUsername) {
return authOptions && !canEditUsername;
}
@discourseComputed("authOptions", "authOptions.can_edit_name")
nameDisabled(authOptions, canEditName) {
return authOptions && !canEditName;
}
@discourseComputed
fullnameRequired() {
return (
this.siteSettings.full_name_required || this.siteSettings.enable_names
);
}
@discourseComputed("authOptions.auth_provider")
passwordRequired(authProvider) {
return isEmpty(authProvider);
}
@discourseComputed
disclaimerHtml() {
if (this.site.tos_url && this.site.privacy_policy_url) {
return I18n.t("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.t("user.email.required"),
reason: forceValidationReason ? I18n.t("user.email.required") : null,
})
);
}
if (rejectedEmails.includes(email) || !emailValid(email)) {
return EmberObject.create(
Object.assign(failedAttrs, {
reason: I18n.t("user.email.invalid"),
})
);
}
if (
this.get("authOptions.email") === email &&
this.get("authOptions.email_valid")
) {
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.authenticated", {
provider: this.authProviderDisplayName(
this.get("authOptions.auth_provider")
),
}),
});
}
return EmberObject.create({
ok: true,
reason: I18n.t("user.email.ok"),
});
}
@action
checkEmailAvailability() {
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.t("user.email.ok"),
}),
});
}
})
.catch(() => {
this.setProperties({
serverAccountEmail: null,
serverEmailValidation: null,
});
});
}
@discourseComputed(
"accountEmail",
"authOptions.email",
"authOptions.email_valid"
)
emailDisabled() {
return (
this.get("authOptions.email") === this.accountEmail &&
this.get("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.set("accountUsername", "");
}
this.set("prefilledUsername", null);
}
if (
this.get("emailValidation.ok") &&
(isEmpty(this.accountUsername) || this.get("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.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.get("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.get("field.id")] = f.get("value"))
);
}
this.set("formSubmitted", true);
return User.createAccount(attrs).then(
(result) => {
if (this.isDestroying || this.isDestroyed) {
return;
}
this.set("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.t("create_account.failed"));
if (result.is_developer) {
this.set("isDeveloper", true);
}
if (
result.errors &&
result.errors.email &&
result.errors.email.length > 0 &&
result.values
) {
this.rejectedEmails.pushObject(result.values.email);
}
if (
result.errors &&
result.errors.password &&
result.errors.password.length > 0
) {
this.rejectedPasswords.pushObject(attrs.accountPassword);
}
this.set("formSubmitted", false);
removeCookie("destination_url");
}
},
() => {
this.set("formSubmitted", false);
removeCookie("destination_url");
return this.set("flash", I18n.t("create_account.failed"));
}
);
}
@discourseComputed("authOptions.associate_url", "authOptions.auth_provider")
associateHtml(url, provider) {
if (!url) {
return;
}
return I18n.t("create_account.associate", {
associate_link: url,
provider: I18n.t(`login.${provider}.name`),
});
}
@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.set("forceValidationReason", 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.set("forceValidationReason", false);
this.performAccountCreation();
}
}

View File

@ -60,19 +60,32 @@ export default {
const applicationController = owner.lookup(
"controller:application"
);
modal.show(LoginModal, {
model: {
showNotActivated: (props) =>
applicationRoute.send("showNotActivated", props),
showCreateAccount: (props) =>
applicationRoute.send("showCreateAccount", props),
canSignUp: applicationController.canSignUp,
flash: errorMsg,
flashType: className || "success",
awaitingApproval: options.awaiting_approval,
...properties,
},
});
const loginProps = {
canSignUp: applicationController.canSignUp,
flash: errorMsg,
flashType: className || "success",
awaitingApproval: options.awaiting_approval,
...properties,
};
if (siteSettings.experimental_full_page_login) {
router.transitionTo("login").then((login) => {
Object.keys(loginProps || {}).forEach((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?.());
};
@ -117,17 +130,25 @@ export default {
return;
}
next(() =>
modal.show(CreateAccount, {
model: {
accountEmail: options.email,
accountUsername: options.username,
accountName: options.name,
authOptions: EmberObject.create(options),
skipConfirmation: siteSettings.auth_skip_create_confirm,
},
})
);
next(() => {
const createAccountProps = {
accountEmail: options.email,
accountUsername: options.username,
accountName: options.name,
authOptions: EmberObject.create(options),
skipConfirmation: siteSettings.auth_skip_create_confirm,
};
if (siteSettings.experimental_full_page_login) {
router.transitionTo("signup").then((login) => {
Object.keys(createAccountProps || {}).forEach((key) => {
login.controller.set(key, createAccountProps[key]);
});
});
} else {
modal.show(CreateAccount, { model: createAccountProps });
}
});
}
});
});

View File

@ -1,9 +1,9 @@
import { action } from "@ember/object";
import { service } from "@ember/service";
import CreateAccount from "discourse/components/modal/create-account";
import ForgotPassword from "discourse/components/modal/forgot-password";
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 { RouteException } from "discourse/controllers/exception";
import { setting } from "discourse/lib/computed";
import cookie from "discourse/lib/cookie";
@ -20,7 +20,6 @@ import deprecated from "discourse-common/lib/deprecated";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import getURL from "discourse-common/lib/get-url";
import I18n from "discourse-i18n";
import NotActivatedModal from "../components/modal/not-activated";
function isStrictlyReadonly(site) {
return site.isReadOnly && !site.isStaffWritesOnly;
@ -207,11 +206,6 @@ export default class ApplicationRoute extends DiscourseRoute {
}
}
@action
showForgotPassword() {
this.modal.show(ForgotPassword);
}
@action
showNotActivated(props) {
this.modal.show(NotActivatedModal, { model: props });
@ -303,6 +297,10 @@ export default class ApplicationRoute extends DiscourseRoute {
} else {
if (this.isOnlyOneExternalLoginMethod) {
this.login.externalLogin(this.externalLoginMethods[0]);
} else if (this.siteSettings.experimental_full_page_login) {
this.router.transitionTo("login").then((login) => {
login.controller.set("canSignUp", this.controller.canSignUp);
});
} else {
this.modal.show(LoginModal, {
model: {
@ -325,6 +323,12 @@ export default class ApplicationRoute extends DiscourseRoute {
this.login.externalLogin(this.externalLoginMethods[0], {
signup: true,
});
} else if (this.siteSettings.experimental_full_page_login) {
this.router.transitionTo("signup").then((signup) => {
Object.keys(createAccountProps || {}).forEach((key) => {
signup.controller.set(key, createAccountProps[key]);
});
});
} else {
this.modal.show(CreateAccount, { model: createAccountProps });
}

View File

@ -8,12 +8,11 @@ export default class LoginRoute extends DiscourseRoute {
@service siteSettings;
@service router;
// `login-page` because `login` controller is the one for
// the login modal
controllerName = "login-page";
beforeModel() {
if (!this.siteSettings.login_required) {
if (
!this.siteSettings.login_required &&
!this.siteSettings.experimental_full_page_login
) {
this.router
.replaceWith(`/${defaultHomepage()}`)
.followRedirects()
@ -22,6 +21,17 @@ export default class LoginRoute extends DiscourseRoute {
}
model() {
return StaticPage.find("login");
if (!this.siteSettings.experimental_full_page_login) {
return StaticPage.find("login");
}
}
setupController(controller) {
super.setupController(...arguments);
const { canSignUp } = this.controllerFor("application");
controller.set("canSignUp", canSignUp);
controller.set("flashType", "");
controller.set("flash", "");
}
}

View File

@ -14,6 +14,9 @@ export default class SignupRoute extends DiscourseRoute {
@action
async showCreateAccount() {
const { canSignUp } = this.controllerFor("application");
if (canSignUp && this.siteSettings.experimental_full_page_login) {
return;
}
const route = await this.router
.replaceWith(
this.siteSettings.login_required ? "login" : "discovery.latest"

View File

@ -7,7 +7,7 @@ import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import deprecated from "discourse-common/lib/deprecated";
import { SCROLLED_UP } from "./scroll-direction";
const VALID_HEADER_BUTTONS_TO_HIDE = ["search", "login", "signup"];
const VALID_HEADER_BUTTONS_TO_HIDE = ["search", "login", "signup", "menu"];
@disableImplicitInjections
export default class Header extends Service {

View File

@ -1,5 +1,5 @@
{{body-class "account-created-page"}}
{{hide-application-header-buttons "search" "login" "signup"}}
{{hide-application-header-buttons "search" "login" "signup" "menu"}}
{{hide-application-sidebar}}
<div class="account-created">
{{outlet}}

View File

@ -1,5 +1,5 @@
{{body-class "invite-page"}}
{{hide-application-header-buttons "search" "login" "signup"}}
{{hide-application-header-buttons "search" "login" "signup" "menu"}}
{{hide-application-sidebar}}
<section>
<div class="container invites-show clearfix">

View File

@ -1,34 +1,175 @@
{{body-class "static-login"}}
{{#if this.siteSettings.experimental_full_page_login}}
{{hide-application-header-buttons "search" "login" "signup" "menu"}}
{{hide-application-sidebar}}
<section class="container">
<div class="contents clearfix body-page">
<div class="login-welcome">
<PluginOutlet @name="above-login" @outletArgs={{hash model=this.model}} />
<PluginOutlet @name="above-static" />
<FlashMessage @flash={{this.flash}} @type={{this.flashType}} />
<div class="login-fullpage">
<div class={{concat-class "login-body" this.bodyClasses}}>
<PluginOutlet @name="login-before-modal-body" @connectorTagName="div" />
<div class="login-content">
{{html-safe this.model.html}}
</div>
<PluginOutlet @name="below-static" />
<PluginOutlet @name="below-login" @outletArgs={{hash model=this.model}} />
<div class="body-page-button-container">
{{#if this.application.canSignUp}}
<DButton
@action={{route-action "showCreateAccount"}}
@label="sign_up"
class="btn-primary sign-up-button"
/>
{{#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">
{{html-safe
(i18n
"login.no_login_methods.description"
(hash adminLoginPath=this.adminLoginPath)
)
}}
</p>
</div>
</div>
{{else}}
{{#if this.site.mobileView}}
<WelcomeHeader
@header={{i18n "login.header_title"}}
@subheader={{i18n "login.subheader_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}}
<DButton
@action={{route-action "showLogin"}}
@icon="user"
@label="log_in"
class="btn-primary login-button"
/>
</div>
{{#if this.canLoginLocal}}
<div class={{if this.site.desktopView "login-left-side"}}>
{{#if this.site.desktopView}}
<WelcomeHeader
@header={{i18n "login.header_title"}}
@subheader={{i18n "login.subheader_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}}
<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}}
/>
{{/if}}
</div>
{{/if}}
{{#if (and this.showLoginButtons this.site.desktopView)}}
{{#unless this.canLoginLocal}}
<div class="login-left-side">
<WelcomeHeader
@header={{i18n "login.header_title"}}
@subheader={{i18n "login.subheader_title"}}
/>
</div>
{{/unless}}
{{#if this.hasAtLeastOneLoginButton}}
<div class="login-right-side">
<LoginButtons
@externalLogin={{this.externalLoginAction}}
@passkeyLogin={{this.passkeyLogin}}
@context="login"
/>
</div>
{{/if}}
{{/if}}
{{/if}}
{{#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}}
</div>
</div>
</section>
{{else}}
{{body-class "static-login"}}
<section class="container">
<div class="contents clearfix body-page">
<div class="login-welcome">
<PluginOutlet
@name="above-login"
@outletArgs={{hash model=this.model}}
/>
<PluginOutlet @name="above-static" />
<div class="login-content">
{{html-safe this.model.html}}
</div>
<PluginOutlet @name="below-static" />
<PluginOutlet
@name="below-login"
@outletArgs={{hash model=this.model}}
/>
<div class="body-page-button-container">
{{#if this.application.canSignUp}}
<DButton
@action={{route-action "showCreateAccount"}}
@label="sign_up"
class="btn-primary sign-up-button"
/>
{{/if}}
<DButton
@action={{route-action "showLogin"}}
@icon="user"
@label="log_in"
class="btn-primary login-button"
/>
</div>
</div>
</div>
</section>
{{/if}}

View File

@ -1,6 +1,6 @@
{{body-class "password-reset-page"}}
{{hide-application-sidebar}}
{{hide-application-header-buttons "search" "login" "signup"}}
{{hide-application-header-buttons "search" "login" "signup" "menu"}}
<div class="container password-reset clearfix">
<div class="pull-left col-image">
<img src={{this.lockImageUrl}} class="password-reset-img" alt="" />

View File

@ -0,0 +1,285 @@
{{! template-lint-disable no-duplicate-id }}
{{hide-application-header-buttons "search" "login" "signup" "menu"}}
{{hide-application-sidebar}}
<FlashMessage @flash={{this.flash}} @type={{this.flashType}} />
<div class="signup-fullpage">
<div class={{concat-class "signup-body" this.bodyClasses}}>
<PluginOutlet
@name="create-account-before-modal-body"
@connectorTagName="div"
/>
<div
class={{concat-class
(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"}}
@subheader={{i18n "create_account.subheader_title"}}
>
<PluginOutlet
@name="create-account-header-bottom"
@outletArgs={{hash showLogin=(route-action "showLogin")}}
/>
</WelcomeHeader>
{{#if this.showCreateForm}}
<form id="login-form">
{{#if this.associateHtml}}
<div class="input-group create-account-associate-link">
<span>{{html-safe this.associateHtml}}</span>
</div>
{{/if}}
<div class="input-group create-account-email">
<Input
{{on "focusout" this.checkEmailAvailability}}
@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={{value-entered this.accountEmail}}
/>
<label class="alt-placeholder" for="new-account-email">
{{i18n "user.email.title"}}
</label>
<InputTip
@validation={{this.emailValidation}}
id="account-email-validation"
/>
{{#unless this.emailValidation.reason}}
<span class="more-info" id="account-email-validation-more-info">
{{i18n "user.email.instructions"}}
</span>
{{/unless}}
</div>
<div class="input-group create-account__username">
<Input
@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={{value-entered this.accountUsername}}
/>
<label class="alt-placeholder" for="new-account-username">
{{i18n "user.username.title"}}
</label>
<InputTip
@validation={{this.usernameValidation}}
id="username-validation"
/>
{{#unless this.usernameValidation.reason}}
<span class="more-info" id="username-validation-more-info">
{{i18n "user.username.instructions"}}
</span>
{{/unless}}
</div>
<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
@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={{value-entered this.accountPassword}}
/>
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}
</label>
<div class="create-account__password-info">
<div class="create-account__password-tip-validation">
<InputTip
@validation={{this.passwordValidation}}
id="password-validation"
/>
{{#unless this.passwordValidation.reason}}
<span class="more-info" id="password-validation-more-info">
{{this.passwordInstructions}}
</span>
{{/unless}}
<div
class={{concat-class
"caps-lock-warning"
(unless this.capsLockOn "hidden")
}}
>
{{d-icon "triangle-exclamation"}}
{{i18n "login.caps_lock_warning"}}
</div>
</div>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
</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
@value={{this.inviteCode}}
id="inviteCode"
class={{value-entered 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
}}
/>
<div
class={{concat-class
"input-group"
"create-account__fullname"
(if this.siteSettings.full_name_required "required")
}}
>
{{#if this.fullnameRequired}}
<TextField
@disabled={{this.nameDisabled}}
@value={{this.accountName}}
@id="new-account-name"
aria-describedby="fullname-validation fullname-validation-more-info"
aria-invalid={{this.nameValidation.failed}}
class={{value-entered this.accountName}}
/>
<label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}}
</label>
<InputTip
@validation={{this.nameValidation}}
id="fullname-validation"
/>
{{#unless this.nameValidation.reason}}
<span class="more-info" id="fullname-validation-more-info">
{{this.nameInstructions}}
</span>
{{/unless}}
{{/if}}
</div>
{{#if this.userFields}}
<div class="user-fields">
{{#each this.userFields as |f|}}
<div class="input-group">
<UserField
@field={{f.field}}
@value={{f.value}}
@validation={{f.validation}}
class={{value-entered 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}}
<SignupPageCta
@formSubmitted={{this.formSubmitted}}
@hasAuthOptions={{this.hasAuthOptions}}
@createAccount={{this.createAccount}}
@submitDisabled={{this.submitDisabled}}
@disclaimerHtml={{this.disclaimerHtml}}
/>
{{/if}}
{{/if}}
{{#if this.skipConfirmation}}
{{loading-spinner 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}}
{{#if (and this.showCreateForm this.site.mobileView)}}
<SignupPageCta
@formSubmitted={{this.formSubmitted}}
@hasAuthOptions={{this.hasAuthOptions}}
@createAccount={{this.createAccount}}
@submitDisabled={{this.submitDisabled}}
@disclaimerHtml={{this.disclaimerHtml}}
/>
{{/if}}
</div>
</div>

View File

@ -8,7 +8,8 @@ acceptance("Modal - Login", function () {
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 twice to get to the 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();

View File

@ -0,0 +1,62 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import FlashMessage from "discourse/components/flash-message";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | flash-message", function (hooks) {
setupRenderingTest(hooks);
test("it renders the correct class for a success flash", async function (assert) {
const flash = "Success message";
const type = "success";
await render(<template>
<FlashMessage @flash={{flash}} @type={{type}} />
</template>);
assert.dom(".alert.alert-success").hasText(flash);
assert.dom(".alert").hasClass("alert-success");
});
test("it renders the correct class for an error flash", async function (assert) {
const flash = "Error message";
const type = "error";
await render(<template>
<FlashMessage @flash={{flash}} @type={{type}} />
</template>);
assert.dom(".alert.alert-error").hasText(flash);
assert.dom(".alert").hasClass("alert-error");
});
test("it renders the correct class for a warning flash", async function (assert) {
const flash = "Warning message";
const type = "warning";
await render(<template>
<FlashMessage @flash={{flash}} @type={{type}} />
</template>);
assert.dom(".alert.alert-warning").hasText(flash);
assert.dom(".alert").hasClass("alert-warning");
});
test("it renders the correct class for an info flash", async function (assert) {
const flash = "Info message";
const type = "info";
await render(<template>
<FlashMessage @flash={{flash}} @type={{type}} />
</template>);
assert.dom(".alert.alert-info").hasText(flash);
assert.dom(".alert").hasClass("alert-info");
});
test("it does not render anything when flash is not provided", async function (assert) {
await render(<template><FlashMessage /></template>);
assert.dom(".alert").doesNotExist();
});
});

View File

@ -54,6 +54,7 @@
@import "sidebar-more-section-links";
@import "sidebar-section-link";
@import "static-login";
@import "login-signup-page";
@import "tagging";
@import "tooltip";
@import "topic-admin-menu";

View File

@ -0,0 +1,306 @@
// Shared styles
.login-fullpage,
.signup-fullpage {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.login-fullpage,
.signup-fullpage,
.invites-show {
.signup-body,
.login-body {
display: flex;
justify-content: center;
width: 100%;
max-width: 800px;
}
.login-page-cta,
.signup-page-cta {
display: flex;
flex-direction: column;
&__existing-account,
&__no-account-yet {
color: var(--primary-medium);
}
&__disclaimer {
color: var(--primary-medium);
margin-bottom: 1rem;
}
}
.login-left-side {
box-sizing: border-box;
width: 100%;
padding: 3rem;
overflow: auto;
width: 100%;
max-width: 500px;
}
.login-right-side {
padding: 3rem;
position: relative;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1px;
height: 80%;
transform: translateY(-50%);
background-color: var(--primary-low);
}
}
.input-group {
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 0.8em;
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: 10px;
box-shadow: 0 0 0 0px 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: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);
}
#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;
}
}
#login-form {
margin-block: 2em 1.2em;
display: flex;
flex-direction: column;
.input-group {
&.create-account-email,
&.create-account__username,
&.create-account__fullname.required {
order: -1;
}
}
.create-account-associate-link {
order: 1;
}
}
#login-buttons {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1rem;
white-space: nowrap;
.btn-social {
border: 1px solid var(--primary-low);
}
}
.login-welcome-header {
width: 100%;
}
.btn-social-title {
@include ellipsis;
}
.tip {
&.bad {
color: var(--danger);
display: block;
}
}
.more-info,
.instructions {
color: var(--primary-medium);
min-height: 1.4em;
overflow-wrap: anywhere;
}
.caps-lock-warning {
color: var(--danger);
font-size: var(--font-down-1);
font-weight: bold;
margin-top: 0.5em;
}
.create-account__password-info {
display: flex;
justify-content: space-between;
}
.inline-spinner {
display: inline-flex;
}
}
// Login page
.login-fullpage {
#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);
}
}
}
// Signup page
.signup-fullpage {
.password-confirmation {
display: none;
}
.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;
top: 12px;
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 {
input[type="checkbox"].ember-checkbox {
width: 1em !important;
min-width: unset;
}
}
}
}
}

View File

@ -74,6 +74,9 @@
order: -1;
}
}
.create-account-associate-link {
order: 1;
}
}
.tip {

View File

@ -12,3 +12,4 @@
@import "post-action-feedback";
@import "topic";
@import "user";
@import "login-signup-page";

View File

@ -0,0 +1,72 @@
// Shared styles
.login-fullpage,
.signup-fullpage,
.invites-show {
.login-page-cta,
.signup-page-cta {
&__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 {
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;
}
}
}
#login-buttons {
height: 100%;
}
@media screen and (max-width: 767px) {
// important to maintain narrow desktop widths
// for auth modals in Discourse Hub on iPad
.has-alt-auth {
flex-direction: column;
overflow: auto;
gap: 0;
.login-page-cta,
.signup-page-cta {
font-size: var(--font-down-1);
}
.login-left-side {
overflow: unset;
padding: 1em;
max-width: unset;
}
.login-right-side {
padding: 1em;
max-width: unset;
&::before {
height: 1px;
width: calc(100% - 2em);
left: 1em;
top: 0;
}
}
}
.signup-progress-bar {
font-size: var(--font-down-1);
}
}
}

View File

@ -19,6 +19,7 @@
@import "menu-panel";
@import "list-controls";
@import "login-modal";
@import "login-signup-page";
@import "modal";
@import "modal-overrides";
@import "new-user";

View File

@ -27,6 +27,7 @@
.login-right-side {
padding: 1rem 0 0;
background: unset;
max-width: unset;
}
.login-or-separator {

View File

@ -0,0 +1,170 @@
// Shared styles
.login-fullpage,
.signup-fullpage,
.invites-show {
.signup-body,
.login-body {
flex-direction: column;
gap: unset;
padding-inline: 0.5rem;
}
.login-page-cta,
.signup-page-cta {
font-size: var(--font-down-1);
padding: 1rem;
padding-top: 0;
&__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;
.input-group {
input {
height: 2.5em;
margin-bottom: 0.25em;
}
margin-bottom: 1em;
.user-field:not(.dropdown) label.alt-placeholder {
top: 8px;
}
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);
}
}
}
#login-buttons {
flex-direction: row;
flex-wrap: wrap;
padding: 0 1rem;
gap: 0.25em;
margin-bottom: 1rem;
.btn {
margin: 0;
padding-block: 0.65rem;
border: 1px solid var(--primary-low);
flex: 1 1 calc(50% - 0.25em);
font-size: var(--font-down-1);
white-space: nowrap;
&:last-child {
margin-right: auto;
}
}
}
.caps-lock-warning {
display: none;
}
.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: 1rem;
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 page
.login-fullpage .login-body {
.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
.signup-fullpage .signup-body {
.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-right-side::before {
display: none;
}
.login-welcome-header {
padding-bottom: 0.25rem;
padding-top: 0;
}
.signup-progress-bar {
padding-inline: 1em;
}
#login-form {
padding-bottom: 0;
}
}

View File

@ -2337,6 +2337,7 @@ en:
associate: "Already have an account? <a href='%{associate_link}'>Log In</a> to link your %{provider} account."
activation_title: "Activate your account"
already_have_account: "Already have an account?"
no_account_yet: "Don't have an account?"
progress_bar:
signup: "Sign Up"
activate: "Activate"
@ -2378,7 +2379,7 @@ en:
login:
header_title: "Welcome back"
subheader_title: "Log in to your account"
title: "Log in"
title: "Log In"
username: "User"
password: "Password"
show_password: "Show"

View File

@ -1871,6 +1871,7 @@ 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."
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"
experimental_full_page_login: "EXPERIMENTAL: Replace the login/signup modal with a full page login/signup form."
ga_version: "Version of Google Universal Analytics to use: v3 (analytics.js), v4 (gtag)"
ga_universal_tracking_code: "Google Universal Analytics tracking code ID, eg: UA-12345678-9; see <a href='https://google.com/analytics' target='_blank'>https://google.com/analytics</a>"
ga_universal_domain_name: "Google Universal Analytics domain name, eg: mysite.com; see <a href='https://google.com/analytics' target='_blank'>https://google.com/analytics</a>"

View File

@ -619,6 +619,11 @@ login:
default: 1440
min: 1
max: 175200
experimental_full_page_login:
default: false
client: true
hidden: true
users:
min_username_length:
client: true

View File

@ -2,8 +2,8 @@
require "rotp"
shared_examples "login scenarios" do
let(:login_modal) { PageObjects::Modals::Login.new }
shared_examples "login scenarios" do |login_page_object|
let(:login_form) { login_page_object }
let(:activate_account) { PageObjects::Pages::ActivateAccount.new }
let(:user_preferences_security_page) { PageObjects::Pages::UserPreferencesSecurity.new }
fab!(:user) { Fabricate(:user, username: "john", password: "supersecurepassword") }
@ -29,18 +29,14 @@ shared_examples "login scenarios" do
it "can login" do
EmailToken.confirm(Fabricate(:email_token, user: user).token)
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
login_form.open.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can login and activate account" do
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
login_form.open.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".not-activated-modal")
login_modal.click(".activation-controls button.resend")
login_form.click(".activation-controls button.resend")
activation_link = wait_for_email_link(user, :activation)
visit activation_link
@ -53,11 +49,9 @@ shared_examples "login scenarios" do
end
it "redirects to the wizard after activating account" do
login_modal.open
login_modal.fill(username: "admin", password: "supersecurepassword")
login_modal.click_login
login_form.open.fill(username: "admin", password: "supersecurepassword").click_login
expect(page).to have_css(".not-activated-modal")
login_modal.click(".activation-controls button.resend")
login_form.click(".activation-controls button.resend")
activation_link = wait_for_email_link(admin, :activation)
visit activation_link
@ -67,9 +61,7 @@ shared_examples "login scenarios" do
end
it "shows error when when activation link is invalid" do
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
login_form.open.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".not-activated-modal")
visit "/u/activate-account/invalid"
@ -83,15 +75,22 @@ shared_examples "login scenarios" do
user.update!(password:)
UserPasswordExpirer.expire_user_password(user)
login_modal.open
login_modal.fill(username: user.username, password:)
login_modal.click_login
login_form.open.fill(username: user.username, password:).click_login
expect(login_modal.find("#modal-alert")).to have_content(
expect(find(".alert-error")).to have_content(
I18n.t("js.login.password_expired", reset_url: "/password-reset").gsub(/<.*?>/, ""),
)
login_modal.find("#modal-alert a").click
find(".alert-error a").click
# TODO: prefill username when fullpage
if find("#username-or-email").value.blank?
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_no_css(".d-modal.is-animating")
end
find("#username-or-email").fill_in(with: user.username)
end
find("button.forgot-password-reset").click
reset_password_link = wait_for_email_link(user, :reset_password)
@ -101,9 +100,7 @@ shared_examples "login scenarios" do
context "with login link" do
it "can login" do
login_modal.open
login_modal.fill_username("john")
login_modal.email_login_link
login_form.open.fill_username("john").email_login_link
login_link = wait_for_email_link(user, :email_login)
visit login_link
@ -113,6 +110,26 @@ shared_examples "login scenarios" do
end
end
context "when login is required" do
before { SiteSetting.login_required = true }
it "cannot browse annonymously" do
visit "/"
if SiteSetting.experimental_full_page_login
expect(page).to have_css(".login-fullpage")
else
expect(page).to have_css(".login-welcome")
expect(page).to have_css(".site-logo")
find(".login-welcome .login-button").click
end
EmailToken.confirm(Fabricate(:email_token, user: user).token)
login_form.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
end
context "with two-factor authentication" do
let!(:user_second_factor) { Fabricate(:user_second_factor_totp, user: user) }
let!(:user_second_factor_backup) { Fabricate(:user_second_factor_backup, user: user) }
@ -127,93 +144,82 @@ shared_examples "login scenarios" do
before { SiteSetting.enforce_second_factor = "all" }
it "requires to set 2FA after login" do
login_modal.open
login_modal.fill(username: "jane", password: "supersecurepassword")
login_modal.click_login
login_form.open.fill(username: "jane", password: "supersecurepassword").click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
expect(page).to have_content(I18n.t("js.user.second_factor.enforced_notice"))
end
end
it "can login with totp" do
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
expect(page).to have_css(".login-modal-body.second-factor")
login_form.open.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".second-factor")
totp = ROTP::TOTP.new(user_second_factor.data).now
find("#login-second-factor").fill_in(with: totp)
login_modal.click_login
login_form.click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can login with backup code" do
login_modal.open
login_modal.fill(username: "john", password: "supersecurepassword")
login_modal.click_login
expect(page).to have_css(".login-modal-body.second-factor")
login_form.open.fill(username: "john", password: "supersecurepassword").click_login
expect(page).to have_css(".second-factor")
find(".toggle-second-factor-method").click
find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode")
login_modal.click_login
login_form.click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can login with login link and totp" do
login_modal.open
login_modal.fill_username("john")
login_modal.email_login_link
login_form.open.fill_username("john").email_login_link
login_link = wait_for_email_link(user, :email_login)
visit login_link
totp = ROTP::TOTP.new(user_second_factor.data).now
find(".second-factor-token-input").fill_in(with: totp)
find(".email-login-form .btn-primary").click
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can login with login link and backup code" do
login_modal.open
login_modal.fill_username("john")
login_modal.email_login_link
login_form.open.fill_username("john").email_login_link
login_link = wait_for_email_link(user, :email_login)
visit login_link
find(".toggle-second-factor-method").click
find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode")
find(".email-login-form .btn-primary").click
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can reset password with TOTP" do
login_modal.open
login_modal.fill_username("john")
login_modal.forgot_password
login_form.open.fill_username("john").forgot_password
find("button.forgot-password-reset").click
reset_password_link = wait_for_email_link(user, :reset_password)
visit reset_password_link
totp = ROTP::TOTP.new(user_second_factor.data).now
find(".second-factor-token-input").fill_in(with: totp)
find(".password-reset .btn-primary").click
find("#new-account-password").fill_in(with: "newsuperpassword")
find(".change-password-form .btn-primary").click
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "shows error correctly when TOTP code is invalid" do
login_modal.open
login_modal.fill_username("john")
login_modal.forgot_password
login_form.open.fill_username("john").forgot_password
find("button.forgot-password-reset").click
reset_password_link = wait_for_email_link(user, :reset_password)
visit reset_password_link
find(".second-factor-token-input").fill_in(with: "123456")
find(".password-reset .btn-primary").click
@ -221,25 +227,21 @@ shared_examples "login scenarios" do
".alert-error",
text: "Invalid authentication code. Each code can only be used once.",
)
expect(page).to have_css(".second-factor-token-input")
end
it "can reset password with a backup code" do
login_modal.open
login_modal.fill_username("john")
login_modal.forgot_password
login_form.open.fill_username("john").forgot_password
find("button.forgot-password-reset").click
reset_password_link = wait_for_email_link(user, :reset_password)
visit reset_password_link
find(".toggle-second-factor-method").click
find(".second-factor-token-input").fill_in(with: "iAmValidBackupCode")
find(".password-reset .btn-primary").click
find("#new-account-password").fill_in(with: "newsuperpassword")
find(".change-password-form .btn-primary").click
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
end
@ -247,10 +249,20 @@ end
describe "Login", type: :system do
context "when desktop" do
include_examples "login scenarios"
include_examples "login scenarios", PageObjects::Modals::Login.new
end
context "when mobile", mobile: true do
include_examples "login scenarios"
include_examples "login scenarios", PageObjects::Modals::Login.new
end
context "when fullpage desktop" do
before { SiteSetting.experimental_full_page_login = true }
include_examples "login scenarios", PageObjects::Pages::Login.new
end
context "when fullpage mobile", mobile: true do
before { SiteSetting.experimental_full_page_login = true }
include_examples "login scenarios", PageObjects::Pages::Login.new
end
end

View File

@ -13,6 +13,7 @@ module PageObjects
def open
visit("/login")
self
end
def open_from_header
@ -47,19 +48,23 @@ module PageObjects
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)

View File

@ -13,6 +13,7 @@ module PageObjects
def open
visit("/signup")
self
end
def open_from_header
@ -51,26 +52,32 @@ module PageObjects
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?

View File

@ -0,0 +1,74 @@
# frozen_string_literal: true
module PageObjects
module Pages
class Login < PageObjects::Pages::Base
def open?
has_css?(".login-fullpage")
end
def closed?
has_no_css?(".login-fullpage")
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_no_css(".d-modal.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_no_css(".d-modal.is-animating")
end
find(selector).fill_in(with: text)
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

@ -0,0 +1,106 @@
# frozen_string_literal: true
module PageObjects
module Pages
class Signup < PageObjects::Pages::Base
def open?
has_css?(".signup-fullpage")
end
def closed?
has_no_css?(".signup-fullpage")
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_no_css(".d-modal.is-animating")
end
find(selector).click
end
def open_login
click("#login-link")
end
def click_create_account
click(".signup-fullpage .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 fill_input(selector, text)
if page.has_css?("html.mobile-view", wait: 0)
expect(page).to have_no_css(".d-modal.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 click_social_button(provider)
click(".btn-social.#{provider}")
end
end
end
end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true
shared_examples "signup scenarios" do
let(:login_modal) { PageObjects::Modals::Login.new }
let(:signup_modal) { PageObjects::Modals::Signup.new }
shared_examples "signup scenarios" do |signup_page_object, login_page_object|
let(:login_form) { login_page_object }
let(:signup_form) { signup_page_object }
let(:invite_form) { PageObjects::Pages::InviteForm.new }
let(:activate_account) { PageObjects::Pages::ActivateAccount.new }
let(:invite) { Fabricate(:invite, email: "johndoe@example.com") }
@ -12,24 +12,26 @@ shared_examples "signup scenarios" do
before { Jobs.run_immediately! }
it "can signup" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
signup_form.click_create_account
expect(page).to have_css(".account-created")
end
it "can signup and activate account" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
signup_form.click_create_account
expect(page).to have_css(".account-created")
mail = ActionMailer::Base.deliveries.first
@ -73,28 +75,30 @@ shared_examples "signup scenarios" do
before { SiteSetting.invite_code = "cupcake" }
it "can signup with valid code" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
signup_modal.fill_code("cupcake")
expect(signup_modal).to have_valid_fields
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
.fill_code("cupcake")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
signup_form.click_create_account
expect(page).to have_css(".account-created")
end
it "cannot signup with invalid code" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
signup_modal.fill_code("pudding")
expect(signup_modal).to have_valid_fields
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
.fill_code("pudding")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
expect(signup_modal).to have_content(I18n.t("login.wrong_invite_code"))
expect(signup_modal).to have_no_css(".account-created")
signup_form.click_create_account
expect(signup_form).to have_content(I18n.t("login.wrong_invite_code"))
expect(signup_form).to have_no_css(".account-created")
end
end
@ -109,26 +113,27 @@ shared_examples "signup scenarios" do
end
it "can signup when filling the custom field" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
signup_modal.fill_custom_field("Occupation", "Jedi")
expect(signup_modal).to have_valid_fields
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
.fill_custom_field("Occupation", "Jedi")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
signup_form.click_create_account
expect(page).to have_css(".account-created")
end
it "cannot signup without filling the custom field" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
signup_modal.click_create_account
expect(signup_modal).to have_content(I18n.t("js.user_fields.required", name: "Occupation"))
expect(signup_modal).to have_no_css(".account-created")
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
.click_create_account
expect(signup_form).to have_content(I18n.t("js.user_fields.required", name: "Occupation"))
expect(signup_form).to have_no_css(".account-created")
end
end
@ -139,47 +144,49 @@ shared_examples "signup scenarios" do
end
it "can signup but cannot login until approval" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_modal.click_create_account
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_fields
signup_form.click_create_account
wait_for(timeout: 5) { User.find_by(username: "john") != nil }
visit "/"
login_modal.open
login_modal.fill_username("john")
login_modal.fill_password("supersecurepassword")
login_modal.click_login
expect(login_modal).to have_content(I18n.t("login.not_approved"))
login_form.open
login_form.fill_username("john")
login_form.fill_password("supersecurepassword")
login_form.click_login
expect(login_form).to have_content(I18n.t("login.not_approved"))
user = User.find_by(username: "john")
user.update!(approved: true)
EmailToken.confirm(Fabricate(:email_token, user: user).token)
login_modal.click_login
login_form.click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
it "can login directly when using an auto approved email" do
signup_modal.open
signup_modal.fill_email("johndoe@awesomeemail.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_modal.click_create_account
signup_form
.open
.fill_email("johndoe@awesomeemail.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_fields
signup_form.click_create_account
wait_for(timeout: 5) { User.find_by(username: "john") != nil }
user = User.find_by(username: "john")
EmailToken.confirm(Fabricate(:email_token, user: user).token)
visit "/"
login_modal.open
login_modal.fill_username("john")
login_modal.fill_password("supersecurepassword")
login_modal.click_login
login_form.open
login_form.fill_username("john")
login_form.fill_password("supersecurepassword")
login_form.click_login
expect(page).to have_css(".header-dropdown-toggle.current-user")
end
end
@ -189,12 +196,13 @@ shared_examples "signup scenarios" do
it "can signup and activate account" do
visit("/discuss/signup")
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_fields
signup_form
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_fields
signup_modal.click_create_account
signup_form.click_create_account
expect(page).to have_css(".account-created")
mail = ActionMailer::Base.deliveries.first
@ -216,13 +224,14 @@ shared_examples "signup scenarios" do
before { SiteSetting.blocked_email_domains = "example.com" }
it "cannot signup" do
signup_modal.open
signup_modal.fill_email("johndoe@example.com")
signup_modal.fill_username("john")
signup_modal.fill_password("supersecurepassword")
expect(signup_modal).to have_valid_username
expect(signup_modal).to have_valid_password
expect(signup_modal).to have_content(I18n.t("user.email.not_allowed"))
signup_form
.open
.fill_email("johndoe@example.com")
.fill_username("john")
.fill_password("supersecurepassword")
expect(signup_form).to have_valid_username
expect(signup_form).to have_valid_password
expect(signup_form).to have_content(I18n.t("user.email.not_allowed"))
end
end
@ -230,12 +239,12 @@ shared_examples "signup scenarios" do
before { SiteSetting.invite_only = true }
it "cannot open the signup modal" do
signup_modal.open
expect(signup_modal).to be_closed
signup_form.open
expect(signup_form).to be_closed
expect(page).to have_no_css(".sign-up-button")
login_modal.open_from_header
expect(login_modal).to have_no_css("#new-account-link")
login_form.open_from_header
expect(login_form).to have_no_css("#new-account-link")
end
it "can signup with invite link" do
@ -254,10 +263,28 @@ end
describe "Signup", type: :system do
context "when desktop" do
include_examples "signup scenarios"
include_examples "signup scenarios",
PageObjects::Modals::Signup.new,
PageObjects::Modals::Login.new
end
context "when mobile", mobile: true do
include_examples "signup scenarios"
include_examples "signup scenarios",
PageObjects::Modals::Signup.new,
PageObjects::Modals::Login.new
end
context "when fullpage desktop" do
before { SiteSetting.experimental_full_page_login = true }
include_examples "signup scenarios",
PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new
end
context "when fullpage mobile", mobile: true do
before { SiteSetting.experimental_full_page_login = true }
include_examples "signup scenarios",
PageObjects::Pages::Signup.new,
PageObjects::Pages::Login.new
end
end