login/signup flow change to handle cookies for both users properly

This commit is contained in:
talatops 2026-03-28 13:04:11 +05:00
parent 54d38755d8
commit 5266053495
66 changed files with 1768 additions and 614 deletions

5
.npmrc Normal file
View File

@ -0,0 +1,5 @@
# Resilient installs for CI/Docker (transient ECONNRESET from registry)
fetch-retries=5
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000
fetch-timeout=300000

View File

@ -1,17 +1,31 @@
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
# Copy package files
COPY package*.json prisma.config.ts ./
# Prisma validate/generate needs DATABASE_URL before .env is copied; no DB connection occurs during generate
ARG DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
ENV DATABASE_URL=$DATABASE_URL
# Copy package files (.npmrc improves registry resilience during docker build)
COPY package*.json prisma.config.ts .npmrc ./
COPY prisma ./prisma
# Install ALL dependencies (including devDependencies needed for build)
RUN npm ci && npx prisma generate
# Retry full npm ci on transient network errors (e.g. ECONNRESET)
RUN set -e; \
attempt=1; max=5; \
until npm ci; do \
attempt=$((attempt + 1)); \
if [ "$attempt" -gt "$max" ]; then echo "npm ci failed after $max attempts"; exit 1; fi; \
echo "npm ci failed (attempt $((attempt - 1))/$max), retrying in 15s..."; \
sleep 15; \
done && npx prisma generate
# Copy application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time)
COPY . .
# Regenerate client after full tree copy (schema may differ from the early prisma/ layer).
RUN npx prisma generate
# Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle)
RUN npm run build

View File

@ -18,6 +18,9 @@ done
echo "Syncing database schema with Prisma (db push)..."
npx prisma db push --accept-data-loss
echo "Regenerating Prisma Client..."
npx prisma generate
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
echo "Seeding predefined email templates..."
# Run seed script; if it fails, log the error but continue starting the app

View File

