From b7ec7ae3110c560968e0cb24a32f1fe9166eec29 Mon Sep 17 00:00:00 2001 From: SleeplessOne1917 <abias1122@gmail.com> Date: Fri, 14 Jul 2023 17:33:24 +0000 Subject: [PATCH] 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 --- lemmy-translations | 2 +- src/assets/symbols.svg | 7 + .../components/common/password-input.tsx | 157 ++++++++++++++++++ src/shared/components/home/login.tsx | 32 +--- src/shared/components/home/setup.tsx | 49 ++---- src/shared/components/home/signup.tsx | 112 ++----------- .../components/person/password-change.tsx | 46 ++--- src/shared/components/person/settings.tsx | 104 +++++------- 8 files changed, 264 insertions(+), 245 deletions(-) create mode 100644 src/shared/components/common/password-input.tsx diff --git a/lemmy-translations b/lemmy-translations index 713ceed..a1a19ae 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit 713ceed9c7ef84deaa222e68361e670e0763cd83 +Subproject commit a1a19aea1ad7d91195775a5ccea62ccc9076a2c7 diff --git a/src/assets/symbols.svg b/src/assets/symbols.svg index 72214ea..6e9c6ef 100644 --- a/src/assets/symbols.svg +++ b/src/assets/symbols.svg @@ -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> diff --git a/src/shared/components/common/password-input.tsx b/src/shared/components/common/password-input.tsx new file mode 100644 index 0000000..47005a0 --- /dev/null +++ b/src/shared/components/common/password-input.tsx @@ -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; diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx index 62e5721..828cbb5 100644 --- a/src/shared/components/home/login.tsx +++ b/src/shared/components/home/login.tsx @@ -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" - id="login-password" - value={this.state.form.password} - onInput={linkEvent(this, this.handleLoginPasswordChange)} - className="form-control" - autoComplete="current-password" - 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 className="mb-3"> + <PasswordInput + id="login-password" + value={this.state.form.password} + onInput={linkEvent(this, this.handleLoginPasswordChange)} + label={I18NextService.i18n.t("password")} + showForgotLink + /> </div> {this.state.showTotp && ( <div className="mb-3 row"> diff --git a/src/shared/components/home/setup.tsx b/src/shared/components/home/setup.tsx index f4bdb55..7b3d4c2 100644 --- a/src/shared/components/home/setup.tsx +++ b/src/shared/components/home/setup.tsx @@ -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,41 +122,21 @@ 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" - id="password" - value={this.state.form.password} - onInput={linkEvent(this, this.handleRegisterPasswordChange)} - className="form-control" - required - autoComplete="new-password" - minLength={10} - maxLength={60} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="password" + value={this.state.form.password} + onInput={linkEvent(this, this.handleRegisterPasswordChange)} + label={I18NextService.i18n.t("password")} + /> </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" - 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} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="verify-password" + value={this.state.form.password_verify} + onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} + label={I18NextService.i18n.t("verify_password")} + /> </div> <div className="mb-3 row"> <div className="col-sm-10"> diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index bb1e1f1..c57d545 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -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" - id="register-password" - value={this.state.form.password} - autoComplete="new-password" - onInput={linkEvent(this, this.handleRegisterPasswordChange)} - minLength={10} - maxLength={60} - className="form-control" - required - /> - {this.state.form.password && ( - <div className={this.passwordColorClass}> - {I18NextService.i18n.t( - this.passwordStrength as NoOptionI18nKeys - )} - </div> - )} - </div> + <div className="mb-3"> + <PasswordInput + id="register-password" + value={this.state.form.password} + onInput={linkEvent(this, this.handleRegisterPasswordChange)} + showStrength + label={I18NextService.i18n.t("password")} + /> </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" - id="register-verify-password" - value={this.state.form.password_verify} - autoComplete="new-password" - onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} - maxLength={60} - className="form-control" - required - /> - </div> + <div className="mb-3"> + <PasswordInput + id="register-verify-password" + value={this.state.form.password_verify} + onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)} + label={I18NextService.i18n.t("verify_password")} + /> </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 { diff --git a/src/shared/components/person/password-change.tsx b/src/shared/components/person/password-change.tsx index 565f55e..1a60c96 100644 --- a/src/shared/components/person/password-change.tsx +++ b/src/shared/components/person/password-change.tsx @@ -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,37 +61,22 @@ 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 - id="new-password" - type="password" - value={this.state.form.password} - onInput={linkEvent(this, this.handlePasswordChange)} - className="form-control" - required - maxLength={60} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="new-password" + value={this.state.form.password} + onInput={linkEvent(this, this.handlePasswordChange)} + showStrength + label={I18NextService.i18n.t("new_password")} + /> </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" - value={this.state.form.password_verify} - onInput={linkEvent(this, this.handleVerifyPasswordChange)} - className="form-control" - required - maxLength={60} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="password" + value={this.state.form.password_verify} + onInput={linkEvent(this, this.handleVerifyPasswordChange)} + label={I18NextService.i18n.t("verify_password")} + /> </div> <div className="mb-3 row"> <div className="col-sm-10"> diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index 2858efc..d024aae 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -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,59 +319,30 @@ 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" - value={this.state.changePasswordForm.new_password} - autoComplete="new-password" - maxLength={60} - onInput={linkEvent(this, this.handleNewPasswordChange)} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="new-password" + value={this.state.changePasswordForm.new_password} + onInput={linkEvent(this, this.handleNewPasswordChange)} + showStrength + label={I18NextService.i18n.t("new_password")} + /> </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" - value={this.state.changePasswordForm.new_password_verify} - autoComplete="new-password" - maxLength={60} - onInput={linkEvent(this, this.handleNewPasswordVerifyChange)} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="verify-new-password" + value={this.state.changePasswordForm.new_password_verify} + onInput={linkEvent(this, this.handleNewPasswordVerifyChange)} + label={I18NextService.i18n.t("verify_password")} + /> </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" - id="user-old-password" - className="form-control" - value={this.state.changePasswordForm.old_password} - autoComplete="new-password" - maxLength={60} - onInput={linkEvent(this, this.handleOldPasswordChange)} - /> - </div> + <div className="mb-3"> + <PasswordInput + id="user-old-password" + value={this.state.changePasswordForm.old_password} + onInput={linkEvent(this, this.handleOldPasswordChange)} + label={I18NextService.i18n.t("old_password")} + /> </div> <div className="input-group mb-3"> <button @@ -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" } }); -- 2.44.1