Skip to content

Commit 6b4b27e

Browse files
feat(clerk-js): Password manager autofill OTP codes (#6247)
Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent f42c4fe commit 6b4b27e

File tree

8 files changed

+695
-39
lines changed

8 files changed

+695
-39
lines changed

.changeset/polite-pants-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
---
4+
5+
Password managers will now autofill OTP code verifications.

integration/templates/elements-next/src/app/otp/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ export default function OTP() {
8787
className='segmented-otp-with-props-wrapper flex justify-center has-[:disabled]:opacity-50'
8888
type='otp'
8989
data-testid='segmented-otp-with-props'
90-
passwordManagerOffset={4}
9190
length={4}
9291
render={({ value, status }) => {
9392
return (

integration/tests/elements/otp.test.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('OTP @elem
221221
// Check that only 4 segments are rendered
222222
await expect(otpSegmentsWrapper.locator('> div')).toHaveCount(4);
223223
});
224-
225-
test('passwordManagerOffset', async ({ page }) => {
226-
const otp = page.getByTestId(otpTypes.segmentedOtpWithProps);
227-
228-
// The computed styles are different on CI/local etc. so it's not use to check the exact value
229-
await expect(otp).toHaveCSS('clip-path', /inset\(0px \d+\.\d+px 0px 0px\)/i);
230-
});
231224
});
232225
});

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "55KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "111.8KB" },
8+
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "112.1KB" },
89
{ "path": "./dist/vendors*.js", "maxSize": "40.2KB" },
910
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
1011
{ "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" },

packages/clerk-js/src/ui/customizables/elementDescriptors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
9898
'otpCodeField',
9999
'otpCodeFieldInputs',
100100
'otpCodeFieldInput',
101+
'otpCodeFieldInputContainer',
101102
'otpCodeFieldErrorText',
102103
'formResendCodeLink',
103104

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

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { PropsWithChildren } from 'react';
33
import React, { useCallback } from 'react';
44

55
import type { LocalizationKey } from '../customizables';
6-
import { descriptors, Flex, Input } from '../customizables';
6+
import { Box, descriptors, Flex, Input } from '../customizables';
77
import { useCardState } from '../elements/contexts';
88
import { useLoadingStatus } from '../hooks';
99
import type { PropsOfComponent } from '../styledSystem';
@@ -160,6 +160,7 @@ export const OTPResendButton = () => {
160160
export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
161161
const [disabled, setDisabled] = React.useState(false);
162162
const refs = React.useRef<Array<HTMLInputElement | null>>([]);
163+
const hiddenInputRef = React.useRef<HTMLInputElement>(null);
163164
const firstClickRef = React.useRef(false);
164165

165166
const { otpControl, isLoading, isDisabled, centerAlign = true } = useOTPInputContext();
@@ -169,6 +170,11 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
169170
reset: () => {
170171
setValues(values.map(() => ''));
171172
setDisabled(false);
173+
174+
if (hiddenInputRef.current) {
175+
hiddenInputRef.current.value = '';
176+
}
177+
172178
setTimeout(() => focusInputAt(0), 0);
173179
},
174180
}));
@@ -183,6 +189,13 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
183189
}
184190
}, [feedback]);
185191

192+
// Update hidden input when values change
193+
React.useEffect(() => {
194+
if (hiddenInputRef.current) {
195+
hiddenInputRef.current.value = values.join('');
196+
}
197+
}, [values]);
198+
186199
const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => {
187200
const eventValues = (eventValue || '').split('');
188201

@@ -274,40 +287,91 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => {
274287
}
275288
};
276289

290+
// Handle hidden input changes (for password manager autofill)
291+
const handleHiddenInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
292+
const value = e.target.value.replace(/\D/g, '').slice(0, length);
293+
const newValues = value.split('').concat(Array.from({ length: length - value.length }, () => ''));
294+
setValues(newValues);
295+
296+
// Focus the appropriate visible input
297+
if (value.length > 0) {
298+
focusInputAt(Math.min(value.length - 1, length - 1));
299+
}
300+
};
301+
277302
const centerSx = centerAlign ? { justifyContent: 'center', alignItems: 'center' } : {};
278303

