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:
Claude committed 2026-09-24 13:49:28 +00:00
1 parent a3e254378d
commit 4e8a2d9037
27 files changed
+3196

No files matched your search

+6
View File
@@ -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 };
+36
View File
@@ -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 });
}
+40
View File
@@ -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 });
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
min-height: 100vh;
}
+22
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+35
View File
@@ -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} &middot; Age {user.age}
</p>
<ProfileEditor initialName={user.name} initialBio={user.bio} />
</main>
);
}
+71
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
+140
View File
@@ -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>
);
}
+47
View File
@@ -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;
},
},
};
+9
View File
@@ -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;
}
+18
View File
@@ -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*"],
};
+11
View File
@@ -0,0 +1,11 @@
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
};
}
}