diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index fe6d680..9b1105f 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { createHash, randomBytes, scryptSync } from "crypto"; +import { createHash } from "crypto"; import { getServiceSupabase } from "@/lib/supabase/client"; export const runtime = "nodejs"; @@ -8,12 +8,6 @@ function hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } -function hashPassword(password: string, salt?: string): string { - const safeSalt = salt || randomBytes(16).toString("hex"); - const derived = scryptSync(password, safeSalt, 64).toString("hex"); - return `scrypt$${safeSalt}$${derived}`; -} - export async function POST(request: Request) { try { const body = (await request.json()) as { @@ -47,7 +41,7 @@ export async function POST(request: Request) { // Find valid token with user info const { data: resetToken } = await supabase .from("password_reset_tokens") - .select("id, user_id, users(email)") + .select("id, user_id, users(email, name)") .eq("token_hash", tokenHash) .eq("used", false) .gt("expires_at", now) @@ -64,22 +58,36 @@ export async function POST(request: Request) { const userEmail = Array.isArray(resetToken.users) ? resetToken.users[0]?.email : resetToken.users?.email; + const userName = Array.isArray(resetToken.users) + ? resetToken.users[0]?.name + : resetToken.users?.name; if (userEmail?.toLowerCase() !== email) { return NextResponse.json({ error: "Invalid reset token" }, { status: 400 }); } - // Hash new password - const passwordHash = hashPassword(password); - - // Update user password - const { error: updateError } = await supabase - .from("users") - .update({ password_hash: passwordHash }) - .eq("id", resetToken.user_id); + // Update Supabase Auth password. If auth user doesn't exist yet (legacy migration), + // create it with the same UUID so app foreign keys remain valid. + const { error: updateError } = await supabase.auth.admin.updateUserById(resetToken.user_id, { + password, + }); if (updateError) { - throw updateError; + const updateMessage = updateError.message || ""; + if (updateMessage.toLowerCase().includes("not found")) { + const { error: createError } = await supabase.auth.admin.createUser({ + id: resetToken.user_id, + email, + password, + email_confirm: true, + user_metadata: { + name: typeof userName === "string" && userName.trim().length > 0 ? userName : undefined, + }, + }); + if (createError) throw createError; + } else { + throw updateError; + } } // Mark token as used diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 40e1c08..f74be37 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,6 +1,8 @@ -import { randomBytes, scryptSync, timingSafeEqual, createHash } from "crypto"; +import { randomBytes, createHash } from "crypto"; import { cookies } from "next/headers"; +import { createClient } from "@supabase/supabase-js"; import { getServiceSupabase } from "@/lib/supabase/client"; +import type { Database } from "@/lib/supabase/database.types"; const SESSION_COOKIE_NAME = "gantt_session"; const SESSION_HOURS_SHORT = 12; @@ -19,10 +21,24 @@ interface UserRow { name: string; email: string; avatar_url: string | null; - password_hash: string; created_at: string; } +function getPublicSupabase() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase public environment variables"); + } + + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} + function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } @@ -40,22 +56,11 @@ function normalizeAvatarUrl(value: string | null | undefined): string | undefine return trimmed; } -function hashPassword(password: string, salt?: string): string { - const safeSalt = salt || randomBytes(16).toString("hex"); - const derived = scryptSync(password, safeSalt, 64).toString("hex"); - return `scrypt$${safeSalt}$${derived}`; -} - -function verifyPassword(password: string, stored: string): boolean { - const parts = stored.split("$"); - if (parts.length !== 3 || parts[0] !== "scrypt") return false; - const [, salt, digest] = parts; - const candidate = hashPassword(password, salt); - const candidateDigest = candidate.split("$")[2]; - const a = Buffer.from(digest, "hex"); - const b = Buffer.from(candidateDigest, "hex"); - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); +function fallbackNameFromEmail(email: string, preferredName?: string | null): string { + const trimmed = (preferredName || "").trim(); + if (trimmed.length >= 2) return trimmed; + const base = email.split("@")[0]?.trim() || "User"; + return base.length >= 2 ? base : "User"; } function hashSessionToken(token: string): string { @@ -84,6 +89,42 @@ function mapUserRow(row: UserRow): AuthUser { }; } +async function upsertUserMirrors(params: { + id: string; + name: string; + email: string; + avatarUrl?: string | null; +}) { + const supabase = getServiceSupabase(); + const { error: userError } = await supabase.from("users").upsert( + { + id: params.id, + name: params.name, + email: normalizeEmail(params.email), + avatar_url: params.avatarUrl ?? null, + }, + { onConflict: "id" } + ); + + if (userError) { + throw new Error(userError.message || "Failed to sync user profile"); + } + + const { error: profileError } = await supabase.from("profiles").upsert( + { + id: params.id, + name: params.name, + avatar_url: params.avatarUrl ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: "id" } + ); + + if (profileError) { + console.error("Failed to sync profiles mirror:", profileError.message); + } +} + export async function registerUser(params: { name: string; email: string; @@ -100,34 +141,41 @@ export async function registerUser(params: { if (password.length < 8) throw new Error("Password must be at least 8 characters"); const supabase = getServiceSupabase(); + const { data, error } = await supabase.auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { name }, + }); - // Check if email already exists - const { data: existing } = await supabase - .from("users") - .select("id") - .eq("email", email) - .maybeSingle(); - - if (existing) { - throw new Error("Email already exists"); + if (error || !data.user) { + const message = error?.message || "Failed to create user"; + if (message.toLowerCase().includes("already")) { + throw new Error("Email already exists"); + } + throw new Error(message); } - // Create user - const { data: user, error } = await supabase + const authUser = data.user; + const userName = fallbackNameFromEmail(email, name); + await upsertUserMirrors({ + id: authUser.id, + name: userName, + email, + avatarUrl: null, + }); + + const { data: userRow, error: userError } = await supabase .from("users") - .insert({ - name, - email, - password_hash: hashPassword(password), - }) .select("id, name, email, avatar_url, created_at") + .eq("id", authUser.id) .single(); - if (error || !user) { - throw new Error(error?.message || "Failed to create user"); + if (userError || !userRow) { + throw new Error(userError?.message || "Failed to load user profile"); } - return mapUserRow(user as UserRow); + return mapUserRow(userRow as UserRow); } export async function authenticateUser(params: { @@ -137,16 +185,42 @@ export async function authenticateUser(params: { await deleteExpiredSessions(); const email = normalizeEmail(params.email); + const authClient = getPublicSupabase(); + const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({ + email, + password: params.password, + }); + + if (signInError || !signInData.user) return null; + const supabase = getServiceSupabase(); + const authUser = signInData.user; + + const { data: existingRow } = await supabase + .from("users") + .select("id, name, email, avatar_url, created_at") + .eq("id", authUser.id) + .maybeSingle(); + + const userName = fallbackNameFromEmail( + authUser.email || email, + typeof authUser.user_metadata?.name === "string" ? authUser.user_metadata.name : existingRow?.name + ); + + await upsertUserMirrors({ + id: authUser.id, + name: userName, + email: authUser.email || email, + avatarUrl: existingRow?.avatar_url || null, + }); const { data: row } = await supabase .from("users") - .select("id, name, email, avatar_url, password_hash, created_at") - .eq("email", email) + .select("id, name, email, avatar_url, created_at") + .eq("id", authUser.id) .maybeSingle(); if (!row) return null; - if (!verifyPassword(params.password, row.password_hash)) return null; return mapUserRow(row as UserRow); } @@ -166,7 +240,7 @@ export async function updateUserAccount(params: { // Get current user data const { data: row } = await supabase .from("users") - .select("id, name, email, avatar_url, password_hash, created_at") + .select("id, name, email, avatar_url, created_at") .eq("id", params.userId) .single(); @@ -189,7 +263,12 @@ export async function updateUserAccount(params: { if (needsPasswordCheck) { if (!currentPassword) throw new Error("Current password is required"); - if (!verifyPassword(currentPassword, row.password_hash)) { + const authClient = getPublicSupabase(); + const { error: authError } = await authClient.auth.signInWithPassword({ + email: row.email, + password: currentPassword, + }); + if (authError) { throw new Error("Current password is incorrect"); } } @@ -205,7 +284,27 @@ export async function updateUserAccount(params: { if (existing) throw new Error("Email already exists"); } - const nextPasswordHash = passwordChanged ? hashPassword(newPassword) : row.password_hash; + if (emailChanged || passwordChanged || requestedName !== row.name) { + const updatePayload: { + email?: string; + password?: string; + user_metadata?: { name: string }; + } = { + user_metadata: { name: requestedName }, + }; + + if (emailChanged) updatePayload.email = requestedEmail; + if (passwordChanged) updatePayload.password = newPassword; + + const { error: authUpdateError } = await supabase.auth.admin.updateUserById(row.id, updatePayload); + if (authUpdateError) { + const message = authUpdateError.message || "Failed to update account"; + if (message.toLowerCase().includes("already")) { + throw new Error("Email already exists"); + } + throw new Error(message); + } + } const { data: updated, error } = await supabase .from("users") @@ -213,7 +312,6 @@ export async function updateUserAccount(params: { name: requestedName, email: requestedEmail, avatar_url: requestedAvatar ?? null, - password_hash: nextPasswordHash, }) .eq("id", row.id) .select("id, name, email, avatar_url, created_at") @@ -223,6 +321,20 @@ export async function updateUserAccount(params: { throw new Error(error?.message || "Failed to update user"); } + const { error: profileError } = await supabase.from("profiles").upsert( + { + id: row.id, + name: requestedName, + avatar_url: requestedAvatar ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: "id" } + ); + + if (profileError) { + console.error("Failed to sync profiles mirror:", profileError.message); + } + return mapUserRow(updated as UserRow); }