From 1117f354962fdafac945cf0dc06da89e4b1584a2 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 12 Apr 2026 19:12:21 +0300 Subject: [PATCH] refactor: use plain input for totp input to fix autofill issues (#790) * fix(ui): allow pw manager extensions to autofill totp * chore: small ui fixes * fix: prevent double totp submissions --------- Co-authored-by: Scott McKendry --- frontend/bun.lock | 3 - frontend/package.json | 1 - frontend/src/components/auth/totp-form.tsx | 46 ++++++------- frontend/src/components/ui/input-otp.tsx | 75 ---------------------- frontend/src/pages/totp-page.tsx | 2 +- 5 files changed, 21 insertions(+), 106 deletions(-) delete mode 100644 frontend/src/components/ui/input-otp.tsx diff --git a/frontend/bun.lock b/frontend/bun.lock index 9b2503d..d986087 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -19,7 +19,6 @@ "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "i18next-resources-to-backend": "^1.2.1", - "input-otp": "^1.4.2", "lucide-react": "^1.7.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", @@ -623,8 +622,6 @@ "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], - "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], - "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], diff --git a/frontend/package.json b/frontend/package.json index 914afc6..dd5fa2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,6 @@ "i18next": "^26.0.3", "i18next-browser-languagedetector": "^8.2.1", "i18next-resources-to-backend": "^1.2.1", - "input-otp": "^1.4.2", "lucide-react": "^1.7.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", diff --git a/frontend/src/components/auth/totp-form.tsx b/frontend/src/components/auth/totp-form.tsx index 2be9216..6561cc2 100644 --- a/frontend/src/components/auth/totp-form.tsx +++ b/frontend/src/components/auth/totp-form.tsx @@ -1,14 +1,10 @@ import { Form, FormControl, FormField, FormItem } from "../ui/form"; -import { - InputOTP, - InputOTPGroup, - InputOTPSeparator, - InputOTPSlot, -} from "../ui/input-otp"; +import { Input } from "../ui/input"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { totpSchema, TotpSchema } from "@/schemas/totp-schema"; import { useTranslation } from "react-i18next"; +import { useRef } from "react"; import z from "zod"; interface Props { @@ -19,6 +15,7 @@ interface Props { export const TotpForm = (props: Props) => { const { formId, onSubmit } = props; const { t } = useTranslation(); + const autoSubmittedRef = useRef(false); z.config({ customError: (iss) => @@ -29,14 +26,19 @@ export const TotpForm = (props: Props) => { resolver: zodResolver(totpSchema), }); - const handleChange = (value: string) => { - form.setValue("code", value, { shouldDirty: true, shouldValidate: true }); - - if (value.length === 6) { - onSubmit({ code: value }); + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, "").slice(0, 6); + form.setValue("code", value, { shouldDirty: true, shouldValidate: false }); + if (value.length === 6 && !autoSubmittedRef.current) { + autoSubmittedRef.current = true; + form.handleSubmit(onSubmit)(); + return; } + autoSubmittedRef.current = false; }; + // Note: This is not the best UX, ideally we would want https://github.com/guilhermerodz/input-otp + // but some password managers cannot autofill the inputs (see #92) so, simple input it is return (
@@ -46,25 +48,17 @@ export const TotpForm = (props: Props) => { render={({ field }) => ( - - - - - - - - - - - - - + className="text-center" + /> )} diff --git a/frontend/src/components/ui/input-otp.tsx b/frontend/src/components/ui/input-otp.tsx deleted file mode 100644 index 43c6ea0..0000000 --- a/frontend/src/components/ui/input-otp.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; -import { OTPInput, OTPInputContext } from "input-otp"; -import { MinusIcon } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -function InputOTP({ - className, - containerClassName, - ...props -}: React.ComponentProps & { - containerClassName?: string; -}) { - return ( - - ); -} - -function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function InputOTPSlot({ - index, - className, - ...props -}: React.ComponentProps<"div"> & { - index: number; -}) { - const inputOTPContext = React.useContext(OTPInputContext); - const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; - - return ( -
- {char} - {hasFakeCaret && ( -
-
-
- )} -
- ); -} - -function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( -
- -
- ); -} - -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index d0b9726..4f1cb87 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -74,7 +74,7 @@ export const TotpPage = () => { {t("totpTitle")} {t("totpSubtitle")} - + totpMutation.mutate(values)}