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

+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET="replace-this-with-a-long-random-string"
NEXTAUTH_URL="http://localhost:3000"
+26
View File
@@ -0,0 +1,26 @@
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# env files (never commit secrets)
.env
.env*.local
# database
/prisma/dev.db
/prisma/dev.db-journal
# misc
.DS_Store
*.pem
npm-debug.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+2450
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "lovemeet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"bcryptjs": "^2.4.3",
"next": "16.3.6",
"next-auth": "^4.24.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.7.4",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"prisma": "^5.20.0",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"age" INTEGER NOT NULL,
"bio" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
+19
View File
@@ -0,0 +1,19 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
age Int
bio String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+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;
};
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
brand: {
DEFAULT: "#e11d48",
dark: "#be123c",
},
},
},
},
plugins: [],
};
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}