diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bb72f3b..9cb3723 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.5", "class-variance-authority": "^0.7.1", @@ -16,6 +17,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.71.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zod": "^4.3.6" @@ -1177,6 +1179,18 @@ "hono": "^4" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3346,6 +3360,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -8610,6 +8630,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d003624..e55fa5c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test": "vitest" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.1.18", "axios": "^1.13.5", "class-variance-authority": "^0.7.1", @@ -19,6 +20,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.71.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "zod": "^4.3.6" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 574d72e..10239de 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,56 @@ +import { useState, useEffect } from "react" import { MainLayout } from "./components/layout/MainLayout" +import { LoginForm } from "./components/auth/LoginForm" +import { RegisterForm } from "./components/auth/RegisterForm" +import { AuthService } from "./services/auth" function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [authMode, setAuthMode] = useState<"login" | "register">("login") + + useEffect(() => { + setIsAuthenticated(AuthService.isAuthenticated()) + }, []) + + const handleAuthSuccess = () => { + setIsAuthenticated(true) + } + + const handleLogout = () => { + AuthService.logout() + setIsAuthenticated(false) + } + + if (!isAuthenticated) { + return ( +
+ {authMode === "login" ? ( + setAuthMode("register")} + /> + ) : ( + setAuthMode("login")} + /> + )} +
+ ) + } + return (
-

Welcome to Election Analytics

+
+

Welcome to Election Analytics

+ +

Select a conversation from the sidebar or start a new one to begin your analysis.

diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..6ebf3b4 --- /dev/null +++ b/frontend/src/components/auth/LoginForm.tsx @@ -0,0 +1,106 @@ +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { loginSchema, type LoginInput } from "@/lib/validations/auth" +import { AuthService } from "@/services/auth" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useState } from "react" + +interface LoginFormProps { + onSuccess: () => void + onToggleMode: () => void +} + +export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) { + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: "", + password: "", + }, + }) + + async function onSubmit(values: LoginInput) { + setIsLoading(true) + setError(null) + try { + await AuthService.login(values.email, values.password) + onSuccess() + } catch (err: any) { + setError(err.response?.data?.detail || "Login failed. Please check your credentials.") + } finally { + setIsLoading(false) + } + } + + return ( + + + Login + Enter your email and password to access your account. + + +
+ + {error && ( +
+ {error} +
+ )} + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + +
+ Don't have an account?{" "} + +
+
+
+ ) +} diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..97ba60a --- /dev/null +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -0,0 +1,123 @@ +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { registerSchema, type RegisterInput } from "@/lib/validations/auth" +import { AuthService } from "@/services/auth" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useState } from "react" + +interface RegisterFormProps { + onSuccess: () => void + onToggleMode: () => void +} + +export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) { + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + email: "", + password: "", + confirmPassword: "", + }, + }) + + async function onSubmit(values: RegisterInput) { + setIsLoading(true) + setError(null) + try { + await AuthService.register(values.email, values.password) + // Automatically login after registration or just switch to login? + // For now, let's just switch to login or notify success + alert("Registration successful! Please login.") + onToggleMode() + } catch (err: any) { + setError(err.response?.data?.detail || "Registration failed. Please try again.") + } finally { + setIsLoading(false) + } + } + + return ( + + + Create an Account + Enter your email and a password to get started. + + +
+ + {error && ( +
+ {error} +
+ )} + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + + +
+ Already have an account?{" "} + +
+
+
+ ) +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..b438928 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import type { Label as LabelPrimitive } from "radix-ui" +import { Slot } from "radix-ui" +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 = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +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 ") + } + + 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( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +