import { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import GoogleProvider from "next-auth/providers/google"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaClient } from "@prisma/client"; import { query } from "@/lib/db-postgres"; import { ensureWorkspaceForUser } from "@/lib/workspaces"; const prisma = new PrismaClient(); const nextAuthUrl = (process.env.NEXTAUTH_URL ?? "").trim(); const isLocalNextAuth = nextAuthUrl.startsWith("http://localhost") || nextAuthUrl.startsWith("http://127.0.0.1") || (process.env.NODE_ENV === "development" && !nextAuthUrl); /** Set in .env.local (server + client): one email for local dev bypass. */ const devLocalEmail = (process.env.NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL ?? "").trim(); const devLocalSecret = (process.env.DEV_LOCAL_AUTH_SECRET ?? "").trim(); const devLocalAuthEnabled = process.env.NODE_ENV === "development" && devLocalEmail.length > 0; function isLocalhostHost(host: string): boolean { const h = host.split(":")[0]?.toLowerCase() ?? ""; return ( h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1" ); } export const authOptions: NextAuthOptions = { debug: process.env.NODE_ENV === "development", adapter: PrismaAdapter(prisma), providers: [ ...(devLocalAuthEnabled ? [ CredentialsProvider({ id: "dev-local", name: "Local dev", credentials: { password: { label: "Dev secret", type: "password" }, }, async authorize(credentials, req) { const headers = (req as { headers?: Headers } | undefined)?.headers; const host = headers && typeof headers.get === "function" ? (headers.get("host") ?? "") : ""; if (devLocalSecret) { if ((credentials?.password ?? "") !== devLocalSecret) { return null; } } else if (!isLocalhostHost(host)) { return null; } const name = (process.env.DEV_LOCAL_AUTH_NAME ?? "").trim() || "Local dev"; const user = await prisma.user.upsert({ where: { email: devLocalEmail }, create: { email: devLocalEmail, name, emailVerified: new Date(), }, update: { name }, }); return { id: user.id, email: user.email, name: user.name, image: user.image, }; }, }), ] : []), GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", }), ], pages: { signIn: "/auth", error: "/auth", }, callbacks: { async session({ session, user }) { if (session.user && "id" in user && user.id) { (session.user as { id: string }).id = user.id; } return session; }, async signIn({ user }) { if (!user?.email) return true; try { const workspace = user.email.split("@")[0].toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-account"; const data = JSON.stringify({ email: user.email, name: user.name, image: user.image, workspace, }); // Two-step upsert avoids relying on ON CONFLICT expression matching const existing = await query<{ id: string }>( `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, [user.email] ); let fsUserId: string; if (existing.length === 0) { const inserted = await query<{ id: string }>( `INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb) RETURNING id`, [user.id, data] ); fsUserId = inserted[0].id; } else { await query( `UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`, [user.id, data, existing[0].id] ); fsUserId = existing[0].id; } // Ensure a Vibn workspace exists for this user. We DO NOT // provision Coolify/Gitea here — that happens lazily on first // project create so signin stays fast and resilient to outages. try { await ensureWorkspaceForUser({ userId: fsUserId, email: user.email, displayName: user.name ?? null, }); } catch (wsErr) { console.error("[signIn] Failed to ensure workspace:", wsErr); } } catch (e) { console.error("[signIn] Failed to upsert fs_user:", e); } return true; }, }, session: { strategy: "database", maxAge: 30 * 24 * 60 * 60, // 30 days }, secret: process.env.NEXTAUTH_SECRET, cookies: { sessionToken: { // __Secure- prefix requires Secure; localhost HTTP needs plain name + secure: false name: isLocalNextAuth ? "next-auth.session-token" : "__Secure-next-auth.session-token", options: { httpOnly: true, sameSite: "lax", path: "/", secure: !isLocalNextAuth, ...(isLocalNextAuth ? {} : { domain: ".vibnai.com" }), }, }, }, };