Add email/password authentication

- Add password_hash to users table
- New login flow: email + password
- Sign up / Sign in toggle
- Simple password hashing (upgrade to bcrypt in prod)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-16 10:37:48 +05:30
parent 09f263b423
commit f967215fc8
3 changed files with 158 additions and 112 deletions

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
// Run user password migration
export async function POST() {
try {
await sql`
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
`;
await sql`
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMP WITH TIME ZONE
`;
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -2,53 +2,96 @@ import { NextResponse } from "next/server";
import { sql } from "@/db";
import { cookies } from "next/headers";
// Simple hash function (for development - in production use bcrypt)
function hashPassword(password: string): string {
// Simple hash for now - should use bcrypt in production
let hash = 0;
for (let i = 0; i < password.length; i++) {
const char = password.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return "hash_" + hash.toString(16);
}
function verifyPassword(password: string, hash: string): boolean {
return hashPassword(password) === hash;
}
export async function POST(request: Request) {
const body = await request.json();
const email = body?.email;
const { email, password, action } = body;
if (!email) {
return NextResponse.json({ error: "Email required" }, { status: 400 });
if (!email || !password) {
return NextResponse.json({ error: "Email and password required" }, { status: 400 });
}
try {
// Find or create user
let users = await sql`
SELECT u.id, u.email, fm.family_id as family_id
// Find user
const users = await sql`
SELECT u.id, u.email, u.password_hash, fm.family_id as family_id
FROM users u
LEFT JOIN family_members fm ON fm.user_id = u.id
WHERE u.email = ${email}
LIMIT 1
`;
let user = users?.[0];
const user = users?.[0];
// Create user if not found
if (!user) {
// Sign up - create new account
if (action === "signup") {
if (user) {
return NextResponse.json({ error: "Email already exists" }, { status: 400 });
}
const newUserId = crypto.randomUUID();
const passwordHash = hashPassword(password);
await sql`
INSERT INTO users (id, email, created_at, updated_at)
VALUES (${newUserId}, ${email}, NOW(), NOW())
INSERT INTO users (id, email, password_hash, password_updated_at, created_at, updated_at)
VALUES (${newUserId}, ${email}, ${passwordHash}, NOW(), NOW(), NOW())
`;
// Fetch the newly created user
users = await sql`
SELECT u.id, u.email
FROM users u
WHERE u.id = ${newUserId}
// Create session
const sessionToken = crypto.randomUUID();
const expires = new Date();
expires.setDate(expires.getDate() + 30);
await sql`
INSERT INTO sessions (session_token, user_id, expires)
VALUES (${sessionToken}, ${newUserId}, ${expires.toISOString()})
`;
user = users?.[0];
const response = NextResponse.json({
success: true,
userId: newUserId,
email,
isNewUser: true,
});
response.cookies.set("tia_session", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
return response;
}
// Sign in - require existing user with password
if (!user) {
return NextResponse.json({ error: "Failed to create user" }, { status: 500 });
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Create session token
if (!user.password_hash) {
return NextResponse.json({ error: "Set password first at /login", status: 400 });
}
if (!verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
}
// Create session
const sessionToken = crypto.randomUUID();
const expires = new Date();
expires.setDate(expires.getDate() + 30);
// Insert session
await sql`
INSERT INTO sessions (session_token, user_id, expires)
VALUES (${sessionToken}, ${user.id}, ${expires.toISOString()})
@ -64,16 +107,14 @@ export async function POST(request: Request) {
family = families?.[0];
}
// Create response
const response = NextResponse.json({
success: true,
userId: user.id,
email: user.email,
familyId: user.family_id,
family: family,
family,
});
// Set cookie
response.cookies.set("tia_session", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
@ -81,53 +122,9 @@ export async function POST(request: Request) {
maxAge: 60 * 60 * 24 * 30,
path: "/",
});
return response;
} catch (error) {
console.error("Signin error:", error);
console.error("Auth error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
export async function GET() {
try {
const cookieStore = await cookies();
const sessionToken = cookieStore.get("tia_session")?.value;
if (!sessionToken) {
return NextResponse.json({ authenticated: false });
}
const sessions = await sql`
SELECT s.user_id, s.expires, u.email
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.session_token = ${sessionToken}
AND s.expires > NOW()
`;
const session = sessions?.[0];
if (!session) {
return NextResponse.json({ authenticated: false });
}
const members = await sql`
SELECT fm.family_id, f.name as family_name, f.tier
FROM family_members fm
JOIN families f ON f.id = fm.family_id
WHERE fm.user_id = ${session.user_id}
`;
return NextResponse.json({
authenticated: true,
userId: session.user_id,
email: session.email,
familyId: members?.[0]?.family_id,
familyName: members?.[0]?.family_name,
tier: members?.[0]?.tier,
});
} catch (error) {
return NextResponse.json({ authenticated: false });
}
}

View file

@ -1,74 +1,106 @@
"use client";
import { useEffect } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
useEffect(() => {
async function checkSession() {
const res = await fetch("/api/auth/signin");
const data = await res.json();
if (data.authenticated) {
if (data.familyId) {
router.push("/");
} else {
router.push("/onboarding");
}
}
}
checkSession();
}, [router]);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [mode, setMode] = useState<"login" | "signup">("login");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const email = (form.elements.namedItem("email") as HTMLInputElement)?.value;
if (!email || !password) return;
if (!email) return;
setLoading(true);
setError("");
const res = await fetch("/api/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
try {
const res = await fetch("/api/auth/signin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, action: mode }),
});
const data = await res.json();
const data = await res.json();
if (data.success) {
if (data.familyId) {
if (!res.ok) {
setError(data.error || "Failed to login");
return;
}
// Redirect based on response
if (data.isNewUser) {
router.push("/onboarding");
} else if (data.familyId) {
router.push("/");
} else {
router.push("/onboarding");
}
} else {
alert(data.error || "Sign in failed");
} catch (err) {
setError("Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-amber-50">
<div className="w-full max-w-md p-8 text-center">
<h1 className="text-4xl font-bold mb-2">Tia</h1>
<p className="text-gray-600 mb-8">Your baby tracking companion</p>
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-lg max-w-md w-full">
<h1 className="text-2xl font-bold text-center mb-6 dark:text-white">
{mode === "login" ? "Welcome Back" : "Create Account"}
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
name="email"
type="email"
placeholder="Enter your email"
className="w-full p-4 border rounded-2xl bg-white shadow-sm"
required
/>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700 dark:text-white"
required
minLength={4}
/>
</div>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<button
type="submit"
className="w-full p-4 bg-rose-400 text-white rounded-2xl font-medium"
disabled={loading}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
>
Sign In
{loading ? "..." : mode === "login" ? "Sign In" : "Sign Up"}
</button>
</form>
<p className="text-center mt-4 text-sm text-gray-500 dark:text-gray-400">
{mode === "login" ? "No account? " : "Already have an account? "}
<button
type="button"
onClick={() => { setMode(mode === "login" ? "signup" : "login"); setError(""); }}
className="text-rose-500 font-medium"
>
{mode === "login" ? "Sign Up" : "Sign In"}
</button>
</p>
</div>
</div>
);