279304
return (
280-
<Flex
281-
isLoading={isLoading}
282-
hasError={feedbackType === 'error'}
283-
elementDescriptor={descriptors.otpCodeFieldInputs}
284-
gap={2}
285-
sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })}
305+
<Box
306+
elementDescriptor={descriptors.otpCodeFieldInputContainer}
307+
sx={{ position: 'relative' }}
286308
>
287-
{values.map((value, index: number) => (
288-
<SingleCharInput
289-
elementDescriptor={descriptors.otpCodeFieldInput}
290-
key={index}
291-
value={value}
292-
onClick={handleOnClick(index)}
293-
onChange={handleOnChange(index)}
294-
onKeyDown={handleOnKeyDown(index)}
295-
onInput={handleOnInput(index)}
296-
onPaste={handleOnPaste(index)}
297-
id={`digit-${index}-field`}
298-
ref={node => (refs.current[index] = node)}
299-
autoFocus={index === 0 || undefined}
300-
autoComplete='one-time-code'
301-
aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`}
302-
isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'}
303-
hasError={feedbackType === 'error'}
304-
isSuccessfullyFilled={feedbackType === 'success'}
305-
type='text'
306-
inputMode='numeric'
307-
name={`codeInput-${index}`}
308-
/>
309-
))}
310-
</Flex>
309+
{/* Hidden input for password manager compatibility */}
310+
<Input
311+
ref={hiddenInputRef}
312+
type='text'
313+
autoComplete='one-time-code'
314+
data-otp-hidden-input
315+
inputMode='numeric'
316+
pattern={`[0-9]{${length}}`}
317+
minLength={length}
318+
maxLength={length}
319+
spellCheck={false}
320+
aria-hidden='true'
321+
tabIndex={-1}
322+
onChange={handleHiddenInputChange}
323+
onFocus={() => {
324+
// When password manager focuses the hidden input, focus the first visible input
325+
focusInputAt(0);
326+
}}
327+
sx={() => ({
328+
...common.visuallyHidden(),
329+
left: '-9999px',
330+
pointerEvents: 'none',
331+
})}
332+
/>
333+
334+
<Flex
335+
isLoading={isLoading}
336+
hasError={feedbackType === 'error'}
337+
elementDescriptor={descriptors.otpCodeFieldInputs}
338+
gap={2}
339+
sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })}
340+
role='group'
341+
aria-label='Verification code input'
342+
>
343+
{values.map((value: string, index: number) => (
344+
<SingleCharInput
345+
elementDescriptor={descriptors.otpCodeFieldInput}
346+
// eslint-disable-next-line react/no-array-index-key
347+
key={index}
348+
value={value}
349+
onClick={handleOnClick(index)}
350+
onChange={handleOnChange(index)}
351+
onKeyDown={handleOnKeyDown(index)}
352+
onInput={handleOnInput(index)}
353+
onPaste={handleOnPaste(index)}
354+
id={`digit-${index}-field`}
355+
ref={node => (refs.current[index] = node)}
356+
// eslint-disable-next-line jsx-a11y/no-autofocus
357+
autoFocus={index === 0 || undefined}
358+
autoComplete='off'
359+
aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`}
360+
isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'}
361+
hasError={feedbackType === 'error'}
362+
isSuccessfullyFilled={feedbackType === 'success'}
363+
type='text'
364+
inputMode='numeric'
365+
name={`codeInput-${index}`}
366+
data-otp-segment
367+
data-1p-ignore
368+
data-lpignore='true'
369+
maxLength={1}
370+
pattern='[0-9]'
371+
/>
372+
))}
373+
</Flex>
374+
</Box>
311375
);
312376
});
313377

0 commit comments

Comments
 (0)