diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index 90da15c56..122c508c7 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -41,7 +41,12 @@ export function LoginForm() { umami -
+ {lockMessage} )} - + {!useBackup ? ( {t(labels.twoFactorEnterCode)} diff --git a/src/components/common/OtpInput.test.tsx b/src/components/common/OtpInput.test.tsx new file mode 100644 index 000000000..56c9ba9ff --- /dev/null +++ b/src/components/common/OtpInput.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; +import { render, screen } from '@/test/render'; +import { OtpInput } from './OtpInput'; + +function getDigitInput(index: number) { + return screen.getByLabelText(`Digit ${index + 1}`) as HTMLInputElement; +} + +test('typing individual digits fills fields in order and advances focus', async () => { + const onChange = vi.fn(); + const { user } = render(); + + await user.click(getDigitInput(0)); + await user.keyboard('1'); + + expect(onChange).toHaveBeenLastCalledWith('1'); +}); + +test('a real paste event distributes the code across all fields and calls onComplete', async () => { + const onChange = vi.fn(); + const onComplete = vi.fn(); + const { user } = render(); + + await user.click(getDigitInput(0)); + await user.paste('123456'); + + expect(onChange).toHaveBeenLastCalledWith('123456'); + expect(onComplete).toHaveBeenCalledWith('123456'); +}); + +test('a multi-character value delivered via onChange (mobile paste fallback) fills all fields', () => { + const onChange = vi.fn(); + const onComplete = vi.fn(); + render(); + + const input = getDigitInput(0); + fireEvent.change(input, { target: { value: '123456' } }); + + expect(onChange).toHaveBeenLastCalledWith('123456'); + expect(onComplete).toHaveBeenCalledWith('123456'); +}); diff --git a/src/components/common/OtpInput.tsx b/src/components/common/OtpInput.tsx index 1a6a8b175..b1cb9e447 100644 --- a/src/components/common/OtpInput.tsx +++ b/src/components/common/OtpInput.tsx @@ -27,8 +27,28 @@ export function OtpInput({ value, onChange, onComplete, disabled }: OtpInputProp } }; + const applyPastedValue = (digitsStr: string) => { + const pasted = digitsStr.replace(/\D/g, '').slice(0, LENGTH); + const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] ?? ''); + const newVal = next.join(''); + onChange(newVal); + if (newVal.length === LENGTH) { + onComplete?.(newVal); + focusInput(refs.current[LENGTH - 1]); + } else { + focusInput(refs.current[pasted.length]); + } + }; + const handleInput = (index: number, raw: string) => { - const char = raw.replace(/\D/g, '').slice(-1); + const digitsOnly = raw.replace(/\D/g, ''); + + if (digitsOnly.length > 1) { + applyPastedValue(digitsOnly); + return; + } + + const char = digitsOnly; if (!char) return; update(index, char); if (index < LENGTH - 1) { @@ -56,16 +76,7 @@ export function OtpInput({ value, onChange, onComplete, disabled }: OtpInputProp const handlePaste = (e: ClipboardEvent) => { e.preventDefault(); - const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH); - const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] ?? ''); - const newVal = next.join(''); - onChange(newVal); - if (newVal.length === LENGTH) { - onComplete?.(newVal); - focusInput(refs.current[LENGTH - 1]); - } else { - focusInput(refs.current[pasted.length]); - } + applyPastedValue(e.clipboardData.getData('text')); }; return (