mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Fixing auth options export issue
This commit is contained in:
parent
b6353cb2d2
commit
8ca14c39ad
7 changed files with 250 additions and 248 deletions
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
243
src/app/api/auth/[...nextauth]/authOptions.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
|
||||
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
user as users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
} from "@/app/db/schema/users";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Environment variables
|
||||
// ------------------------------------------------------------------
|
||||
const {
|
||||
GOOGLE_CLIENT_ID = "",
|
||||
GOOGLE_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
EMAIL_SERVER_HOST = "",
|
||||
EMAIL_SERVER_PORT = "",
|
||||
EMAIL_SERVER_USER = "",
|
||||
EMAIL_SERVER_PASSWORD = "",
|
||||
EMAIL_FROM = "",
|
||||
} = process.env;
|
||||
|
||||
type OauthProvider = "google" | "azure-ad-b2c";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NextAuth configuration
|
||||
// ------------------------------------------------------------------
|
||||
export const AuthOptions: NextAuthOptions = {
|
||||
adapter: DrizzleEmailAdapter(db, {
|
||||
user: users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
}),
|
||||
|
||||
providers: [
|
||||
// ------------------ Google ------------------
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Azure AD B2C ------------------
|
||||
AzureADB2CProvider({
|
||||
tenantId: AZURE_AD_B2C_TENANT_NAME,
|
||||
clientId: AZURE_AD_B2C_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
|
||||
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access",
|
||||
prompt: "login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Email (SES Magic Link) ------------------
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: EMAIL_SERVER_HOST,
|
||||
port: Number(EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: EMAIL_SERVER_USER,
|
||||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
sendVerificationRequest: MagicLinksEmail,
|
||||
}),
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
signIn: "/", // your landing/login page
|
||||
verifyRequest: "/auth/verify-request",
|
||||
error: "/auth/error",
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Callbacks
|
||||
// ------------------------------------------------------------------
|
||||
callbacks: {
|
||||
/**
|
||||
* Sign in callback — ensures user exists and links OAuth provider if needed
|
||||
*/
|
||||
async signIn({ user, account, profile }) {
|
||||
try {
|
||||
if (!user?.email) return false;
|
||||
const normalisedEmail = user.email.toLowerCase();
|
||||
|
||||
// Fetch the user (NextAuth will have created them already if new)
|
||||
const [dbUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
|
||||
// New user - next auth will handle
|
||||
if (!dbUser) {
|
||||
console.log("New user sign up for email:", normalisedEmail);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-link provider if same verified email but account not linked yet
|
||||
if (account?.provider && account.type === "oauth") {
|
||||
const existingLink = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, dbUser.id),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
);
|
||||
|
||||
const emailVerified =
|
||||
(profile as any)?.email_verified ?? account.provider === "google";
|
||||
|
||||
if (existingLink.length === 0 && emailVerified) {
|
||||
// This handles the case where we had not set up accounts but
|
||||
// signed up users with oauth
|
||||
console.log(
|
||||
`Linking ${account.provider} account for user ${normalisedEmail}`
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId: dbUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
id_token: account.id_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Link OAuth ID if missing (helps for older accounts)
|
||||
if (account && !dbUser.oauthId) {
|
||||
console.log("Linking OAuth ID for user:", normalisedEmail);
|
||||
const provider = account.provider as OauthProvider;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
}
|
||||
|
||||
// Always update last login timestamp
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, dbUser.id));
|
||||
|
||||
// Pass bigint ID into NextAuth session/jwt
|
||||
user.dbId = dbUser.id.toString();
|
||||
user.onboarded = dbUser.onboarded ?? false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during sign-in:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist dbId in the JWT so it’s available in sessions
|
||||
*/
|
||||
async jwt({ token, user: userFromLogin }) {
|
||||
// Initial sign-in: attach DB fields
|
||||
if (userFromLogin) {
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, userFromLogin.email!),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
} else if (token.email) {
|
||||
// On subsequent calls, keep token synced from DB
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, token.email),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
}
|
||||
|
||||
if (userFromLogin?.dbId) {
|
||||
token.dbId = userFromLogin.dbId;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach dbId to session.user
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.dbId) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect users after login
|
||||
*/
|
||||
async redirect({ baseUrl }) {
|
||||
// If the user has not onboarded, send them to onboarding
|
||||
return `${baseUrl}/home`;
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// General options
|
||||
// ------------------------------------------------------------------
|
||||
session: {
|
||||
strategy: "jwt", // lightweight, avoids DB session writes
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
debug: false,
|
||||
};
|
||||
|
|
@ -1,246 +1,5 @@
|
|||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
|
||||
import EmailProvider from "next-auth/providers/email";
|
||||
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
|
||||
import { MagicLinksEmail } from "@/app/email_templates/magic_link";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
user as users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
} from "@/app/db/schema/users";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Environment variables
|
||||
// ------------------------------------------------------------------
|
||||
const {
|
||||
GOOGLE_CLIENT_ID = "",
|
||||
GOOGLE_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_TENANT_NAME = "",
|
||||
AZURE_AD_B2C_CLIENT_ID = "",
|
||||
AZURE_AD_B2C_CLIENT_SECRET = "",
|
||||
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
|
||||
EMAIL_SERVER_HOST = "",
|
||||
EMAIL_SERVER_PORT = "",
|
||||
EMAIL_SERVER_USER = "",
|
||||
EMAIL_SERVER_PASSWORD = "",
|
||||
EMAIL_FROM = "",
|
||||
} = process.env;
|
||||
|
||||
type OauthProvider = "google" | "azure-ad-b2c";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// NextAuth configuration
|
||||
// ------------------------------------------------------------------
|
||||
export const AuthOptions: NextAuthOptions = {
|
||||
adapter: DrizzleEmailAdapter(db, {
|
||||
user: users,
|
||||
accounts,
|
||||
verificationTokens,
|
||||
}),
|
||||
|
||||
providers: [
|
||||
// ------------------ Google ------------------
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
authorization: {
|
||||
params: {
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
response_type: "code",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Azure AD B2C ------------------
|
||||
AzureADB2CProvider({
|
||||
tenantId: AZURE_AD_B2C_TENANT_NAME,
|
||||
clientId: AZURE_AD_B2C_CLIENT_ID,
|
||||
clientSecret: AZURE_AD_B2C_CLIENT_SECRET,
|
||||
primaryUserFlow: AZURE_AD_B2C_PRIMARY_USER_FLOW,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: "openid profile offline_access",
|
||||
prompt: "login",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// ------------------ Email (SES Magic Link) ------------------
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: EMAIL_SERVER_HOST,
|
||||
port: Number(EMAIL_SERVER_PORT),
|
||||
auth: {
|
||||
user: EMAIL_SERVER_USER,
|
||||
pass: EMAIL_SERVER_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: EMAIL_FROM, // noreply email
|
||||
maxAge: 60 * 60, // magic link valid for 1 hour
|
||||
sendVerificationRequest: MagicLinksEmail,
|
||||
}),
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pages
|
||||
// ------------------------------------------------------------------
|
||||
pages: {
|
||||
signIn: "/", // your landing/login page
|
||||
verifyRequest: "/auth/verify-request",
|
||||
error: "/auth/error",
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Callbacks
|
||||
// ------------------------------------------------------------------
|
||||
callbacks: {
|
||||
/**
|
||||
* Sign in callback — ensures user exists and links OAuth provider if needed
|
||||
*/
|
||||
async signIn({ user, account, profile }) {
|
||||
try {
|
||||
if (!user?.email) return false;
|
||||
const normalisedEmail = user.email.toLowerCase();
|
||||
|
||||
// Fetch the user (NextAuth will have created them already if new)
|
||||
const [dbUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
|
||||
// New user - next auth will handle
|
||||
if (!dbUser) {
|
||||
console.log("New user sign up for email:", normalisedEmail);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Auto-link provider if same verified email but account not linked yet
|
||||
if (account?.provider && account.type === "oauth") {
|
||||
const existingLink = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, dbUser.id),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
);
|
||||
|
||||
const emailVerified =
|
||||
(profile as any)?.email_verified ?? account.provider === "google";
|
||||
|
||||
if (existingLink.length === 0 && emailVerified) {
|
||||
// This handles the case where we had not set up accounts but
|
||||
// signed up users with oauth
|
||||
console.log(
|
||||
`Linking ${account.provider} account for user ${normalisedEmail}`
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId: dbUser.id,
|
||||
type: account.type,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
access_token: account.access_token,
|
||||
id_token: account.id_token,
|
||||
refresh_token: account.refresh_token,
|
||||
expires_at: account.expires_at,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
// Link OAuth ID if missing (helps for older accounts)
|
||||
if (account && !dbUser.oauthId) {
|
||||
console.log("Linking OAuth ID for user:", normalisedEmail);
|
||||
const provider = account.provider as OauthProvider;
|
||||
await db
|
||||
.update(users)
|
||||
.set({ oauthId: user.id, oauthProvider: provider })
|
||||
.where(eq(users.email, normalisedEmail));
|
||||
}
|
||||
|
||||
// Always update last login timestamp
|
||||
await db
|
||||
.update(users)
|
||||
.set({ lastLogin: new Date() })
|
||||
.where(eq(users.id, dbUser.id));
|
||||
|
||||
// Pass bigint ID into NextAuth session/jwt
|
||||
user.dbId = dbUser.id.toString();
|
||||
user.onboarded = dbUser.onboarded ?? false;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error during sign-in:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist dbId in the JWT so it’s available in sessions
|
||||
*/
|
||||
async jwt({ token, user: userFromLogin }) {
|
||||
// Initial sign-in: attach DB fields
|
||||
if (userFromLogin) {
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, userFromLogin.email!),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
} else if (token.email) {
|
||||
// On subsequent calls, keep token synced from DB
|
||||
const existing = await db.query.user.findFirst({
|
||||
where: eq(users.email, token.email),
|
||||
});
|
||||
if (existing) {
|
||||
token.onboarded = existing.onboarded;
|
||||
}
|
||||
}
|
||||
|
||||
if (userFromLogin?.dbId) {
|
||||
token.dbId = userFromLogin.dbId;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach dbId to session.user
|
||||
*/
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.dbId) {
|
||||
session.user.dbId = token.dbId;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
/**
|
||||
* Redirect users after login
|
||||
*/
|
||||
async redirect({ baseUrl }) {
|
||||
// If the user has not onboarded, send them to onboarding
|
||||
return `${baseUrl}/home`;
|
||||
},
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// General options
|
||||
// ------------------------------------------------------------------
|
||||
session: {
|
||||
strategy: "jwt", // lightweight, avoids DB session writes
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
debug: false,
|
||||
};
|
||||
import NextAuth from "next-auth";
|
||||
import { AuthOptions } from "./authOptions";
|
||||
|
||||
const handler = NextAuth(AuthOptions);
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { db } from "@/app/db/db";
|
|||
import { user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
||||
const OnboardedSchema = z.object({
|
||||
onboarded: z.boolean(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { db } from "@/app/db/db";
|
|||
import { userProfiles, user } from "@/app/db/schema/users";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
||||
const UserProfileSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import CardTiles from "../components/home/CardTiles";
|
||||
import { getPortfolios } from "./utils";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import "./globals.css";
|
|||
import Provider from "./components/Provider";
|
||||
import Nav from "./components/Navbar";
|
||||
import { ReactQueryProvider } from "./ReactQueryProvider";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { cache } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getServerSession } from "next-auth/next";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/route";
|
||||
import { AuthOptions } from "./api/auth/[...nextauth]/authOptions";
|
||||
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
|
||||
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
|
||||
import EmailSignInButton from "./components/signin/EmailSignInButton";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue