feat: make forms functional

This commit is contained in:
Stavros
2025-05-08 18:08:56 +03:00
parent fd96f39d3a
commit 56ae246ff4
18 changed files with 870 additions and 3387 deletions

View 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>
);
};

View 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>
);
};

View 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,
}

View File

@@ -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" />

View File

@@ -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 };

View File

@@ -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>.",

View File

@@ -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 />,

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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")}

View File

@@ -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>
);
};

View File

@@ -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>
);
};