174 lines
5.5 KiB
TypeScript
174 lines
5.5 KiB
TypeScript
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 = {
|
|
/** Verbose adapter/session logs; enable with NEXTAUTH_DEBUG=true when needed */
|
|
debug: process.env.NEXTAUTH_DEBUG === "true",
|
|
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: "/signin",
|
|
error: "/signin",
|
|
},
|
|
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" }),
|
|
},
|
|
},
|
|
},
|
|
};
|