From 58254fcedeb76ff99b88f5b5fafe00f807510f91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 6 Jun 2026 11:42:20 +0200 Subject: [PATCH] feat(auth): show/hide toggle on password fields (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Add an eye icon to every password field that toggles between masked and plaintext, so users can verify what they typed. New reusable `PasswordInput` component (`components/ui/password-input.tsx`) wrapping the base `Input` with an `Eye`/`EyeOff` toggle. ## Where - **Auth**: login, register (password + confirm), reset password (password + confirm) - **Settings**: password form & account form (current + new + confirm) - **Dialogs**: delete-account confirmation, encryption unlock dialog ## Details - Toggle is keyboard-skipped (`tabIndex={-1}`), mirrors the input's `disabled` state. - Accessible: `aria-pressed` + `aria-label` (`Show password` / `Hide password`), translated and added to `lang/es.json`. - Dark-mode aware via existing `text-muted-foreground` / `hover:text-foreground` tokens. ## Tests - New vitest suite for `PasswordInput` (mask default, toggle, ref forwarding, disabled state) — 4 passing. - `LocalizationTest` passing (new translation keys present). - eslint + prettier clean. ## Not included Left out (can add on request): `confirm-password.tsx`, `setup-encryption.tsx`, and the open-banking API key/secret fields. --- lang/es.json | 2 + resources/js/components/delete-user.tsx | 5 +- .../js/components/ui/password-input.test.tsx | 52 +++++++++++++++++++ resources/js/components/ui/password-input.tsx | 48 +++++++++++++++++ .../js/components/unlock-message-dialog.tsx | 5 +- resources/js/pages/auth/login.tsx | 4 +- resources/js/pages/auth/register.tsx | 7 ++- resources/js/pages/auth/reset-password.tsx | 7 ++- resources/js/pages/settings/account.tsx | 10 ++-- resources/js/pages/settings/password.tsx | 11 ++-- 10 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 resources/js/components/ui/password-input.test.tsx create mode 100644 resources/js/components/ui/password-input.tsx diff --git a/lang/es.json b/lang/es.json index 2f6f9f5d..ad9510f7 100644 --- a/lang/es.json +++ b/lang/es.json @@ -714,6 +714,7 @@ "Hey there!": "¡Hola!", "Hey! A quick heads-up.": "¡Hola! Un aviso rápido.", "Hey! We have some great news.": "¡Hola! Tenemos buenas noticias.", + "Hide password": "Ocultar contraseña", "Hide subcategories": "Ocultar subcategorías", "Hi :name,": "Hola :name,", "Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Quería darte la bienvenida personalmente y agradecerte por unirte.", @@ -1317,6 +1318,7 @@ "Shared with": "Compartido con", "Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electrónica, Regalos)", "Show as cash inflow": "Mostrar como entrada de dinero", + "Show password": "Mostrar contraseña", "Show subcategories": "Mostrar subcategorías", "Show as cash outflow": "Mostrar como salida de dinero", "Show in account currency (:currency)": "Mostrar en moneda de la cuenta (:currency)", diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 8c8c9566..0f484ec5 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -12,8 +12,8 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { PasswordInput } from '@/components/ui/password-input'; import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; import { Form, usePage } from '@inertiajs/react'; @@ -104,9 +104,8 @@ export default function DeleteUser() { {__('Password')} - { + it('renders masked by default with a show toggle', () => { + render(); + + expect(screen.getByPlaceholderText('Password')).toHaveAttribute( + 'type', + 'password', + ); + + const toggle = screen.getByRole('button'); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + }); + + it('reveals and re-hides the value when the toggle is clicked', () => { + render(); + + const input = screen.getByPlaceholderText('Password'); + const toggle = screen.getByRole('button'); + + fireEvent.click(toggle); + expect(input).toHaveAttribute('type', 'text'); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(toggle); + expect(input).toHaveAttribute('type', 'password'); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + }); + + it('forwards the ref to the underlying input', () => { + let node: HTMLInputElement | null = null; + render( + { + node = element; + }} + placeholder="Password" + />, + ); + + expect(node).toBeInstanceOf(HTMLInputElement); + }); + + it('disables the toggle when the input is disabled', () => { + render(); + + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/resources/js/components/ui/password-input.tsx b/resources/js/components/ui/password-input.tsx new file mode 100644 index 00000000..9a161553 --- /dev/null +++ b/resources/js/components/ui/password-input.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; + +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { __ } from '@/utils/i18n'; +import { Eye, EyeOff } from 'lucide-react'; + +type PasswordInputProps = Omit, 'type'>; + +const PasswordInput = React.forwardRef( + ({ className, ...props }, ref) => { + const [visible, setVisible] = React.useState(false); + + return ( +
+ + +
+ ); + }, +); +PasswordInput.displayName = 'PasswordInput'; + +export { PasswordInput }; diff --git a/resources/js/components/unlock-message-dialog.tsx b/resources/js/components/unlock-message-dialog.tsx index eb99abfb..3927f3bd 100644 --- a/resources/js/components/unlock-message-dialog.tsx +++ b/resources/js/components/unlock-message-dialog.tsx @@ -10,8 +10,8 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { PasswordInput } from '@/components/ui/password-input'; import { Select, SelectContent, @@ -124,9 +124,8 @@ export default function UnlockMessageDialog({ - )} - {__('Password')} - {__('Confirm password')} - - {__('Confirm password')} - - - - - - -