diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..7347cdb --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "xbid-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index 11af828..c6121df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: environment: DATABASE_URL: postgresql://postgres:postgres@postgres:5432/xbid - NEXTAUTH_SECRET: change-me-in-production + NEXTAUTH_SECRET: hZTKvtZtchjbJulsVE8Kf5YPLibR619JoK/z7vO2i/s= NEXTAUTH_URL: https://xbid.twotalesvfx.com depends_on: diff --git a/package.json b/package.json index fdf4c34..47a4aea 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:seed": "ts-node -P tsconfig.seed.json prisma/seed.ts", + "db:create-user": "ts-node -P tsconfig.seed.json scripts/create-user.ts", "db:studio": "prisma studio" }, "prisma": { diff --git a/scripts/create-user.ts b/scripts/create-user.ts new file mode 100644 index 0000000..1739205 --- /dev/null +++ b/scripts/create-user.ts @@ -0,0 +1,50 @@ +import { config } from "dotenv"; +config(); +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import bcrypt from "bcryptjs"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter } as any); + +async function main() { + const [, , email, password, name] = process.argv; + + if (!email || !password) { + console.error("Usage: npm run db:create-user -- [name]"); + process.exit(1); + } + + if (password.length < 8) { + console.error("Password must be at least 8 characters long."); + process.exit(1); + } + + const hashedPassword = await bcrypt.hash(password, 12); + const normalizedEmail = email.toLowerCase().trim(); + + const user = await prisma.user.upsert({ + where: { email: normalizedEmail }, + create: { + email: normalizedEmail, + password: hashedPassword, + name: name ?? null, + role: "ADMIN", + }, + update: { + password: hashedPassword, + ...(name ? { name } : {}), + }, + }); + + console.log(`✅ User ready: ${user.email} (role: ${user.role})`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7b38c1b --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cb283b0..55f9bae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; -import { Sidebar } from "@/components/layout/sidebar"; +import { AppShell } from "@/components/layout/app-shell"; const inter = Inter({ subsets: ["latin"] }); @@ -15,12 +15,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( -
- -
- {children} -
-
+ {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..b8aed8c --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,38 @@ +import { Suspense } from "react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/lib/auth"; +import { LoginForm } from "@/components/auth/login-form"; +import { Film } from "lucide-react"; + +export const metadata = { + title: "Sign in — xBid", +}; + +export default async function LoginPage() { + const session = await getServerSession(authOptions); + if (session) { + redirect("/"); + } + + return ( +
+
+
+
+ +
+
+

+ xBid +

+

Sign in to continue

+
+
+ + + +
+
+ ); +} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..5cfb8f8 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent } from "@/components/ui/card"; + +export function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/"; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setPending(true); + + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + setPending(false); + + if (result?.error) { + setError("Invalid email or password."); + return; + } + + router.push(callbackUrl); + router.refresh(); + } + + return ( + + +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..4e9a5f2 --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { Sidebar } from "@/components/layout/sidebar"; + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + if (pathname === "/login") { + return
{children}
; + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 1c45b9f..5fd625f 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; import { cn } from "@/lib/utils"; import { LayoutDashboard, @@ -13,6 +14,7 @@ import { ChevronLeft, ChevronRight, Settings, + LogOut, } from "lucide-react"; import { useState } from "react"; @@ -89,6 +91,13 @@ export function Sidebar() { ); })()} +