Refine role-aware login and theming
Made-with: Cursor
This commit is contained in:
parent
ab14d7b004
commit
387cfb0aa2
16
README.md
16
README.md
@ -1,8 +1,20 @@
|
||||
## JUNO
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img src="./public/icon.png" alt="JUNO logo" width="420" />
|
||||
</p>
|
||||
|
||||
**Tech stack:** ⚛️ Next.js · 💙 TypeScript · 🐘 PostgreSQL · ☁️ AWS Bedrock · 📬 Gmail API · 🐳 Docker · 🔐 NextAuth
|
||||
**Tech stack**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Modern commerce and support infrastructure for Shopify‑based dropshipping businesses. 🚀
|
||||
|
||||
|
||||
@ -21,9 +21,9 @@ export default async function DashboardLayout({
|
||||
|
||||
if (session.user.role !== "VENDOR") {
|
||||
if (session.user.role === "SUPPLIER") {
|
||||
redirect("/supplier")
|
||||
redirect("/login?error=WrongPortal&role=SUPPLIER")
|
||||
}
|
||||
redirect("/login")
|
||||
redirect("/login?error=WrongRole")
|
||||
}
|
||||
|
||||
const { prisma } = await import("@/lib/prisma")
|
||||
|
||||
@ -1,21 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { motion } from "framer-motion"
|
||||
import { Mail, Lock, Loader2, Shield } from "lucide-react"
|
||||
import { Mail, Lock, Loader2, Shield, ShoppingBag, Truck } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialRoleFromQuery =
|
||||
searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR"
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [requiresMFA, setRequiresMFA] = useState(false)
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [mfaCode, setMfaCode] = useState("")
|
||||
const [role, setRole] = useState<"VENDOR" | "SUPPLIER">(initialRoleFromQuery)
|
||||
|
||||
const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => {
|
||||
try {
|
||||
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600`
|
||||
} catch {
|
||||
// ignore cookie errors – server-side will just skip role enforcement
|
||||
}
|
||||
}
|
||||
|
||||
// Map Auth.js error codes from query string to friendly messages
|
||||
useEffect(() => {
|
||||
const errorCode = searchParams.get("error")
|
||||
const existingRole = searchParams.get("existingRole")
|
||||
|
||||
if (errorCode === "SigninRoleMismatch") {
|
||||
if (existingRole === "SUPPLIER") {
|
||||
setError(
|
||||
"This email is registered as a Supplier. Please choose the Supplier option or use another email."
|
||||
)
|
||||
setRole("SUPPLIER")
|
||||
} else if (existingRole === "VENDOR") {
|
||||
setError(
|
||||
"This email is registered as a Vendor. Please choose the Vendor option or use another email."
|
||||
)
|
||||
setRole("VENDOR")
|
||||
} else {
|
||||
setError(
|
||||
"This email is already registered with a different role. Please select the correct role or use another email."
|
||||
)
|
||||
}
|
||||
} else if (errorCode === "WrongRole" || errorCode === "WrongPortal") {
|
||||
setError(
|
||||
"You tried to open a different portal with this account. Please sign in with the correct role or use a separate account."
|
||||
)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const handleCredentialsLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
@ -69,6 +110,9 @@ export default function LoginPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the selected role is visible to the auth callbacks
|
||||
applyRoleCookie(role)
|
||||
|
||||
// If MFA was required and code provided, or MFA not required, proceed with NextAuth signIn
|
||||
const res = await signIn("credentials", {
|
||||
email: emailValue,
|
||||
@ -94,6 +138,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
applyRoleCookie(role)
|
||||
signIn("google", { callbackUrl: "/onboarding" })
|
||||
}
|
||||
|
||||
@ -127,6 +172,56 @@ export default function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:gap-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("VENDOR")}
|
||||
className={`p-3 rounded-2xl border transition-all duration-200 text-left ${
|
||||
role === "VENDOR"
|
||||
? "border-indigo-500/50 bg-indigo-500/20"
|
||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
|
||||
role === "VENDOR"
|
||||
? "bg-indigo-500/50 text-white border border-indigo-400/30"
|
||||
: "bg-white/10 text-slate-400 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="font-bold text-xs text-white">Vendor</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium">
|
||||
Log in to your store dashboard
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("SUPPLIER")}
|
||||
className={`p-3 rounded-2xl border transition-all duration-200 text-left ${
|
||||
role === "SUPPLIER"
|
||||
? "border-blue-500/50 bg-blue-500/20"
|
||||
: "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
|
||||
role === "SUPPLIER"
|
||||
? "bg-blue-500/50 text-white border border-blue-400/30"
|
||||
: "bg-white/10 text-slate-400 border border-white/10"
|
||||
}`}
|
||||
>
|
||||
<Truck className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="font-bold text-xs text-white">Supplier</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium">
|
||||
Log in to your supplier portal
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCredentialsLogin} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@ -7,19 +7,31 @@ import { LandingFeatures } from "@/components/landing/LandingFeatures"
|
||||
import { LandingFooter } from "@/components/landing/LandingFooter"
|
||||
import { MobileHero } from "@/components/landing/mobile/MobileHero"
|
||||
import { MobileFeatures } from "@/components/landing/mobile/MobileFeatures"
|
||||
import { useTheme } from "@/components/ThemeProvider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
|
||||
const LandingSolutions = dynamic(() => import("@/components/landing/LandingSolutions").then(mod => mod.LandingSolutions), {
|
||||
ssr: true,
|
||||
loading: () => <div className="min-h-[400px] bg-[#0a0e1a]" />
|
||||
})
|
||||
const LandingSolutions = dynamic(
|
||||
() => import("@/components/landing/LandingSolutions").then((mod) => mod.LandingSolutions),
|
||||
{
|
||||
ssr: true,
|
||||
loading: () => <div className="min-h-[400px] bg-[#0a0e1a]" />,
|
||||
}
|
||||
)
|
||||
|
||||
export default function HomePage() {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||
const { theme } = useTheme()
|
||||
const isLight = theme === "light"
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0e1a] selection:bg-indigo-500/30">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-screen selection:bg-indigo-500/30 transition-colors duration-300",
|
||||
isLight ? "bg-slate-50 text-slate-900" : "bg-[#0a0e1a] text-slate-50"
|
||||
)}
|
||||
>
|
||||
<LandingNavbar isLoggedIn={false} />
|
||||
<main>
|
||||
{isMobile ? (
|
||||
|
||||
@ -20,7 +20,10 @@ export default async function SupplierLayout({
|
||||
|
||||
// Only allow SUPPLIER role to access supplier dashboard
|
||||
if (session.user.role !== "SUPPLIER") {
|
||||
redirect("/dashboard")
|
||||
if (session.user.role === "VENDOR") {
|
||||
redirect("/login?error=WrongPortal&role=VENDOR")
|
||||
}
|
||||
redirect("/login?error=WrongRole")
|
||||
}
|
||||
|
||||
// Ensure supplier profile exists, create if not
|
||||
|
||||
44
src/auth.ts
44
src/auth.ts
@ -3,6 +3,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import authConfig from "./auth.config"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||
@ -13,6 +14,45 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true, // Trust all hosts (needed for ngrok/proxies)
|
||||
...authConfig,
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
// Allow non-email-based sign-in flows to proceed as usual
|
||||
if (!user?.email) return true
|
||||
|
||||
// Never constrain the admin account via role toggle
|
||||
if (user.email === ADMIN_EMAIL || user.role === "ADMIN") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Read the desired role from the role toggle cookie set on the client
|
||||
const cookieStore = await cookies()
|
||||
const cookieRole = cookieStore.get("juno_login_role")?.value
|
||||
const desiredRole =
|
||||
cookieRole === "SUPPLIER" || cookieRole === "VENDOR" ? cookieRole : null
|
||||
|
||||
// If no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour
|
||||
if (!desiredRole) {
|
||||
return true
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: user.email },
|
||||
select: { id: true, role: true },
|
||||
})
|
||||
|
||||
// New user via OAuth: align their role with the chosen role on first sign-in
|
||||
if (existingUser && existingUser.role !== desiredRole) {
|
||||
// If the role already exists and differs, block sign-in with a clear error code
|
||||
const url = new URL("/login", process.env.NEXTAUTH_URL || "http://localhost:3000")
|
||||
url.searchParams.set("error", "SigninRoleMismatch")
|
||||
url.searchParams.set("existingRole", existingUser.role || "VENDOR")
|
||||
url.searchParams.set("desiredRole", desiredRole)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// If the user record exists but has the default role and this is effectively a first login
|
||||
// we keep the current behaviour and let onboarding/layouts route correctly.
|
||||
return true
|
||||
},
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
@ -89,6 +129,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
|
||||
return user
|
||||
},
|
||||
} as unknown as import("@auth/core/providers").Provider
|
||||
]
|
||||
} as unknown as import("@auth/core/providers").Provider,
|
||||
],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user