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 GitHub
parent cd1a11c77a
commit b7ec7ae311
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 264 additions and 245 deletions

@ -1 +1 @@
Subproject commit 713ceed9c7ef84deaa222e68361e670e0763cd83
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.70605 13.353L15 16.5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</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>
</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 { isBrowser } from "@utils/browser";
import { Component, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
import { I18NextService, UserService } from "../../services";
import { HttpService, RequestState } from "../../services/HttpService";
import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State {
loginRes: RequestState<LoginResponse>;
@ -90,28 +90,14 @@ export class Login extends Component<any, State> {
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="login-password">
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="login-password"
value={this.state.form.password}
onInput={linkEvent(this, this.handleLoginPasswordChange)}
className="form-control"
autoComplete="current-password"
required
maxLength={60}
label={I18NextService.i18n.t("password")}
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>
{this.state.showTotp && (
<div className="mb-3 row">

View file

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

View file

@ -1,8 +1,6 @@
import { myAuth, setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser";
import { validEmail } from "@utils/helpers";
import { Options, passwordStrength } from "check-password-strength";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
@ -20,33 +18,7 @@ import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
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,
},
];
import PasswordInput from "../common/password-input";
interface State {
registerRes: RequestState<LoginResponse>;
@ -210,57 +182,26 @@ export class Signup extends Component<any, State> {
</div>
</div>
<div className="mb-3 row">
<label
className="col-sm-2 col-form-label"
htmlFor="register-password"
>
{I18NextService.i18n.t("password")}
</label>
<div className="col-sm-10">
<input
type="password"
<div className="mb-3">
<PasswordInput
id="register-password"
value={this.state.form.password}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordChange)}
minLength={10}
maxLength={60}
className="form-control"
required
showStrength
label={I18NextService.i18n.t("password")}
/>
{this.state.form.password && (
<div className={this.passwordColorClass}>
{I18NextService.i18n.t(
this.passwordStrength as NoOptionI18nKeys
)}
</div>
)}
</div>
</div>
<div className="mb-3 row">
<label
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"
<div className="mb-3">
<PasswordInput
id="register-verify-password"
value={this.state.form.password_verify}
autoComplete="new-password"
onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
maxLength={60}
className="form-control"
required
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
{siteView.local_site.registration_mode == "RequireApplication" && (
{siteView.local_site.registration_mode === "RequireApplication" && (
<>
<div className="mb-3 row">
<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) {
event.preventDefault();
const {

View file

@ -6,6 +6,7 @@ import { HttpService, I18NextService, UserService } from "../../services";
import { RequestState } from "../../services/HttpService";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
interface State {
passwordChangeRes: RequestState<LoginResponse>;
@ -60,38 +61,23 @@ export class PasswordChange extends Component<any, State> {
passwordChangeForm() {
return (
<form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="new-password">
{I18NextService.i18n.t("new_password")}
</label>
<div className="col-sm-10">
<input
<div className="mb-3">
<PasswordInput
id="new-password"
type="password"
value={this.state.form.password}
onInput={linkEvent(this, this.handlePasswordChange)}
className="form-control"
required
maxLength={60}
showStrength
label={I18NextService.i18n.t("new_password")}
/>
</div>
</div>
<div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="verify-password">
{I18NextService.i18n.t("verify_password")}
</label>
<div className="col-sm-10">
<input
id="verify-password"
type="password"
<div className="mb-3">
<PasswordInput
id="password"
value={this.state.form.password_verify}
onInput={linkEvent(this, this.handleVerifyPasswordChange)}
className="form-control"
required
maxLength={60}
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
<div className="mb-3 row">
<div className="col-sm-10">
<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 { ListingTypeSelect } from "../common/listing-type-select";
import { MarkdownTextArea } from "../common/markdown-textarea";
import PasswordInput from "../common/password-input";
import { SearchableSelect } from "../common/searchable-select";
import { SortSelect } from "../common/sort-select";
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>
<form onSubmit={linkEvent(this, this.handleChangePasswordSubmit)}>
<div className="mb-3 row">
<label className="col-sm-5 col-form-label" htmlFor="user-password">
{I18NextService.i18n.t("new_password")}
</label>
<div className="col-sm-7">
<input
type="password"
id="user-password"
className="form-control"
<div className="mb-3">
<PasswordInput
id="new-password"
value={this.state.changePasswordForm.new_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordChange)}
showStrength
label={I18NextService.i18n.t("new_password")}
/>
</div>
</div>
<div className="mb-3 row">
<label
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"
<div className="mb-3">
<PasswordInput
id="verify-new-password"
value={this.state.changePasswordForm.new_password_verify}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleNewPasswordVerifyChange)}
label={I18NextService.i18n.t("verify_password")}
/>
</div>
</div>
<div className="mb-3 row">
<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"
<div className="mb-3">
<PasswordInput
id="user-old-password"
className="form-control"
value={this.state.changePasswordForm.old_password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(this, this.handleOldPasswordChange)}
label={I18NextService.i18n.t("old_password")}
/>
</div>
</div>
<div className="input-group mb-3">
<button
type="submit"
@ -816,8 +788,12 @@ export class Settings extends Component<any, SettingsState> {
</button>
</div>
<hr />
<div className="input-group mb-3">
<form
className="mb-3"
onSubmit={linkEvent(this, this.handleDeleteAccount)}
>
<button
type="button"
className="btn d-block btn-danger"
onClick={linkEvent(
this,
@ -828,24 +804,26 @@ export class Settings extends Component<any, SettingsState> {
</button>
{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")}
</div>
<input
type="password"
</label>
<PasswordInput
id="password-delete-account"
value={this.state.deleteAccountForm.password}
autoComplete="new-password"
maxLength={60}
onInput={linkEvent(
this,
this.handleDeleteAccountPasswordChange
)}
className="form-control my-2"
className="my-2"
/>
<button
type="submit"
className="btn btn-danger me-4"
disabled={!this.state.deleteAccountForm.password}
onClick={linkEvent(this, this.handleDeleteAccount)}
>
{this.state.deleteAccountRes.state === "loading" ? (
<Spinner />
@ -855,6 +833,7 @@ export class Settings extends Component<any, SettingsState> {
</button>
<button
className="btn btn-secondary"
type="button"
onClick={linkEvent(
this,
this.handleDeleteAccountShowConfirmToggle
@ -864,7 +843,7 @@ export class Settings extends Component<any, SettingsState> {
</button>
</>
)}
</div>
</form>
</form>
</>
);
@ -1225,7 +1204,8 @@ export class Settings extends Component<any, SettingsState> {
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;
if (password) {
i.setState({ deleteAccountRes: { state: "loading" } });