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:
parent
91929477ab
commit
58254fcede
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
Loading…
Reference in New Issue