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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Prisma validate/generate needs DATABASE_URL before .env is copied; no DB connection occurs during generate
|
||||||
COPY package*.json prisma.config.ts ./
|
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
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Install ALL dependencies (including devDependencies needed for build)
|
# 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 application code (includes .env created in CI from EC2_ENV for NEXT_PUBLIC_* at build time)
|
||||||
COPY . .
|
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)
|
# Build the Next.js application (reads .env and embeds NEXT_PUBLIC_* into the bundle)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@ -18,6 +18,9 @@ done
|
|||||||
echo "Syncing database schema with Prisma (db push)..."
|
echo "Syncing database schema with Prisma (db push)..."
|
||||||
npx prisma db push --accept-data-loss
|
npx prisma db push --accept-data-loss
|
||||||
|
|
||||||
|
echo "Regenerating Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
|
if [ "$RUN_TEMPLATE_SEED_ON_START" = "true" ]; then
|
||||||
echo "Seeding predefined email templates..."
|
echo "Seeding predefined email templates..."
|
||||||
# Run seed script; if it fails, log the error but continue starting the app
|
# 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 = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* 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: {
|
images: {
|
||||||
remotePatterns: [
|
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",
|
"re-embed-knowledge-base": "tsx scripts/re-embed-knowledge-base.ts",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "prisma migrate reset",
|
||||||
"db:studio": "prisma studio",
|
"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": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.41.0",
|
"@auth/core": "^0.41.0",
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.989.0",
|
||||||
"@aws-sdk/client-s3": "^3.989.0",
|
"@aws-sdk/client-s3": "^3.989.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/nodemailer": "^7.0.9",
|
"@types/nodemailer": "^7.0.9",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
@ -34,13 +35,13 @@
|
|||||||
"lenis": "^1.3.17",
|
"lenis": "^1.3.17",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"next": "16.1.6",
|
"next": "^16.2.1",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"otplib": "^13.2.1",
|
"otplib": "^13.2.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.6.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "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 "dotenv/config"
|
||||||
import pg from "pg"
|
|
||||||
import { PrismaPg } from "@prisma/adapter-pg"
|
import { PrismaPg } from "@prisma/adapter-pg"
|
||||||
import { PrismaClient } from "@prisma/client"
|
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({
|
const prisma = new PrismaClient({
|
||||||
adapter: adapter as unknown as PrismaClientOptions["adapter"]
|
adapter: new PrismaPg({
|
||||||
|
connectionString: process.env.DATABASE_URL!,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function fixStoreDomains() {
|
async function fixStoreDomains() {
|
||||||
@ -52,7 +49,6 @@ async function fixStoreDomains() {
|
|||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect()
|
await prisma.$disconnect()
|
||||||
await pool.end()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ async function reEmbedKnowledgeBase() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate embeddings for this batch
|
// 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)
|
const embeddings = await embedBatch(texts)
|
||||||
|
|
||||||
if (embeddings.length !== batch.length) {
|
if (embeddings.length !== batch.length) {
|
||||||
@ -70,7 +70,7 @@ async function reEmbedKnowledgeBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update each chunk with its new embedding
|
// 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]
|
const embedding = embeddings[index]
|
||||||
|
|
||||||
if (!embedding || embedding.length === 0) {
|
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[] = [
|
const events: AdminEvent[] = [
|
||||||
...connectionEvents.map((c) => ({
|
...connectionEvents.map((c: ConnectionEventRow) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
type: "CONNECTION" as AdminEventType,
|
type: "CONNECTION" as AdminEventType,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
@ -46,7 +50,7 @@ async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
|||||||
supplierName: c.supplier.companyName,
|
supplierName: c.supplier.companyName,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})),
|
})),
|
||||||
...productSyncEvents.map((p) => ({
|
...productSyncEvents.map((p: ProductSyncEventRow) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
type: "PRODUCT_SYNC" as AdminEventType,
|
type: "PRODUCT_SYNC" as AdminEventType,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
@ -55,7 +59,7 @@ async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
|||||||
supplierName: p.supplier.companyName,
|
supplierName: p.supplier.companyName,
|
||||||
metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId },
|
metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId },
|
||||||
})),
|
})),
|
||||||
...syncHealthEvents.map((s) => ({
|
...syncHealthEvents.map((s: SyncHealthEventRow) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
type: "SYNC_HEALTH" as AdminEventType,
|
type: "SYNC_HEALTH" as AdminEventType,
|
||||||
status: s.syncStatus,
|
status: s.syncStatus,
|
||||||
|
|||||||
@ -25,8 +25,9 @@ async function getOrders(): Promise<AdminOrder[]> {
|
|||||||
})
|
})
|
||||||
if (acceptedSyncs.length === 0) return []
|
if (acceptedSyncs.length === 0) return []
|
||||||
|
|
||||||
|
type AcceptedSyncPick = (typeof acceptedSyncs)[number]
|
||||||
const syncsByStore = new Map<string, Set<string>>()
|
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())
|
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
|
||||||
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||||
})
|
})
|
||||||
@ -40,7 +41,9 @@ async function getOrders(): Promise<AdminOrder[]> {
|
|||||||
orderBy: { shopifyCreatedAt: "desc" },
|
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)
|
const syncedProductIds = syncsByStore.get(order.storeId)
|
||||||
if (!syncedProductIds?.size) return false
|
if (!syncedProductIds?.size) return false
|
||||||
try {
|
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 }
|
const store = order.store as { id: string; businessName: string; shopifyStoreName: string }
|
||||||
return {
|
return {
|
||||||
id: order.shopifyOrderId,
|
id: order.shopifyOrderId,
|
||||||
|
|||||||
@ -72,17 +72,14 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regular user login flow
|
// Regular user login flow
|
||||||
const userResult = await prisma.$queryRawUnsafe<
|
const userResult = await prisma.$queryRaw`
|
||||||
Array<{
|
SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email}
|
||||||
id: string
|
` as Array<{
|
||||||
password: string | null
|
id: string
|
||||||
mfaEnabled: boolean | null
|
password: string | null
|
||||||
mfaSecret: string | null
|
mfaEnabled: boolean | null
|
||||||
}>
|
mfaSecret: string | null
|
||||||
>(
|
}>
|
||||||
`SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
|
|
||||||
email,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!userResult || userResult.length === 0) {
|
if (!userResult || userResult.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -125,7 +122,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify MFA code
|
// Verify MFA code
|
||||||
const mfaSecret = decryptString(user.mfaSecret)
|
const mfaSecret = decryptString(user.mfaSecret)
|
||||||
if (!mfaSecret) {
|
if (!mfaSecret) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "MFA is enabled but no secret found. Please contact support." },
|
{ message: "MFA is enabled but no secret found. Please contact support." },
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export async function POST(req: Request) {
|
|||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
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
|
// 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 }>>(
|
const userResult = await prisma.$queryRaw<
|
||||||
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`,
|
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
|
||||||
email
|
>`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = ${email}`
|
||||||
)
|
|
||||||
|
|
||||||
if (!userResult || userResult.length === 0) {
|
if (!userResult || userResult.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache"
|
import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache"
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +32,8 @@ export async function GET() {
|
|||||||
return NextResponse.json({ orders: [] })
|
return NextResponse.json({ orders: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoreRow = (typeof stores)[number]
|
||||||
|
|
||||||
type OrderItem = {
|
type OrderItem = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@ -50,7 +53,8 @@ export async function GET() {
|
|||||||
const cacheFresh = await isCacheFresh(store.id, "orders")
|
const cacheFresh = await isCacheFresh(store.id, "orders")
|
||||||
if (cacheFresh) {
|
if (cacheFresh) {
|
||||||
const cached = await getCachedOrders(store.id)
|
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),
|
id: parseInt(order.shopifyOrderId),
|
||||||
name: order.orderNumber,
|
name: order.orderNumber,
|
||||||
total_price: order.totalPrice,
|
total_price: order.totalPrice,
|
||||||
@ -93,8 +97,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(stores.map((s) => fetchOrdersForStore(s)))
|
const results = await Promise.allSettled(
|
||||||
const allOrders: OrderItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
|
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))
|
allOrders.sort((a, b) => (b.id > a.id ? 1 : -1))
|
||||||
return NextResponse.json({ orders: allOrders.slice(0, 50) })
|
return NextResponse.json({ orders: allOrders.slice(0, 50) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache"
|
import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache"
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ const PER_STORE_LIMIT = 25
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +32,8 @@ export async function GET() {
|
|||||||
return NextResponse.json({ products: [] })
|
return NextResponse.json({ products: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoreRow = (typeof stores)[number]
|
||||||
|
|
||||||
type ProductItem = { id: number; title: string; storeId: string; storeName: string }
|
type ProductItem = { id: number; title: string; storeId: string; storeName: string }
|
||||||
|
|
||||||
const fetchProductsForStore = async (
|
const fetchProductsForStore = async (
|
||||||
@ -43,7 +46,8 @@ export async function GET() {
|
|||||||
const cacheFresh = await isCacheFresh(store.id, "products")
|
const cacheFresh = await isCacheFresh(store.id, "products")
|
||||||
if (cacheFresh) {
|
if (cacheFresh) {
|
||||||
const cached = await getCachedProducts(store.id)
|
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),
|
id: parseInt(product.shopifyProductId),
|
||||||
title: product.title || "Product",
|
title: product.title || "Product",
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
@ -82,8 +86,12 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(stores.map((s) => fetchProductsForStore(s)))
|
const results = await Promise.allSettled(
|
||||||
const allProducts: ProductItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
|
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) })
|
return NextResponse.json({ products: allProducts.slice(0, 50) })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Chat attach products error:", 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,
|
id: c.supplier.user.id,
|
||||||
name: c.supplier.companyName || c.supplier.user.name,
|
name: c.supplier.companyName || c.supplier.user.name,
|
||||||
email: c.supplier.user.email,
|
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,
|
id: c.store.user.id,
|
||||||
name: c.store.businessName || c.store.user.name,
|
name: c.store.businessName || c.store.user.name,
|
||||||
email: c.store.user.email,
|
email: c.store.user.email,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { NextResponse } from "next/server"
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { getBaseUrl } from "@/lib/utils"
|
import { getBaseUrl } from "@/lib/utils"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/integrations/gmail/callback
|
* GET /api/integrations/gmail/callback
|
||||||
@ -9,30 +11,53 @@ import { getBaseUrl } from "@/lib/utils"
|
|||||||
*/
|
*/
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
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()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.redirect(
|
const response = NextResponse.redirect(new URL("/login?error=unauthorized", getBaseUrl()))
|
||||||
new URL("/login?error=unauthorized", getBaseUrl())
|
response.cookies.delete("juno_gmail_oauth_state")
|
||||||
)
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const code = searchParams.get("code")
|
const code = searchParams.get("code")
|
||||||
const error = searchParams.get("error")
|
const error = searchParams.get("error")
|
||||||
const _state = searchParams.get("state")
|
const state = searchParams.get("state")
|
||||||
|
|
||||||
// Handle OAuth errors
|
// Handle OAuth errors
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Gmail OAuth error:", error)
|
console.error("Gmail OAuth error:", error)
|
||||||
return NextResponse.redirect(
|
return redirectWithError("oauth_failed")
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", getBaseUrl())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return NextResponse.redirect(
|
return redirectWithError("no_code")
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=no_code", getBaseUrl())
|
}
|
||||||
)
|
|
||||||
|
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
|
// 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
|
// Provide more specific error message if it's a redirect_uri_mismatch
|
||||||
if (errorData.error === "redirect_uri_mismatch") {
|
if (errorData.error === "redirect_uri_mismatch") {
|
||||||
return NextResponse.redirect(
|
return redirectWithError("redirect_uri_mismatch", {
|
||||||
new URL(
|
redirect_uri: redirectUri,
|
||||||
`/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`,
|
})
|
||||||
getBaseUrl()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.redirect(
|
return redirectWithError("token_exchange_failed")
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", getBaseUrl())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json()
|
||||||
@ -113,24 +133,19 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching user info:", error)
|
console.error("Error fetching user info:", error)
|
||||||
return NextResponse.redirect(
|
return redirectWithError("user_info_failed")
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userInfo?.email) {
|
if (!userInfo?.email) {
|
||||||
console.error("No email found in user info")
|
console.error("No email found in user info")
|
||||||
return NextResponse.redirect(
|
return redirectWithError("user_info_failed")
|
||||||
new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", getBaseUrl())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate token expiration
|
// Calculate token expiration
|
||||||
const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000))
|
const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000))
|
||||||
|
|
||||||
// Store or update Gmail integration
|
// Store or update Gmail integration
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await prisma.gmailIntegration.upsert({
|
||||||
await (prisma as any).gmailIntegration.upsert({
|
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id
|
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())
|
new URL("/dashboard/juno-engine/gmail-connect?connected=true", getBaseUrl())
|
||||||
)
|
)
|
||||||
|
successResponse.cookies.delete("juno_gmail_oauth_state")
|
||||||
|
return successResponse
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Gmail callback error:", 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())
|
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
|
// Generate state token for CSRF protection
|
||||||
const state = crypto.randomBytes(32).toString("hex")
|
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
|
// Gmail API scopes
|
||||||
// Adding 'email' and 'profile' scopes to get user email address
|
// Adding 'email' and 'profile' scopes to get user email address
|
||||||
const scopes = [
|
const scopes = [
|
||||||
@ -56,12 +53,19 @@ export async function POST() {
|
|||||||
authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token
|
authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token
|
||||||
authUrl.searchParams.set("state", state)
|
authUrl.searchParams.set("state", state)
|
||||||
|
|
||||||
return NextResponse.json({
|
const response = NextResponse.json({
|
||||||
authUrl: authUrl.toString(),
|
authUrl: authUrl.toString(),
|
||||||
state, // Return state so frontend can verify it on callback
|
|
||||||
redirectUri, // Include redirect URI in response for debugging
|
redirectUri, // Include redirect URI in response for debugging
|
||||||
message: "Redirecting to Google OAuth"
|
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) {
|
} catch (error) {
|
||||||
console.error("Error initiating Gmail OAuth:", error)
|
console.error("Error initiating Gmail OAuth:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
async function getActiveStoreId(userId: string): Promise<string | null> {
|
async function getActiveStoreId(userId: string): Promise<string | null> {
|
||||||
@ -25,7 +26,7 @@ export async function DELETE(
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
async function getActiveStoreId(userId: string): Promise<string | null> {
|
async function getActiveStoreId(userId: string): Promise<string | null> {
|
||||||
@ -22,7 +23,7 @@ export async function GET() {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,8 +37,9 @@ export async function GET() {
|
|||||||
orderBy: { flaggedAt: "desc" },
|
orderBy: { flaggedAt: "desc" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type FlaggedEmailRow = (typeof flaggedEmails)[number]
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
flaggedEmails: flaggedEmails.map((e) => ({
|
flaggedEmails: flaggedEmails.map((e: FlaggedEmailRow) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
subject: e.subject,
|
subject: e.subject,
|
||||||
from: e.from,
|
from: e.from,
|
||||||
@ -61,7 +63,7 @@ export async function POST(req: Request) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +105,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,7 +205,7 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
@ -24,7 +25,7 @@ export async function GET(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +99,7 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
@ -26,7 +27,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
if (!isVendorPortalRole(session.user.role)) {
|
||||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,8 @@ export async function GET() {
|
|||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
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 })
|
return NextResponse.json({ complete: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
@ -9,6 +10,12 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
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 body = await req.json()
|
||||||
const {
|
const {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
@ -9,6 +10,12 @@ export async function POST(req: Request) {
|
|||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
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 {
|
const {
|
||||||
businessName,
|
businessName,
|
||||||
|
|||||||
@ -163,9 +163,16 @@ export async function DELETE(
|
|||||||
|
|
||||||
// Verify user owns the sync (vendor who requested or supplier who received)
|
// Verify user owns the sync (vendor who requested or supplier who received)
|
||||||
const isVendor = productSync.store.userId === session.user.id
|
const isVendor = productSync.store.userId === session.user.id
|
||||||
const isSupplier = session.user.role === "SUPPLIER"
|
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 && !isSupplier) {
|
if (!isVendor && !isSupplierOwner) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Unauthorized" },
|
{ message: "Unauthorized" },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
@ -173,7 +180,7 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only vendor can delete pending requests, supplier should reject instead
|
// Only vendor can delete pending requests, supplier should reject instead
|
||||||
if (isSupplier && productSync.status === "PENDING") {
|
if (isSupplierOwner && productSync.status === "PENDING") {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: "Please reject the request instead of deleting it" },
|
{ message: "Please reject the request instead of deleting it" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,7 +11,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +64,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
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)
|
// Store the secret temporarily (not enabled yet - will be enabled after verification)
|
||||||
// Encrypt the secret at rest
|
// Encrypt the secret at rest
|
||||||
const encryptedSecret = encryptString(secret)
|
const encryptedSecret = encryptString(secret)
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRaw`UPDATE "User" SET "mfaSecret" = ${encryptedSecret} WHERE id = ${session.user.id}`
|
||||||
`UPDATE "User" SET "mfaSecret" = $1 WHERE id = $2`,
|
|
||||||
encryptedSecret,
|
|
||||||
session.user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
secret,
|
secret,
|
||||||
|
|||||||
@ -12,10 +12,9 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use raw query to access MFA fields until Prisma client is fully regenerated
|
// 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 }>>(
|
const result = await prisma.$queryRaw<
|
||||||
`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = $1`,
|
Array<{ mfaEnabled: boolean | null; mfaSecret: string | null }>
|
||||||
session.user.id
|
>`SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
|
||||||
)
|
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!result || result.length === 0) {
|
||||||
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
||||||
|
|||||||
@ -13,10 +13,9 @@ export async function POST(req: Request) {
|
|||||||
const { enabled } = await req.json()
|
const { enabled } = await req.json()
|
||||||
|
|
||||||
// Use raw query to check mfaSecret until Prisma client is fully regenerated
|
// Use raw query to check mfaSecret until Prisma client is fully regenerated
|
||||||
const result = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
|
const result = await prisma.$queryRaw<
|
||||||
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
|
Array<{ mfaSecret: string | null }>
|
||||||
session.user.id
|
>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
|
||||||
)
|
|
||||||
|
|
||||||
if (!result || result.length === 0) {
|
if (!result || result.length === 0) {
|
||||||
return NextResponse.json({ message: "User not found" }, { status: 404 })
|
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)
|
// Disable MFA (can be done without verification)
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = false WHERE id = ${session.user.id}`
|
||||||
`UPDATE "User" SET "mfaEnabled" = false WHERE id = $1`,
|
|
||||||
session.user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "MFA disabled successfully",
|
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
|
// Use raw query to access mfaSecret until Prisma client is fully regenerated
|
||||||
const userResult = await prisma.$queryRawUnsafe<Array<{ mfaSecret: string | null }>>(
|
const userResult = await prisma.$queryRaw<Array<{ mfaSecret: string | null }>>`SELECT "mfaSecret" FROM "User" WHERE id = ${session.user.id}`
|
||||||
`SELECT "mfaSecret" FROM "User" WHERE id = $1`,
|
|
||||||
session.user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!userResult || userResult.length === 0) {
|
if (!userResult || userResult.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -64,10 +61,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enable MFA
|
// Enable MFA
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRaw`UPDATE "User" SET "mfaEnabled" = true WHERE id = ${session.user.id}`
|
||||||
`UPDATE "User" SET "mfaEnabled" = true WHERE id = $1`,
|
|
||||||
session.user.id
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "MFA enabled successfully",
|
message: "MFA enabled successfully",
|
||||||
|
|||||||
@ -31,7 +31,8 @@ export async function GET() {
|
|||||||
if (cacheFresh) {
|
if (cacheFresh) {
|
||||||
// Return cached data transformed to match Shopify format
|
// Return cached data transformed to match Shopify format
|
||||||
const cachedCustomers = await getCachedCustomers(store.id)
|
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),
|
id: parseInt(customer.shopifyCustomerId),
|
||||||
email: customer.email,
|
email: customer.email,
|
||||||
first_name: customer.firstName,
|
first_name: customer.firstName,
|
||||||
|
|||||||
@ -31,7 +31,8 @@ export async function GET() {
|
|||||||
if (cacheFresh) {
|
if (cacheFresh) {
|
||||||
// Return cached data transformed to match Shopify format
|
// Return cached data transformed to match Shopify format
|
||||||
const cachedProducts = await getCachedProducts(store.id)
|
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 }[]) || []
|
const images = (product.images as { src: string }[]) || []
|
||||||
return {
|
return {
|
||||||
id: parseInt(product.shopifyProductId),
|
id: parseInt(product.shopifyProductId),
|
||||||
|
|||||||
@ -31,7 +31,8 @@ export async function GET() {
|
|||||||
if (cacheFresh) {
|
if (cacheFresh) {
|
||||||
// Return cached data transformed to match Shopify format
|
// Return cached data transformed to match Shopify format
|
||||||
const cachedOrders = await getCachedOrders(store.id)
|
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),
|
id: parseInt(order.shopifyOrderId),
|
||||||
name: order.orderNumber,
|
name: order.orderNumber,
|
||||||
email: order.email,
|
email: order.email,
|
||||||
|
|||||||
@ -12,6 +12,16 @@ type QueryBody = {
|
|||||||
includeDocuments?: boolean
|
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(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ storeId: string }> },
|
{ params }: { params: Promise<{ storeId: string }> },
|
||||||
@ -73,17 +83,7 @@ export async function POST(
|
|||||||
|
|
||||||
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""
|
const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : ""
|
||||||
|
|
||||||
const chunks = await prisma.$queryRawUnsafe<
|
const chunks = await prisma.$queryRawUnsafe<KnowledgeChunkRow[]>(
|
||||||
{
|
|
||||||
id: string
|
|
||||||
content: string
|
|
||||||
sourceType: "DOCUMENT" | "FAQ"
|
|
||||||
documentId: string | null
|
|
||||||
faqItemId: string | null
|
|
||||||
metadata: unknown
|
|
||||||
similarity: number
|
|
||||||
}[]
|
|
||||||
>(
|
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
"id",
|
"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 =
|
const label =
|
||||||
chunk.sourceType === "FAQ"
|
chunk.sourceType === "FAQ"
|
||||||
? `FAQ #${index + 1}`
|
? `FAQ #${index + 1}`
|
||||||
@ -145,7 +145,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
answer,
|
answer,
|
||||||
sources: chunks.map((chunk) => ({
|
sources: chunks.map((chunk: KnowledgeChunkRow) => ({
|
||||||
id: chunk.id,
|
id: chunk.id,
|
||||||
type: chunk.sourceType,
|
type: chunk.sourceType,
|
||||||
documentId: chunk.documentId,
|
documentId: chunk.documentId,
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const averagePrice = products.length > 0
|
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
|
: 0
|
||||||
|
|
||||||
// Get recent activity (last 7 days)
|
// Get recent activity (last 7 days)
|
||||||
|
|||||||
@ -49,8 +49,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all accepted product syncs for this supplier
|
// Get all accepted product syncs for this supplier
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const acceptedSyncs = await prisma.productSync.findMany({
|
||||||
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
supplierId: supplierProfile.id,
|
supplierId: supplierProfile.id,
|
||||||
status: "ACCEPTED"
|
status: "ACCEPTED"
|
||||||
@ -71,10 +70,11 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AcceptedSyncRow = (typeof acceptedSyncs)[number]
|
||||||
|
|
||||||
// Group syncs by storeId
|
// Group syncs by storeId
|
||||||
const syncsByStore = new Map<string, Set<string>>()
|
const syncsByStore = new Map<string, Set<string>>()
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
acceptedSyncs.forEach((sync: AcceptedSyncRow) => {
|
||||||
acceptedSyncs.forEach((sync: any) => {
|
|
||||||
if (!syncsByStore.has(sync.storeId)) {
|
if (!syncsByStore.has(sync.storeId)) {
|
||||||
syncsByStore.set(sync.storeId, new Set())
|
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
|
// Filter orders that contain synced products
|
||||||
const relevantOrders = cachedOrders.filter((order) => {
|
const relevantOrders = cachedOrders.filter((order: CachedOrderWithStore) => {
|
||||||
const syncedProductIds = syncsByStore.get(order.storeId)
|
const syncedProductIds = syncsByStore.get(order.storeId)
|
||||||
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
||||||
|
|
||||||
@ -115,8 +118,7 @@ export async function GET() {
|
|||||||
if (!Array.isArray(lineItems)) return false
|
if (!Array.isArray(lineItems)) return false
|
||||||
|
|
||||||
// Check if any line item's product_id matches a synced product
|
// 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: ShopifyLineItem) => {
|
||||||
return lineItems.some((item: any) => {
|
|
||||||
const productId = item.product_id?.toString()
|
const productId = item.product_id?.toString()
|
||||||
return productId && syncedProductIds.has(productId)
|
return productId && syncedProductIds.has(productId)
|
||||||
})
|
})
|
||||||
@ -126,22 +128,20 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Transform orders to include supplier-relevant information
|
// 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()
|
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
let lineItems: ShopifyLineItem[] = []
|
||||||
let lineItems: any[] = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lineItems = typeof order.lineItems === "string"
|
const parsed =
|
||||||
? JSON.parse(order.lineItems)
|
typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems
|
||||||
: order.lineItems
|
lineItems = Array.isArray(parsed) ? (parsed as ShopifyLineItem[]) : []
|
||||||
} catch {
|
} catch {
|
||||||
lineItems = []
|
lineItems = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter line items to only show synced products
|
// Filter line items to only show synced products
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const syncedLineItems = lineItems.filter((item: ShopifyLineItem) => {
|
||||||
const syncedLineItems = lineItems.filter((item: any) => {
|
|
||||||
const productId = item.product_id?.toString()
|
const productId = item.product_id?.toString()
|
||||||
return productId && syncedProductIds.has(productId)
|
return productId && syncedProductIds.has(productId)
|
||||||
})
|
})
|
||||||
@ -158,8 +158,7 @@ export async function GET() {
|
|||||||
store: order.store,
|
store: order.store,
|
||||||
lineItems: syncedLineItems,
|
lineItems: syncedLineItems,
|
||||||
// Include all line items for reference, but mark synced ones
|
// Include all line items for reference, but mark synced ones
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
allLineItems: lineItems.map((item: ShopifyLineItem) => ({
|
||||||
allLineItems: lineItems.map((item: any) => ({
|
|
||||||
...item,
|
...item,
|
||||||
isSynced: syncedProductIds.has(item.product_id?.toString() || "")
|
isSynced: syncedProductIds.has(item.product_id?.toString() || "")
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
|
import { sendEmail, generateInvitationEmailHtml } from "@/lib/email"
|
||||||
import { notifyInvitationReceived } from "@/lib/notifications"
|
import { notifyInvitationReceived } from "@/lib/notifications"
|
||||||
@ -17,7 +18,7 @@ export async function DELETE(
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,9 +41,10 @@ export async function DELETE(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteFlowStoreRow = (typeof vendorStores)[number]
|
||||||
const storeIdsToCheck = storeId
|
const storeIdsToCheck = storeId
|
||||||
? [storeId]
|
? [storeId]
|
||||||
: vendorStores.map(s => s.id)
|
: vendorStores.map((s: DeleteFlowStoreRow) => s.id)
|
||||||
|
|
||||||
// Find and delete pending invitations
|
// Find and delete pending invitations
|
||||||
const pendingInvitations = await prisma.invitation.findMany({
|
const pendingInvitations = await prisma.invitation.findMany({
|
||||||
@ -64,9 +66,10 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete all pending invitations
|
// Delete all pending invitations
|
||||||
|
type PendingInvRow = (typeof pendingInvitations)[number]
|
||||||
await prisma.invitation.deleteMany({
|
await prisma.invitation.deleteMany({
|
||||||
where: {
|
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 {
|
try {
|
||||||
const session = await auth()
|
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 })
|
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
|
// Determine which stores to connect
|
||||||
const storesToConnect = connectToAllStores
|
const storesToConnect = connectToAllStores
|
||||||
? vendorStores
|
? vendorStores
|
||||||
: vendorStores.filter(store => store.isActive)
|
: vendorStores.filter((store: PostFlowStoreRow) => store.isActive)
|
||||||
|
|
||||||
if (storesToConnect.length === 0) {
|
if (storesToConnect.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -161,7 +166,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing connections and invitations for all stores
|
// 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({
|
const existingConnections = await prisma.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: storeIds },
|
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
|
// Filter out stores that already have connections or pending invitations
|
||||||
const connectedStoreIds = new Set([
|
const connectedStoreIds = new Set([
|
||||||
...existingConnections.map(c => c.storeId),
|
...existingConnections.map((c: ExistingConnectionRow) => c.storeId),
|
||||||
...existingInvitations.map((i) => i.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) {
|
if (storesToInvite.length === 0) {
|
||||||
const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length
|
const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length
|
||||||
@ -204,8 +214,8 @@ export async function POST(
|
|||||||
const expiresAt = new Date()
|
const expiresAt = new Date()
|
||||||
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
|
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry
|
||||||
|
|
||||||
const invitations = []
|
const invitations: Array<{ id: string; storeId: string; token: string }> = []
|
||||||
const storeNames = storesToInvite.map(s => s.businessName).join(", ")
|
const storeNames = storesToInvite.map((s: PostFlowStoreRow) => s.businessName).join(", ")
|
||||||
|
|
||||||
for (const store of storesToInvite) {
|
for (const store of storesToInvite) {
|
||||||
const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16)
|
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 })
|
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)
|
console.error(`Database error creating invitation for store ${store.id}:`, dbError)
|
||||||
// Continue with other stores
|
// Continue with other stores
|
||||||
}
|
}
|
||||||
@ -280,7 +290,7 @@ export async function POST(
|
|||||||
message: connectToAllStores && storesToInvite.length > 1
|
message: connectToAllStores && storesToInvite.length > 1
|
||||||
? `Invitations sent successfully to ${invitations.length} store(s)`
|
? `Invitations sent successfully to ${invitations.length} store(s)`
|
||||||
: "Invitation sent successfully",
|
: "Invitation sent successfully",
|
||||||
invitations: invitations.map(inv => ({
|
invitations: invitations.map((inv) => ({
|
||||||
id: inv.id,
|
id: inv.id,
|
||||||
storeId: inv.storeId,
|
storeId: inv.storeId,
|
||||||
token: inv.token,
|
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 { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,7 +11,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +28,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all connections for this vendor's stores
|
// 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({
|
const connections = await prisma.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: vendorStoreIds }
|
storeId: { in: vendorStoreIds }
|
||||||
@ -65,7 +67,7 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Get pending invitations (not yet accepted - no connection created)
|
// Get pending invitations (not yet accepted - no connection created)
|
||||||
const pendingInvitations = await (prisma as any).invitation.findMany({
|
const pendingInvitations = await prisma.invitation.findMany({
|
||||||
where: {
|
where: {
|
||||||
storeId: { in: vendorStoreIds },
|
storeId: { in: vendorStoreIds },
|
||||||
used: false,
|
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
|
// 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
|
id: invitation.id, // Use invitation ID as temporary connection ID
|
||||||
status: "PENDING" as const,
|
status: "PENDING" as const,
|
||||||
createdAt: invitation.createdAt,
|
createdAt: invitation.createdAt,
|
||||||
supplier: invitation.supplier,
|
supplier: invitation.supplier,
|
||||||
store: {
|
store: {
|
||||||
id: invitation.storeId,
|
id: invitation.storeId,
|
||||||
businessName: vendorStores.find(s => s.id === invitation.storeId)?.businessName || "Unknown Store",
|
businessName:
|
||||||
isActive: vendorStores.find(s => s.id === invitation.storeId)?.isActive || false
|
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
|
// Combine connections and pending invitations, removing duplicates
|
||||||
// (if a connection exists, don't include the invitation)
|
// (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(
|
const uniquePendingConnections = pendingConnections.filter(
|
||||||
(pc: any) => !connectionSupplierIds.has(pc.supplier.id)
|
(pc: PendingConnectionRow) => !connectionSupplierIds.has(pc.supplier.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
const allConnections = [...connections, ...uniquePendingConnections].sort(
|
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 })
|
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 { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,7 +11,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
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
|
// Mark which suppliers are already connected/pending
|
||||||
const suppliersWithStatus = await Promise.all(
|
const suppliersWithStatus = await Promise.all(
|
||||||
suppliers.map(async (supplier) => {
|
suppliers.map(async (supplier: SupplierListRow) => {
|
||||||
let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE"
|
let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE"
|
||||||
|
|
||||||
if (vendorStore) {
|
if (vendorStore) {
|
||||||
@ -71,7 +74,7 @@ export async function GET() {
|
|||||||
connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED"
|
connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED"
|
||||||
} else {
|
} else {
|
||||||
// Check for pending invitation (not yet accepted)
|
// Check for pending invitation (not yet accepted)
|
||||||
const pendingInvitation = await (prisma as any).invitation.findFirst({
|
const pendingInvitation = await prisma.invitation.findFirst({
|
||||||
where: {
|
where: {
|
||||||
storeId: vendorStore.id,
|
storeId: vendorStore.id,
|
||||||
supplierId: supplier.id,
|
supplierId: supplier.id,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,7 +17,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
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 { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { isVendorPortalRole } from "@/lib/auth-roles"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,7 +14,7 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const session = await auth()
|
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 })
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -571,19 +571,19 @@ export default function InventoryPage() {
|
|||||||
|
|
||||||
{/* Supplier Selection Modal */}
|
{/* Supplier Selection Modal */}
|
||||||
{showSupplierModal && selectedProduct && (
|
{showSupplierModal && selectedProduct && (
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<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="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={`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 border-b border-white/10 flex items-center justify-between">
|
<div className={`p-6 flex items-center justify-between ${isLight ? "border-b border-slate-200" : "border-b border-white/10"}`}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white">Select Supplier</h2>
|
<h2 className={`text-2xl font-bold ${isLight ? "text-slate-900" : "text-white"}`}>Select Supplier</h2>
|
||||||
<p className="text-sm text-slate-400 mt-1">Choose a supplier to sync "{selectedProduct.title}"</p>
|
<p className={`text-sm mt-1 ${isLight ? "text-slate-600" : "text-slate-400"}`}>Choose a supplier to sync "{selectedProduct.title}"</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSupplierModal(false)
|
setShowSupplierModal(false)
|
||||||
setSelectedProduct(null)
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -591,9 +591,9 @@ export default function InventoryPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{connectedSuppliers.length === 0 ? (
|
{connectedSuppliers.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<AlertCircle className="w-12 h-12 text-slate-500 mx-auto mb-4" />
|
<AlertCircle className={`w-12 h-12 mx-auto mb-4 ${isLight ? "text-slate-400" : "text-slate-500"}`} />
|
||||||
<p className="text-slate-400 font-medium">No connected suppliers</p>
|
<p className={`font-medium ${isLight ? "text-slate-700" : "text-slate-400"}`}>No connected suppliers</p>
|
||||||
<p className="text-sm text-slate-500 mt-2">Please connect with suppliers first from the Suppliers page.</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
@ -602,15 +602,18 @@ export default function InventoryPage() {
|
|||||||
key={supplier.id}
|
key={supplier.id}
|
||||||
onClick={() => handleSelectSupplier(supplier.id)}
|
onClick={() => handleSelectSupplier(supplier.id)}
|
||||||
disabled={syncingProduct}
|
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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<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 && (
|
{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>
|
</div>
|
||||||
{syncingProduct ? (
|
{syncingProduct ? (
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-indigo-400 ml-4" />
|
<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 NotificationBell from "@/components/NotificationBell"
|
||||||
import PageTransition from "@/components/PageTransition"
|
import PageTransition from "@/components/PageTransition"
|
||||||
import ThemeToggle from "@/components/ThemeToggle"
|
import ThemeToggle from "@/components/ThemeToggle"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth.vendor"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
@ -15,13 +15,18 @@ export default async function DashboardLayout({
|
|||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user) {
|
||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role !== "VENDOR") {
|
const role = session.user.role
|
||||||
if (session.user.role === "SUPPLIER") {
|
if (!role) {
|
||||||
redirect("/login?error=WrongPortal&role=SUPPLIER")
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role !== "VENDOR") {
|
||||||
|
if (role === "SUPPLIER") {
|
||||||
|
redirect("/supplier")
|
||||||
}
|
}
|
||||||
redirect("/login?error=WrongRole")
|
redirect("/login?error=WrongRole")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { signIn } from "next-auth/react"
|
|
||||||
import { Suspense, useEffect, useState } from "react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
@ -24,12 +23,22 @@ function LoginFormContent() {
|
|||||||
|
|
||||||
const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => {
|
const applyRoleCookie = (selectedRole: "VENDOR" | "SUPPLIER") => {
|
||||||
try {
|
try {
|
||||||
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600`
|
document.cookie = `juno_login_role=${selectedRole}; path=/; max-age=600; samesite=lax`
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cookie errors – server-side will just skip role enforcement
|
// 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
|
// Map Auth.js error codes from query string to friendly messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const errorCode = searchParams.get("error")
|
const errorCode = searchParams.get("error")
|
||||||
@ -113,22 +122,41 @@ function LoginFormContent() {
|
|||||||
// Ensure the selected role is visible to the auth callbacks
|
// Ensure the selected role is visible to the auth callbacks
|
||||||
applyRoleCookie(role)
|
applyRoleCookie(role)
|
||||||
|
|
||||||
// If MFA was required and code provided, or MFA not required, proceed with NextAuth signIn
|
const callbackUrl = role === "SUPPLIER" ? "/supplier" : "/dashboard"
|
||||||
const res = await signIn("credentials", {
|
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,
|
email: emailValue,
|
||||||
password: passwordValue,
|
password: passwordValue,
|
||||||
redirect: false,
|
role,
|
||||||
callbackUrl: "/onboarding",
|
csrfToken,
|
||||||
})
|
callbackUrl,
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
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 {
|
} catch {
|
||||||
setError("An error occurred. Please try again.")
|
setError("An error occurred. Please try again.")
|
||||||
setRequiresMFA(false)
|
setRequiresMFA(false)
|
||||||
@ -137,9 +165,22 @@ function LoginFormContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = async () => {
|
||||||
applyRoleCookie(role)
|
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 (
|
return (
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { motion } from "framer-motion"
|
|||||||
import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react"
|
import { Mail, Lock, Loader2, User, ShoppingBag, Truck } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { signIn } from "next-auth/react"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
function SignupFormContent() {
|
function SignupFormContent() {
|
||||||
@ -16,6 +15,24 @@ function SignupFormContent() {
|
|||||||
const initialRole = searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR"
|
const initialRole = searchParams.get("role")?.toUpperCase() === "SUPPLIER" ? "SUPPLIER" : "VENDOR"
|
||||||
const [role, setRole] = useState<"VENDOR" | "SUPPLIER">(initialRole)
|
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>) => {
|
const handleSignup = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -47,7 +64,24 @@ function SignupFormContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
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 (
|
return (
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import UserAccountMenu from "@/components/UserAccountMenu"
|
|||||||
import NotificationBell from "@/components/NotificationBell"
|
import NotificationBell from "@/components/NotificationBell"
|
||||||
import PageTransition from "@/components/PageTransition"
|
import PageTransition from "@/components/PageTransition"
|
||||||
import ThemeToggle from "@/components/ThemeToggle"
|
import ThemeToggle from "@/components/ThemeToggle"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth.supplier"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
export default async function SupplierLayout({
|
export default async function SupplierLayout({
|
||||||
@ -14,14 +14,19 @@ export default async function SupplierLayout({
|
|||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user) {
|
||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow SUPPLIER role to access supplier dashboard
|
// Only allow SUPPLIER role to access supplier dashboard
|
||||||
if (session.user.role !== "SUPPLIER") {
|
const role = session.user.role
|
||||||
if (session.user.role === "VENDOR") {
|
if (!role) {
|
||||||
redirect("/login?error=WrongPortal&role=VENDOR")
|
redirect("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role !== "SUPPLIER") {
|
||||||
|
if (role === "VENDOR") {
|
||||||
|
redirect("/dashboard")
|
||||||
}
|
}
|
||||||
redirect("/login?error=WrongRole")
|
redirect("/login?error=WrongRole")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,6 @@ export default {
|
|||||||
Google({
|
Google({
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
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({
|
Credentials({
|
||||||
name: "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 { prisma } from "@/lib/prisma"
|
||||||
import authConfig from "./auth.config"
|
import authConfig from "./auth.config"
|
||||||
import bcrypt from "bcryptjs"
|
import bcrypt from "bcryptjs"
|
||||||
import { cookies } from "next/headers"
|
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_EMAIL = process.env.ADMIN_EMAIL
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
const nextAuth = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
session: { strategy: "jwt" },
|
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,
|
...authConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user, account }) {
|
async signIn({ user, credentials }) {
|
||||||
// Allow non-email-based sign-in flows to proceed as usual
|
// Allow non-email-based sign-in flows to proceed as usual
|
||||||
if (!user?.email) return true
|
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
|
// Read the desired role from the role toggle cookie set on the client
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const cookieRole = cookieStore.get("juno_login_role")?.value
|
const cookieRole = cookieStore.get("juno_login_role")?.value
|
||||||
|
const credentialsRole =
|
||||||
|
typeof credentials?.role === "string" ? credentials.role : null
|
||||||
|
const desiredRoleRaw = cookieRole ?? credentialsRole
|
||||||
const desiredRole =
|
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 no role was explicitly chosen (e.g. legacy flows), fall back to existing behaviour
|
||||||
if (!desiredRole) {
|
if (!desiredRole) {
|
||||||
@ -61,18 +70,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (token && session.user) {
|
if (!session.user) return session
|
||||||
session.user.id = token.id as string
|
|
||||||
if (token.role) {
|
const userIdFromToken =
|
||||||
session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN"
|
typeof (token as { id?: unknown })?.id === "string"
|
||||||
}
|
? (token as { id: string }).id
|
||||||
// Load chatPublicKey from DB so it stays current after chat-setup
|
: null
|
||||||
const user = await prisma.user.findUnique({
|
const userIdFromSession =
|
||||||
where: { id: token.id as string },
|
typeof (session.user as { id?: unknown }).id === "string"
|
||||||
select: { chatPublicKey: true },
|
? (session.user as { id: string }).id
|
||||||
})
|
: null
|
||||||
if (user) session.user.chatPublicKey = user.chatPublicKey ?? undefined
|
|
||||||
|
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
|
return session
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -86,11 +117,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" },
|
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
|
if (!credentials?.email || !credentials?.password) return null
|
||||||
|
|
||||||
const email = credentials.email as string
|
const email = credentials.email as string
|
||||||
const password = credentials.password as string
|
const password = credentials.password as string
|
||||||
|
const mfaCode = typeof credentials.mfaCode === "string" ? credentials.mfaCode.trim() : ""
|
||||||
|
|
||||||
// Handle env-configured admin login
|
// Handle env-configured admin login
|
||||||
if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) {
|
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)
|
const isValid = await bcrypt.compare(password, user.password)
|
||||||
if (!isValid) return null
|
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
|
return user
|
||||||
},
|
},
|
||||||
} as unknown as import("@auth/core/providers").Provider,
|
} 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,
|
Flag,
|
||||||
Mail,
|
Mail,
|
||||||
FileText,
|
FileText,
|
||||||
HelpCircle
|
HelpCircle,
|
||||||
|
Store
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
@ -52,6 +53,12 @@ const routes = [
|
|||||||
href: "/dashboard/new-products",
|
href: "/dashboard/new-products",
|
||||||
color: "text-yellow-500",
|
color: "text-yellow-500",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Stores",
|
||||||
|
icon: Store,
|
||||||
|
href: "/dashboard/stores",
|
||||||
|
color: "text-teal-500",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Suppliers",
|
label: "Suppliers",
|
||||||
icon: Users,
|
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 (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { signOut, useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { Settings, LogOut, ChevronDown } from "lucide-react"
|
import { Settings, LogOut, ChevronDown } from "lucide-react"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
@ -10,6 +10,7 @@ import { useTheme } from "@/components/ThemeProvider"
|
|||||||
export default function UserAccountMenu() {
|
export default function UserAccountMenu() {
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const pathname = usePathname()
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@ -37,7 +38,40 @@ export default function UserAccountMenu() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsOpen(false)
|
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"
|
const userInitial = session?.user?.name?.[0] || session?.user?.email?.[0] || "V"
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Layout
|
Layout
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { signOut } from "next-auth/react"
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" },
|
||||||
@ -32,6 +31,40 @@ export default function DashboardSidebar({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-slate-50 font-sans text-slate-900">
|
<div className="flex h-screen bg-slate-50 font-sans text-slate-900">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@ -75,7 +108,7 @@ export default function DashboardSidebar({
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<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"
|
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" />
|
<LogOut className="w-5 h-5" />
|
||||||
|
|||||||
@ -3,10 +3,36 @@
|
|||||||
import { SessionProvider } from "next-auth/react"
|
import { SessionProvider } from "next-auth/react"
|
||||||
import { Toaster } from "react-hot-toast"
|
import { Toaster } from "react-hot-toast"
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider"
|
import { ThemeProvider } from "@/components/ThemeProvider"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
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 (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider basePath={basePath}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster
|
<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 { PrismaClient } from "@prisma/client"
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
||||||
const adapter = new PrismaPg(pool)
|
|
||||||
|
|
||||||
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
|
const adapter = new PrismaPg({ connectionString })
|
||||||
let prismaClient: PrismaClient
|
|
||||||
|
|
||||||
if (globalForPrisma.prisma) {
|
export const prisma =
|
||||||
prismaClient = globalForPrisma.prisma
|
globalForPrisma.prisma ??
|
||||||
} else {
|
new PrismaClient({
|
||||||
try {
|
adapter,
|
||||||
prismaClient = new PrismaClient({ adapter: adapter as never })
|
log:
|
||||||
if (process.env.NODE_ENV !== "production") {
|
process.env.NODE_ENV === "development"
|
||||||
globalForPrisma.prisma = prismaClient
|
? ["error", "warn"]
|
||||||
}
|
: ["error"],
|
||||||
} catch (error) {
|
})
|
||||||
logger.error("Failed to initialize Prisma Client", error)
|
|
||||||
throw error
|
if (process.env.NODE_ENV !== "production") {
|
||||||
}
|
globalForPrisma.prisma = prisma
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Prisma client is up to date
|
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
// Server-side only
|
void prisma.$connect().catch((error: unknown) => {
|
||||||
prismaClient.$connect().catch((error) => {
|
|
||||||
// Ignore connection errors in development, but log them
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
logger.warn("Prisma connection warning", error)
|
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' }
|
return { success: false, reason: 'No subscriptions found' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PushSubscriptionRow = (typeof subscriptions)[number]
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
subscriptions.map(async (sub) => {
|
subscriptions.map(async (sub: PushSubscriptionRow) => {
|
||||||
try {
|
try {
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const authRegisterSchema = z.object({
|
|||||||
.max(100, "Name is too long"),
|
.max(100, "Name is too long"),
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
role: z.enum(["VENDOR", "SUPPLIER"]).optional(),
|
role: z.enum(["VENDOR", "SUPPLIER"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const authLoginSchema = z.object({
|
export const authLoginSchema = z.object({
|
||||||
|
|||||||
114
src/proxy.ts
114
src/proxy.ts
@ -1,47 +1,97 @@
|
|||||||
import NextAuth from "next-auth"
|
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server"
|
||||||
import authConfig from "./auth.config"
|
import { auth as vendorAuth } from "@/auth.vendor"
|
||||||
import { NextResponse } from "next/server"
|
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) => {
|
function selectAuthForRequest(req: NextRequest) {
|
||||||
const isLoggedIn = !!req.auth
|
|
||||||
const { pathname } = req.nextUrl
|
const { pathname } = req.nextUrl
|
||||||
const userRole = req.auth?.user?.role
|
|
||||||
|
|
||||||
const isDashboard = pathname.startsWith("/dashboard")
|
const isDashboard = pathname.startsWith("/dashboard")
|
||||||
const isSupplier = pathname.startsWith("/supplier")
|
const isSupplier = pathname.startsWith("/supplier")
|
||||||
const isOnboarding = pathname === "/onboarding"
|
|
||||||
const isAuthPage = pathname === "/login" || pathname === "/signup"
|
const isAuthPage = pathname === "/login" || pathname === "/signup"
|
||||||
|
const roleFromQuery = req.nextUrl.searchParams.get("role")?.toUpperCase()
|
||||||
|
|
||||||
// Simple auth protection
|
// When the URL clearly indicates the portal, prefer that role.
|
||||||
if ((isDashboard || isSupplier || isOnboarding) && !isLoggedIn) {
|
if (isSupplier) return supplierAuth
|
||||||
return NextResponse.redirect(new URL("/login", req.nextUrl))
|
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
|
// For role-agnostic pages like `/login`, `/signup`, `/onboarding`,
|
||||||
if (isLoggedIn) {
|
// choose the auth instance based on which role cookie exists.
|
||||||
if (isAuthPage) {
|
const { supplierCookie, vendorCookie } = roleSessionCookiesPresent(req)
|
||||||
// Redirect to appropriate dashboard based on role
|
if (supplierCookie) return supplierAuth
|
||||||
if (userRole === "SUPPLIER") {
|
if (vendorCookie) return vendorAuth
|
||||||
return NextResponse.redirect(new URL("/supplier", req.nextUrl))
|
|
||||||
|
// 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
|
return NextResponse.next()
|
||||||
if (isDashboard && userRole === "SUPPLIER") {
|
})(req)
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*", "/supplier/:path*", "/onboarding", "/login", "/signup"],
|
matcher: ["/dashboard/:path*", "/supplier/:path*", "/onboarding", "/login", "/signup"],
|
||||||
|
|||||||
@ -1,43 +1,46 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": [
|
||||||
"./src/*"
|
"./src/*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"**/*.mts",
|
"**/*.mts",
|
||||||
".next/dev/types/**/*.ts"
|
".next/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules"
|
||||||
"scripts"
|
]
|
||||||
]
|
}
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user