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,3 @@
|
|||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
NEXTAUTH_SECRET="replace-this-with-a-long-random-string"
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
+26
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2450
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user