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 { sql } from "@/db";
import { cookies } from "next/headers"; 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) { export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const email = body?.email; const { email, password, action } = body;
if (!email) { if (!email || !password) {
return NextResponse.json({ error: "Email required" }, { status: 400 }); return NextResponse.json({ error: "Email and password required" }, { status: 400 });
} }
try { try {
// Find or create user // Find user
let users = await sql` const users = await sql`
SELECT u.id, u.email, fm.family_id as family_id SELECT u.id, u.email, u.password_hash, fm.family_id as family_id
FROM users u FROM users u
LEFT JOIN family_members fm ON fm.user_id = u.id LEFT JOIN family_members fm ON fm.user_id = u.id
WHERE u.email = ${email} WHERE u.email = ${email}
LIMIT 1 LIMIT 1
`; `;
let user = users?.[0]; const user = users?.[0];
// Create user if not found // Sign up - create new account
if (!user) { if (action === "signup") {
if (user) {
return NextResponse.json({ error: "Email already exists" }, { status: 400 });
}
const newUserId = crypto.randomUUID(); const newUserId = crypto.randomUUID();
const passwordHash = hashPassword(password);
await sql` await sql`
INSERT INTO users (id, email, created_at, updated_at) INSERT INTO users (id, email, password_hash, password_updated_at, created_at, updated_at)
VALUES (${newUserId}, ${email}, NOW(), NOW()) VALUES (${newUserId}, ${email}, ${passwordHash}, NOW(), NOW(), NOW())
`; `;
// Fetch the newly created user // Create session
users = await sql` const sessionToken = crypto.randomUUID();
SELECT u.id, u.email const expires = new Date();
FROM users u expires.setDate(expires.getDate() + 30);
WHERE u.id = ${newUserId} 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) { 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 sessionToken = crypto.randomUUID();
const expires = new Date(); const expires = new Date();
expires.setDate(expires.getDate() + 30); expires.setDate(expires.getDate() + 30);
// Insert session
await sql` await sql`
INSERT INTO sessions (session_token, user_id, expires) INSERT INTO sessions (session_token, user_id, expires)
VALUES (${sessionToken}, ${user.id}, ${expires.toISOString()}) VALUES (${sessionToken}, ${user.id}, ${expires.toISOString()})
@ -64,16 +107,14 @@ export async function POST(request: Request) {
family = families?.[0]; family = families?.[0];
} }
// Create response
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,
userId: user.id, userId: user.id,
email: user.email, email: user.email,
familyId: user.family_id, familyId: user.family_id,
family: family, family,
}); });
// Set cookie
response.cookies.set("tia_session", sessionToken, { response.cookies.set("tia_session", sessionToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
@ -81,53 +122,9 @@ export async function POST(request: Request) {
maxAge: 60 * 60 * 24 * 30, maxAge: 60 * 60 * 24 * 30,
path: "/", path: "/",
}); });
return response; return response;
} catch (error) { } catch (error) {
console.error("Signin error:", error); console.error("Auth error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 }); 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"; "use client";
import { useEffect } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState("");
useEffect(() => { const [password, setPassword] = useState("");
async function checkSession() { const [loading, setLoading] = useState(false);
const res = await fetch("/api/auth/signin"); const [error, setError] = useState("");
const data = await res.json(); const [mode, setMode] = useState<"login" | "signup">("login");
if (data.authenticated) {
if (data.familyId) {
router.push("/");
} else {
router.push("/onboarding");
}
}
}
checkSession();
}, [router]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const form = e.target as HTMLFormElement; if (!email || !password) return;
const email = (form.elements.namedItem("email") as HTMLInputElement)?.value;
if (!email) return; setLoading(true);
setError("");
const res = await fetch("/api/auth/signin", { try {
method: "POST", const res = await fetch("/api/auth/signin", {
headers: { "Content-Type": "application/json" }, method: "POST",
body: JSON.stringify({ email }), 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 (!res.ok) {
if (data.familyId) { setError(data.error || "Failed to login");
return;
}
// Redirect based on response
if (data.isNewUser) {
router.push("/onboarding");
} else if (data.familyId) {
router.push("/"); router.push("/");
} else { } else {
router.push("/onboarding"); router.push("/onboarding");
} }
} else { } catch (err) {
alert(data.error || "Sign in failed"); setError("Something went wrong");
} finally {
setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-amber-50"> <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="w-full max-w-md p-8 text-center"> <div className="bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-lg max-w-md w-full">
<h1 className="text-4xl font-bold mb-2">Tia</h1> <h1 className="text-2xl font-bold text-center mb-6 dark:text-white">
<p className="text-gray-600 mb-8">Your baby tracking companion</p> {mode === "login" ? "Welcome Back" : "Create Account"}
</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<input <div>
name="email" <label className="block text-sm font-medium mb-1 dark:text-gray-200">Email</label>
type="email" <input
placeholder="Enter your email" type="email"
className="w-full p-4 border rounded-2xl bg-white shadow-sm" value={email}
required 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 <button
type="submit" 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> </button>
</form> </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>
</div> </div>
); );