FEATURE: add user toggle to mask/unmask passwords (#19306)

This commit is contained in:
Kris 2022-12-19 18:56:51 -05:00 committed by GitHub
parent a176ce2fd0
commit bd5f57e90c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 150 additions and 24 deletions

View File

@ -0,0 +1,6 @@
<DButton
@action={{@togglePasswordMask}}
@label={{if @maskPassword "login.show_password" "login.hide_password"}}
@class="btn-link toggle-password-mask"
@title={{if @maskPassword "login.show_password_title" "login.hide_password_title"}}
/>

View File

@ -42,6 +42,7 @@ export default Controller.extend(
prefilledUsername: null, prefilledUsername: null,
userFields: null, userFields: null,
isDeveloper: false, isDeveloper: false,
maskPassword: true,
hasAuthOptions: notEmpty("authOptions"), hasAuthOptions: notEmpty("authOptions"),
canCreateLocal: setting("enable_local_logins"), canCreateLocal: setting("enable_local_logins"),
@ -68,6 +69,7 @@ export default Controller.extend(
rejectedPasswords: [], rejectedPasswords: [],
prefilledUsername: null, prefilledUsername: null,
isDeveloper: false, isDeveloper: false,
maskPassword: true,
}); });
this._createUserFields(); this._createUserFields();
}, },
@ -435,6 +437,11 @@ export default Controller.extend(
}); });
}, },
@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
},
actions: { actions: {
externalLogin(provider) { externalLogin(provider) {
this.login.send("externalLogin", provider, { signup: true }); this.login.send("externalLogin", provider, { signup: true });

View File

@ -1,7 +1,7 @@
import { alias, bool, not, readOnly } from "@ember/object/computed"; import { alias, bool, not, readOnly } from "@ember/object/computed";
import Controller, { inject as controller } from "@ember/controller"; import Controller, { inject as controller } from "@ember/controller";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import EmberObject from "@ember/object"; import EmberObject, { action } from "@ember/object";
import I18n from "I18n"; import I18n from "I18n";
import NameValidation from "discourse/mixins/name-validation"; import NameValidation from "discourse/mixins/name-validation";
import PasswordValidation from "discourse/mixins/password-validation"; import PasswordValidation from "discourse/mixins/password-validation";
@ -47,6 +47,7 @@ export default Controller.extend(
inviteImageUrl: getUrl("/images/envelope.svg"), inviteImageUrl: getUrl("/images/envelope.svg"),
isInviteLink: readOnly("model.is_invite_link"), isInviteLink: readOnly("model.is_invite_link"),
rejectedEmails: null, rejectedEmails: null,
maskPassword: true,
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -288,6 +289,11 @@ export default Controller.extend(
}); });
}, },
@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
},
actions: { actions: {
submit() { submit() {
const userFields = this.userFields; const userFields = this.userFields;

View File

@ -41,6 +41,7 @@ export default Controller.extend(ModalFunctionality, {
showLoginButtons: true, showLoginButtons: true,
showSecondFactor: false, showSecondFactor: false,
awaitingApproval: false, awaitingApproval: false,
maskPassword: true,
canLoginLocal: setting("enable_local_logins"), canLoginLocal: setting("enable_local_logins"),
canLoginLocalWithEmail: setting("enable_local_logins_via_email"), canLoginLocalWithEmail: setting("enable_local_logins_via_email"),
@ -58,6 +59,7 @@ export default Controller.extend(ModalFunctionality, {
showSecurityKey: false, showSecurityKey: false,
showLoginButtons: true, showLoginButtons: true,
awaitingApproval: false, awaitingApproval: false,
maskPassword: true,
}); });
}, },
@ -188,6 +190,11 @@ export default Controller.extend(ModalFunctionality, {
this.send("showForgotPassword"); this.send("showForgotPassword");
}, },
@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
},
actions: { actions: {
forgotPassword() { forgotPassword() {
this.handleForgotPassword(); this.handleForgotPassword();

View File

@ -33,6 +33,7 @@ export default Controller.extend(PasswordValidation, {
successMessage: null, successMessage: null,
requiresApproval: false, requiresApproval: false,
redirected: false, redirected: false,
maskPassword: true,
@discourseComputed() @discourseComputed()
continueButtonText() { continueButtonText() {
@ -58,6 +59,11 @@ export default Controller.extend(PasswordValidation, {
DiscourseURL.redirectTo(this.redirectTo || "/"); DiscourseURL.redirectTo(this.redirectTo || "/");
}, },
@action
togglePasswordMask() {
this.toggleProperty("maskPassword");
},
actions: { actions: {
submit() { submit() {
ajax({ ajax({

View File

@ -94,18 +94,21 @@
{{#unless this.externalAuthsOnly}} {{#unless this.externalAuthsOnly}}
<div class="input password-input input-group"> <div class="input password-input input-group">
<PasswordField @value={{this.accountPassword}} @class={{value-entered this.accountPassword}} @type="password" @id="new-account-password" @capsLockOn={{this.capsLockOn}} /> <PasswordField @value={{this.accountPassword}} @class={{value-entered this.accountPassword}} @type={{if this.maskPassword "password" "text"}} @id="new-account-password" @capsLockOn={{this.capsLockOn}} />
<label class="alt-placeholder" for="new-account-password"> <label class="alt-placeholder" for="new-account-password">
{{i18n "invites.password_label"}} {{i18n "invites.password_label"}}
<span class="required">*</span> <span class="required">*</span>
</label> </label>
<InputTip @validation={{this.passwordValidation}} /> <div class="create-account__password-info">
<div class="instructions"> <div class="create-account__password-tip-validation">
{{this.passwordInstructions}} <InputTip @validation={{this.passwordValidation}} @id="password-validation" />
<span class="more-info">{{this.passwordInstructions}}</span>
<div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}"> <div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}">
{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}} {{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}
</div> </div>
</div> </div>
<TogglePasswordMask @maskPassword={{this.maskPassword}} @togglePasswordMask={{this.togglePasswordMask}} @parentController={{"invites-show"}} />
</div>
</div> </div>
{{/unless}} {{/unless}}

View File

@ -68,19 +68,26 @@
<div class="input-group create-account__password"> <div class="input-group create-account__password">
{{#if this.passwordRequired}} {{#if this.passwordRequired}}
<PasswordField @value={{this.accountPassword}} @class={{value-entered this.accountPassword}} @type="password" @id="new-account-password" @autocomplete="current-password" @capsLockOn={{this.capsLockOn}} @aria-describedby="password-validation" @aria-invalid={{this.passwordValidation.failed}} /> <PasswordField @value={{this.accountPassword}} @class={{value-entered this.accountPassword}} @type={{if this.maskPassword "password" "text"}} id="new-account-password" @autocomplete="current-password" @capsLockOn={{this.capsLockOn}} @aria-describedby="password-validation" @aria-invalid={{this.passwordValidation.failed}} />
<label class="alt-placeholder" for="new-account-password"> <label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}} {{i18n "user.password.title"}}
{{~#if this.userFields~}} {{~#if this.userFields~}}
<span class="required">*</span> <span class="required">*</span>
{{/if}} {{/if}}
</label> </label>
<div class="create-account__password-info">
<div class="create-account__password-tip-validation">
<InputTip @validation={{this.passwordValidation}} @id="password-validation" /> <InputTip @validation={{this.passwordValidation}} @id="password-validation" />
<span class="more-info">{{this.passwordInstructions}}</span> <span class="more-info">{{this.passwordInstructions}}</span>
<div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}"> <div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}">
{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}} {{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}
</div> </div>
</div>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
</div>
{{/if}} {{/if}}
<div class="password-confirmation"> <div class="password-confirmation">

View File

@ -20,9 +20,16 @@
{{/if}} {{/if}}
</div> </div>
<div class="input-group"> <div class="input-group">
<PasswordField @value={{this.loginPassword}} @type="password" class={{value-entered this.loginPassword}} id="login-account-password" autocomplete="current-password" maxlength="200" @capsLockOn={{this.capsLockOn}} disabled={{this.disableLoginFields}} tabindex="1" /> <PasswordField @value={{this.loginPassword}} @type={{if this.maskPassword "password" "text"}} class={{value-entered this.loginPassword}} id="login-account-password" autocomplete="current-password" maxlength="200" @capsLockOn={{this.capsLockOn}} disabled={{this.disableLoginFields}} tabindex="1" />
<label class="alt-placeholder" for="login-account-password">{{i18n "login.password"}}</label> <label class="alt-placeholder" for="login-account-password">{{i18n "login.password"}}</label>
<div class="login__password-links">
<a href id="forgot-password-link" tabindex="3" {{on "click" this.handleForgotPassword}}>{{i18n "forgot_password.action"}}</a> <a href id="forgot-password-link" tabindex="3" {{on "click" this.handleForgotPassword}}>{{i18n "forgot_password.action"}}</a>
<TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
tabindex="3"
/>
</div>
<div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}">{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}</div> <div class="caps-lock-warning {{unless this.capsLockOn "hidden"}}">{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}</div>
</div> </div>
</div> </div>

View File

@ -36,8 +36,12 @@
<h2>{{i18n "user.change_password.choose"}}</h2> <h2>{{i18n "user.change_password.choose"}}</h2>
<div class="input"> <div class="input">
<PasswordField @value={{this.accountPassword}} @type="password" @id="new-account-password" @capsLockOn={{this.capsLockOn}} @autofocus="autofocus" /> <PasswordField @value={{this.accountPassword}} @type={{if this.maskPassword "password" "text"}} @id="new-account-password" @capsLockOn={{this.capsLockOn}} @autofocus="autofocus" />
&nbsp;<InputTip @validation={{this.passwordValidation}} /> <TogglePasswordMask
@maskPassword={{this.maskPassword}}
@togglePasswordMask={{this.togglePasswordMask}}
/>
<InputTip @validation={{this.passwordValidation}} />
</div> </div>
<div class="instructions"> <div class="instructions">

View File

@ -3,7 +3,7 @@ import {
exists, exists,
query, query,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { fillIn, visit } from "@ember/test-helpers"; import { click, fillIn, visit } from "@ember/test-helpers";
import PreloadStore from "discourse/lib/preload-store"; import PreloadStore from "discourse/lib/preload-store";
import I18n from "I18n"; import I18n from "I18n";
import { test } from "qunit"; import { test } from "qunit";
@ -111,6 +111,16 @@ acceptance("Invite accept", function (needs) {
"submit is disabled because name and email is not filled" "submit is disabled because name and email is not filled"
); );
assert.ok(
exists("#new-account-password[type='password']"),
"password is masked by default"
);
await click(".toggle-password-mask");
assert.ok(
exists("#new-account-password[type='text']"),
"password is unmasked when toggle is clicked"
);
await fillIn("#new-account-name", "John Doe"); await fillIn("#new-account-name", "John Doe");
assert.ok( assert.ok(
exists(".invites-show .btn-primary:disabled"), exists(".invites-show .btn-primary:disabled"),

View File

@ -80,7 +80,7 @@ acceptance("Password Reset", function (needs) {
); );
await fillIn(".password-reset input", "jonesyAlienSlayer"); await fillIn(".password-reset input", "jonesyAlienSlayer");
await click(".password-reset form button"); await click(".password-reset form button[type='submit']");
assert.ok(exists(".password-reset .tip.bad"), "input is not valid"); assert.ok(exists(".password-reset .tip.bad"), "input is not valid");
assert.ok( assert.ok(
query(".password-reset .tip.bad").innerHTML.includes( query(".password-reset .tip.bad").innerHTML.includes(
@ -89,9 +89,19 @@ acceptance("Password Reset", function (needs) {
"server validation error message shows" "server validation error message shows"
); );
assert.ok(
exists("#new-account-password[type='password']"),
"password is masked by default"
);
await click(".toggle-password-mask");
assert.ok(
exists("#new-account-password[type='text']"),
"password is unmasked after toggle is clicked"
);
await fillIn(".password-reset input", "perf3ctly5ecur3"); await fillIn(".password-reset input", "perf3ctly5ecur3");
sinon.stub(DiscourseURL, "redirectTo"); sinon.stub(DiscourseURL, "redirectTo");
await click(".password-reset form button"); await click(".password-reset form button[type='submit']");
assert.ok(DiscourseURL.redirectTo.calledWith("/"), "form is gone"); assert.ok(DiscourseURL.redirectTo.calledWith("/"), "form is gone");
}); });
@ -125,7 +135,7 @@ acceptance("Password Reset", function (needs) {
await fillIn(".password-reset input", "perf3ctly5ecur3"); await fillIn(".password-reset input", "perf3ctly5ecur3");
sinon.stub(DiscourseURL, "redirectTo"); sinon.stub(DiscourseURL, "redirectTo");
await click(".password-reset form button"); await click(".password-reset form button[type='submit']");
assert.ok( assert.ok(
DiscourseURL.redirectTo.calledWith("/"), DiscourseURL.redirectTo.calledWith("/"),
"it redirects after submitting form" "it redirects after submitting form"

View File

@ -23,6 +23,17 @@ acceptance("Signing In", function () {
"enables the login button" "enables the login button"
); );
// Test password unmasking
assert.ok(
exists("#login-account-password[type='password']"),
"password is masked by default"
);
await click(".toggle-password-mask");
assert.ok(
exists("#login-account-password[type='text']"),
"password is unmasked after toggle is clicked"
);
// Use the correct password // Use the correct password
await fillIn("#login-account-password", "correct"); await fillIn("#login-account-password", "correct");
await click(".modal-footer .btn-primary"); await click(".modal-footer .btn-primary");

View File

@ -217,9 +217,11 @@ body.invite-page {
} }
} }
#forgot-password-link, #email-login-link,
#email-login-link { .login__password-links {
font-size: var(--font-down-1); font-size: var(--font-down-1);
display: flex;
justify-content: space-between;
} }
.tip:not(:empty) + label.more-info { .tip:not(:empty) + label.more-info {
@ -334,6 +336,20 @@ body.invite-page {
color: var(--primary-medium); color: var(--primary-medium);
} }
} }
#new-account-password {
width: 15em;
}
.tip {
margin: 0 0 0.5em;
}
.toggle-password-mask {
margin-left: 0.25em;
}
}
.toggle-password-mask {
align-self: start;
line-height: 1.4; // aligns with input description text
} }
// admin invite page // admin invite page
@ -399,9 +415,11 @@ body.invite-page {
} }
.tip { .tip {
font-size: var(--font-down-1); font-size: var(--font-down-1);
&:not(:empty) {
margin-bottom: 0.25em; margin-bottom: 0.25em;
} }
} }
}
.invitation-cta { .invitation-cta {
display: flex; display: flex;
@ -490,3 +508,8 @@ button#new-account-link {
} }
} }
} }
.create-account__password-info {
display: flex;
justify-content: space-between;
}

View File

@ -339,6 +339,21 @@
border: 0; border: 0;
padding: 0; padding: 0;
color: var(--tertiary); color: var(--tertiary);
.discourse-no-touch & {
&:hover {
color: var(--tertiary);
background: transparent;
}
}
&:focus {
color: var(--tertiary);
background: transparent;
}
&:focus-visible {
color: var(--tertiary);
background: transparent;
@include default-focus;
}
} }
.btn-mini-toggle { .btn-mini-toggle {

View File

@ -2124,6 +2124,10 @@ en:
title: "Log in" title: "Log in"
username: "User" username: "User"
password: "Password" password: "Password"
show_password: "Show"
hide_password: "Hide"
show_password_title: "Show password"
hide_password_title: "Hide password"
second_factor_title: "Two-Factor Authentication" second_factor_title: "Two-Factor Authentication"
second_factor_description: "Please enter the authentication code from your app:" second_factor_description: "Please enter the authentication code from your app:"
second_factor_backup: "Log in using a backup code" second_factor_backup: "Log in using a backup code"