mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-11-03 15:45:51 +00:00
feat: make forms functional
This commit is contained in:
82
frontend/src/components/auth/login-form.tsx
Normal file
82
frontend/src/components/auth/login-form.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "../ui/input";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
type LoginFormType = z.infer<typeof schema>;
|
||||
|
||||
const form = useForm<LoginFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = (data: LoginFormType) => {
|
||||
// Handle login logic here
|
||||
console.log("Login data:", data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t("loginUsername")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("loginUsername")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel className="flex flex-row justify-between">
|
||||
<span>{t("loginPassword")}</span>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-muted-foreground font-normal"
|
||||
>
|
||||
{t("forgotPasswordTitle")}
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("loginPassword")}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className="w-full" type="submit">
|
||||
{t("loginSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
59
frontend/src/components/auth/totp-form.tsx
Normal file
59
frontend/src/components/auth/totp-form.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
import { Form, FormControl, FormField, FormItem } from "../ui/form";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "../ui/input-otp";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface Props {
|
||||
formId: string;
|
||||
onSubmit: (code: FormValues) => void;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export const TotpForm = (props: Props) => {
|
||||
const { formId, onSubmit } = props;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP maxLength={6} {...field}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
165
frontend/src/components/ui/form.tsx
Normal file
165
frontend/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Button } from "./button";
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Props {
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
@@ -10,13 +11,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export const OAuthButton = (props: Props) => {
|
||||
const { title, icon, onClick, loading } = props;
|
||||
const { title, icon, onClick, loading, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className="rounded-full basis-1/3"
|
||||
className={twMerge("rounded-full", className)}
|
||||
variant="outline"
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@@ -18,11 +18,21 @@ function Separator({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
function SeperatorWithChildren({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-sm text-muted-foreground">{children}</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator, SeperatorWithChildren };
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"totpFailSubtitle": "Please check your code and try again",
|
||||
"totpSuccessTitle": "Verified",
|
||||
"totpSuccessSubtitle": "Redirecting to your app",
|
||||
"totpTitle": "TOTP Verification",
|
||||
"totpTitle": "Enter your TOTP code",
|
||||
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||
"unauthorizedTitle": "Unauthorized",
|
||||
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorPage } from "./pages/error-page.tsx";
|
||||
import { NotFoundPage } from "./pages/not-found-page.tsx";
|
||||
import { ContinuePage } from "./pages/continue-page.tsx";
|
||||
import { TotpPage } from "./pages/totp-page.tsx";
|
||||
import { ForgotPasswordPage } from "./pages/forgot-password.tsx";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -31,6 +32,11 @@ const router = createBrowserRouter([
|
||||
element: <TotpPage />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: "/forgot-password",
|
||||
element: <ForgotPasswordPage />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
@@ -55,7 +55,7 @@ export const ContinuePage = () => {
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 items-stretch">
|
||||
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.replace(redirectURI)}
|
||||
variant="destructive"
|
||||
@@ -65,7 +65,7 @@ export const ContinuePage = () => {
|
||||
<Button onClick={() => navigate("/")} variant="outline">
|
||||
{t("cancelTitle")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const ContinuePage = () => {
|
||||
/>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 items-stretch">
|
||||
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.replace(redirectURI)}
|
||||
variant="warning"
|
||||
@@ -99,7 +99,7 @@ export const ContinuePage = () => {
|
||||
<Button onClick={() => navigate("/")} variant="outline">
|
||||
{t("cancelTitle")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -110,11 +110,11 @@ export const ContinuePage = () => {
|
||||
<CardTitle className="text-3xl">{t("continueTitle")}</CardTitle>
|
||||
<CardDescription>{t("continueSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-stretch">
|
||||
<CardFooter className="flex flex-col items-stretch">
|
||||
<Button onClick={() => window.location.replace(redirectURI)}>
|
||||
{t("continueTitle")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
26
frontend/src/pages/forgot-password.tsx
Normal file
26
frontend/src/pages/forgot-password.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const ForgotPasswordPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card className="min-w-xs md:max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">{t("forgotPasswordTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
<Markdown>
|
||||
You can reset your password by changing the `USERS` environment
|
||||
variable.
|
||||
</Markdown>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import { OAuthButton } from "@/components/auth/oauth-button";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
import { GenericIcon } from "@/components/icons/generic";
|
||||
import { GithubIcon } from "@/components/icons/github";
|
||||
import { GoogleIcon } from "@/components/icons/google";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -10,9 +9,8 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { OAuthButton } from "@/components/ui/oauth-button";
|
||||
import { SeperatorWithChildren } from "@/components/ui/separator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const LoginPage = () => {
|
||||
@@ -35,9 +33,9 @@ export const LoginPage = () => {
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
{oauthConfigured && (
|
||||
<div className="flex flex-row gap-3 flex-wrap items-center justify-center">
|
||||
<div className="flex flex-row flex-wrap gap-3 items-center justify-center">
|
||||
{configuredProviders.includes("google") && (
|
||||
<OAuthButton title="Google" icon={<GoogleIcon />} />
|
||||
)}
|
||||
@@ -50,45 +48,9 @@ export const LoginPage = () => {
|
||||
</div>
|
||||
)}
|
||||
{userAuthConfigured && oauthConfigured && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("loginDivider")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
)}
|
||||
{userAuthConfigured && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="#username">{t("loginUsername")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder={t("loginUsername")}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="#password">
|
||||
<div className="flex flex-row min-w-full items-center justify-between">
|
||||
<span>{t("loginPassword")}</span>
|
||||
<a
|
||||
href="/forgot"
|
||||
className="text-muted-foreground font-normal"
|
||||
>
|
||||
{t("forgotPasswordTitle")}
|
||||
</a>
|
||||
</div>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
placeholder={t("loginPassword")}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<Button>{t("loginSubmit")}</Button>
|
||||
</div>
|
||||
<SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren>
|
||||
)}
|
||||
{userAuthConfigured && <LoginForm />}
|
||||
{configuredProviders.length == 0 && (
|
||||
<h3 className="text-center text-xl text-red-600">
|
||||
{t("failedToFetchProvidersTitle")}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
@@ -19,9 +19,9 @@ export const NotFoundPage = () => {
|
||||
<CardTitle className="text-3xl">{t("notFoundTitle")}</CardTitle>
|
||||
<CardDescription>{t("notFoundSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-stretch">
|
||||
<CardFooter className="flex flex-col items-stretch">
|
||||
<Button onClick={() => navigate("/")}>{t("notFoundButton")}</Button>
|
||||
</CardContent>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { FormValues, TotpForm } from "@/components/auth/totp-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const TotpPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const formId = useId();
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
console.log("TOTP data:", data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-xs md:max-w-sm">
|
||||
@@ -23,22 +25,14 @@ export const TotpPage = () => {
|
||||
<CardTitle className="text-3xl">{t("totpTitle")}</CardTitle>
|
||||
<CardDescription>{t("totpSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6 items-stretch">
|
||||
<InputOTP maxLength={6}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<Button>{t("continueTitle")}</Button>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<TotpForm formId={formId} onSubmit={onSubmit} />
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-stretch">
|
||||
<Button form={formId} type="submit">
|
||||
{t("continueTitle")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user