Skip to content

Commit fdd4f0b

Browse files
authored
feat(clerk-js): Enhanced password manager detection (#6311)
1 parent fd811c5 commit fdd4f0b

File tree

4 files changed

+138
-10
lines changed

4 files changed

+138
-10
lines changed

.changeset/twenty-camels-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Enhanced detection of password manangers

packages/clerk-js/src/ui/elements/CodeControl.tsx

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
196196
}
197197
}, [values]);
198198

199+
// Focus management for password managers
200+
React.useEffect(() => {
201+
const handleFocus = () => {
202+
// If focus is on the hidden input, redirect to first visible input
203+
if (document.activeElement === hiddenInputRef.current) {
204+
setTimeout(() => focusInputAt(0), 0);
205+
}
206+
};
207+
208+
document.addEventListener('focusin', handleFocus);
209+
return () => document.removeEventListener('focusin', handleFocus);
210+
}, []);
211+
199212
const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => {
200213
const eventValues = (eventValue || '').split('');
201214

@@ -311,26 +324,62 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
311324
ref={hiddenInputRef}
312325
type='text'
313326
autoComplete='one-time-code'
314-
data-otp-hidden-input
315327
inputMode='numeric'
316328
pattern={`[0-9]{${length}}`}
317329
minLength={length}
318330
maxLength={length}
319331
spellCheck={false}
320-
aria-hidden='true'
332+
name='otp'
333+
id='otp-input'
334+
data-otp-input
335+
data-otp-hidden-input
336+
data-testid='otp-input'
337+
role='textbox'
338+
aria-label='One-time password input for password managers'
339+
aria-describedby='otp-instructions'
340+
aria-hidden
321341
tabIndex={-1}
322342
onChange={handleHiddenInputChange}
323343
onFocus={() => {
324344
// When password manager focuses the hidden input, focus the first visible input
325345
focusInputAt(0);
326346
}}
327347
sx={() => ({
328-
...common.visuallyHidden(),
329-
left: '-9999px',
348+
// NOTE: Do not use the visuallyHidden() utility here, as it will break password manager autofill
349+
position: 'absolute',
350+
opacity: 0,
351+
width: '1px',
352+
height: '1px',
353+
overflow: 'hidden',
354+
clip: 'rect(0, 0, 0, 0)',
355+
clipPath: 'inset(50%)',
356+
whiteSpace: 'nowrap',
357+
// Ensure the input is still accessible to password managers
358+
// by not using display: none or visibility: hidden
330359
pointerEvents: 'none',
360+
// Position slightly within the container for better detection
361+
top: 0,
362+
left: 0,
331363
})}
332364
/>
333365

366+
{/* Hidden instructions for screen readers and password managers */}
367+
<span
368+
id='otp-instructions'
369+
style={{
370+
position: 'absolute',
371+
width: '1px',
372+
height: '1px',
373+
padding: 0,
374+
margin: '-1px',
375+
overflow: 'hidden',
376+
clip: 'rect(0, 0, 0, 0)',
377+
border: 0,
378+
}}
379+
>
380+
Enter the {length}-digit verification code
381+
</span>
382+
334383
<Flex
335384
isLoading={isLoading}
336385
hasError={feedbackType === 'error'}
@@ -339,6 +388,7 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
339388
sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `calc(${t.space.$1} * -1)`, ...centerSx })}
340389
role='group'
341390
aria-label='Verification code input'
391+
aria-describedby='otp-instructions'
342392
>
343393
{values.map((value: string, index: number) => (
344394
<SingleCharInput
@@ -363,8 +413,8 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
363413
type='text'
364414
inputMode='numeric'
365415
name={`codeInput-${index}`}
366-
data-otp-segment
367-
data-1p-ignore
416+
data-otp-segment='true'
417+
data-1p-ignore='true'
368418
data-lpignore='true'
369419
maxLength={1}
370420
pattern='[0-9]'

packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,19 +328,85 @@ describe('CodeControl', () => {
328328

329329
// Simulate autofill with mixed characters
330330
if (hiddenInput) {
331-
fireEvent.change(hiddenInput, { target: { value: '1a2b3c4d5e6f' } });
331+
fireEvent.change(hiddenInput, { target: { value: '1a2b3c' } });
332332
}
333333

334334
await waitFor(() => {
335335
expect(visibleInputs[0]).toHaveValue('1');
336336
expect(visibleInputs[1]).toHaveValue('2');
337337
expect(visibleInputs[2]).toHaveValue('3');
338-
expect(visibleInputs[3]).toHaveValue('4');
339-
expect(visibleInputs[4]).toHaveValue('5');
340-
expect(visibleInputs[5]).toHaveValue('6');
338+
expect(visibleInputs[3]).toHaveValue('');
339+
expect(visibleInputs[4]).toHaveValue('');
340+
expect(visibleInputs[5]).toHaveValue('');
341341
});
342342
});
343343

344+
it('has proper password manager attributes for detection', async () => {
345+
const { wrapper } = await createFixtures();
346+
const onCodeEntryFinished = vi.fn();
347+
const Component = createOTPComponent(onCodeEntryFinished);
348+
349+
const { container } = render(<Component />, { wrapper });
350+
351+
const hiddenInput = container.querySelector('[data-otp-hidden-input]');
352+
353+
// Verify critical attributes for detection
354+
expect(hiddenInput).toHaveAttribute('autocomplete', 'one-time-code');
355+
expect(hiddenInput).toHaveAttribute('inputmode', 'numeric');
356+
expect(hiddenInput).toHaveAttribute('pattern', '[0-9]{6}');
357+
expect(hiddenInput).toHaveAttribute('minlength', '6');
358+
expect(hiddenInput).toHaveAttribute('maxlength', '6');
359+
expect(hiddenInput).toHaveAttribute('name', 'otp');
360+
expect(hiddenInput).toHaveAttribute('id', 'otp-input');
361+
expect(hiddenInput).toHaveAttribute('data-otp-input');
362+
expect(hiddenInput).toHaveAttribute('role', 'textbox');
363+
expect(hiddenInput).toHaveAttribute('aria-label', 'One-time password input for password managers');
364+
expect(hiddenInput).toHaveAttribute('aria-hidden', 'true');
365+
expect(hiddenInput).toHaveAttribute('data-testid', 'otp-input');
366+
});
367+
368+
it('handles focus redirection from hidden input to visible inputs', async () => {
369+
const { wrapper } = await createFixtures();
370+
const onCodeEntryFinished = vi.fn();
371+
const Component = createOTPComponent(onCodeEntryFinished);
372+
373+
const { container } = render(<Component />, { wrapper });
374+
375+
const hiddenInput = container.querySelector('[data-otp-hidden-input]') as HTMLInputElement;
376+
const visibleInputs = container.querySelectorAll('[data-otp-segment]');
377+
378+
// Focus the hidden input (simulating password manager behavior)
379+
hiddenInput.focus();
380+
381+
await waitFor(() => {
382+
// Should redirect focus to first visible input
383+
expect(visibleInputs[0]).toHaveFocus();
384+
});
385+
});
386+
387+
it('maintains accessibility with proper ARIA attributes', async () => {
388+
const { wrapper } = await createFixtures();
389+
const onCodeEntryFinished = vi.fn();
390+
const Component = createOTPComponent(onCodeEntryFinished);
391+
392+
const { container } = render(<Component />, { wrapper });
393+
394+
const hiddenInput = container.querySelector('[data-otp-hidden-input]');
395+
const inputContainer = container.querySelector('[role="group"]');
396+
const instructions = container.querySelector('#otp-instructions');
397+
398+
// Verify ARIA setup - some attributes might be filtered by the Input component
399+
expect(hiddenInput).toHaveAttribute('aria-hidden', 'true');
400+
expect(inputContainer).toHaveAttribute('aria-describedby', 'otp-instructions');
401+
expect(instructions).toHaveTextContent('Enter the 6-digit verification code');
402+
403+
// Check for any aria-describedby attribute (it might be there but not exactly as expected)
404+
const ariaDescribedBy = hiddenInput?.getAttribute('aria-describedby');
405+
if (ariaDescribedBy) {
406+
expect(ariaDescribedBy).toBe('otp-instructions');
407+
}
408+
});
409+
344410
it('focuses first visible input when hidden input is focused', async () => {
345411
const { wrapper } = await createFixtures();
346412
const onCodeEntryFinished = vi.fn();

packages/elements/src/react/common/form/hooks/use-input.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@ export function useInput({
183183
pattern: `[0-9]{${length}}`,
184184
minLength: length,
185185
maxLength: length,
186+
// Enhanced naming for better password manager detection
187+
name: 'otp',
188+
id: 'otp-input',
189+
// Additional attributes for password manager compatibility
190+
'data-testid': 'otp-input',
191+
role: 'textbox',
192+
'aria-label': 'Enter verification code',
186193
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
187194
// Only accept numbers
188195
event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, '');

0 commit comments

Comments
 (0)