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:
parent
09f263b423
commit
f967215fc8
3 changed files with 158 additions and 112 deletions
17
src/app/api/auth/migrate/route.ts
Normal file
17
src/app/api/auth/migrate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue