Add show/hide button to password fields (#1861)

* Make working password inputs

* Make show/hide password button use icon

* Tweak look

* Handle delete account form separately from change settings form

* Adjust password strengthometer position

* Incorporate PR feedback

* Add translations
This commit is contained in:
SleeplessOne1917 2023-07-14 17:33:24 +00:00 committed by Dessalines
parent eaeea374be
commit faeb66954c
8 changed files with 264 additions and 245 deletions

@ -1 +1 @@
Subproject commit 3638cde3b3d59a969872d5f8e65f80faa9d3ab1c Subproject commit a1a19aea1ad7d91195775a5ccea62ccc9076a2c7

View file

@ -258,5 +258,12 @@
<path d="M8.72046 10.6397L14.9999 7.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8.72046 10.6397L14.9999 7.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.70605 13.353L15 16.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M8.70605 13.353L15 16.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol> </symbol>
<symbol id="icon-eye" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</symbol>
<symbol id="icon-eye-slash" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</symbol>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,157 @@
import { Options, passwordStrength } from "check-password-strength";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, FormEventHandler, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { I18NextService } from "../../services";
import { Icon } from "./icon";
interface PasswordInputProps {
id: string;
value?: string;
onInput: FormEventHandler<HTMLInputElement>;
className?: string;
showStrength?: boolean;
label?: string | null;
showForgotLink?: boolean;
}
interface PasswordInputState {
show: boolean;
}
const passwordStrengthOptions: Options<string> = [
{
id: 0,
value: "very_weak",
minDiversity: 0,
minLength: 0,
},
{
id: 1,
value: "weak",
minDiversity: 2,
minLength: 10,
},
{
id: 2,
value: "medium",
minDiversity: 3,
minLength: 12,
},
{
id: 3,
value: "strong",
minDiversity: 4,
minLength: 14,
},
];
function handleToggleShow(i: PasswordInput) {
i.setState(prev => ({
...prev,
show: !prev.show,
}));
}
class PasswordInput extends Component<PasswordInputProps, PasswordInputState> {
state: PasswordInputState = {
show: false,
};
constructor(props: PasswordInputProps, context: any) {
super(props, context);
}
render() {
const {
props: {
id,
value,
onInput,
className,
showStrength,
label,
showForgotLink,
},
state: { show },
} = this;
return (
<>
<div className={classNames("row", className)}>
{label && (
<label className="col-sm-2 col-form-label" htmlFor={id}>
{label}
</label>
)}
<div className={`col-sm-${label ? 10 : 12}`}>
<div className="input-group">
<input
type={show ? "text" : "password"}
className="form-control"
aria-describedby={id}
autoComplete="on"
onInput={onInput}
value={value}
required
maxLength={60}
minLength={10}
/>
<button
className="btn btn-outline-dark"
type="button"
id={id}
onClick={linkEvent(this, handleToggleShow)}
aria-label={I18NextService.i18n.t(
`${show ? "show" : "hide"}_password`
)}
data-tippy-content={I18NextService.i18n.t(
`${show ? "show" : "hide"}_password`
)}
>
<Icon icon={`eye${show ? "-slash" : ""}`} inline />
</button>
</div>
{showStrength && value && (
<div className={this.passwordColorClass}>
{I18NextService.i18n.t(
this.passwordStrength as NoOptionI18nKeys
)}
</div>
)}
{showForgotLink && (
<NavLink
className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold pointer-events not-allowed"
to="/login_reset"
>
{I18NextService.i18n.t("forgot_password")}
</NavLink>
)}
</div>
</div>
</>
);
}
get passwordStrength(): string | undefined {
const password = this.props.value;
return password
? passwordStrength(password, passwordStrengthOptions).value
: undefined;
}
get passwordColorClass(): string {
const strength = this.passwordStrength;
if (strength && ["weak", "medium"].includes(strength)) {
return "text-warning";
} else if (strength == "strong") {
return "text-success";
} else {
return "text-danger";
}
}
}
export default PasswordInput;

View file

@ -1,13 +1,13 @@
import { myAuth, setIsoData } from "@utils/app"; import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client"; import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService"; import { HttpService, RequestState } from "../../services/HttpService";
import { toast } from "../../toast"; import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State { interface State {
loginRes: RequestState<LoginResponse>; loginRes: RequestState<LoginResponse>;
@ -90,28 +90,14 @@ export class Login extends Component<any, State> {
/> />
</div> </div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-2 col-form-label" htmlFor="login-password"> <PasswordInput
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="login-password" id="login-password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, this.handleLoginPasswordChange)} onInput={linkEvent(this, this.handleLoginPasswordChange)}
className="form-control" label={I18NextService.i18n.t("password")}
autoComplete="current-password" showForgotLink
required
maxLength={60}
/> />
<NavLink
className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold pointer-events not-allowed"
to="/login_reset"
>
{I18NextService.i18n.t("forgot_password")}
</NavLink>
</div>
</div> </div>
{this.state.showTotp && ( {this.state.showTotp && (
<div className="mb-3 row"> <div className="mb-3 row">

View file

@ -10,6 +10,7 @@ import {
import { I18NextService, UserService } from "../../services"; import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService"; import { HttpService, RequestState } from "../../services/HttpService";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import { SiteForm } from "./site-form"; import { SiteForm } from "./site-form";
interface State { interface State {
@ -121,42 +122,22 @@ export class Setup extends Component<any, State> {
/> />
</div> </div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-2 col-form-label" htmlFor="password"> <PasswordInput
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="password" id="password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, this.handleRegisterPasswordChange)} onInput={linkEvent(this, this.handleRegisterPasswordChange)}
className="form-control" label={I18NextService.i18n.t("password")}
required
autoComplete="new-password"
minLength={10}
maxLength={60}
/> />
</div> </div>
</div> <div className="mb-3">
<div className="mb-3 row"> <PasswordInput
<label className="col-sm-2 col-form-label" htmlFor="verify-password">
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="verify-password" id="verify-password"
value={this.state.form.password_verify} value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
className="form-control" label={I18NextService.i18n.t("verify_password")}
required
autoComplete="new-password"
minLength={10}
maxLength={60}
/> />
</div> </div>
</div>
<div className="mb-3 row"> <div className="mb-3 row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">

View file

@ -1,8 +1,6 @@
import { myAuth, setIsoData } from "@utils/app"; import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { validEmail } from "@utils/helpers"; import { validEmail } from "@utils/helpers";
import { Options, passwordStrength } from "check-password-strength";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { import {
@ -20,33 +18,7 @@ import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
import PasswordInput from "../common/password-input";
const passwordStrengthOptions: Options<string> = [
{
id: 0,
value: "very_weak",
minDiversity: 0,
minLength: 0,
},
{
id: 1,
value: "weak",
minDiversity: 2,
minLength: 10,
},
{
id: 2,
value: "medium",
minDiversity: 3,
minLength: 12,
},
{
id: 3,
value: "strong",
minDiversity: 4,
minLength: 14,
},
];
interface State { interface State {
registerRes: RequestState<LoginResponse>; registerRes: RequestState<LoginResponse>;
@ -210,57 +182,26 @@ export class Signup extends Component<any, State> {
</div> </div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label <PasswordInput
className="col-sm-2 col-form-label"
htmlFor="register-password"
>
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="register-password" id="register-password"
value={this.state.form.password} value={this.state.form.password}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordChange)} onInput={linkEvent(this, this.handleRegisterPasswordChange)}
minLength={10} showStrength
maxLength={60} label={I18NextService.i18n.t("password")}
className="form-control"
required
/> />
{this.state.form.password && (
<div className={this.passwordColorClass}>
{I18NextService.i18n.t(
this.passwordStrength as NoOptionI18nKeys
)}
</div>
)}
</div>
</div> </div>
<div className="mb-3 row"> <div className="mb-3">
<label <PasswordInput
className="col-sm-2 col-form-label"
htmlFor="register-verify-password"
>
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
type="password"
id="register-verify-password" id="register-verify-password"
value={this.state.form.password_verify} value={this.state.form.password_verify}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
maxLength={60} label={I18NextService.i18n.t("verify_password")}
className="form-control"
required
/> />
</div> </div>
</div>
{siteView.local_site.registration_mode == "RequireApplication" && ( {siteView.local_site.registration_mode === "RequireApplication" && (
<> <>
<div className="mb-3 row"> <div className="mb-3 row">
<div className="offset-sm-2 col-sm-10"> <div className="offset-sm-2 col-sm-10">
@ -411,25 +352,6 @@ export class Signup extends Component<any, State> {
); );
} }
get passwordStrength(): string | undefined {
const password = this.state.form.password;
return password
? passwordStrength(password, passwordStrengthOptions).value
: undefined;
}
get passwordColorClass(): string {
const strength = this.passwordStrength;
if (strength && ["weak", "medium"].includes(strength)) {
return "text-warning";
} else if (strength == "strong") {
return "text-success";
} else {
return "text-danger";
}
}
async handleRegisterSubmit(i: Signup, event: any) { async handleRegisterSubmit(i: Signup, event: any) {
event.preventDefault(); event.preventDefault();
const { const {

View file

@ -6,6 +6,7 @@ import { HttpService, I18NextService, UserService } from "../../services";
import { RequestState } from "../../services/HttpService"; import { RequestState } from "../../services/HttpService";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State { interface State {
passwordChangeRes: RequestState<LoginResponse>; passwordChangeRes: RequestState<LoginResponse>;
@ -60,38 +61,23 @@ export class PasswordChange extends Component<any, State> {
passwordChangeForm() { passwordChangeForm() {
return ( return (
<form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}> <form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-2 col-form-label" htmlFor="new-password"> <PasswordInput
{I18NextService.i18n.t("new_password")}
</label>
<div className="col-sm-10">
<input
id="new-password" id="new-password"
type="password"
value={this.state.form.password} value={this.state.form.password}
onInput={linkEvent(this, this.handlePasswordChange)} onInput={linkEvent(this, this.handlePasswordChange)}
className="form-control" showStrength
required label={I18NextService.i18n.t("new_password")}
maxLength={60}
/> />
</div> </div>
</div> <div className="mb-3">
<div className="mb-3 row"> <PasswordInput
<label className="col-sm-2 col-form-label" htmlFor="verify-password"> id="password"
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
id="verify-password"
type="password"
value={this.state.form.password_verify} value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleVerifyPasswordChange)} onInput={linkEvent(this, this.handleVerifyPasswordChange)}
className="form-control" label={I18NextService.i18n.t("verify_password")}
required
maxLength={60}
/> />
</div> </div>
</div>
<div className="mb-3 row"> <div className="mb-3 row">
<div className="col-sm-10"> <div className="col-sm-10">
<button type="submit" className="btn btn-secondary"> <button type="submit" className="btn btn-secondary">

View file

@ -40,6 +40,7 @@ import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select"; import { LanguageSelect } from "../common/language-select";
import { ListingTypeSelect } from "../common/listing-type-select"; import { ListingTypeSelect } from "../common/listing-type-select";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
import PasswordInput from "../common/password-input";
import { SearchableSelect } from "../common/searchable-select"; import { SearchableSelect } from "../common/searchable-select";
import { SortSelect } from "../common/sort-select"; import { SortSelect } from "../common/sort-select";
import Tabs from "../common/tabs"; import Tabs from "../common/tabs";
@ -318,60 +319,31 @@ export class Settings extends Component<any, SettingsState> {
<> <>
<h2 className="h5">{I18NextService.i18n.t("change_password")}</h2> <h2 className="h5">{I18NextService.i18n.t("change_password")}</h2>
<form onSubmit={linkEvent(this, this.handleChangePasswordSubmit)}> <form onSubmit={linkEvent(this, this.handleChangePasswordSubmit)}>
<div className="mb-3 row"> <div className="mb-3">
<label className="col-sm-5 col-form-label" htmlFor="user-password"> <PasswordInput
{I18NextService.i18n.t("new_password")} id="new-password"
</label>
<div className="col-sm-7">
<input
type="password"
id="user-password"
className="form-control"
value={this.state.changePasswordForm.new_password} value={this.state.changePasswordForm.new_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordChange)} onInput={linkEvent(this, this.handleNewPasswordChange)}
showStrength
label={I18NextService.i18n.t("new_password")}
/> />
</div> </div>
</div> <div className="mb-3">
<div className="mb-3 row"> <PasswordInput
<label id="verify-new-password"
className="col-sm-5 col-form-label"
htmlFor="user-verify-password"
>
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-7">
<input
type="password"
id="user-verify-password"
className="form-control"
value={this.state.changePasswordForm.new_password_verify} value={this.state.changePasswordForm.new_password_verify}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordVerifyChange)} onInput={linkEvent(this, this.handleNewPasswordVerifyChange)}
label={I18NextService.i18n.t("verify_password")}
/> />
</div> </div>
</div> <div className="mb-3">
<div className="mb-3 row"> <PasswordInput
<label
className="col-sm-5 col-form-label"
htmlFor="user-old-password"
>
{I18NextService.i18n.t("old_password")}
</label>
<div className="col-sm-7">
<input
type="password"
id="user-old-password" id="user-old-password"
className="form-control"
value={this.state.changePasswordForm.old_password} value={this.state.changePasswordForm.old_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleOldPasswordChange)} onInput={linkEvent(this, this.handleOldPasswordChange)}
label={I18NextService.i18n.t("old_password")}
/> />
</div> </div>
</div>
<div className="input-group mb-3"> <div className="input-group mb-3">
<button <button
type="submit" type="submit"
@ -816,8 +788,12 @@ export class Settings extends Component<any, SettingsState> {
</button> </button>
</div> </div>
<hr /> <hr />
<div className="input-group mb-3"> <form
className="mb-3"
onSubmit={linkEvent(this, this.handleDeleteAccount)}
>
<button <button
type="button"
className="btn d-block btn-danger" className="btn d-block btn-danger"
onClick={linkEvent( onClick={linkEvent(
this, this,
@ -828,24 +804,26 @@ export class Settings extends Component<any, SettingsState> {
</button> </button>
{this.state.deleteAccountShowConfirm && ( {this.state.deleteAccountShowConfirm && (
<> <>
<div className="my-2 alert alert-danger" role="alert"> <label
className="my-2 alert alert-danger d-block"
role="alert"
htmlFor="password-delete-account"
>
{I18NextService.i18n.t("delete_account_confirm")} {I18NextService.i18n.t("delete_account_confirm")}
</div> </label>
<input <PasswordInput
type="password" id="password-delete-account"
value={this.state.deleteAccountForm.password} value={this.state.deleteAccountForm.password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent( onInput={linkEvent(
this, this,
this.handleDeleteAccountPasswordChange this.handleDeleteAccountPasswordChange
)} )}
className="form-control my-2" className="my-2"
/> />
<button <button
type="submit"
className="btn btn-danger me-4" className="btn btn-danger me-4"
disabled={!this.state.deleteAccountForm.password} disabled={!this.state.deleteAccountForm.password}
onClick={linkEvent(this, this.handleDeleteAccount)}
> >
{this.state.deleteAccountRes.state === "loading" ? ( {this.state.deleteAccountRes.state === "loading" ? (
<Spinner /> <Spinner />
@ -855,6 +833,7 @@ export class Settings extends Component<any, SettingsState> {
</button> </button>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
type="button"
onClick={linkEvent( onClick={linkEvent(
this, this,
this.handleDeleteAccountShowConfirmToggle this.handleDeleteAccountShowConfirmToggle
@ -864,7 +843,7 @@ export class Settings extends Component<any, SettingsState> {
</button> </button>
</> </>
)} )}
</div> </form>
</form> </form>
</> </>
); );
@ -1225,7 +1204,8 @@ export class Settings extends Component<any, SettingsState> {
i.setState(s => ((s.deleteAccountForm.password = event.target.value), s)); i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
} }
async handleDeleteAccount(i: Settings) { async handleDeleteAccount(i: Settings, event: Event) {
event.preventDefault();
const password = i.state.deleteAccountForm.password; const password = i.state.deleteAccountForm.password;
if (password) { if (password) {
i.setState({ deleteAccountRes: { state: "loading" } }); i.setState({ deleteAccountRes: { state: "loading" } });