login/signup flow change to handle cookies for both users properly
This commit is contained in:
parent
54d38755d8
commit
5266053495
5
.npmrc
Normal file
5
.npmrc
Normal 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
|
||||
22
Dockerfile
22
Dockerfile
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
732
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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",
|
||||
|
||||
77
scripts/audit-role-consistency.ts
Normal file
77
scripts/audit-role-consistency.ts
Normal 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()
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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." },
|
||||
|
||||
@ -44,7 +44,7 @@ export async function POST(req: Request) {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: role || "VENDOR"
|
||||
role
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
4
src/app/api/auth/supplier/[...nextauth]/route.ts
Normal file
4
src/app/api/auth/supplier/[...nextauth]/route.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { handlers } from "@/auth.supplier"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
4
src/app/api/auth/vendor/[...nextauth]/route.ts
vendored
Normal file
4
src/app/api/auth/vendor/[...nextauth]/route.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import { handlers } from "@/auth.vendor"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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() || "")
|
||||
}))
|
||||
|
||||
@ -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,
|
||||
|
||||
33
src/app/api/vendors/suppliers/my/route.ts
vendored
33
src/app/api/vendors/suppliers/my/route.ts
vendored
@ -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 })
|
||||
|
||||
9
src/app/api/vendors/suppliers/route.ts
vendored
9
src/app/api/vendors/suppliers/route.ts
vendored
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
3
src/app/api/vendors/synced-products/route.ts
vendored
3
src/app/api/vendors/synced-products/route.ts
vendored
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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 "{selectedProduct.title}"</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 "{selectedProduct.title}"</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" />
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
183
src/auth.supplier.ts
Normal 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,
|
||||
],
|
||||
})
|
||||
|
||||
142
src/auth.ts
142
src/auth.ts
@ -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
183
src/auth.vendor.ts
Normal 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,
|
||||
],
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
4
src/lib/auth-roles.ts
Normal 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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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({
|
||||
|
||||
114
src/proxy.ts
114
src/proxy.ts
@ -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"],
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user