AUTH0 SETUP (done via Management API): - Created 'Pledge Now Pay Later' app (regular_web) on quikcue.us.auth0.com - Enabled connections: Google, Apple, Username-Password - Callback: https://pledge.quikcue.com/api/auth/callback/auth0 - Client ID: hpr7JcEAAk3Q5ADkzyyZSRDxGIZTcjRJ CODE CHANGES: - Auth0Provider added to NextAuth alongside existing CredentialsProvider - findOrCreateSocialUser(): first Google login auto-creates org + user - Login page: 'Continue with Google' button at top, email/password below - Signup page: 'Sign up with Google' button at top, form below - JWT callback: resolves Auth0 users to DB users on every token refresh - Docker compose: AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_ISSUER env vars FLOW: - Click 'Continue with Google' → Auth0 Universal Login → Google consent - First time: auto-creates '{Name}'s Charity' org + org_admin user - Return time: finds existing user, loads their org - Demo login still works via credentials provider
170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
import { type NextAuthOptions } from "next-auth"
|
|
import CredentialsProvider from "next-auth/providers/credentials"
|
|
import Auth0Provider from "next-auth/providers/auth0"
|
|
import { compare } from "bcryptjs"
|
|
import prisma from "@/lib/prisma"
|
|
|
|
/**
|
|
* Find or create a user+org from an Auth0 social login.
|
|
* First login creates the org; subsequent logins find existing.
|
|
*/
|
|
async function findOrCreateSocialUser(profile: { email: string; name?: string; picture?: string }) {
|
|
if (!prisma || !profile.email) return null
|
|
|
|
const email = profile.email.toLowerCase().trim()
|
|
|
|
// Check if user exists
|
|
const existing = await prisma.user.findUnique({
|
|
where: { email },
|
|
include: { organization: { select: { id: true, name: true, slug: true } } },
|
|
})
|
|
|
|
if (existing) {
|
|
return {
|
|
id: existing.id,
|
|
email: existing.email,
|
|
name: existing.name,
|
|
role: existing.role,
|
|
orgId: existing.organizationId,
|
|
orgName: existing.organization.name,
|
|
orgSlug: existing.organization.slug,
|
|
}
|
|
}
|
|
|
|
// First-time social login → create org + user
|
|
const name = profile.name || email.split("@")[0]
|
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 30) + "-" + Date.now().toString(36)
|
|
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const org = await tx.organization.create({
|
|
data: {
|
|
name: `${name}'s Charity`,
|
|
slug,
|
|
country: "GB",
|
|
refPrefix: slug.substring(0, 4).toUpperCase(),
|
|
},
|
|
})
|
|
const user = await tx.user.create({
|
|
data: {
|
|
email,
|
|
name,
|
|
role: "org_admin",
|
|
organizationId: org.id,
|
|
},
|
|
})
|
|
return { user, org }
|
|
})
|
|
|
|
return {
|
|
id: result.user.id,
|
|
email: result.user.email,
|
|
name: result.user.name,
|
|
role: result.user.role,
|
|
orgId: result.org.id,
|
|
orgName: result.org.name,
|
|
orgSlug: result.org.slug,
|
|
}
|
|
}
|
|
|
|
export const authOptions: NextAuthOptions = {
|
|
session: { strategy: "jwt" },
|
|
pages: {
|
|
signIn: "/login",
|
|
},
|
|
providers: [
|
|
// Auth0 — Google, Apple, email/password via Universal Login
|
|
Auth0Provider({
|
|
clientId: process.env.AUTH0_CLIENT_ID || "hpr7JcEAAk3Q5ADkzyyZSRDxGIZTcjRJ",
|
|
clientSecret: process.env.AUTH0_CLIENT_SECRET || "ha6Q5bK1B-YaluwznBvgi8jaCpqwdNmLq-UAca_-WHVy6Yfscf1tfNCrHPxKwvAh",
|
|
issuer: process.env.AUTH0_ISSUER || "https://quikcue.us.auth0.com",
|
|
}),
|
|
|
|
// Keep credentials for demo login + existing password users
|
|
CredentialsProvider({
|
|
name: "credentials",
|
|
credentials: {
|
|
email: { label: "Email", type: "email" },
|
|
password: { label: "Password", type: "password" },
|
|
},
|
|
async authorize(credentials) {
|
|
if (!credentials?.email || !credentials?.password || !prisma) return null
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { email: credentials.email.toLowerCase().trim() },
|
|
include: { organization: { select: { id: true, name: true, slug: true } } },
|
|
})
|
|
|
|
if (!user || !user.hashedPassword) return null
|
|
|
|
const valid = await compare(credentials.password, user.hashedPassword)
|
|
if (!valid) return null
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
orgId: user.organizationId,
|
|
orgName: user.organization.name,
|
|
orgSlug: user.organization.slug,
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
callbacks: {
|
|
async signIn({ user, account, profile }) {
|
|
// For Auth0 social logins, find/create user in our DB
|
|
if (account?.provider === "auth0" && profile?.email) {
|
|
const dbUser = await findOrCreateSocialUser({
|
|
email: profile.email,
|
|
name: (profile as { name?: string }).name || undefined,
|
|
picture: (profile as { picture?: string }).picture || undefined,
|
|
})
|
|
if (dbUser) {
|
|
// Attach our DB fields to the user object for the jwt callback
|
|
Object.assign(user, dbUser)
|
|
}
|
|
return true
|
|
}
|
|
return true
|
|
},
|
|
async jwt({ token, user }) {
|
|
if (user) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const u = user as any
|
|
if (u.orgId) {
|
|
token.role = u.role
|
|
token.orgId = u.orgId
|
|
token.orgName = u.orgName
|
|
token.orgSlug = u.orgSlug
|
|
token.dbId = u.id
|
|
}
|
|
}
|
|
// For Auth0 users on first token creation, look up from DB
|
|
if (!token.orgId && token.email) {
|
|
const dbUser = await findOrCreateSocialUser({ email: token.email as string, name: token.name || undefined })
|
|
if (dbUser) {
|
|
token.role = dbUser.role
|
|
token.orgId = dbUser.orgId
|
|
token.orgName = dbUser.orgName
|
|
token.orgSlug = dbUser.orgSlug
|
|
token.dbId = dbUser.id
|
|
}
|
|
}
|
|
return token
|
|
},
|
|
async session({ session, token }) {
|
|
if (session.user) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const s = session as any
|
|
s.user.id = token.dbId || token.sub
|
|
s.user.role = token.role
|
|
s.user.orgId = token.orgId
|
|
s.user.orgName = token.orgName
|
|
s.user.orgSlug = token.orgSlug
|
|
}
|
|
return session
|
|
},
|
|
},
|
|
}
|