diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..e4e3d34 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/lib/database"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; + +export async function POST(request: NextRequest) { + try { + const { email, password } = await request.json(); + + // Validate input + if (!email || !password) { + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 } + ); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + // Find user by email + const result = await client.query( + `SELECT id, email, password_hash, first_name, last_name, role, subscription_tier, created_at + FROM users WHERE email = $1`, + [email] + ); + + if (result.rows.length === 0) { + return NextResponse.json( + { error: "Invalid email or password" }, + { status: 401 } + ); + } + + const user = result.rows[0]; + + // Verify password + const isValidPassword = await bcrypt.compare( + password, + user.password_hash + ); + if (!isValidPassword) { + return NextResponse.json( + { error: "Invalid email or password" }, + { status: 401 } + ); + } + + // Create JWT token + const token = jwt.sign( + { + userId: user.id, + email: user.email, + role: user.role, + }, + process.env.JWT_SECRET || "your-secret-key", + { expiresIn: "7d" } + ); + + return NextResponse.json({ + success: true, + token, + user: { + id: user.id, + email: user.email, + FirstName: user.first_name, + LastName: user.last_name, + role: user.role, + subscriptionTier: user.subscription_tier, + createdAt: user.created_at, + }, + }); + } finally { + client.release(); + } + } catch (error) { + console.error("Login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..aca383f --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/lib/database"; +import bcrypt from "bcryptjs"; + +export async function POST(request: NextRequest) { + try { + const { firstName, lastName, email, password } = await request.json(); + + // Validate input + if (!firstName || !lastName || !email || !password) { + return NextResponse.json( + { error: "All fields are required" }, + { status: 400 } + ); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 } + ); + } + + // Validate password length + if (password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters" }, + { status: 400 } + ); + } + + const pool = getPool(); + const client = await pool.connect(); + + try { + // Check if user already exists + const existingUser = await client.query( + "SELECT id FROM users WHERE email = $1", + [email] + ); + + if (existingUser.rows.length > 0) { + return NextResponse.json( + { error: "User with this email already exists" }, + { status: 409 } + ); + } + + // Hash the password + const saltRounds = 12; + const passwordHash = await bcrypt.hash(password, saltRounds); + + // Insert new user + const result = await client.query( + `INSERT INTO users (email, password_hash, first_name, last_name, role, subscription_tier) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, email, first_name, last_name, role, subscription_tier, created_at`, + [email, passwordHash, firstName, lastName, "USER", "BASIC"] + ); + + const newUser = result.rows[0]; + + // Create a basic subscription for the user + await client.query( + `INSERT INTO subscriptions (user_id, tier, status, storage_limit_gb, current_usage_gb) + VALUES ($1, $2, $3, $4, $5)`, + [newUser.id, "BASIC", "ACTIVE", 5, 0] + ); + + return NextResponse.json({ + success: true, + message: "User created successfully", + user: { + id: newUser.id, + email: newUser.email, + firstName: newUser.first_name, + lastName: newUser.last_name, + role: newUser.role, + subscriptionTier: newUser.subscription_tier, + createdAt: newUser.created_at, + }, + }); + } finally { + client.release(); + } + } catch (error) { + console.error("Registration error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 5cbf4e6..83a06f8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { AuthProvider } from "@/lib/auth-context"; import "./globals.css"; const geistSans = Geist({ @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Reya Render", + description: "Professional 3D rendering service", }; export default function RootLayout({ @@ -39,7 +40,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..7161f57 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,16 @@ +import { LoginForm } from "@/components/LoginForm"; + +export default function LoginPage() { + return ( +
+
+
+

+ Log in to your account +

+
+ +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 1d17733..f75f1c3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,13 +1,33 @@ +"use client"; + import Image from "next/image"; import UploadArea from "@/components/UploadArea"; +import { useAuth } from "@/lib/auth-context"; import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuLink, } from "@/components/ui/navigation-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDownIcon } from "lucide-react"; export default function Home() { + const { isAuthenticated, user, logout } = useAuth(); + + // Get user initials for the circle icon + const getUserInitials = () => { + if (!user) return "U"; + const firstName = user.FirstName || ""; + const lastName = user.LastName || ""; + return (firstName.charAt(0) + lastName.charAt(0)).toUpperCase() || "U"; + }; + return (
{/* Sticky Navigation */} @@ -38,17 +58,71 @@ export default function Home() { Pricing + {/* Show Upload File only when authenticated */} + {isAuthenticated && ( + + + Upload File + + + )} - {/* Auth Buttons */} + {/* Auth Section */}
- - + {isAuthenticated ? ( + // Show user circle with dropdown when logged in +
+ + + + + + + + My Subscriptions + + + + + My Renders + + + + Logout + + + +
+ ) : ( + // Show login/signup buttons when not authenticated + <> + + Log In + + + Sign Up + + + )}
@@ -250,15 +324,17 @@ export default function Home() { - {/* Upload area below hero, same width as orange container */} -
-
-

- Start by uploading at least one .blend file -

- + {/* Upload area below hero, same width as orange container - only show when authenticated */} + {isAuthenticated && ( +
+
+

+ Start by uploading at least one .blend file +

+ +
-
+ )}
); } diff --git a/app/signup/page.tsx b/app/signup/page.tsx new file mode 100644 index 0000000..1563ec6 --- /dev/null +++ b/app/signup/page.tsx @@ -0,0 +1,16 @@ +import { CreateAccountForm } from "@/components/CreateAccountForm"; + +export default function SignUpPage() { + return ( +
+
+
+

+ Create your account +

+
+ +
+
+ ); +} diff --git a/components/CreateAccountForm.tsx b/components/CreateAccountForm.tsx new file mode 100644 index 0000000..719c2bd --- /dev/null +++ b/components/CreateAccountForm.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast, Toaster } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z + .object({ + firstName: z + .string() + .min(2, { message: "First name must be at least 2 characters." }), + lastName: z + .string() + .min(2, { message: "Last name must be at least 2 characters." }), + email: z.string().email({ message: "Please enter a valid email address." }), + password: z + .string() + .min(6, { message: "Password must be at least 6 characters." }), + repeatPassword: z + .string() + .min(6, { message: "Repeat password must be at least 6 characters." }), + }) + .refine((data) => data.password === data.repeatPassword, { + path: ["repeatPassword"], + message: "Passwords do not match.", + }); + +export function CreateAccountForm() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + firstName: "", + lastName: "", + email: "", + password: "", + repeatPassword: "", + }, + }); + + const onSubmit = async (data: z.infer) => { + try { + setIsLoading(true); + + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (!response.ok) { + toast.error(result.error || "Registration failed"); + return; + } + + // Show success message + toast.success("Account Created", { + duration: 2000, + position: "top-center", + }); + + // Redirect to login page + setTimeout(() => { + router.push("/login"); + }, 1000); + } catch (error) { + console.error("Registration error:", error); + toast.error("Registration failed"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ + ( + + First Name + + + + + + )} + /> + + ( + + Last Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Repeat Password + + + + + + )} + /> + + + + +
+ ); +} + diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..184ccba --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast, Toaster } from "sonner"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/lib/auth-context"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +const formSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + password: z + .string() + .min(6, { message: "Password must be at least 6 characters" }), +}); + +export function LoginForm() { + const router = useRouter(); + const searchParams = new URLSearchParams( + typeof window !== "undefined" ? window.location.search : "" + ); + const redirectPath = searchParams.get("redirect") || "/"; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const { login } = useAuth(); + + const onSubmit = async (data: z.infer) => { + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (!response.ok) { + toast.error(result.error || "Login failed"); + return; + } + + // Use the auth context to handle login + login(result.token, { + ...result.user, + FirstName: result.user.FirstName, + LastName: result.user.LastName, + Email: result.user.Email, + }); + toast.success("Login successful!"); + + // Redirect to the original destination or default route + router.push(redirectPath); + } catch (error) { + console.error("Login error:", error); + toast.error("Connection error. Please try again."); + } + }; + + return ( +
+ +
+ + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + + + +
+ ); +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bbe6fb0 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +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 = 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 ( +