Skip to content

Commit 435d812

Browse files
authored
Merge pull request #2953 from leofilmon/feat/password-manager-compatible-otp-input
feat: add password manager compatible OTP input component
2 parents 3f1bf2b + 18b8b26 commit 435d812

3 files changed

Lines changed: 85 additions & 91 deletions

File tree

apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,7 @@ import {
2525
FormMessage,
2626
} from "@/components/ui/form";
2727
import { Input } from "@/components/ui/input";
28-
import {
29-
InputOTP,
30-
InputOTPGroup,
31-
InputOTPSlot,
32-
} from "@/components/ui/input-otp";
28+
import { InputOTP } from "@/components/ui/input-otp";
3329
import {
3430
Tooltip,
3531
TooltipContent,
@@ -423,23 +419,14 @@ export const Enable2FA = () => {
423419
)}
424420
</div>
425421

426-
<div className="flex flex-col justify-center items-center">
422+
<div className="flex flex-col gap-2">
427423
<FormLabel>Verification Code</FormLabel>
428424
<InputOTP
429425
maxLength={6}
430426
value={otpValue}
431427
onChange={setOtpValue}
432-
autoComplete="off"
433-
>
434-
<InputOTPGroup>
435-
<InputOTPSlot index={0} />
436-
<InputOTPSlot index={1} />
437-
<InputOTPSlot index={2} />
438-
<InputOTPSlot index={3} />
439-
<InputOTPSlot index={4} />
440-
<InputOTPSlot index={5} />
441-
</InputOTPGroup>
442-
</InputOTP>
428+
autoFocus
429+
/>
443430
<FormDescription>
444431
Enter the 6-digit code from your authenticator app
445432
</FormDescription>
Lines changed: 74 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,87 @@
1-
import { OTPInput, OTPInputContext } from "input-otp";
2-
import { Dot } from "lucide-react";
31
import * as React from "react";
42

53
import { cn } from "@/lib/utils";
64

75
const InputOTP = React.forwardRef<
8-
React.ElementRef<typeof OTPInput>,
9-
React.ComponentPropsWithoutRef<typeof OTPInput>
10-
>(({ className, containerClassName, ...props }, ref) => (
11-
<OTPInput
12-
ref={ref}
13-
containerClassName={cn(
14-
"flex items-center gap-2 has-[:disabled]:opacity-50",
15-
containerClassName,
16-
)}
17-
className={cn("disabled:cursor-not-allowed", className)}
18-
{...props}
19-
/>
20-
));
21-
InputOTP.displayName = "InputOTP";
6+
HTMLInputElement,
7+
Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> & {
8+
value: string;
9+
onChange: (value: string) => void;
10+
maxLength: number;
11+
}
12+
>(({ className, value, onChange, maxLength, ...props }, ref) => {
13+
const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);
14+
const inputRef = React.useRef<HTMLInputElement>(null);
15+
const previousValueRef = React.useRef<string>(value);
16+
17+
React.useImperativeHandle(ref, () => inputRef.current!);
18+
19+
React.useEffect(() => {
20+
if (value !== previousValueRef.current) {
21+
const newLength = value.length;
22+
setFocusedIndex(newLength);
23+
previousValueRef.current = value;
24+
}
25+
}, [value]);
26+
27+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
28+
const newValue = e.target.value.replace(/\D/g, "").slice(0, maxLength);
29+
onChange(newValue);
30+
};
2231

23-
const InputOTPGroup = React.forwardRef<
24-
React.ElementRef<"div">,
25-
React.ComponentPropsWithoutRef<"div">
26-
>(({ className, ...props }, ref) => (
27-
<div ref={ref} className={cn("flex items-center", className)} {...props} />
28-
));
29-
InputOTPGroup.displayName = "InputOTPGroup";
32+
const handleBoxClick = (index: number) => {
33+
inputRef.current?.focus();
34+
setFocusedIndex(index);
35+
};
3036

