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
|
## 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. 🚀
|
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 !== "VENDOR") {
|
||||||
if (session.user.role === "SUPPLIER") {
|
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")
|
const { prisma } = await import("@/lib/prisma")
|
||||||
|
|||||||
@ -1,21 +1,62 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { signIn } from "next-auth/react"
|
import { signIn } from "next-auth/react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { motion } from "framer-motion"
|
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 Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const initialRoleFromQuery =
|
||||||
|
searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR"
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [requiresMFA, setRequiresMFA] = useState(false)
|
const [requiresMFA, setRequiresMFA] = useState(false)
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [mfaCode, setMfaCode] = 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>) => {
|
const handleCredentialsLogin = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -69,6 +110,9 @@ export default function LoginPage() {
|
|||||||
return
|
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
|
// If MFA was required and code provided, or MFA not required, proceed with NextAuth signIn
|
||||||
const res = await signIn("credentials", {
|
const res = await signIn("credentials", {
|
||||||
email: emailValue,
|
email: emailValue,
|
||||||
@ -94,6 +138,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
|
applyRoleCookie(role)
|
||||||
signIn("google", { callbackUrl: "/onboarding" })
|
signIn("google", { callbackUrl: "/onboarding" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,6 +172,56 @@ export default function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleCredentialsLogin} className="space-y-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -7,19 +7,31 @@ import { LandingFeatures } from "@/components/landing/LandingFeatures"
|
|||||||
import { LandingFooter } from "@/components/landing/LandingFooter"
|
import { LandingFooter } from "@/components/landing/LandingFooter"
|
||||||
import { MobileHero } from "@/components/landing/mobile/MobileHero"
|
import { MobileHero } from "@/components/landing/mobile/MobileHero"
|
||||||
import { MobileFeatures } from "@/components/landing/mobile/MobileFeatures"
|
import { MobileFeatures } from "@/components/landing/mobile/MobileFeatures"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
|
||||||
const LandingSolutions = dynamic(() => import("@/components/landing/LandingSolutions").then(mod => mod.LandingSolutions), {
|
const LandingSolutions = dynamic(
|
||||||
|
() => import("@/components/landing/LandingSolutions").then((mod) => mod.LandingSolutions),
|
||||||
|
{
|
||||||
ssr: true,
|
ssr: true,
|
||||||
loading: () => <div className="min-h-[400px] bg-[#0a0e1a]" />
|
loading: () => <div className="min-h-[400px] bg-[#0a0e1a]" />,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)")
|
const isMobile = useMediaQuery("(max-width: 768px)")
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
return (
|
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} />
|
<LandingNavbar isLoggedIn={false} />
|
||||||
<main>
|
<main>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
|||||||
@ -20,7 +20,10 @@ export default async function SupplierLayout({
|
|||||||
|
|
||||||
// Only allow SUPPLIER role to access supplier dashboard
|
// Only allow SUPPLIER role to access supplier dashboard
|
||||||
if (session.user.role !== "SUPPLIER") {
|
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
|
// 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 { prisma } from "@/lib/prisma"
|
||||||
import authConfig from "./auth.config"
|
import authConfig from "./auth.config"
|
||||||
import bcrypt from "bcryptjs"
|
import bcrypt from "bcryptjs"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
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)
|
trustHost: true, // Trust all hosts (needed for ngrok/proxies)
|
||||||
...authConfig,
|
...authConfig,
|
||||||
callbacks: {
|
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 }) {
|
async jwt({ token, user }) {
|
||||||
if (user) {
|
if (user) {
|
||||||
token.id = user.id
|
token.id = user.id
|
||||||
@ -89,6 +129,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
|
|
||||||
return user
|
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