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