feat(auth): show/hide toggle on password fields (#499)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-06 11:42:20 +02:00 committed by GitHub
parent 91929477ab
commit 58254fcede
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 122 additions and 29 deletions

View File

@ -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)",

View File

@ -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')}
</Label>
<Input
<PasswordInput
id="password"
type="password"
name="password"
ref={passwordInput}
placeholder={__('Password')}

View File

@ -0,0 +1,52 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { PasswordInput } from './password-input';
describe('PasswordInput', () => {
it('renders masked by default with a show toggle', () => {
render(<PasswordInput placeholder="Password" />);
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(<PasswordInput placeholder="Password" />);
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(
<PasswordInput
ref={(element) => {
node = element;
}}
placeholder="Password"
/>,
);
expect(node).toBeInstanceOf(HTMLInputElement);
});
it('disables the toggle when the input is disabled', () => {
render(<PasswordInput placeholder="Password" disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});
});

View File

@ -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<React.ComponentProps<'input'>, 'type'>;
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
({ className, ...props }, ref) => {
const [visible, setVisible] = React.useState(false);
return (
<div className="relative">
<Input
ref={ref}
type={visible ? 'text' : 'password'}
className={cn(
'pr-10 [&::-ms-reveal]:hidden [&::-ms-clear]:hidden',
className,
)}
{...props}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
disabled={props.disabled}
tabIndex={-1}
aria-label={
visible ? __('Hide password') : __('Show password')
}
aria-pressed={visible}
className="absolute inset-y-0 right-0 flex items-center justify-center px-3 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{visible ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
);
},
);
PasswordInput.displayName = 'PasswordInput';
export { PasswordInput };

View File

@ -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({
<Label htmlFor="unlock-password">
{__('Encryption Password')}
</Label>
<Input
<PasswordInput
id="unlock-password"
type="password"
required
autoFocus
value={password}

View File

@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordInput } from '@/components/ui/password-input';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
import { clearKey } from '@/lib/key-storage';
@ -95,9 +96,8 @@ export default function Login({
</TextLink>
)}
</div>
<Input
<PasswordInput
id="password"
type="password"
name="password"
required
tabIndex={2}

View File

@ -9,6 +9,7 @@ import TextLink from '@/components/text-link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordInput } from '@/components/ui/password-input';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
import { transactionSyncService } from '@/services/transaction-sync';
@ -113,9 +114,8 @@ export default function Register({
<Label htmlFor="password">
{__('Password')}
</Label>
<Input
<PasswordInput
id="password"
type="password"
required
tabIndex={3}
autoComplete="new-password"
@ -130,9 +130,8 @@ export default function Register({
<Label htmlFor="password_confirmation">
{__('Confirm password')}
</Label>
<Input
<PasswordInput
id="password_confirmation"
type="password"
required
tabIndex={4}
autoComplete="new-password"

View File

@ -6,6 +6,7 @@ import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordInput } from '@/components/ui/password-input';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/auth-layout';
@ -49,9 +50,8 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
<div className="grid gap-2">
<Label htmlFor="password">{__('Password')}</Label>
<Input
<PasswordInput
id="password"
type="password"
name="password"
autoComplete="new-password"
className="mt-1 block w-full"
@ -66,9 +66,8 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
<Label htmlFor="password_confirmation">
{__('Confirm password')}
</Label>
<Input
<PasswordInput
id="password_confirmation"
type="password"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"

View File

@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordInput } from '@/components/ui/password-input';
import {
Select,
SelectContent,
@ -344,11 +345,10 @@ export default function Account({
{__('Current password')}
</Label>
<Input
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder={__('Current password')}
@ -364,11 +364,10 @@ export default function Account({
{__('New password')}
</Label>
<Input
<PasswordInput
id="password"
ref={passwordInput}
name="password"
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder={__('New password')}
@ -382,10 +381,9 @@ export default function Account({
{__('Confirm password')}
</Label>
<Input
<PasswordInput
id="password_confirmation"
name="password_confirmation"
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder={__('Confirm password')}

View File

@ -11,8 +11,8 @@ import { useRef } from 'react';
import HeadingSmall from '@/components/heading-small';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordInput } from '@/components/ui/password-input';
import { edit } from '@/routes/user-password';
import { InfoIcon } from 'lucide-react';
@ -82,11 +82,10 @@ export default function Password() {
{__('Current password')}
</Label>
<Input
<PasswordInput
id="current_password"
ref={currentPasswordInput}
name="current_password"
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder={__('Current password')}
@ -102,11 +101,10 @@ export default function Password() {
{__('New password')}
</Label>
<Input
<PasswordInput
id="password"
ref={passwordInput}
name="password"
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder={__('New password')}
@ -120,10 +118,9 @@ export default function Password() {
{__('Confirm password')}
</Label>
<Input
<PasswordInput
id="password_confirmation"
name="password_confirmation"
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder={__('Confirm password')}