Refine role-aware login and theming

Made-with: Cursor
This commit is contained in:
talatops 2026-03-04 21:52:42 +05:00
parent ab14d7b004
commit 387cfb0aa2
6 changed files with 177 additions and 15 deletions

View File

@ -1,8 +1,20 @@
## JUNO
![JUNO logo](./public/icon.png)
<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**
![Next.js](https://img.shields.io/badge/Next.js-000000?logo=nextdotjs&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white)
![AWS Bedrock](https://img.shields.io/badge/AWS%20Bedrock-232F3E?logo=amazonaws&logoColor=white)
![Gmail API](https://img.shields.io/badge/Gmail%20API-EA4335?logo=gmail&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
![NextAuth](https://img.shields.io/badge/NextAuth-000000?logo=nextdotjs&logoColor=white)
![Shopify API](https://img.shields.io/badge/Shopify%20API-7AB55C?logo=shopify&logoColor=white)
![AES Encryption](https://img.shields.io/badge/AES%20Encryption-4B0082)
Modern commerce and support infrastructure for Shopify‑based dropshipping businesses. 🚀

View File

@ -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")

View File

@ -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>

View File

@ -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), {
const LandingSolutions = dynamic(
() => import("@/components/landing/LandingSolutions").then((mod) => mod.LandingSolutions),
{
ssr: true,
loading: () => <div className="min-h-[400px] bg-[#0a0e1a]" />
})
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 ? (

View File

@ -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

View File

@ -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,
],
})