Merge pull request #2953 from leofilmon/feat/password-manager-compatible-otp-input

feat: add password manager compatible OTP input component
This commit is contained in:
Mauricio Siu
2026-04-03 17:05:49 -06:00
committed by GitHub
3 changed files with 85 additions and 91 deletions

View File

@@ -25,11 +25,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { InputOTP } from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
@@ -423,23 +419,14 @@ export const Enable2FA = () => {
)}
</div>
<div className="flex flex-col justify-center items-center">
<div className="flex flex-col gap-2">
<FormLabel>Verification Code</FormLabel>
<InputOTP
maxLength={6}
value={otpValue}
onChange={setOtpValue}
autoComplete="off"
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
autoFocus
/>
<FormDescription>
Enter the 6-digit code from your authenticator app
</FormDescription>

View File

@@ -1,70 +1,87 @@
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
HTMLInputElement,
Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
value: string;
onChange: (value: string) => void;
maxLength: number;
}
>(({ className, value, onChange, maxLength, ...props }, ref) => {
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const previousValueRef = React.useRef<string>(value);
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
React.useImperativeHandle(ref, () => inputRef.current!);
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
// @ts-ignore
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
React.useEffect(() => {
if (value !== previousValueRef.current) {
const newLength = value.length;
setFocusedIndex(newLength);
previousValueRef.current = value;
}
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value.replace(/\D/g, "").slice(0, maxLength);
onChange(newValue);
};
const handleBoxClick = (index: number) => {
inputRef.current?.focus();
setFocusedIndex(index);
};
const slots = Array.from({ length: maxLength }, (_, i) => {
const char = value[i] || "";
const isActive =
focusedIndex === i || (focusedIndex === null && i === value.length);
const isFilled = !!char;
return (
<div
key={i}
onClick={() => handleBoxClick(i)}
className={cn(
"relative flex h-11 w-11 items-center justify-center rounded-lg border-2 border-input bg-background text-base font-semibold transition-all cursor-text hover:border-ring/50",
isActive && "border-ring ring-2 ring-ring/20 ring-offset-1",
isFilled && "border-primary/50 bg-primary/5",
className,
)}
>
<span className="text-foreground">{char}</span>
{isActive && !char && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-5 w-0.5 animate-caret-blink bg-primary duration-1000" />
</div>
)}
</div>
);
});
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
<div className="relative">
<input
ref={inputRef}
type="text"
value={value}
onChange={handleChange}
onFocus={() => setFocusedIndex(value.length)}
onBlur={() => setFocusedIndex(null)}
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
maxLength={maxLength}
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
style={{ caretColor: "transparent" }}
{...props}
/>
<div className="flex items-center gap-2">{slots}</div>
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
InputOTP.displayName = "InputOTP";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
export { InputOTP };

View File

@@ -33,11 +33,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { InputOTP } from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
@@ -253,26 +249,20 @@ export default function Home({ IS_CLOUD }: Props) {
onSubmit={onTwoFactorSubmit}
className="space-y-4"
id="two-factor-form"
autoComplete="off"
autoComplete="on"
>
<div className="flex flex-col gap-2">
<Label>2FA Code</Label>
<Label htmlFor="totp-code">2FA Code</Label>
<InputOTP
id="totp-code"
name="totp"
value={twoFactorCode}
onChange={setTwoFactorCode}
maxLength={6}
placeholder="••••••"
pattern={REGEXP_ONLY_DIGITS}
autoFocus
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border-border" />
<InputOTPSlot index={1} className="border-border" />
<InputOTPSlot index={2} className="border-border" />
<InputOTPSlot index={3} className="border-border" />
<InputOTPSlot index={4} className="border-border" />
<InputOTPSlot index={5} className="border-border" />
</InputOTPGroup>
</InputOTP>
/>
<CardDescription>
Enter the 6-digit code from your authenticator app
</CardDescription>