@ -2,6 +2,16 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Keep Prisma + pg as Node externals so Turbopack does not bundle the
// generated client (fixes "Cannot read properties of undefined (reading 'graph')"
// during `next build` in some Docker/Linux environments).
serverExternalPackages: [
"@prisma/client",
"prisma",
"@prisma/adapter-pg",
"@prisma/driver-adapter-utils",
"pg",
],
images: {
remotePatterns: [
{

732
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,15 +10,16 @@
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
"db:reset": "prisma migrate reset",
"db:studio": "prisma studio",
"db:fix-migration": "tsx scripts/fix-failed-migration.ts"
"db:fix-migration": "tsx scripts/fix-failed-migration.ts",
"db:role-audit": "tsx scripts/audit-role-consistency.ts"
},
"dependencies": {
"@auth/core": "^0.41.0",
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
"@aws-sdk/client-s3": "^3.989.0",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tailwindcss/postcss": "^4",
"@types/nodemailer": "^7.0.9",
"@types/qrcode": "^1.5.6",
@ -34,13 +35,13 @@
"lenis": "^1.3.17",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"next": "16.1.6",
"next": "^16.2.1",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.7",
"otplib": "^13.2.1",
"pdf-parse": "^2.4.5",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"prisma": "^7.6.0",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",

View File

@ -0,0 +1,77 @@
#!/usr/bin/env tsx
import "dotenv/config"
import { prisma } from "../src/lib/prisma"
async function main() {
const shouldApply = process.argv.includes("--apply")
const mode = shouldApply ? "APPLY" : "DRY_RUN"
console.log(`[ROLE_AUDIT] Starting role consistency audit in ${mode} mode`)
const mismatches = await prisma.user.findMany({
where: {
role: {
not: "SUPPLIER",
},
supplierProfile: {
isNot: null,
},
},
select: {
id: true,
email: true,
role: true,
supplierProfile: {
select: {
id: true,
companyName: true,
},
},
},
orderBy: {
createdAt: "asc",
},
})
if (mismatches.length === 0) {
console.log("[ROLE_AUDIT] No mismatches found.")
return
}
console.log(`[ROLE_AUDIT] Found ${mismatches.length} mismatched users:`)
for (const user of mismatches) {
console.log(
` - userId=${user.id} email=${user.email ?? "N/A"} currentRole=${user.role} supplierProfileId=${user.supplierProfile?.id ?? "N/A"} company=${user.supplierProfile?.companyName ?? "N/A"}`
)
}
if (!shouldApply) {
console.log(
'[ROLE_AUDIT] Dry run only. Re-run with "--apply" to update these users to role=SUPPLIER.'
)
return
}
type MismatchUser = (typeof mismatches)[number]
const result = await prisma.user.updateMany({
where: {
id: {
in: mismatches.map((u: MismatchUser) => u.id),
},
},
data: {
role: "SUPPLIER",
},
})
console.log(`[ROLE_AUDIT] Updated ${result.count} user role(s) to SUPPLIER.`)
}
main()
.catch((error) => {
console.error("[ROLE_AUDIT] Failed:", error)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -6,16 +6,13 @@
*/
import "dotenv/config"
import pg from "pg"
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { PrismaClientOptions } from "@prisma/client/runtime/client"
// Use the same setup as the main app
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({
adapter: adapter as unknown as PrismaClientOptions["adapter"]
const prisma = new PrismaClient({
adapter: new PrismaPg({
connectionString: process.env.DATABASE_URL!,
}),
})
async function fixStoreDomains() {
@ -52,7 +49,6 @@ async function fixStoreDomains() {
throw error
} finally {
await prisma.$disconnect()
await pool.end()
}
}

View File

@ -58,7 +58,7 @@ async function reEmbedKnowledgeBase() {
try {
// Generate embeddings for this batch
const texts = batch.map((chunk) => chunk.content)
const texts = batch.map((chunk: KnowledgeChunk) => chunk.content)
const embeddings = await embedBatch(texts)
if (embeddings.length !== batch.length) {
@ -70,7 +70,7 @@ async function reEmbedKnowledgeBase() {
}
// Update each chunk with its new embedding
const updatePromises = batch.map(async (chunk, index) => {
const updatePromises = batch.map(async (chunk: KnowledgeChunk, index: number) => {
const embedding = embeddings[index]
if (!embedding || embedding.length === 0) {

View File

@ -36,8 +36,12 @@ async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
}),
])
type ConnectionEventRow = (typeof connectionEvents)[number]
type ProductSyncEventRow = (typeof productSyncEvents)[number]
type SyncHealthEventRow = (typeof syncHealthEvents)[number]
const events: AdminEvent[] = [
...connectionEvents.map((c) => ({
...connectionEvents.map((c: ConnectionEventRow) => ({
id: c.id,
type: "CONNECTION" as AdminEventType,
status: c.status,
@ -46,7 +50,7 @@ async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
supplierName: c.supplier.companyName,
metadata: {},
})),
...productSyncEvents.map((p) => ({
...productSyncEvents.map((p: ProductSyncEventRow) => ({
id: p.id,
type: "PRODUCT_SYNC" as AdminEventType,
status: p.status,
@ -55,7 +59,7 @@ async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
supplierName: p.supplier.companyName,
metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId },
})),
...syncHealthEvents.map((s) => ({
...syncHealthEvents.map((s: SyncHealthEventRow) => ({
id: s.id,
type: "SYNC_HEALTH" as AdminEventType,
status: s.syncStatus,

View File

@ -25,8 +25,9 @@ async function getOrders(): Promise<AdminOrder[]> {
})
if (acceptedSyncs.length === 0) return []
type AcceptedSyncPick = (typeof acceptedSyncs)[number]
const syncsByStore = new Map<string, Set<string>>()
acceptedSyncs.forEach((sync) => {
acceptedSyncs.forEach((sync: AcceptedSyncPick) => {
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
})
@ -40,7 +41,9 @@ async function getOrders(): Promise<AdminOrder[]> {
orderBy: { shopifyCreatedAt: "desc" },
})
const relevantOrders = cachedOrders.filter((order) => {
type CachedOrderRow = (typeof cachedOrders)[number]
const relevantOrders = cachedOrders.filter((order: CachedOrderRow) => {
const syncedProductIds = syncsByStore.get(order.storeId)
if (!syncedProductIds?.size) return false
try {
@ -56,7 +59,7 @@ async function getOrders(): Promise<AdminOrder[]> {
}
})
return relevantOrders.map((order) => {
return relevantOrders.map((order: CachedOrderRow) => {
const store = order.store as { id: string; businessName: string; shopifyStoreName: string }
return {
id: order.shopifyOrderId,

View File

@ -72,17 +72,14 @@ export async function POST(req: Request) {
}
// Regular user login flow
const userResult = await prisma.$queryRawUnsafe<
Array<{
id: string
password: string | null
mfaEnabled: boolean | null
mfaSecret: string | null
}>
>(
`SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
email,
)
const userResult = await prisma.$queryRaw`
SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email}
` as Array<{
id: string
password: string | null
mfaEnabled: boolean | null
mfaSecret: string | null
}>
if (!userResult || userResult.length === 0) {
return NextResponse.json(
@ -125,7 +122,7 @@ export async function POST(req: Request) {
}
// Verify MFA code
const mfaSecret = decryptString(user.mfaSecret)
const mfaSecret = decryptString(user.mfaSecret)
if (!mfaSecret) {
return NextResponse.json(
{ message: "MFA is enabled but no secret found. Please contact support." },

View File

@ -44,7 +44,7 @@ export async function POST(req: Request) {
name,
email,
password: hashedPassword,
role: role || "VENDOR"
role
}
})

View File

@ -0,0 +1,4 @@
import { handlers } from "@/auth.supplier"
export const { GET, POST } = handlers

View File

@ -0,0 +1,4 @@
import { handlers } from "@/auth.vendor"
export const { GET, POST } = handlers

View File

@ -25,10 +25,9 @@ export async function POST(req: Request) {
}
// Use raw query to access MFA fields until Prisma client is fully regenerated
const userResult = await prisma.$queryRawUnsafe<Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>>(
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
email
)
const userResult = await prisma.$queryRaw<
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
>`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email}`
if (!userResult || userResult.length === 0) {
return NextResponse.json(

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache"
@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -31,6 +32,8 @@ export async function GET() {
return NextResponse.json({ orders: [] })
}
type StoreRow = (typeof stores)[number]
type OrderItem = {
id: number
name: string
@ -50,7 +53,8 @@ export async function GET() {
const cacheFresh = await isCacheFresh(store.id, "orders")
if (cacheFresh) {
const cached = await getCachedOrders(store.id)
return cached.slice(0, PER_STORE_LIMIT).map((order) => ({
type CachedOrderRow = (typeof cached)[number]
return cached.slice(0, PER_STORE_LIMIT).map((order: CachedOrderRow) => ({
id: parseInt(order.shopifyOrderId),
name: order.orderNumber,
total_price: order.totalPrice,
@ -93,8 +97,12 @@ export async function GET() {
}
}
const results = await Promise.allSettled(stores.map((s) => fetchOrdersForStore(s)))
const allOrders: OrderItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
const results = await Promise.allSettled(
stores.map((s: StoreRow) => fetchOrdersForStore(s))
)
const allOrders: OrderItem[] = results.flatMap((r: PromiseSettledResult<OrderItem[]>) =>
r.status === "fulfilled" ? r.value : []
)
allOrders.sort((a, b) => (b.id > a.id ? 1 : -1))
return NextResponse.json({ orders: allOrders.slice(0, 50) })
} catch (error) {

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache"
@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -31,6 +32,8 @@ export async function GET() {
return NextResponse.json({ products: [] })
}
type StoreRow = (typeof stores)[number]
type ProductItem = { id: number; title: string; storeId: string; storeName: string }
const fetchProductsForStore = async (
@ -43,7 +46,8 @@ export async function GET() {
const cacheFresh = await isCacheFresh(store.id, "products")
if (cacheFresh) {
const cached = await getCachedProducts(store.id)
return cached.slice(0, PER_STORE_LIMIT).map((product) => ({
type CachedProductRow = (typeof cached)[number]
return cached.slice(0, PER_STORE_LIMIT).map((product: CachedProductRow) => ({
id: parseInt(product.shopifyProductId),
title: product.title || "Product",
storeId: store.id,
@ -82,8 +86,12 @@ export async function GET() {
}
}
const results = await Promise.allSettled(stores.map((s) => fetchProductsForStore(s)))
const allProducts: ProductItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
const results = await Promise.allSettled(
stores.map((s: StoreRow) => fetchProductsForStore(s))
)
const allProducts: ProductItem[] = results.flatMap((r: PromiseSettledResult<ProductItem[]>) =>
r.status === "fulfilled" ? r.value : []
)
return NextResponse.json({ products: allProducts.slice(0, 50) })
} catch (error) {
console.error("Chat attach products error:", error)

View File

@ -53,7 +53,8 @@ export async function GET() {
}
}
})
contacts = connections.map(c => ({
type VendorSideConnection = (typeof connections)[number]
contacts = connections.map((c: VendorSideConnection) => ({
id: c.supplier.user.id,
name: c.supplier.companyName || c.supplier.user.name,
email: c.supplier.user.email,
@ -84,7 +85,8 @@ export async function GET() {
}
}
})
contacts = connections.map(c => ({
type SupplierSideConnection = (typeof connections)[number]
contacts = connections.map((c: SupplierSideConnection) => ({
id: c.store.user.id,
name: c.store.businessName || c.store.user.name,
email: c.store.user.email,

View File

@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { getBaseUrl } from "@/lib/utils"
import { cookies } from "next/headers"
import crypto from "crypto"
/**
* GET /api/integrations/gmail/callback
@ -9,30 +11,53 @@ import { getBaseUrl } from "@/lib/utils"
*/
export async function GET(req: Request) {
try {
const cookieStore = await cookies()
const stateCookie = cookieStore.get("juno_gmail_oauth_state")?.value
const redirectWithError = (errorCode: string, extra?: Record<string, string>) => {
const url = new URL(`/dashboard/juno-engine/gmail-connect?error=${errorCode}`, getBaseUrl())
if (extra) {
for (const [key, value] of Object.entries(extra)) {
url.searchParams.set(key, value)
}
}
const response = NextResponse.redirect(url)
response.cookies.delete("juno_gmail_oauth_state")
return response
}
const session = await auth()
if (!session?.user?.id) {
return NextResponse.redirect(
new URL("/login?error=unauthorized", getBaseUrl())
)
const response = NextResponse.redirect(new URL("/login?error=unauthorized", getBaseUrl()))
response.cookies.delete("juno_gmail_oauth_state")
return response
}
const { searchParams } = new URL(req.url)
const code = searchParams.get("code")
const error = searchParams.get("error")
const _state = searchParams.get("state")
const state = searchParams.get("state")
// Handle OAuth errors
if (error) {
console.error("Gmail OAuth error:", error)
return NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", getBaseUrl())
)
return redirectWithError("oauth_failed")
}
if (!code) {
return NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=no_code", getBaseUrl())
)
return redirectWithError("no_code")
}
if (!state || !stateCookie) {
return redirectWithError("invalid_state")
}
const stateBuffer = Buffer.from(state)
const cookieBuffer = Buffer.from(stateCookie)
if (
stateBuffer.length !== cookieBuffer.length ||
!crypto.timingSafeEqual(stateBuffer, cookieBuffer)
) {
return redirectWithError("invalid_state")
}
// Exchange authorization code for tokens
@ -62,17 +87,12 @@ export async function GET(req: Request) {
// Provide more specific error message if it's a redirect_uri_mismatch
if (errorData.error === "redirect_uri_mismatch") {
return NextResponse.redirect(
new URL(
`/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`,
getBaseUrl()
)
)
return redirectWithError("redirect_uri_mismatch", {
redirect_uri: redirectUri,
})
}
return NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", getBaseUrl())
)
return redirectWithError("token_exchange_failed")
}
const tokens = await tokenResponse.json()
@ -113,24 +133,19 @@ export async function GET(req: Request) {
}
} catch (error) {
console.error("Error fetching user info:", error)
return NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl())
)
return redirectWithError("user_info_failed")
}
if (!userInfo?.email) {
console.error("No email found in user info")
return NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl())
)
return redirectWithError("user_info_failed")
}
// Calculate token expiration
const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000))
// Store or update Gmail integration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (prisma as any).gmailIntegration.upsert({
await prisma.gmailIntegration.upsert({
where: {
userId: session.user.id
},
@ -151,13 +166,17 @@ export async function GET(req: Request) {
}
})
return NextResponse.redirect(
const successResponse = NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?connected=true", getBaseUrl())
)
successResponse.cookies.delete("juno_gmail_oauth_state")
return successResponse
} catch (error) {
console.error("Gmail callback error:", error)
return NextResponse.redirect(
const response = NextResponse.redirect(
new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", getBaseUrl())
)
response.cookies.delete("juno_gmail_oauth_state")
return response
}
}

View File

@ -20,9 +20,6 @@ export async function POST() {
// Generate state token for CSRF protection
const state = crypto.randomBytes(32).toString("hex")
// Store state in a cookie or session (for simplicity, we'll include it in the callback URL)
// In production, use a proper session store or encrypted cookie
// Gmail API scopes
// Adding 'email' and 'profile' scopes to get user email address
const scopes = [
@ -56,12 +53,19 @@ export async function POST() {
authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token
authUrl.searchParams.set("state", state)
return NextResponse.json({
const response = NextResponse.json({
authUrl: authUrl.toString(),
state, // Return state so frontend can verify it on callback
redirectUri, // Include redirect URI in response for debugging
message: "Redirecting to Google OAuth"
})
response.cookies.set("juno_gmail_oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10,
path: "/",
})
return response
} catch (error) {
console.error("Error initiating Gmail OAuth:", error)
return NextResponse.json(

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
async function getActiveStoreId(userId: string): Promise<string | null> {
@ -25,7 +26,7 @@ export async function DELETE(
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
async function getActiveStoreId(userId: string): Promise<string | null> {
@ -22,7 +23,7 @@ export async function GET() {
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
@ -36,8 +37,9 @@ export async function GET() {
orderBy: { flaggedAt: "desc" },
})
type FlaggedEmailRow = (typeof flaggedEmails)[number]
return NextResponse.json({
flaggedEmails: flaggedEmails.map((e) => ({
flaggedEmails: flaggedEmails.map((e: FlaggedEmailRow) => ({
id: e.id,
subject: e.subject,
from: e.from,
@ -61,7 +63,7 @@ export async function POST(req: Request) {
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
@ -41,7 +42,7 @@ export async function GET(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
@ -104,7 +105,7 @@ export async function PUT(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
@ -204,7 +205,7 @@ export async function DELETE(
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { randomUUID } from "crypto"
@ -24,7 +25,7 @@ export async function GET(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
@ -98,7 +99,7 @@ export async function POST(req: Request) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
@ -26,7 +27,7 @@ export async function GET() {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

View File

@ -9,7 +9,8 @@ export async function GET() {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
// Suppliers use a different portal; treat them as "onboarding N/A"
if (session.user.role === "SUPPLIER") {
return NextResponse.json({ complete: true })
}

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
@ -9,6 +10,12 @@ export async function POST(req: NextRequest) {
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json(
{ message: "Only vendor accounts can complete store onboarding." },
{ status: 403 }
)
}
const body = await req.json()
const {

View File

@ -1,4 +1,5 @@
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { NextResponse } from "next/server"
import { logger } from "@/lib/logger"
@ -9,6 +10,12 @@ export async function POST(req: Request) {
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (!isVendorPortalRole(session.user.role)) {
return NextResponse.json(
{ message: "Only vendor accounts can connect Shopify stores." },
{ status: 403 }
)
}
const {
businessName,

View File

@ -163,9 +163,16 @@ export async function DELETE(
// Verify user owns the sync (vendor who requested or supplier who received)
const isVendor = productSync.store.userId === session.user.id
const isSupplier = session.user.role === "SUPPLIER"
if (!isVendor && !isSupplier) {
let isSupplierOwner = false
if (session.user.role === "SUPPLIER") {
const supplierProfile = await prisma.supplierProfile.findUnique({
where: { userId: session.user.id },
select: { id: true },
})
isSupplierOwner = supplierProfile?.id === productSync.supplierId
}
if (!isVendor && !isSupplierOwner) {
return NextResponse.json(
{ message: "Unauthorized" },
{ status: 403 }
@ -173,7 +180,7 @@ export async function DELETE(
}
// Only vendor can delete pending requests, supplier should reject instead
if (isSupplier && productSync.status === "PENDING") {
if (isSupplierOwner && productSync.status === "PENDING") {
return NextResponse.json(
{ message: "Please reject the request instead of deleting it" },
{ status: 400 }

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
/**
@ -10,7 +11,7 @@ export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -63,7 +64,7 @@ export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

View File

@ -47,11 +47,7 @@ export async function POST() {
// Store the secret temporarily (not enabled yet - will be enabled after verification)
// Encrypt the secret at rest
const encryptedSecret = encryptString(secret)
await prisma.$executeRawUnsafe(
`UPDATE "User" SET "mfaSecret" = $1 WHERE id = $2`,
encryptedSecret,
session.user.id
)
await prisma.$executeRaw`UPDATE "User" SET "mfaSecret" = ${encryptedSecret} WHERE id = ${session.user.id}`
return NextResponse.json({
secret,

View File

@ -12,10 +12,9 @@ export async function GET() {
}
// Use raw query to access MFA fields until Prisma client is fully regenerated
const result = await prisma.$queryRawUnsafe<Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>>(
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = $1`,
session.user.id
)
const result = await prisma.$queryRaw<
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
>`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
if (!result || result.length === 0) {
return NextResponse.json({ message: "User not found" }, { status: 404 })

View File

@ -13,10 +13,9 @@ export async function POST(req: Request) {
const { enabled } = await req.json()
// Use raw query to check mfaSecret until Prisma client is fully regenerated
const result = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
session.user.id
)
const result = await prisma.$queryRaw<
Array<{ mfaSecret: string | null }>
>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
if (!result || result.length === 0) {
return NextResponse.json({ message: "User not found" }, { status: 404 })
@ -33,10 +32,7 @@ export async function POST(req: Request) {
// Disable MFA (can be done without verification)
if (!enabled) {
await prisma.$executeRawUnsafe(
`UPDATE "User" SET "mfaEnabled" = false WHERE id = $1`,
session.user.id
)
await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = false WHERE id = ${session.user.id}`
return NextResponse.json({
message: "MFA disabled successfully",

View File

@ -31,10 +31,7 @@ export async function POST(req: Request) {
}
// Use raw query to access mfaSecret until Prisma client is fully regenerated
const userResult = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
session.user.id
)
const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
if (!userResult || userResult.length === 0) {
return NextResponse.json(
@ -64,10 +61,7 @@ export async function POST(req: Request) {
}
// Enable MFA
await prisma.$executeRawUnsafe(
`UPDATE "User" SET "mfaEnabled" = true WHERE id = $1`,
session.user.id
)
await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = true WHERE id = ${session.user.id}`
return NextResponse.json({
message: "MFA enabled successfully",

View File

@ -31,7 +31,8 @@ export async function GET() {
if (cacheFresh) {
// Return cached data transformed to match Shopify format
const cachedCustomers = await getCachedCustomers(store.id)
const transformedCustomers = cachedCustomers.map(customer => ({
type CachedCustomerRow = (typeof cachedCustomers)[number]
const transformedCustomers = cachedCustomers.map((customer: CachedCustomerRow) => ({
id: parseInt(customer.shopifyCustomerId),
email: customer.email,
first_name: customer.firstName,

View File

@ -31,7 +31,8 @@ export async function GET() {
if (cacheFresh) {
// Return cached data transformed to match Shopify format
const cachedProducts = await getCachedProducts(store.id)
const transformedProducts = cachedProducts.map(product => {
type CachedProductRow = (typeof cachedProducts)[number]
const transformedProducts = cachedProducts.map((product: CachedProductRow) => {
const images = (product.images as { src: string }[]) || []
return {
id: parseInt(product.shopifyProductId),

View File

@ -31,7 +31,8 @@ export async function GET() {
if (cacheFresh) {
// Return cached data transformed to match Shopify format
const cachedOrders = await getCachedOrders(store.id)
const transformedOrders = cachedOrders.map(order => ({
type CachedOrderRow = (typeof cachedOrders)[number]
const transformedOrders = cachedOrders.map((order: CachedOrderRow) => ({
id: parseInt(order.shopifyOrderId),
name: order.orderNumber,
email: order.email,

View File

@ -12,6 +12,16 @@ type QueryBody = {
includeDocuments?: boolean
}
type KnowledgeChunkRow = {
id: string
content: string
sourceType: "DOCUMENT" | "FAQ"
documentId: string | null
faqItemId: string | null
metadata: unknown
similarity: number
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ storeId: string }> },
@ -73,17 +83,7 @@ export async function POST(
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""
const chunks = await prisma.$queryRawUnsafe<
{
id: string
content: string
sourceType: "DOCUMENT" | "FAQ"
documentId: string | null
faqItemId: string | null
metadata: unknown
similarity: number
}[]
>(
const chunks = await prisma.$queryRawUnsafe<KnowledgeChunkRow[]>(
`
SELECT
"id",
@ -109,7 +109,7 @@ export async function POST(
})
}
const contextPieces = chunks.map((chunk, index) => {
const contextPieces = chunks.map((chunk: KnowledgeChunkRow, index: number) => {
const label =
chunk.sourceType === "FAQ"
? `FAQ #${index + 1}`
@ -145,7 +145,7 @@ export async function POST(
return NextResponse.json({
answer,
sources: chunks.map((chunk) => ({
sources: chunks.map((chunk: KnowledgeChunkRow) => ({
id: chunk.id,
type: chunk.sourceType,
documentId: chunk.documentId,

View File

@ -49,7 +49,7 @@ export async function GET() {
})
const averagePrice = products.length > 0
? products.reduce((sum, p) => sum + p.price, 0) / products.length
? products.reduce((sum: number, p: { price: number }) => sum + p.price, 0) / products.length
: 0
// Get recent activity (last 7 days)

View File

@ -49,8 +49,7 @@ export async function GET() {
}
// Get all accepted product syncs for this supplier
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const acceptedSyncs = await (prisma as any).productSync.findMany({
const acceptedSyncs = await prisma.productSync.findMany({
where: {
supplierId: supplierProfile.id,
status: "ACCEPTED"
@ -71,10 +70,11 @@ export async function GET() {
})
}
type AcceptedSyncRow = (typeof acceptedSyncs)[number]
// Group syncs by storeId
const syncsByStore = new Map<string, Set<string>>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
acceptedSyncs.forEach((sync: any) => {
acceptedSyncs.forEach((sync: AcceptedSyncRow) => {
if (!syncsByStore.has(sync.storeId)) {
syncsByStore.set(sync.storeId, new Set())
}
@ -101,8 +101,11 @@ export async function GET() {
}
})
type ShopifyLineItem = { product_id?: number }
type CachedOrderWithStore = (typeof cachedOrders)[number]
// Filter orders that contain synced products
const relevantOrders = cachedOrders.filter((order) => {
const relevantOrders = cachedOrders.filter((order: CachedOrderWithStore) => {
const syncedProductIds = syncsByStore.get(order.storeId)
if (!syncedProductIds || syncedProductIds.size === 0) return false
@ -115,8 +118,7 @@ export async function GET() {
if (!Array.isArray(lineItems)) return false
// Check if any line item's product_id matches a synced product
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return lineItems.some((item: any) => {
return lineItems.some((item: ShopifyLineItem) => {
const productId = item.product_id?.toString()
return productId && syncedProductIds.has(productId)
})
@ -126,22 +128,20 @@ export async function GET() {
})
// Transform orders to include supplier-relevant information
const transformedOrders = relevantOrders.map((order) => {
const transformedOrders = relevantOrders.map((order: CachedOrderWithStore) => {
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let lineItems: any[] = []
let lineItems: ShopifyLineItem[] = []
try {
lineItems = typeof order.lineItems === "string"
? JSON.parse(order.lineItems)
: order.lineItems
const parsed =
typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems
lineItems = Array.isArray(parsed) ? (parsed as ShopifyLineItem[]) : []
} catch {
lineItems = []
}
// Filter line items to only show synced products
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncedLineItems = lineItems.filter((item: any) => {
const syncedLineItems = lineItems.filter((item: ShopifyLineItem) => {
const productId = item.product_id?.toString()
return productId && syncedProductIds.has(productId)
})
@ -158,8 +158,7 @@ export async function GET() {
store: order.store,
lineItems: syncedLineItems,
// Include all line items for reference, but mark synced ones
// eslint-disable-next-line @typescript-eslint/no-explicit-any
allLineItems: lineItems.map((item: any) => ({
allLineItems: lineItems.map((item: ShopifyLineItem) => ({
...item,
isSynced: syncedProductIds.has(item.product_id?.toString() || "")
}))

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
import { notifyInvitationReceived } from "@/lib/notifications"
@ -17,7 +18,7 @@ export async function DELETE(
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -40,9 +41,10 @@ export async function DELETE(
)
}
type DeleteFlowStoreRow = (typeof vendorStores)[number]
const storeIdsToCheck = storeId
? [storeId]
: vendorStores.map(s => s.id)
: vendorStores.map((s: DeleteFlowStoreRow) => s.id)
// Find and delete pending invitations
const pendingInvitations = await prisma.invitation.findMany({
@ -64,9 +66,10 @@ export async function DELETE(
}
// Delete all pending invitations
type PendingInvRow = (typeof pendingInvitations)[number]
await prisma.invitation.deleteMany({
where: {
id: { in: pendingInvitations.map((inv) => inv.id) }
id: { in: pendingInvitations.map((inv: PendingInvRow) => inv.id) }
}
})
@ -93,7 +96,7 @@ export async function POST(
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -122,10 +125,12 @@ export async function POST(
)
}
type PostFlowStoreRow = (typeof vendorStores)[number]
// Determine which stores to connect
const storesToConnect = connectToAllStores
? vendorStores
: vendorStores.filter(store => store.isActive)
: vendorStores.filter((store: PostFlowStoreRow) => store.isActive)
if (storesToConnect.length === 0) {
return NextResponse.json(
@ -161,7 +166,7 @@ export async function POST(
}
// Check for existing connections and invitations for all stores
const storeIds = storesToConnect.map(s => s.id)
const storeIds = storesToConnect.map((s: PostFlowStoreRow) => s.id)
const existingConnections = await prisma.connection.findMany({
where: {
storeId: { in: storeIds },
@ -180,13 +185,18 @@ export async function POST(
}
})
type ExistingConnectionRow = (typeof existingConnections)[number]
type ExistingInvitationRow = (typeof existingInvitations)[number]
// Filter out stores that already have connections or pending invitations
const connectedStoreIds = new Set([
...existingConnections.map(c => c.storeId),
...existingInvitations.map((i) => i.storeId)
...existingConnections.map((c: ExistingConnectionRow) => c.storeId),
...existingInvitations.map((i: ExistingInvitationRow) => i.storeId)
])
const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id))
const storesToInvite = storesToConnect.filter(
(store: PostFlowStoreRow) => !connectedStoreIds.has(store.id)
)
if (storesToInvite.length === 0) {
const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length
@ -204,8 +214,8 @@ export async function POST(
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
const invitations = []
const storeNames = storesToInvite.map(s => s.businessName).join(", ")
const invitations: Array<{ id: string; storeId: string; token: string }> = []
const storeNames = storesToInvite.map((s: PostFlowStoreRow) => s.businessName).join(", ")
for (const store of storesToInvite) {
const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16)
@ -221,7 +231,7 @@ export async function POST(
}
})
invitations.push({ id: invitation.id, storeId: store.id, token: invitationToken })
} catch (dbError: any) {
} catch (dbError: unknown) {
console.error(`Database error creating invitation for store ${store.id}:`, dbError)
// Continue with other stores
}
@ -280,7 +290,7 @@ export async function POST(
message: connectToAllStores && storesToInvite.length > 1
? `Invitations sent successfully to ${invitations.length} store(s)`
: "Invitation sent successfully",
invitations: invitations.map(inv => ({
invitations: invitations.map((inv) => ({
id: inv.id,
storeId: inv.storeId,
token: inv.token,

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
/**
@ -10,7 +11,7 @@ export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -27,7 +28,8 @@ export async function GET() {
}
// Get all connections for this vendor's stores
const vendorStoreIds = vendorStores.map(s => s.id)
type VendorStoreRow = (typeof vendorStores)[number]
const vendorStoreIds = vendorStores.map((s: VendorStoreRow) => s.id)
const connections = await prisma.connection.findMany({
where: {
storeId: { in: vendorStoreIds }
@ -65,7 +67,7 @@ export async function GET() {
})
// Get pending invitations (not yet accepted - no connection created)
const pendingInvitations = await (prisma as any).invitation.findMany({
const pendingInvitations = await prisma.invitation.findMany({
where: {
storeId: { in: vendorStoreIds },
used: false,
@ -98,28 +100,39 @@ export async function GET() {
}
})
type PendingInvitationRow = (typeof pendingInvitations)[number]
type ConnectionRow = (typeof connections)[number]
// Convert invitations to connection-like objects with PENDING status
const pendingConnections = pendingInvitations.map((invitation: any) => ({
const pendingConnections = pendingInvitations.map((invitation: PendingInvitationRow) => ({
id: invitation.id, // Use invitation ID as temporary connection ID
status: "PENDING" as const,
createdAt: invitation.createdAt,
supplier: invitation.supplier,
store: {
id: invitation.storeId,
businessName: vendorStores.find(s => s.id === invitation.storeId)?.businessName || "Unknown Store",
isActive: vendorStores.find(s => s.id === invitation.storeId)?.isActive || false
}
businessName:
vendorStores.find((s: VendorStoreRow) => s.id === invitation.storeId)?.businessName ||
"Unknown Store",
isActive:
vendorStores.find((s: VendorStoreRow) => s.id === invitation.storeId)?.isActive || false,
},
}))
type PendingConnectionRow = (typeof pendingConnections)[number]
// Combine connections and pending invitations, removing duplicates
// (if a connection exists, don't include the invitation)
const connectionSupplierIds = new Set(connections.map(c => c.supplier.id))
const connectionSupplierIds = new Set(
connections.map((c: ConnectionRow) => c.supplier.id)
)
const uniquePendingConnections = pendingConnections.filter(
(pc: any) => !connectionSupplierIds.has(pc.supplier.id)
(pc: PendingConnectionRow) => !connectionSupplierIds.has(pc.supplier.id)
)
const allConnections = [...connections, ...uniquePendingConnections].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
(a: ConnectionRow | PendingConnectionRow, b: ConnectionRow | PendingConnectionRow) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
return NextResponse.json({ connections: allConnections })

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
/**
@ -10,7 +11,7 @@ export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
@ -48,9 +49,11 @@ export async function GET() {
}
})
type SupplierListRow = (typeof suppliers)[number]
// Mark which suppliers are already connected/pending
const suppliersWithStatus = await Promise.all(
suppliers.map(async (supplier) => {
suppliers.map(async (supplier: SupplierListRow) => {
let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE"
if (vendorStore) {
@ -71,7 +74,7 @@ export async function GET() {
connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED"
} else {
// Check for pending invitation (not yet accepted)
const pendingInvitation = await (prisma as any).invitation.findFirst({
const pendingInvitation = await prisma.invitation.findFirst({
where: {
storeId: vendorStore.id,
supplierId: supplier.id,

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
/**
@ -16,7 +17,7 @@ export async function GET(
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { isVendorPortalRole } from "@/lib/auth-roles"
import { prisma } from "@/lib/prisma"
/**
@ -13,7 +14,7 @@ export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
if (!session?.user?.id || !isVendorPortalRole(session.user.role)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

View File

@ -571,19 +571,19 @@ export default function InventoryPage() {
{/* Supplier Selection Modal */}
{showSupplierModal && selectedProduct && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<div className="p-6 border-b border-white/10 flex items-center justify-between">
<div className={`fixed inset-0 backdrop-blur-sm z-50 flex items-center justify-center p-4 ${isLight ? "bg-black/40" : "bg-black/60"}`}>
<div className={`rounded-2xl shadow-2xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col ${isLight ? "bg-white border border-slate-200" : "bg-slate-900 border border-white/10"}`}>
<div className={`p-6 flex items-center justify-between ${isLight ? "border-b border-slate-200" : "border-b border-white/10"}`}>
<div>
<h2 className="text-2xl font-bold text-white">Select Supplier</h2>
<p className="text-sm text-slate-400 mt-1">Choose a supplier to sync &quot;{selectedProduct.title}&quot;</p>
<h2 className={`text-2xl font-bold ${isLight ? "text-slate-900" : "text-white"}`}>Select Supplier</h2>
<p className={`text-sm mt-1 ${isLight ? "text-slate-600" : "text-slate-400"}`}>Choose a supplier to sync &quot;{selectedProduct.title}&quot;</p>
</div>
<button
onClick={() => {
setShowSupplierModal(false)
setSelectedProduct(null)
}}
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-white transition-colors"
className={`p-2 rounded-lg transition-colors ${isLight ? "text-slate-500 hover:text-slate-900 hover:bg-slate-100" : "text-slate-400 hover:text-white hover:bg-white/10"}`}
>
<X className="w-5 h-5" />
</button>
@ -591,9 +591,9 @@ export default function InventoryPage() {
<div className="flex-1 overflow-y-auto p-6">
{connectedSuppliers.length === 0 ? (
<div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-slate-500 mx-auto mb-4" />
<p className="text-slate-400 font-medium">No connected suppliers</p>
<p className="text-sm text-slate-500 mt-2">Please connect with suppliers first from the Suppliers page.</p>
<AlertCircle className={`w-12 h-12 mx-auto mb-4 ${isLight ? "text-slate-400" : "text-slate-500"}`} />
<p className={`font-medium ${isLight ? "text-slate-700" : "text-slate-400"}`}>No connected suppliers</p>
<p className={`text-sm mt-2 ${isLight ? "text-slate-500" : "text-slate-500"}`}>Please connect with suppliers first from the Suppliers page.</p>
</div>
) : (
<div className="grid gap-3">
@ -602,15 +602,18 @@ export default function InventoryPage() {
key={supplier.id}
onClick={() => handleSelectSupplier(supplier.id)}
disabled={syncingProduct}
className="p-4 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 hover:border-indigo-500/50 transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group"
className={`p-4 rounded-xl transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed group ${isLight
? "bg-slate-50 border border-slate-200 hover:bg-slate-100 hover:border-indigo-300"
: "bg-white/5 border border-white/10 hover:bg-white/10 hover:border-indigo-500/50"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold text-white">{supplier.companyName}</h3>
<h3 className={`font-bold ${isLight ? "text-slate-900" : "text-white"}`}>{supplier.companyName}</h3>
{supplier.description && (
<p className="text-sm text-slate-400 mt-1 line-clamp-2">{supplier.description}</p>
<p className={`text-sm mt-1 line-clamp-2 ${isLight ? "text-slate-600" : "text-slate-400"}`}>{supplier.description}</p>
)}
<p className="text-xs text-slate-500 mt-2">{supplier.user.email}</p>
<p className={`text-xs mt-2 ${isLight ? "text-slate-500" : "text-slate-500"}`}>{supplier.user.email}</p>
</div>
{syncingProduct ? (
<Loader2 className="w-5 h-5 animate-spin text-indigo-400 ml-4" />

View File

@ -5,7 +5,7 @@ import UserAccountMenu from "@/components/UserAccountMenu"
import NotificationBell from "@/components/NotificationBell"
import PageTransition from "@/components/PageTransition"
import ThemeToggle from "@/components/ThemeToggle"
import { auth } from "@/auth"
import { auth } from "@/auth.vendor"
import { redirect } from "next/navigation"
export default async function DashboardLayout({
@ -15,13 +15,18 @@ export default async function DashboardLayout({
}) {
const session = await auth()
if (!session) {
if (!session?.user) {
redirect("/login")
}
if (session.user.role !== "VENDOR") {
if (session.user.role === "SUPPLIER") {
redirect("/login?error=WrongPortal&role=SUPPLIER")
const role = session.user.role
if (!role) {
redirect("/login")
}
if (role !== "VENDOR") {
if (role === "SUPPLIER") {
redirect("/supplier")
}
redirect("/login?error=WrongRole")
}

View File

@ -1,6 +1,5 @@
"use client"
import { signIn } from "next-auth/react"
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion } from "framer-motion"
@ -24,12 +23,22 @@ function LoginFormContent() {
const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => {
try {
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600`
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600; samesite=lax`
} catch {
// ignore cookie errors – server-side will just skip role enforcement
}
}
const roleBasePath = (selectedRole: "VENDOR" | "SUPPLIER") =>
selectedRole === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor"
const getRoleCsrfToken = async (selectedRole: "VENDOR" | "SUPPLIER") => {
const res = await fetch(`${roleBasePath(selectedRole)}/csrf`, { method: "GET" })
if (!res.ok) return ""
const data = await res.json()
return data?.csrfToken ?? ""
}
// Map Auth.js error codes from query string to friendly messages
useEffect(() => {
const errorCode = searchParams.get("error")
@ -113,22 +122,41 @@ function LoginFormContent() {
// 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", {
const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard"
const csrfToken = await getRoleCsrfToken(role)
if (!csrfToken) {
setError("Unable to start login session. Please refresh and try again.")
setRequiresMFA(false)
return
}
// Use a real browser form POST for credentials callback.
// This is the most reliable way to ensure Auth.js sets cookies and performs redirect flow.
const form = document.createElement("form")
form.method = "POST"
form.action = `${roleBasePath(role)}/callback/credentials`
form.style.display = "none"
const fields: Record<string, string> = {
email: emailValue,
password: passwordValue,
redirect: false,
callbackUrl: "/onboarding",
})
if (res?.error) {
setError("Authentication failed. Please try again.")
setRequiresMFA(false)
} else {
// Redirect based on user role - middleware will handle the final routing
// For now, just refresh and let middleware redirect appropriately
router.refresh()
role,
csrfToken,
callbackUrl,
}
if (mfaCodeValue) fields.mfaCode = mfaCodeValue
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement("input")
input.type = "hidden"
input.name = name
input.value = value
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
return
} catch {
setError("An error occurred. Please try again.")
setRequiresMFA(false)
@ -137,9 +165,22 @@ function LoginFormContent() {
}
}
const handleGoogleSignIn = () => {
const handleGoogleSignIn = async () => {
applyRoleCookie(role)
signIn("google", { callbackUrl: "/onboarding" })
const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard"
const csrfToken = await getRoleCsrfToken(role)
const res = await fetch(`${roleBasePath(role)}/signin/google`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
})
const data = await res.json()
const url = data?.url ?? callbackUrl
window.location.href = url
}
return (

View File

@ -5,7 +5,6 @@ import { motion } from "framer-motion"
import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import Image from "next/image"
function SignupFormContent() {
@ -16,6 +15,24 @@ function SignupFormContent() {
const initialRole = searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR"
const [role, setRole] = useState<"VENDOR" | "SUPPLIER">(initialRole)
const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => {
try {
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600; samesite=lax`
} catch {
// ignore cookie errors – server-side will just skip role enforcement
}
}
const roleBasePath = (selectedRole: "VENDOR" | "SUPPLIER") =>
selectedRole === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor"
const getRoleCsrfToken = async (selectedRole: "VENDOR" | "SUPPLIER") => {
const res = await fetch(`${roleBasePath(selectedRole)}/csrf`, { method: "GET" })
if (!res.ok) return ""
const data = await res.json()
return data?.csrfToken ?? ""
}
const handleSignup = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
@ -47,7 +64,24 @@ function SignupFormContent() {
}
const handleGoogleSignIn = () => {
signIn("google", { callbackUrl: "/onboarding" })
void (async () => {
applyRoleCookie(role)
const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard"
const csrfToken = await getRoleCsrfToken(role)
const res = await fetch(`${roleBasePath(role)}/signin/google`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
})
const data = await res.json()
const url = data?.url ?? callbackUrl
window.location.href = url
})()
}
return (

View File

@ -4,7 +4,7 @@ import UserAccountMenu from "@/components/UserAccountMenu"
import NotificationBell from "@/components/NotificationBell"
import PageTransition from "@/components/PageTransition"
import ThemeToggle from "@/components/ThemeToggle"
import { auth } from "@/auth"
import { auth } from "@/auth.supplier"
import { redirect } from "next/navigation"
export default async function SupplierLayout({
@ -14,14 +14,19 @@ export default async function SupplierLayout({
}) {
const session = await auth()
if (!session) {
if (!session?.user) {
redirect("/login")
}
// Only allow SUPPLIER role to access supplier dashboard
if (session.user.role !== "SUPPLIER") {
if (session.user.role === "VENDOR") {
redirect("/login?error=WrongPortal&role=VENDOR")
const role = session.user.role
if (!role) {
redirect("/login")
}
if (role !== "SUPPLIER") {
if (role === "VENDOR") {
redirect("/dashboard")
}
redirect("/login?error=WrongRole")
}

View File

@ -7,9 +7,6 @@ export default {
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// Allow account linking: users can sign up/sign in with Google OAuth
// This enables Google sign-in to create new accounts or link to existing accounts with matching email
allowDangerousEmailAccountLinking: true,
}),
Credentials({
name: "Credentials",

183
src/auth.supplier.ts Normal file
View File

@ -0,0 +1,183 @@
import NextAuth from "next-auth"
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"
import { decryptString } from "@/lib/encryption"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
// Required when behind proxies/tunnels (e.g. ngrok) in production.
trustHost: true,
// Ensure OAuth callback URLs match the role-scoped NextAuth route.
basePath: "/api/auth/supplier",
...authConfig,
// Role-scoped session cookie to prevent cross-private-window session overwrites.
cookies: {
sessionToken: {
name: "authjs.session-token-supplier",
},
},
callbacks: {
async signIn({ user, credentials }) {
// 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 credentialsRole =
typeof credentials?.role === "string" ? credentials.role : null
const desiredRoleRaw = cookieRole ?? credentialsRole
const desiredRole =
desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR"
? desiredRoleRaw
: 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
token.role = user.role
}
return token
},
async session({ session, token }) {
if (!session.user) return session
const userIdFromToken =
typeof (token as { id?: unknown })?.id === "string"
? (token as { id: string }).id
: null
const userIdFromSession =
typeof (session.user as { id?: unknown }).id === "string"
? (session.user as { id: string }).id
: null
const sessionEmail =
typeof session.user.email === "string" ? session.user.email : null
const userId = userIdFromToken ?? userIdFromSession
const user = userId
? await prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true, chatPublicKey: true },
})
: sessionEmail
? await prisma.user.findUnique({
where: { email: sessionEmail },
select: { id: true, role: true, chatPublicKey: true },
})
: null
if (user) {
session.user.id = user.id
session.user.role = user.role
session.user.chatPublicKey = user.chatPublicKey ?? undefined
}
return session
},
},
providers: [
...authConfig.providers.filter((p) => (p as { id?: string }).id !== "credentials"),
{
id: "credentials",
name: "Credentials",
type: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) {
if (!credentials?.email || !credentials?.password) return null
const email = credentials.email as string
const password = credentials.password as string
const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : ""
// Handle env-configured admin login
if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) {
// Ensure an ADMIN user exists (create or update on the fly)
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10)
const adminUser = await prisma.user.upsert({
where: { email: ADMIN_EMAIL },
update: {
password: hashedPassword,
role: "ADMIN",
},
create: {
email: ADMIN_EMAIL,
password: hashedPassword,
role: "ADMIN",
name: "Admin",
},
})
const isValid = await bcrypt.compare(password, adminUser.password!)
if (!isValid) return null
return adminUser
}
// Default credentials flow for non-admin users
const user = await prisma.user.findUnique({
where: { email },
})
if (!user || !user.password) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
if (user.mfaEnabled) {
const secret = decryptString(user.mfaSecret)
if (!secret || !mfaCode) return null
const { verify } = await import("otplib")
const verifyResult = await verify({ token: mfaCode, secret })
const isValidMfa = verifyResult.valid ?? false
if (!isValidMfa) return null
}
return user
},
} as unknown as import("@auth/core/providers").Provider,
],
})

View File

@ -3,18 +3,22 @@ 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"
import { cookies, headers } from "next/headers"
import { decryptString } from "@/lib/encryption"
import { auth as vendorAuth } from "@/auth.vendor"
import { auth as supplierAuth } from "@/auth.supplier"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
export const { handlers, auth, signIn, signOut } = NextAuth({
const nextAuth = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
trustHost: true, // Trust all hosts (needed for ngrok/proxies)
// Required when behind proxies/tunnels (e.g. ngrok) in production.
trustHost: true,
...authConfig,
callbacks: {
async signIn({ user, account }) {
async signIn({ user, credentials }) {
// Allow non-email-based sign-in flows to proceed as usual
if (!user?.email) return true
@ -26,8 +30,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// 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 credentialsRole =
typeof credentials?.role === "string" ? credentials.role : null
const desiredRoleRaw = cookieRole ?? credentialsRole
const desiredRole =
cookieRole === "SUPPLIER" || cookieRole === "VENDOR" ? cookieRole : null
desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR"
? desiredRoleRaw
: null
// If no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour
if (!desiredRole) {
@ -61,18 +70,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
if (token.role) {
session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN"
}
// Load chatPublicKey from DB so it stays current after chat-setup
const user = await prisma.user.findUnique({
where: { id: token.id as string },
select: { chatPublicKey: true },
})
if (user) session.user.chatPublicKey = user.chatPublicKey ?? undefined
if (!session.user) return session
const userIdFromToken =
typeof (token as { id?: unknown })?.id === "string"
? (token as { id: string }).id
: null
const userIdFromSession =
typeof (session.user as { id?: unknown }).id === "string"
? (session.user as { id: string }).id
: null
const sessionEmail =
typeof session.user.email === "string" ? session.user.email : null
const userId = userIdFromToken ?? userIdFromSession
const user = userId
? await prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true, chatPublicKey: true },
})
: sessionEmail
? await prisma.user.findUnique({
where: { email: sessionEmail },
select: { id: true, role: true, chatPublicKey: true },
})
: null
if (user) {
session.user.id = user.id
session.user.role = user.role
session.user.chatPublicKey = user.chatPublicKey ?? undefined
}
return session
},
},
@ -86,11 +117,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials: { email: string; password: string }) {
async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) {
if (!credentials?.email || !credentials?.password) return null
const email = credentials.email as string
const password = credentials.password as string
const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : ""
// Handle env-configured admin login
if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) {
@ -127,8 +159,84 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
if (user.mfaEnabled) {
const secret = decryptString(user.mfaSecret)
if (!secret || !mfaCode) return null
const { verify } = await import("otplib")
const verifyResult = await verify({ token: mfaCode, secret })
const isValidMfa = verifyResult.valid ?? false
if (!isValidMfa) return null
}
return user
},
} as unknown as import("@auth/core/providers").Provider,
],
})
export const { handlers, signIn, signOut } = nextAuth
const baseAuth = nextAuth.auth
/** Auth.js may split large JWT session cookies into chunked names like `name.0`, `name.1`. */
function hasRoleSessionCookie(
cookieStore: Awaited<ReturnType<typeof cookies>>,
sessionTokenBaseName: string
): boolean {
const secureName = `__Secure-${sessionTokenBaseName}`
return cookieStore.getAll().some(
(c) =>
c.name === sessionTokenBaseName ||
c.name === secureName ||
c.name.startsWith(`${sessionTokenBaseName}.`) ||
c.name.startsWith(`${secureName}.`)
)
}
/**
* Match proxy/middleware portal routing so API routes see the same session as the page.
* (Previously supplier cookie was always checked first, so vendor dashboard + both cookies
* could resolve the wrong NextAuth instance → 403 on vendor-only APIs.)
*/
function portalHintFromReferer(referer: string | null): "vendor" | "supplier" | null {
if (!referer) return null
try {
const path = new URL(referer).pathname
if (path.startsWith("/supplier")) return "supplier"
if (
path.startsWith("/dashboard") ||
path.startsWith("/onboarding") ||
path.startsWith("/admin")
) {
return "vendor"
}
} catch {
/* invalid referer URL */
}
return null
}
// Role-aware auth for all existing `import { auth } from "@/auth"` call sites.
// Prefer the session that matches the page (Referer); otherwise prefer vendor cookie so
// dashboard flows win over a stale supplier cookie.
export const auth = async () => {
const cookieStore = await cookies()
const hint = portalHintFromReferer((await headers()).get("referer"))
const hasSupplier = hasRoleSessionCookie(cookieStore, "authjs.session-token-supplier")
const hasVendor = hasRoleSessionCookie(cookieStore, "authjs.session-token-vendor")
if (hint === "vendor" && hasVendor) {
return vendorAuth()
}
if (hint === "supplier" && hasSupplier) {
return supplierAuth()
}
if (hasVendor) {
return vendorAuth()
}
if (hasSupplier) {
return supplierAuth()
}
return baseAuth()
}

183
src/auth.vendor.ts Normal file
View File

@ -0,0 +1,183 @@
import NextAuth from "next-auth"
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"
import { decryptString } from "@/lib/encryption"
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
// Required when behind proxies/tunnels (e.g. ngrok) in production.
trustHost: true,
// Ensure OAuth callback URLs match the role-scoped NextAuth route.
basePath: "/api/auth/vendor",
...authConfig,
// Role-scoped session cookie to prevent cross-private-window session overwrites.
cookies: {
sessionToken: {
name: "authjs.session-token-vendor",
},
},
callbacks: {
async signIn({ user, credentials }) {
// 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 credentialsRole =
typeof credentials?.role === "string" ? credentials.role : null
const desiredRoleRaw = cookieRole ?? credentialsRole
const desiredRole =
desiredRoleRaw === "SUPPLIER" || desiredRoleRaw === "VENDOR"
? desiredRoleRaw
: 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
token.role = user.role
}
return token
},
async session({ session, token }) {
if (!session.user) return session
const userIdFromToken =
typeof (token as { id?: unknown })?.id === "string"
? (token as { id: string }).id
: null
const userIdFromSession =
typeof (session.user as { id?: unknown }).id === "string"
? (session.user as { id: string }).id
: null
const sessionEmail =
typeof session.user.email === "string" ? session.user.email : null
const userId = userIdFromToken ?? userIdFromSession
const user = userId
? await prisma.user.findUnique({
where: { id: userId },
select: { id: true, role: true, chatPublicKey: true },
})
: sessionEmail
? await prisma.user.findUnique({
where: { email: sessionEmail },
select: { id: true, role: true, chatPublicKey: true },
})
: null
if (user) {
session.user.id = user.id
session.user.role = user.role
session.user.chatPublicKey = user.chatPublicKey ?? undefined
}
return session
},
},
providers: [
...authConfig.providers.filter((p) => (p as { id?: string }).id !== "credentials"),
{
id: "credentials",
name: "Credentials",
type: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials: { email: string; password: string; mfaCode?: string; role?: string }) {
if (!credentials?.email || !credentials?.password) return null
const email = credentials.email as string
const password = credentials.password as string
const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : ""
// Handle env-configured admin login
if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) {
// Ensure an ADMIN user exists (create or update on the fly)
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10)
const adminUser = await prisma.user.upsert({
where: { email: ADMIN_EMAIL },
update: {
password: hashedPassword,
role: "ADMIN",
},
create: {
email: ADMIN_EMAIL,
password: hashedPassword,
role: "ADMIN",
name: "Admin",
},
})
const isValid = await bcrypt.compare(password, adminUser.password!)
if (!isValid) return null
return adminUser
}
// Default credentials flow for non-admin users
const user = await prisma.user.findUnique({
where: { email },
})
if (!user || !user.password) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
if (user.mfaEnabled) {
const secret = decryptString(user.mfaSecret)
if (!secret || !mfaCode) return null
const { verify } = await import("otplib")
const verifyResult = await verify({ token: mfaCode, secret })
const isValidMfa = verifyResult.valid ?? false
if (!isValidMfa) return null
}
return user
},
} as unknown as import("@auth/core/providers").Provider,
],
})

View File

@ -15,7 +15,8 @@ import {
Flag,
Mail,
FileText,
HelpCircle
HelpCircle,
Store
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useTheme } from "@/components/ThemeProvider"
@ -52,6 +53,12 @@ const routes = [
href: "/dashboard/new-products",
color: "text-yellow-500",
},
{
label: "Stores",
icon: Store,
href: "/dashboard/stores",
color: "text-teal-500",
},
{
label: "Suppliers",
icon: Users,

View File

@ -89,7 +89,27 @@ export default function StoreSwitcher() {
}
}
if (stores.length === 0) return null
if (stores.length === 0) {
return (
<button
type="button"
onClick={() => router.push("/dashboard/stores")}
className={`flex max-w-full items-center gap-1.5 rounded-xl border px-2 py-2 text-left backdrop-blur-sm transition-all duration-200 ease-out md:gap-2 md:px-4 ${isLight
? "bg-white border-slate-200 hover:bg-slate-50"
: "bg-white/10 border-white/10 hover:bg-white/15"
}`}
>
<Store
className={`h-3.5 w-3.5 shrink-0 md:h-4 md:w-4 ${isLight ? "text-slate-600" : "text-slate-300"}`}
/>
<span
className={`truncate text-xs font-bold md:text-sm ${isLight ? "text-slate-900" : "text-white"}`}
>
Stores
</span>
</button>
)
}
return (
<div className="relative" ref={dropdownRef}>

View File

@ -1,8 +1,8 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { signOut, useSession } from "next-auth/react"
import { usePathname, useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { Settings, LogOut, ChevronDown } from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { useTheme } from "@/components/ThemeProvider"
@ -10,6 +10,7 @@ import { useTheme } from "@/components/ThemeProvider"
export default function UserAccountMenu() {
const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const { theme } = useTheme()
@ -37,7 +38,40 @@ export default function UserAccountMenu() {
const handleLogout = async () => {
setIsOpen(false)
await signOut({ callbackUrl: "/login" })
const role = pathname?.startsWith("/supplier") ? "SUPPLIER" : "VENDOR"
const basePath = role === "SUPPLIER" ? "/api/auth/supplier" : "/api/auth/vendor"
const callbackUrl = `/login?role=${role}&loggedOut=1`
try {
const csrfRes = await fetch(`${basePath}/csrf`, { method: "GET" })
const csrfData = await csrfRes.json().catch(() => ({}))
const csrfToken = csrfData?.csrfToken ?? ""
const signoutRes = await fetch(`${basePath}/signout`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
})
const data = await signoutRes.json().catch(() => ({}))
window.location.href = data?.url ?? callbackUrl
} catch {
// Fallback: best-effort local cookie clear (role-scoped cookie names)
const cookieNames =
role === "SUPPLIER"
? ["authjs.session-token-supplier", "__Secure-authjs.session-token-supplier"]
: ["authjs.session-token-vendor", "__Secure-authjs.session-token-vendor"]
for (const cookieName of cookieNames) {
try {
document.cookie = `${cookieName}=; path=/; max-age=0; samesite=lax`
} catch {
// ignore
}
}
window.location.href = callbackUrl
}
}
const userInitial = session?.user?.name?.[0] || session?.user?.email?.[0] || "V"

View File

@ -15,7 +15,6 @@ import {
ChevronRight,
Layout
} from "lucide-react"
import { signOut } from "next-auth/react"
const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" },
@ -32,6 +31,40 @@ export default function DashboardSidebar({
}) {
const pathname = usePathname()
const handleSignOut = async () => {
const callbackUrl = "/login?role=VENDOR&loggedOut=1"
const basePath = "/api/auth/vendor"
try {
const csrfRes = await fetch(`${basePath}/csrf`, { method: "GET" })
const csrfData = await csrfRes.json().catch(() => ({}))
const csrfToken = csrfData?.csrfToken ?? ""
const signoutRes = await fetch(`${basePath}/signout`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
body: new URLSearchParams({ csrfToken, callbackUrl }),
})
const data = await signoutRes.json().catch(() => ({}))
window.location.href = data?.url ?? callbackUrl
} catch {
// Fallback: best-effort local cookie clear (role-scoped cookie names)
const cookieNames = ["authjs.session-token-vendor", "__Secure-authjs.session-token-vendor"]
for (const cookieName of cookieNames) {
try {
document.cookie = `${cookieName}=; path=/; max-age=0; samesite=lax`
} catch {
// ignore
}
}
window.location.href = callbackUrl
}
}
return (
<div className="flex h-screen bg-slate-50 font-sans text-slate-900">
{/* Sidebar */}
@ -75,7 +108,7 @@ export default function DashboardSidebar({
<span>Settings</span>
</Link>
<button
onClick={() => signOut({ callbackUrl: "/" })}
onClick={() => void handleSignOut()}
className="w-full flex items-center gap-4 px-4 h-12 rounded-xl font-bold text-red-500 hover:bg-red-50 transition-all cursor-pointer"
>
<LogOut className="w-5 h-5" />

View File

@ -3,10 +3,36 @@
import { SessionProvider } from "next-auth/react"
import { Toaster } from "react-hot-toast"
import { ThemeProvider } from "@/components/ThemeProvider"
import { usePathname } from "next/navigation"
import { useMemo } from "react"
export function Providers({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const basePath = useMemo(() => {
// Prefer portal path when it is explicit.
if (pathname?.startsWith("/supplier")) return "/api/auth/supplier"
if (pathname?.startsWith("/dashboard")) return "/api/auth/vendor"
// For role-agnostic pages (login/signup/home), pick based on existing role-scoped cookies.
// This keeps `useSession()` and `getSession()` aligned with the active role lane.
const cookieStr = typeof document !== "undefined" ? document.cookie : ""
const hasSupplier =
cookieStr.includes("authjs.session-token-supplier=") ||
cookieStr.includes("__Secure-authjs.session-token-supplier=")
const hasVendor =
cookieStr.includes("authjs.session-token-vendor=") ||
cookieStr.includes("__Secure-authjs.session-token-vendor=")
if (hasSupplier) return "/api/auth/supplier"
if (hasVendor) return "/api/auth/vendor"
// Default fallback.
return "/api/auth/vendor"
}, [pathname])
return (
<SessionProvider>
<SessionProvider basePath={basePath}>
<ThemeProvider>
{children}
<Toaster

4
src/lib/auth-roles.ts Normal file
View File

@ -0,0 +1,4 @@
/** Vendor dashboard, Shopify, and supplier-management APIs (excludes supplier portal). */
export function isVendorPortalRole(role: string | null | undefined): boolean {
return role === "VENDOR" || role === "ADMIN"
}

View File

@ -1,39 +1,38 @@
import * as pg from "pg"
import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { PrismaPg } from "@prisma/adapter-pg"
import { logger } from "@/lib/logger"
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
/**
* Prisma ORM 7+ uses the driver adapter for PostgreSQL.
* Use `PoolConfig` (`connectionString`) so the adapter owns the pool — avoids
* brittle bundling around a shared `pg.Pool` instance.
*/
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@127.0.0.1:5432/postgres"
// Initialize Prisma Client with proper error handling
let prismaClient: PrismaClient
const adapter = new PrismaPg({ connectionString })
if (globalForPrisma.prisma) {
prismaClient = globalForPrisma.prisma
} else {
try {
prismaClient = new PrismaClient({ adapter: adapter as never })
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prismaClient
}
} catch (error) {
logger.error("Failed to initialize Prisma Client", error)
throw error
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter,
log:
process.env.NODE_ENV === "development"
? ["error", "warn"]
: ["error"],
})
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma
}
// Ensure Prisma client is up to date
if (typeof window === "undefined") {
// Server-side only
prismaClient.$connect().catch((error) => {
// Ignore connection errors in development, but log them
void prisma.$connect().catch((error: unknown) => {
if (process.env.NODE_ENV === "development") {
logger.warn("Prisma connection warning", error)
}
})
}
export const prisma = prismaClient

View File

@ -37,8 +37,9 @@ export async function sendPushNotification(userId: string, payload: PushPayload)
return { success: false, reason: 'No subscriptions found' }
}
type PushSubscriptionRow = (typeof subscriptions)[number]
const results = await Promise.all(
subscriptions.map(async (sub) => {
subscriptions.map(async (sub: PushSubscriptionRow) => {
try {
await webpush.sendNotification(
{

View File

@ -22,7 +22,7 @@ export const authRegisterSchema = z.object({
.max(100, "Name is too long"),
email: emailSchema,
password: passwordSchema,
role: z.enum(["VENDOR", "SUPPLIER"]).optional(),
role: z.enum(["VENDOR", "SUPPLIER"]),
})
export const authLoginSchema = z.object({

View File

@ -1,47 +1,97 @@
import NextAuth from "next-auth"
import authConfig from "./auth.config"
import { NextResponse } from "next/server"
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server"
import { auth as vendorAuth } from "@/auth.vendor"
import { auth as supplierAuth } from "@/auth.supplier"
const { auth } = NextAuth(authConfig)
function roleSessionCookiesPresent(req: NextRequest) {
const supplierCookie =
req.cookies.get("authjs.session-token-supplier")?.value ??
req.cookies.get("__Secure-authjs.session-token-supplier")?.value
const vendorCookie =
req.cookies.get("authjs.session-token-vendor")?.value ??
req.cookies.get("__Secure-authjs.session-token-vendor")?.value
return { supplierCookie, vendorCookie }
}
export default auth((req) => {
const isLoggedIn = !!req.auth
function selectAuthForRequest(req: NextRequest) {
const { pathname } = req.nextUrl
const userRole = req.auth?.user?.role
const isDashboard = pathname.startsWith("/dashboard")
const isSupplier = pathname.startsWith("/supplier")
const isOnboarding = pathname === "/onboarding"
const isAuthPage = pathname === "/login" || pathname === "/signup"
const roleFromQuery = req.nextUrl.searchParams.get("role")?.toUpperCase()
// Simple auth protection
if ((isDashboard || isSupplier || isOnboarding) && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", req.nextUrl))
// When the URL clearly indicates the portal, prefer that role.
if (isSupplier) return supplierAuth
if (isDashboard) return vendorAuth
// On auth pages, respect explicit role query if present.
if (isAuthPage) {
if (roleFromQuery === "SUPPLIER") return supplierAuth
if (roleFromQuery === "VENDOR") return vendorAuth
}
// Role-based routing
if (isLoggedIn) {
if (isAuthPage) {
// Redirect to appropriate dashboard based on role
if (userRole === "SUPPLIER") {
return NextResponse.redirect(new URL("/supplier", req.nextUrl))
// For role-agnostic pages like `/login`, `/signup`, `/onboarding`,
// choose the auth instance based on which role cookie exists.
const { supplierCookie, vendorCookie } = roleSessionCookiesPresent(req)
if (supplierCookie) return supplierAuth
if (vendorCookie) return vendorAuth
// Default fallback: vendor.
return vendorAuth
}
export default function middleware(req: NextRequest, ev: NextFetchEvent) {
const auth = selectAuthForRequest(req)
type Role = "SUPPLIER" | "VENDOR"
type ReqWithAuth = {
auth?: { user?: { role?: Role } }
nextUrl: URL
}
// NextAuth's `auth()` typing in middleware differs between versions/framework integrations.
// Cast to the wrapper shape we use at runtime: auth((req) => NextResponse)(req).
const authAsMiddlewareWrapper = auth as unknown as (fn: (req: unknown) => NextResponse) => (req: NextRequest) => NextResponse
return authAsMiddlewareWrapper((innerReq: unknown) => {
const { auth: innerAuth, nextUrl } = innerReq as ReqWithAuth
const isLoggedIn = !!innerAuth
const { pathname } = nextUrl
const userRole = innerAuth?.user?.role
const isDashboard = pathname.startsWith("/dashboard")
const isSupplier = pathname.startsWith("/supplier")
const isOnboarding = pathname === "/onboarding"
const isAuthPage = pathname === "/login" || pathname === "/signup"
// Simple auth protection
if ((isDashboard || isSupplier || isOnboarding) && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl))
}
// Role-based routing
if (isLoggedIn) {
if (isAuthPage) {
// Redirect to appropriate dashboard based on role
if (userRole === "SUPPLIER") {
return NextResponse.redirect(new URL("/supplier", nextUrl))
}
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
// Prevent suppliers from accessing vendor dashboard
if (isDashboard && userRole === "SUPPLIER") {
return NextResponse.redirect(new URL("/supplier", nextUrl))
}
// Prevent vendors from accessing supplier dashboard
if (isSupplier && userRole === "VENDOR") {
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
return NextResponse.redirect(new URL("/dashboard", req.nextUrl))
}
// Prevent suppliers from accessing vendor dashboard
if (isDashboard && userRole === "SUPPLIER") {
return NextResponse.redirect(new URL("/supplier", req.nextUrl))
}
// Prevent vendors from accessing supplier dashboard
if (isSupplier && userRole === "VENDOR") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl))
}
}
return NextResponse.next()
})
return NextResponse.next()
})(req)
}
export const config = {
matcher: ["/dashboard/:path*", "/supplier/:path*", "/onboarding", "/login", "/signup"],

View File

@ -1,43 +1,46 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"types": [
"node"
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"**/*.mts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"scripts"
]
}
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"**/*.mts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}