Add Phase 0: sign-up, login, and profile
- Next.js (App Router) + TypeScript + Tailwind - Prisma + SQLite for the User model - Sign-up with 18+ age gate, email/password validation (zod), bcrypt hashing - Login via NextAuth credentials provider, JWT sessions - Protected /profile route (view + edit name/bio) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VL5YUEf2B9LG4xdVUQwcSg
This commit is contained in:
27 files changed
+3196
No files matched your search
@@ -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 };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const updateSchema = z.object({
|
||||
bio: z.string().trim().max(500).optional(),
|
||||
name: z.string().trim().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: parsed.data,
|
||||
select: { id: true, name: true, email: true, age: true, bio: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const signupSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").max(80),
|
||||
email: z.string().trim().toLowerCase().email("Enter a valid email"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
age: z.number().int().min(18, "You must be 18 or older to join").max(120),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = signupSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name, email, password, age } = parsed.data;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "An account with that email already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { name, email, age, passwordHash },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ user }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "LoveMeet",
|
||||
description: "Find genuine connections.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gradient-to-b from-rose-50 to-white text-gray-900">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const email = String(form.get("email") ?? "");
|
||||
const password = String(form.get("password") ?? "");
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Incorrect email or password.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/profile");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-12">
|
||||
<h1 className="mb-6 text-2xl font-bold text-brand">Log in</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-lg bg-brand px-4 py-3 font-semibold text-white transition hover:bg-brand-dark disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Logging in..." : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
Need an account?{" "}
|
||||
<Link href="/signup" className="font-semibold text-brand">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<h1 className="text-4xl font-bold text-brand">LoveMeet</h1>
|
||||
<p className="text-gray-600">
|
||||
A simple, honest place to make a profile and start real conversations.
|
||||
</p>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="rounded-lg bg-brand px-4 py-3 font-semibold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
Create your profile
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">You must be 18 or older to join.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import ProfileEditor from "./profile-editor";
|
||||
import SignOutButton from "./sign-out-button";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, name: true, email: true, age: true, bio: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6 py-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-brand">Your profile</h1>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{user.email} · Age {user.age}
|
||||
</p>
|
||||
<ProfileEditor initialName={user.name} initialBio={user.bio} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
|
||||
export default function ProfileEditor({
|
||||
initialName,
|
||||
initialBio,
|
||||
}: {
|
||||
initialName: string;
|
||||
initialBio: string;
|
||||
}) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [bio, setBio] = useState(initialBio);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("saving");
|
||||
|
||||
const res = await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, bio }),
|
||||
});
|
||||
|
||||
setStatus(res.ok ? "saved" : "error");
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={80}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="bio" className="mb-1 block text-sm font-medium">
|
||||
About you
|
||||
</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
placeholder="Tell people a bit about yourself..."
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "saving"}
|
||||
className="rounded-lg bg-brand px-4 py-3 font-semibold text-white transition hover:bg-brand-dark disabled:opacity-60"
|
||||
>
|
||||
{status === "saving" ? "Saving..." : "Save changes"}
|
||||
</button>
|
||||
|
||||
{status === "saved" && <p className="text-sm text-green-600">Saved.</p>}
|
||||
{status === "error" && <p className="text-sm text-red-600">Couldn't save. Try again.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
|
||||
export default function SignOutButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
name: String(form.get("name") ?? ""),
|
||||
email: String(form.get("email") ?? ""),
|
||||
password: String(form.get("password") ?? ""),
|
||||
age: Number(form.get("age") ?? 0),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Something went wrong. Please try again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const signInResult = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
});
|
||||
|
||||
if (signInResult?.error) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/profile");
|
||||
} catch {
|
||||
setError("Something went wrong. Please try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center px-6 py-12">
|
||||
<h1 className="mb-1 text-2xl font-bold text-brand">Create your profile</h1>
|
||||
<p className="mb-6 text-sm text-gray-500">You must be 18 or older to join.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1 block text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
maxLength={80}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="age" className="mb-1 block text-sm font-medium">
|
||||
Age
|
||||
</label>
|
||||
<input
|
||||
id="age"
|
||||
name="age"
|
||||
type="number"
|
||||
min={18}
|
||||
max={120}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-brand focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">At least 8 characters.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 rounded-lg bg-brand px-4 py-3 font-semibold text-white transition hover:bg-brand-dark disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Creating your profile..." : "Sign up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="font-semibold text-brand">
|
||||
Log in
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email.toLowerCase() },
|
||||
});
|
||||
if (!user) return null;
|
||||
|
||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||
if (!valid) return null;
|
||||
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as { id?: string }).id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
export default async function proxy(request: NextRequest) {
|
||||
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
|
||||
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/profile/:path*"],
|
||||
};
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user