From 69c4dd55b0879e9e330062b9295c3880438efd9b Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Sat, 18 Jul 2026 15:48:36 +0200 Subject: [PATCH] feat: add option to show passwords as plain text (@fehmer) --- .../components/ui/form/InputField.spec.tsx | 103 ++++++++++++++++++ .../src/ts/components/ui/form/InputField.tsx | 37 ++++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/frontend/__tests__/components/ui/form/InputField.spec.tsx b/frontend/__tests__/components/ui/form/InputField.spec.tsx index 95f269accc50..03b7d6f20519 100644 --- a/frontend/__tests__/components/ui/form/InputField.spec.tsx +++ b/frontend/__tests__/components/ui/form/InputField.spec.tsx @@ -173,4 +173,107 @@ describe("InputField", () => { fireEvent.input(input, { target: { value: "6" } }); expect(field.handleChange).toHaveBeenCalledWith(6); }); + + it("renders password toggle button when type is password", () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + const toggleButton = container.querySelector( + 'button[type="button"]', + ) as HTMLButtonElement; + expect(toggleButton).toBeInTheDocument(); + }); + + it("password field starts with type=password (masked)", () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + expect(container.querySelector("input")).toHaveAttribute( + "type", + "password", + ); + }); + + it("toggling password button switches input type to text", async () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + const toggleButton = container.querySelector( + 'button[type="button"]', + ) as HTMLButtonElement; + expect(container.querySelector("input")).toHaveAttribute( + "type", + "password", + ); + + fireEvent.click(toggleButton); + expect(container.querySelector("input")).toHaveAttribute("type", "text"); + }); + + it("toggling password button a second time switches back to password", async () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + const toggleButton = container.querySelector( + 'button[type="button"]', + ) as HTMLButtonElement; + + // First toggle: show + fireEvent.click(toggleButton); + expect(container.querySelector("input")).toHaveAttribute("type", "text"); + + // Second toggle: hide + fireEvent.click(toggleButton); + expect(container.querySelector("input")).toHaveAttribute( + "type", + "password", + ); + }); + + it("does not render password toggle when type is not password", () => { + const field = makeField("name"); + const { container } = render(() => ( + field} type="text" /> + )); + + const toggleButton = container.querySelector('button[type="button"]'); + expect(toggleButton).not.toBeInTheDocument(); + }); + + it("shows eye icon when password is hidden (masked)", () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + expect( + container.querySelector(".fa-eye")?.classList.contains("fa-eye"), + ).toBe(true); + }); + + it("shows eye-slash icon when password is visible", async () => { + const field = makeField("password"); + const { container } = render(() => ( + field} type="password" /> + )); + + const toggleButton = container.querySelector( + 'button[type="button"]', + ) as HTMLButtonElement; + fireEvent.click(toggleButton); + + expect( + container + .querySelector(".fa-eye-slash") + ?.classList.contains("fa-eye-slash"), + ).toBe(true); + }); }); diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index 75074d3b5071..6a36784576ec 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -5,6 +5,7 @@ import { ZodDate, ZodFirstPartyTypeKind, ZodNumber, ZodTypeAny } from "zod"; import { cn } from "../../../utils/cn"; import { getZodType, unwrapSchema } from "../../../utils/zod"; +import { Button } from "../../common/Button"; import { FieldIndicator } from "./FieldIndicator"; export function InputField(props: { @@ -30,6 +31,9 @@ export function InputField(props: { alwaysShowFieldIndicator?: boolean; }): JSXElement { const [shake, setShake] = createSignal(false); + const [showPassword, setShowPassword] = createSignal(false); + const isPasswordType = () => props.type === "password"; + const hasValidators = () => !!props.field().options.validators; const shakeItIfYouWantIt = () => { if ( @@ -62,10 +66,18 @@ export function InputField(props: { "rounded border-none bg-sub-alt p-[0.5em] text-em-base leading-[1.25em] caret-main outline-none", "focus-visible:shadow-[0_0_0_0.1rem_var(--bg-color),0_0_0_0.2rem_var(--text-color)]", "autofill-fix", - props.field().options.validators ? "pr-[1.85em]" : "", + isPasswordType() || props.field().options.validators + ? "pr-[1.85em]" + : "", props.class, )} - type={props.type ?? "text"} + type={ + isPasswordType() + ? showPassword() + ? "text" + : "password" + : (props.type ?? "text") + } placeholder={props.placeholder ?? ""} autocomplete={props.autocomplete} name={props.field().name as string} @@ -110,6 +122,27 @@ export function InputField(props: { max={props.max} step={props.step?.toString()} /> + +