Use segmented OTP input for delete confirmation dialogs (#12495)

This commit is contained in:
Tom Moor
2026-05-27 19:44:16 -04:00
committed by GitHub
parent 45c797653f
commit f7b2eb0173
4 changed files with 65 additions and 29 deletions
+17 -2
View File
@@ -6,15 +6,30 @@ import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
/**
* Whether to accept uppercase letters in addition to digits. Lowercase input
* is normalized to uppercase. Defaults to numeric only.
*/
alphanumeric?: boolean;
};
const sanitizeAlphanumeric = (value: string) =>
value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
export const OneTimePasswordInput = React.forwardRef(
function OneTimePasswordInput_(
{ length = 6, ...rest }: Props,
{ length = 6, alphanumeric, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
const alphanumericProps = alphanumeric
? {
validationType: "none" as const,
sanitizeValue: sanitizeAlphanumeric,
}
: undefined;
return (
<OneTimePasswordRoot {...rest}>
<OneTimePasswordRoot {...alphanumericProps} {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
+24 -13
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -25,7 +25,7 @@ function TeamDelete({ onSubmit }: Props) {
const team = useCurrentTeam({ rejectOnEmpty: false });
const { t } = useTranslation();
const {
register,
control,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>();
@@ -62,9 +62,6 @@ function TeamDelete({ onSubmit }: Props) {
[auth, onSubmit]
);
const inputProps = register("code", {
required: env.EMAIL_ENABLED,
});
const appName = env.APP_NAME;
const workspaceName = team?.name;
@@ -78,13 +75,27 @@ function TeamDelete({ onSubmit }: Props) {
enter the code below to permanently destroy this workspace.
</Trans>
</Text>
<Input
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
<Controller
control={control}
name="code"
rules={{
required: env.EMAIL_ENABLED,
minLength: 8,
}}
render={({ field }) => (
<OneTimePasswordInput
length={8}
alphanumeric
autoComplete="off"
autoFocus
name={field.name}
value={field.value ?? ""}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
style={{ marginBottom: "1em" }}
/>
)}
/>
</>
) : (
+24 -13
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { OneTimePasswordInput } from "~/components/OneTimePasswordInput";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
@@ -24,7 +24,7 @@ function UserDelete({ onSubmit }: Props) {
const { auth } = useStores();
const { t } = useTranslation();
const {
register,
control,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>();
@@ -61,9 +61,6 @@ function UserDelete({ onSubmit }: Props) {
[auth, onSubmit]
);
const inputProps = register("code", {
required: env.EMAIL_ENABLED,
});
const appName = env.APP_NAME;
return (
@@ -76,13 +73,27 @@ function UserDelete({ onSubmit }: Props) {
enter the code below to permanently destroy your account.
</Trans>
</Text>
<Input
placeholder={t("Confirmation code")}
autoComplete="off"
autoFocus
maxLength={8}
required
{...inputProps}
<Controller
control={control}
name="code"
rules={{
required: env.EMAIL_ENABLED,
minLength: 8,
}}
render={({ field }) => (
<OneTimePasswordInput
length={8}
alphanumeric
autoComplete="off"
autoFocus
name={field.name}
value={field.value ?? ""}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
style={{ marginBottom: "1em" }}
/>
)}
/>
</>
) : (
@@ -1401,7 +1401,6 @@
"No templates have been created yet": "No templates have been created yet",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
"Confirmation code": "Confirmation code",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",
"Please note that workspaces are completely separated. They can have a different domain, settings, users, and billing.": "Please note that workspaces are completely separated. They can have a different domain, settings, users, and billing.",
"You are creating a new workspace using your current account — <em>{{email}}</em>": "You are creating a new workspace using your current account — <em>{{email}}</em>",