auth0: Google login, social auth auto-provisioning

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
This commit is contained in:
2026-03-03 06:17:34 +08:00
parent 369860d8b9
commit 05acda0adb
3 changed files with 204 additions and 70 deletions

View File

@@ -1,15 +1,85 @@
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",
newUser: "/dashboard/setup",
},
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: {
@@ -42,14 +112,44 @@ export const authOptions: NextAuthOptions = {
}),
],
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
token.role = u.role
token.orgId = u.orgId
token.orgName = u.orgName
token.orgSlug = u.orgSlug
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
},
@@ -57,7 +157,7 @@ export const authOptions: NextAuthOptions = {
if (session.user) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = session as any
s.user.id = token.sub
s.user.id = token.dbId || token.sub
s.user.role = token.role
s.user.orgId = token.orgId
s.user.orgName = token.orgName