diff --git a/.content-collections/generated/index.js b/.content-collections/generated/index.js index 3ef6585..29212dc 100644 --- a/.content-collections/generated/index.js +++ b/.content-collections/generated/index.js @@ -1,4 +1,4 @@ -// generated by content-collections at Wed Dec 25 2024 20:28:37 GMT-0500 (Eastern Standard Time) +// generated by content-collections at Sat Jan 04 2025 18:53:35 GMT+0100 (Central European Standard Time) import allDocs from "./allDocs.js"; import allPages from "./allPages.js"; diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..bf65f63 --- /dev/null +++ b/TODO.md @@ -0,0 +1,17 @@ +# TODOs + +- decide design +- write 1st version of docs +- set up npm package +- landing page + +decide how to organize the codebase +do we need js files in .content-collections? + +- add at least 3 components before making a video about it: + - auth + - landing page + - stripe + +landing page: +- design it for our website and then use a very simple version to release \ No newline at end of file diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..00b17d1 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { ReactNode } from "react"; +import { SessionProvider } from "next-auth/react"; + +interface AuthLayoutProps { + children: ReactNode; +} + +export default function AuthLayout({ children }: AuthLayoutProps) { + return {children}; +} \ No newline at end of file diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..7dcb660 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Icons } from "@/components/ui/icons/icons"; + +const loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFormValues = z.infer; + +export default function LoginPage() { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const onSubmit = async (values: LoginFormValues) => { + const result = await signIn("credentials", { + redirect: false, + email: values.email, + password: values.password, + }); + + if (result?.error) { + toast.error(result.error); + } else { + toast.success("Successfully logged in!"); + router.push("/"); + } + }; + + const handleGitHubSignIn = () => { + signIn("github", { callbackUrl: "/" }); + toast.success("Successfully signed in with GitHub!"); + }; + + const handleGoogleSignIn = () => { + signIn("google", { callbackUrl: "/" }); + }; + + return ( + + + + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + Login + + + + + + + + + Or continue with + + + + + + + + Google + + + + Github + + + + + + ); +} \ No newline at end of file diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..488df97 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Icons } from "@/components/ui/icons/icons"; +import { signIn } from "next-auth/react"; + +const signupSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + password: z.string().min(6, "Password must be at least 6 characters"), +}); + +type SignupFormValues = z.infer; + +export default function SignupPage() { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver(signupSchema), + defaultValues: { + name: "", + email: "", + password: "", + }, + }); + + const onSubmit = async (values: SignupFormValues) => { + try { + await axios.post("/api/auth/signup", values); + toast.success("Account created successfully!"); + router.push("/auth/login"); + } catch (error) { + toast.error((error as Error).message || "Something went wrong"); + } + }; + + const handleGitHubSignIn = () => { + signIn("github", { callbackUrl: "/" }); + toast.success("Successfully signed in with GitHub!"); + }; + + const handleGoogleSignIn = () => { + signIn("google", { callbackUrl: "/" }); + }; + + return ( + + + + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + Sign Up + + + + + + + + + Or continue with + + + + + + + + Google + + + + Github + + + + + + ); +} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index d4e5637..b6149fb 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,6 @@ -import { authOptions } from "@/lib/auth"; -import NextAuth from "next-auth/next"; +import NextAuth from "next-auth" +import { authOptions } from "@/lib/auth" -const handler = NextAuth(authOptions); +const handler = NextAuth(authOptions) -export { handler as GET, handler as POST }; +export { handler as GET, handler as POST } diff --git a/lib/auth.ts b/lib/auth.ts index bf5cc65..0026bee 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,68 +1,128 @@ -import { PrismaAdapter } from "@auth/prisma-adapter"; -import { db } from "@/lib/db"; -import NextAuth from "next-auth"; -import { AuthOptions } from "next-auth"; -import GithubProvider from "next-auth/providers/github"; -import GoogleProvider from "next-auth/providers/google"; -import CredentialsProvider from "next-auth/providers/credentials"; -import bcrypt from "bcrypt"; +import { AuthOptions } from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import GithubProvider from "next-auth/providers/github" +import GoogleProvider from "next-auth/providers/google" +import { db } from "@/lib/db" +import { compare } from "bcrypt" +import { User as PrismaUser } from "@prisma/client" + +declare module "next-auth" { + interface Session { + user: PrismaUser & { id: string } + } + + interface User { + id: string + password: string | null + } +} export const authOptions: AuthOptions = { - adapter: PrismaAdapter(db), providers: [ - GithubProvider({ - clientId: process.env.GITHUB_ID as string, - clientSecret: process.env.GITHUB_SECRET as string, - }), - GoogleProvider({ - clientId: process.env.GOOGLE_ID as string, - clientSecret: process.env.GOOGLE_SECRET as string, - }), CredentialsProvider({ - name: "credentials", + name: "Credentials", credentials: { - email: { label: "Email", type: "text" }, - password: { label: "Password", type: "password" }, + email: { label: "Email", type: "email", placeholder: "john@example.com" }, + password: { label: "Password", type: "password" } }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { - throw new Error("Invalid credentials"); + throw new Error("Email and password required") } const user = await db.user.findUnique({ - where: { - email: credentials.email, - }, - }); + where: { email: credentials.email } + }) - if (!user || !user?.password) { - throw new Error("Invalid credentials"); + if (!user || !user.password) { + throw new Error("Invalid credentials") } - const isCorrectPassword = await bcrypt.compare( - credentials.password, - user.password - ); + const isValid = await compare(credentials.password, user.password) - if (!isCorrectPassword) { - throw new Error("Invalid credentials"); + if (!isValid) { + throw new Error("Invalid credentials") } + // Map the user ID to string to conform with NextAuth expectations return { - id: user.id.toString(), - email: user.email, - name: user.name, - image: user.image, - }; - }, + ...user, + id: user.id.toString(), // Ensure ID is a string + } + } }), + GithubProvider({ + clientId: process.env.GITHUB_ID as string, + clientSecret: process.env.GITHUB_SECRET as string, + }), + GoogleProvider({ + clientId: process.env.GOOGLE_ID as string, + clientSecret: process.env.GOOGLE_SECRET as string, + }) ], + callbacks: { + async signIn({ user, account, profile }) { + if (account?.provider === "google" || account?.provider === "github") { + if (!profile?.email) { + console.warn(`${account.provider} sign-in attempted without an email.`) + return false + } + + try { + const existingUser = await db.user.findUnique({ + where: { email: profile.email } + }) + + if (existingUser) { + if (existingUser.password) { + console.warn(`Sign-in with ${account.provider} attempted for existing email: ${profile.email} which has a credentials-based account.`) + return false + } else { + user.id = existingUser.id.toString() + user.password = null + return true + } + } else { + const newUser = await db.user.create({ + data: { + name: profile.name || profile.email, + email: profile.email, + password: null + } + }) + user.id = newUser.id.toString() + user.password = null + console.info(`New ${account.provider} user created: ${profile.email}`) + return true + } + } catch (error) { + console.error(`Error during ${account.provider} sign-in:`, error) + return false + } + } + return true + }, + async jwt({ token, user }) { + if (user) { + token.user = user + } + return token + }, + async session({ session, token }) { + if (token.user) { + session.user = token.user as PrismaUser & { id: string } + } + return session + } + }, + pages: { + signIn: "/login", + signOut: "/", + error: "/login", + }, session: { strategy: "jwt", - }, - secret: process.env.NEXTAUTH_SECRET, - debug: process.env.NODE_ENV === "development", -}; - -const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; + maxAge: 7 * 24 * 60 * 60, + updateAge: 24 * 60 * 60, + } +} \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..b31ca1d --- /dev/null +++ b/middleware.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { getToken } from "next-auth/jwt"; + +// Define the paths that should be excluded from the middleware (public routes) +const PUBLIC_FILE = /\.(.*)$/; +const PUBLIC_PATHS = ["/login", "/signup", "/api/auth", "/favicon.ico"]; + +export async function middleware(req: NextRequest) { + const { pathname } = req.nextUrl; + + // Allow the request if it's for a public file or a public path + if (PUBLIC_PATHS.some((path) => pathname.startsWith(path)) || PUBLIC_FILE.test(pathname)) { + return NextResponse.next(); + } + + // Get the token from NextAuth + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + + // If no token is found, redirect to the login page + if (!token) { + const loginUrl = req.nextUrl.clone(); + loginUrl.pathname = "/login"; + loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname); + return NextResponse.redirect(loginUrl); + } + + // If token exists, allow the request + return NextResponse.next(); +} + +export const config = { + matcher: [ + "/((?!api/auth|auth/login|auth/signup|favicon.ico|public|_next/static|_next/image|assets).*)", + ], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3219d79..cfe32c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,11 +33,13 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-table": "^8.20.6", + "@types/bcryptjs": "^2.4.6", "@types/lodash.template": "^4.5.3", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.3", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4", @@ -3330,6 +3332,11 @@ "@types/node": "*" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -4129,6 +4136,11 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index b4fa899..885467d 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,13 @@ "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-table": "^8.20.6", + "@types/bcryptjs": "^2.4.6", "@types/lodash.template": "^4.5.3", "@upstash/ratelimit": "^2.0.5", "@upstash/redis": "^1.34.3", "axios": "^1.7.9", "bcrypt": "^5.1.1", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.4",