import { randomBytes, createHash } from "crypto"; import { cookies } from "next/headers"; import { createClient } from "@supabase/supabase-js"; import { getServiceSupabase } from "@/lib/supabase/client"; const SESSION_COOKIE_NAME = "mission_control_session"; const SESSION_HOURS_SHORT = 12; const SESSION_DAYS_REMEMBER = 30; export interface AuthUser { id: string; name: string; email: string; avatarUrl?: string; createdAt: string; } interface UserRow { id: string; name: string; email: string; avatar_url: string | null; created_at: string; } function getPublicSupabase() { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.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(); } function normalizeAvatarUrl(value: string | null | undefined): string | undefined { if (value == null) return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; if (!trimmed.startsWith("data:image/")) { throw new Error("Avatar must be an image"); } if (trimmed.length > 2_000_000) { throw new Error("Avatar image is too large"); } return trimmed; } 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 { return createHash("sha256").update(token).digest("hex"); } async function deleteExpiredSessions() { const supabase = getServiceSupabase(); const { error } = await supabase .from("sessions") .delete() .lte("expires_at", new Date().toISOString()); if (error) { console.error("Failed to delete expired sessions:", error); } } function mapUserRow(row: UserRow): AuthUser { return { id: row.id, name: row.name, email: row.email, avatarUrl: row.avatar_url ?? undefined, createdAt: row.created_at, }; } 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; password: string; }): Promise { await deleteExpiredSessions(); const name = params.name.trim(); const email = normalizeEmail(params.email); const password = params.password; if (name.length < 2) throw new Error("Name must be at least 2 characters"); if (!email.includes("@")) throw new Error("Invalid email"); 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 }, }); 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); } 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") .select("id, name, email, avatar_url, created_at") .eq("id", authUser.id) .single(); if (userError || !userRow) { throw new Error(userError?.message || "Failed to load user profile"); } return mapUserRow(userRow as UserRow); } export async function authenticateUser(params: { email: string; password: string; }): Promise { 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, created_at") .eq("id", authUser.id) .maybeSingle(); if (!row) return null; return mapUserRow(row as UserRow); } export async function updateUserAccount(params: { userId: string; name?: string; email?: string; avatarUrl?: string | null; currentPassword?: string; newPassword?: string; }): Promise { await deleteExpiredSessions(); const supabase = getServiceSupabase(); // Get current user data const { data: row } = await supabase .from("users") .select("id, name, email, avatar_url, created_at") .eq("id", params.userId) .single(); if (!row) throw new Error("User not found"); const requestedName = typeof params.name === "string" ? params.name.trim() : row.name; const requestedEmail = typeof params.email === "string" ? normalizeEmail(params.email) : row.email; const hasAvatarInput = Object.prototype.hasOwnProperty.call(params, "avatarUrl"); const requestedAvatar = hasAvatarInput ? normalizeAvatarUrl(params.avatarUrl) : row.avatar_url; const currentPassword = params.currentPassword || ""; const newPassword = params.newPassword || ""; if (requestedName.length < 2) throw new Error("Name must be at least 2 characters"); if (!requestedEmail.includes("@")) throw new Error("Invalid email"); if (newPassword && newPassword.length < 8) throw new Error("New password must be at least 8 characters"); const emailChanged = requestedEmail !== row.email; const passwordChanged = newPassword.length > 0; const needsPasswordCheck = emailChanged || passwordChanged; if (needsPasswordCheck) { if (!currentPassword) throw new Error("Current password is required"); const authClient = getPublicSupabase(); const { error: authError } = await authClient.auth.signInWithPassword({ email: row.email, password: currentPassword, }); if (authError) { throw new Error("Current password is incorrect"); } } if (emailChanged) { const { data: existing } = await supabase .from("users") .select("id") .eq("email", requestedEmail) .neq("id", row.id) .maybeSingle(); if (existing) throw new Error("Email already exists"); } 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") .update({ name: requestedName, email: requestedEmail, avatar_url: requestedAvatar ?? null, }) .eq("id", row.id) .select("id, name, email, avatar_url, created_at") .single(); if (error || !updated) { 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); } export async function listUsers(): Promise { const supabase = getServiceSupabase(); const { data: rows, error } = await supabase .from("users") .select("id, name, email, avatar_url, created_at") .order("name", { ascending: true }); if (error) { console.error("Failed to list users:", error); return []; } return (rows || []).map(mapUserRow); } export async function createUserSession( userId: string, rememberMe: boolean ): Promise<{ token: string; expiresAt: string }> { await deleteExpiredSessions(); const supabase = getServiceSupabase(); const now = Date.now(); const ttlMs = rememberMe ? SESSION_DAYS_REMEMBER * 24 * 60 * 60 * 1000 : SESSION_HOURS_SHORT * 60 * 60 * 1000; const createdAt = new Date(now).toISOString(); const expiresAt = new Date(now + ttlMs).toISOString(); const token = randomBytes(32).toString("hex"); const tokenHash = hashSessionToken(token); const { error } = await supabase.from("sessions").insert({ user_id: userId, token_hash: tokenHash, created_at: createdAt, expires_at: expiresAt, }); if (error) { throw new Error("Failed to create session"); } return { token, expiresAt }; } export async function revokeSession(token: string): Promise { const supabase = getServiceSupabase(); const tokenHash = hashSessionToken(token); await supabase.from("sessions").delete().eq("token_hash", tokenHash); } export async function getUserBySessionToken(token: string): Promise { await deleteExpiredSessions(); const supabase = getServiceSupabase(); const tokenHash = hashSessionToken(token); const now = new Date().toISOString(); const { data: row } = await supabase .from("sessions") .select("user_id, users(id, name, email, avatar_url, created_at)") .eq("token_hash", tokenHash) .gt("expires_at", now) .maybeSingle(); if (!row || !row.users) return null; const user = Array.isArray(row.users) ? row.users[0] : row.users; return mapUserRow(user as UserRow); } export async function setSessionCookie(token: string, rememberMe: boolean): Promise { const cookieStore = await cookies(); const baseOptions = { httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", path: "/", }; if (rememberMe) { cookieStore.set(SESSION_COOKIE_NAME, token, { ...baseOptions, maxAge: SESSION_DAYS_REMEMBER * 24 * 60 * 60, }); return; } // Session cookie (clears on browser close) cookieStore.set(SESSION_COOKIE_NAME, token, baseOptions); } export async function clearSessionCookie(): Promise { const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE_NAME, "", { httpOnly: true, sameSite: "lax" as const, secure: process.env.NODE_ENV === "production", path: "/", maxAge: 0, }); } export async function getSessionTokenFromCookies(): Promise { const cookieStore = await cookies(); return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; } export async function getAuthenticatedUser(): Promise { const token = await getSessionTokenFromCookies(); if (!token) return null; return getUserBySessionToken(token); }