31-
const InputOTPSlot = React.forwardRef<
32-
React.ElementRef<"div">,
33-
React.ComponentPropsWithoutRef<"div"> & { index: number }
34-
>(({ index, className, ...props }, ref) => {
35-
const inputOTPContext = React.useContext(OTPInputContext);
36-
// @ts-ignore
37-
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
37+
const slots = Array.from({ length: maxLength }, (_, i) => {
38+
const char = value[i] || "";
39+
const isActive =
40+
focusedIndex === i || (focusedIndex === null && i === value.length);
41+
const isFilled = !!char;
42+
43+
return (
44+
<div
45+
key={i}
46+
onClick={() => handleBoxClick(i)}
47+
className={cn(
48+
"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",
49+
isActive && "border-ring ring-2 ring-ring/20 ring-offset-1",
50+
isFilled && "border-primary/50 bg-primary/5",
51+
className,
52+
)}
53+
>
54+
<span className="text-foreground">{char}</span>
55+
{isActive && !char && (
56+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
57+
<div className="h-5 w-0.5 animate-caret-blink bg-primary duration-1000" />
58+
</div>
59+
)}
60+
</div>
61+
);
62+
});
3863

3964
return (
40-
<div
41-
ref={ref}
42-
className={cn(
43-
"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",
44-
isActive && "z-10 ring-2 ring-ring ring-offset-background",
45-
className,
46-
)}
47-
{...props}
48-
>
49-
{char}
50-
{hasFakeCaret && (
51-
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
52-
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
53-
</div>
54-
)}
65+
<div className="relative">
66+
<input
67+
ref={inputRef}
68+
type="text"
69+
value={value}
70+
onChange={handleChange}
71+
onFocus={() => setFocusedIndex(value.length)}
72+
onBlur={() => setFocusedIndex(null)}
73+
autoComplete="one-time-code"
74+
inputMode="numeric"
75+
pattern="[0-9]*"
76+
maxLength={maxLength}
77+
className="absolute inset-0 w-full h-full opacity-0 cursor-default"
78+
style={{ caretColor: "transparent" }}
79+
{...props}
80+
/>
81+
<div className="flex items-center gap-2">{slots}</div>
5582
</div>
5683
);
5784
});
58-
InputOTPSlot.displayName = "InputOTPSlot";
59-
60-
const InputOTPSeparator = React.forwardRef<
61-
React.ElementRef<"div">,
62-
React.ComponentPropsWithoutRef<"div">
63-
>(({ ...props }, ref) => (
64-
<div ref={ref} {...props}>
65-
<Dot />
66-
</div>
67-
));
68-
InputOTPSeparator.displayName = "InputOTPSeparator";
85+
InputOTP.displayName = "InputOTP";
6986

70-
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
87+
export { InputOTP };

apps/dokploy/pages/index.tsx

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,7 @@ import {
3333
FormMessage,
3434
} from "@/components/ui/form";
3535
import { Input } from "@/components/ui/input";
36-
import {
37-
InputOTP,
38-
InputOTPGroup,
39-
InputOTPSlot,
40-
} from "@/components/ui/input-otp";
36+
import { InputOTP } from "@/components/ui/input-otp";
4137
import { Label } from "@/components/ui/label";
4238
import { authClient } from "@/lib/auth-client";
4339
import { api } from "@/utils/api";
@@ -253,26 +249,20 @@ export default function Home({ IS_CLOUD }: Props) {
253249
onSubmit={onTwoFactorSubmit}
254250
className="space-y-4"
255251
id="two-factor-form"
256-
autoComplete="off"
252+
autoComplete="on"
257253
>
258254
<div className="flex flex-col gap-2">
259-
<Label>2FA Code</Label>
255+
<Label htmlFor="totp-code">2FA Code</Label>
260256
<InputOTP
257+
id="totp-code"
258+
name="totp"
261259
value={twoFactorCode}
262260
onChange={setTwoFactorCode}
263261
maxLength={6}
262+
placeholder="••••••"
264263
pattern={REGEXP_ONLY_DIGITS}
265264
autoFocus
266-
>
267-
<InputOTPGroup>
268-
<InputOTPSlot index={0} className="border-border" />
269-
<InputOTPSlot index={1} className="border-border" />
270-
<InputOTPSlot index={2} className="border-border" />
271-
<InputOTPSlot index={3} className="border-border" />
272-
<InputOTPSlot index={4} className="border-border" />
273-
<InputOTPSlot index={5} className="border-border" />
274-
</InputOTPGroup>
275-
</InputOTP>
265+
/>
276266
<CardDescription>
277267
Enter the 6-digit code from your authenticator app
278268
</CardDescription>

0 commit comments

Comments
 (0)