fix: supplier order side & removed chat encryption
This commit is contained in:
parent
036ad53acb
commit
28ec17409f
@ -0,0 +1,15 @@
|
|||||||
|
-- Remove obsolete chat key columns from User.
|
||||||
|
ALTER TABLE "User"
|
||||||
|
DROP COLUMN IF EXISTS "chatPublicKey",
|
||||||
|
DROP COLUMN IF EXISTS "chatPrivateKeyEncrypted";
|
||||||
|
|
||||||
|
-- Rename encrypted message payload to plain content.
|
||||||
|
ALTER TABLE "ChatMessage"
|
||||||
|
RENAME COLUMN "encryptedContent" TO "content";
|
||||||
|
|
||||||
|
-- Remove obsolete encryption metadata columns.
|
||||||
|
ALTER TABLE "ChatMessage"
|
||||||
|
DROP COLUMN IF EXISTS "encryptedKeyForSender",
|
||||||
|
DROP COLUMN IF EXISTS "encryptedKeyForReceiver",
|
||||||
|
DROP COLUMN IF EXISTS "iv";
|
||||||
|
|
||||||
@ -27,9 +27,6 @@ model User {
|
|||||||
mfaEnabled Boolean @default(false)
|
mfaEnabled Boolean @default(false)
|
||||||
mfaSecret String? // Encrypted TOTP secret
|
mfaSecret String? // Encrypted TOTP secret
|
||||||
|
|
||||||
chatPublicKey String?
|
|
||||||
chatPrivateKeyEncrypted String? @db.Text
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
sentMessages ChatMessage[] @relation("SentMessages")
|
sentMessages ChatMessage[] @relation("SentMessages")
|
||||||
receivedMessages ChatMessage[] @relation("ReceivedMessages")
|
receivedMessages ChatMessage[] @relation("ReceivedMessages")
|
||||||
@ -157,6 +154,9 @@ model CachedOrder {
|
|||||||
currency String?
|
currency String?
|
||||||
financialStatus String?
|
financialStatus String?
|
||||||
fulfillmentStatus String?
|
fulfillmentStatus String?
|
||||||
|
trackingNumber String?
|
||||||
|
trackingCompany String?
|
||||||
|
trackingUrl String?
|
||||||
lineItems Json // Store line items as JSON
|
lineItems Json // Store line items as JSON
|
||||||
customer Json? // Store customer info as JSON
|
customer Json? // Store customer info as JSON
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -559,10 +559,7 @@ model ChatMessage {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
senderId String
|
senderId String
|
||||||
receiverId String
|
receiverId String
|
||||||
encryptedContent String @db.Text
|
content String @db.Text
|
||||||
encryptedKeyForSender String @db.Text
|
|
||||||
encryptedKeyForReceiver String @db.Text
|
|
||||||
iv String
|
|
||||||
|
|
||||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@ -25,7 +25,6 @@ export async function GET() {
|
|||||||
name: string | null
|
name: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
image: string | null
|
image: string | null
|
||||||
publicKey: string | null
|
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
let contacts: Contact[] = []
|
let contacts: Contact[] = []
|
||||||
@ -46,7 +45,6 @@ export async function GET() {
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
chatPublicKey: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,7 +57,6 @@ export async function GET() {
|
|||||||
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,
|
||||||
image: c.supplier.user.image,
|
image: c.supplier.user.image,
|
||||||
publicKey: c.supplier.user.chatPublicKey,
|
|
||||||
role: "SUPPLIER"
|
role: "SUPPLIER"
|
||||||
}))
|
}))
|
||||||
} else if (user.role === "SUPPLIER" && user.supplierProfile) {
|
} else if (user.role === "SUPPLIER" && user.supplierProfile) {
|
||||||
@ -78,7 +75,6 @@ export async function GET() {
|
|||||||
name: true,
|
name: true,
|
||||||
email: true,
|
email: true,
|
||||||
image: true,
|
image: true,
|
||||||
chatPublicKey: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,7 +87,6 @@ export async function GET() {
|
|||||||
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,
|
||||||
image: c.store.user.image,
|
image: c.store.user.image,
|
||||||
publicKey: c.store.user.chatPublicKey,
|
|
||||||
role: "VENDOR"
|
role: "VENDOR"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,9 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Unauthorized", { status: 401 })
|
return new NextResponse("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { receiverId, encryptedContent, encryptedKeyForSender, encryptedKeyForReceiver, iv } = await req.json()
|
const { receiverId, content } = await req.json()
|
||||||
|
|
||||||
if (!receiverId || !encryptedContent || !encryptedKeyForSender || !encryptedKeyForReceiver || !iv) {
|
if (!receiverId || !content || typeof content !== "string") {
|
||||||
return new NextResponse("Missing fields", { status: 400 })
|
return new NextResponse("Missing fields", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,18 +20,15 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
senderId: session.user.id,
|
senderId: session.user.id,
|
||||||
receiverId,
|
receiverId,
|
||||||
encryptedContent,
|
content,
|
||||||
encryptedKeyForSender,
|
|
||||||
encryptedKeyForReceiver,
|
|
||||||
iv,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send push notification to receiver
|
// Send push notification to receiver
|
||||||
try {
|
try {
|
||||||
await sendPushNotification(receiverId, {
|
await sendPushNotification(receiverId, {
|
||||||
title: "New Encrypted Message",
|
title: "New Message",
|
||||||
body: `${session.user.name || "A user"} sent you a secure message.`,
|
body: `${session.user.name || "A user"} sent you a message.`,
|
||||||
url: "/dashboard/chat" // Correct to your chat dashboard path
|
url: "/dashboard/chat" // Correct to your chat dashboard path
|
||||||
})
|
})
|
||||||
} catch (pushError) {
|
} catch (pushError) {
|
||||||
|
|||||||
216
src/app/api/supplier/orders/[orderId]/route.ts
Normal file
216
src/app/api/supplier/orders/[orderId]/route.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
|
||||||
|
async function getSupplierContext(userId: string) {
|
||||||
|
const profile = await prisma.supplierProfile.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!profile) return null
|
||||||
|
|
||||||
|
const acceptedSyncs = await prisma.productSync.findMany({
|
||||||
|
where: { supplierId: profile.id, status: "ACCEPTED" },
|
||||||
|
select: { storeId: true, shopifyProductId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncsByStore = new Map<string, Set<string>>()
|
||||||
|
for (const sync of acceptedSyncs) {
|
||||||
|
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
|
||||||
|
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { profile, syncsByStore }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLineItems(lineItems: unknown): Array<{ product_id?: number }> {
|
||||||
|
try {
|
||||||
|
const parsed = typeof lineItems === "string" ? JSON.parse(lineItems) : lineItems
|
||||||
|
return Array.isArray(parsed) ? (parsed as Array<{ product_id?: number }>) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toObject(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupplierAllowedForOrder(
|
||||||
|
syncsByStore: Map<string, Set<string>>,
|
||||||
|
storeId: string,
|
||||||
|
lineItems: unknown,
|
||||||
|
) {
|
||||||
|
const syncedProductIds = syncsByStore.get(storeId)
|
||||||
|
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
||||||
|
const items = extractLineItems(lineItems)
|
||||||
|
return items.some((item) => {
|
||||||
|
const pid = item.product_id?.toString()
|
||||||
|
return !!pid && syncedProductIds.has(pid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ orderId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orderId } = await params
|
||||||
|
const ctx = await getSupplierContext(session.user.id)
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.cachedOrder.findFirst({
|
||||||
|
where: { shopifyOrderId: orderId },
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: { id: true, businessName: true, shopifyStoreName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
||||||
|
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let liveOrder: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
const storeCredentials = await prisma.store.findUnique({
|
||||||
|
where: { id: order.storeId },
|
||||||
|
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||||
|
})
|
||||||
|
if (storeCredentials?.shopifyDomain && storeCredentials?.shopifyAccessToken) {
|
||||||
|
const cleanDomain = storeCredentials.shopifyDomain
|
||||||
|
.trim()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/orders/${orderId}.json`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": storeCredentials.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (response.ok) {
|
||||||
|
const payload = (await response.json()) as { order?: Record<string, unknown> }
|
||||||
|
liveOrder = toObject(payload.order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[SUPPLIER_ORDER_GET_SHOPIFY]", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedCustomer = toObject(order.customer)
|
||||||
|
const customer = toObject(liveOrder?.customer) || cachedCustomer
|
||||||
|
const shippingAddress = toObject(liveOrder?.shipping_address)
|
||||||
|
const billingAddress = toObject(liveOrder?.billing_address)
|
||||||
|
const lineItems = Array.isArray(liveOrder?.line_items)
|
||||||
|
? (liveOrder?.line_items as Array<{ product_id?: number }>)
|
||||||
|
: extractLineItems(order.lineItems)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
order: {
|
||||||
|
id: order.shopifyOrderId,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
email: (typeof liveOrder?.email === "string" && liveOrder.email) || order.email,
|
||||||
|
totalPrice: order.totalPrice,
|
||||||
|
currency: order.currency,
|
||||||
|
financialStatus: order.financialStatus,
|
||||||
|
fulfillmentStatus: order.fulfillmentStatus || "UNFULFILLED",
|
||||||
|
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
||||||
|
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
||||||
|
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
||||||
|
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||||
|
lineItems,
|
||||||
|
customer,
|
||||||
|
shippingAddress,
|
||||||
|
billingAddress,
|
||||||
|
store: order.store,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SUPPLIER_ORDER_GET]", error)
|
||||||
|
return NextResponse.json({ message: "Failed to fetch order" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ orderId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||||
|
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
const { orderId } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
|
||||||
|
const allowedStatuses = new Set(["UNFULFILLED", "IN_PROGRESS", "SHIPPED", "FULFILLED", "CANCELLED"])
|
||||||
|
const fulfillmentStatus = String(body.fulfillmentStatus || "").toUpperCase()
|
||||||
|
if (!allowedStatuses.has(fulfillmentStatus)) {
|
||||||
|
return NextResponse.json({ message: "Invalid fulfillment status" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackingNumber = String(body.trackingNumber || "").trim() || null
|
||||||
|
const trackingCompany = String(body.trackingCompany || "").trim() || null
|
||||||
|
const trackingUrl = String(body.trackingUrl || "").trim() || null
|
||||||
|
|
||||||
|
const ctx = await getSupplierContext(session.user.id)
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await prisma.cachedOrder.findFirst({
|
||||||
|
where: { shopifyOrderId: orderId },
|
||||||
|
select: { id: true, storeId: true, lineItems: true },
|
||||||
|
})
|
||||||
|
if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
||||||
|
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
||||||
|
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.cachedOrder.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: {
|
||||||
|
fulfillmentStatus,
|
||||||
|
trackingNumber,
|
||||||
|
trackingCompany,
|
||||||
|
trackingUrl,
|
||||||
|
} as unknown as Record<string, unknown>,
|
||||||
|
select: {
|
||||||
|
shopifyOrderId: true,
|
||||||
|
fulfillmentStatus: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
order: {
|
||||||
|
...updated,
|
||||||
|
trackingNumber,
|
||||||
|
trackingCompany,
|
||||||
|
trackingUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SUPPLIER_ORDER_PATCH]", error)
|
||||||
|
return NextResponse.json({ message: "Failed to update order" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -151,9 +151,15 @@ export async function GET() {
|
|||||||
orderNumber: order.orderNumber,
|
orderNumber: order.orderNumber,
|
||||||
email: order.email,
|
email: order.email,
|
||||||
totalPrice: order.totalPrice,
|
totalPrice: order.totalPrice,
|
||||||
|
total: Number.parseFloat(order.totalPrice || "0"),
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
financialStatus: order.financialStatus,
|
financialStatus: order.financialStatus,
|
||||||
fulfillmentStatus: order.fulfillmentStatus,
|
fulfillmentStatus: order.fulfillmentStatus,
|
||||||
|
status: order.fulfillmentStatus || "UNFULFILLED",
|
||||||
|
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
||||||
|
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
||||||
|
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
||||||
|
vendorName: order.store.businessName || order.store.shopifyStoreName,
|
||||||
createdAt: order.shopifyCreatedAt || order.createdAt,
|
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||||
store: order.store,
|
store: order.store,
|
||||||
lineItems: syncedLineItems,
|
lineItems: syncedLineItems,
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import { decryptString } from "@/lib/encryption"
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const session = await auth()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: session.user.id }
|
|
||||||
}) as { chatPrivateKeyEncrypted?: string | null } | null
|
|
||||||
|
|
||||||
if (!user?.chatPrivateKeyEncrypted) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ hasBackup: false },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const decrypted = decryptString(user.chatPrivateKeyEncrypted)
|
|
||||||
if (!decrypted) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ hasBackup: false },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ hasBackup: true, privateKey: decrypted },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Cache-Control": "no-store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CHAT_KEY_GET]", error)
|
|
||||||
return new NextResponse("Internal Error", { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { NextResponse } from "next/server"
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { prisma } from "@/lib/prisma"
|
|
||||||
import { encryptString } from "@/lib/encryption"
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const session = await auth()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { publicKey, privateKey } = await req.json() as {
|
|
||||||
publicKey?: string
|
|
||||||
privateKey?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!publicKey) {
|
|
||||||
return new NextResponse("Missing publicKey", { status: 400 })
|
|
||||||
}
|
|
||||||
|
|
||||||
let chatPrivateKeyEncrypted: string | undefined
|
|
||||||
if (privateKey) {
|
|
||||||
try {
|
|
||||||
chatPrivateKeyEncrypted = encryptString(privateKey)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[CHAT_SETUP] Failed to encrypt private key", err)
|
|
||||||
return new NextResponse("Failed to encrypt private key", { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
data: {
|
|
||||||
chatPublicKey: publicKey,
|
|
||||||
...(chatPrivateKeyEncrypted ? { chatPrivateKeyEncrypted } : {})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CHAT_SETUP]", error)
|
|
||||||
return new NextResponse("Internal Error", { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
348
src/app/supplier/orders/[orderId]/page.tsx
Normal file
348
src/app/supplier/orders/[orderId]/page.tsx
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin } from "lucide-react"
|
||||||
|
import toast from "react-hot-toast"
|
||||||
|
|
||||||
|
const SHOPIFY_TRACKING_COMPANIES = [
|
||||||
|
"4PX",
|
||||||
|
"Amazon Logistics UK",
|
||||||
|
"An Post",
|
||||||
|
"Australia Post",
|
||||||
|
"UPS",
|
||||||
|
"USPS",
|
||||||
|
"FedEx",
|
||||||
|
"DHL Express",
|
||||||
|
"DHL eCommerce",
|
||||||
|
"Canada Post",
|
||||||
|
"China Post",
|
||||||
|
"DPD",
|
||||||
|
"Royal Mail",
|
||||||
|
"Purolator",
|
||||||
|
"OnTrac",
|
||||||
|
"GLS",
|
||||||
|
"PostNL",
|
||||||
|
"Singapore Post",
|
||||||
|
"La Poste",
|
||||||
|
"Yodel",
|
||||||
|
"Evri",
|
||||||
|
"Japan Post",
|
||||||
|
"New Zealand Post",
|
||||||
|
"Aramex",
|
||||||
|
"Other",
|
||||||
|
]
|
||||||
|
|
||||||
|
type SupplierOrder = {
|
||||||
|
id: string
|
||||||
|
orderNumber: string
|
||||||
|
email?: string | null
|
||||||
|
totalPrice: string
|
||||||
|
currency?: string | null
|
||||||
|
financialStatus?: string | null
|
||||||
|
fulfillmentStatus: string
|
||||||
|
trackingNumber?: string | null
|
||||||
|
trackingCompany?: string | null
|
||||||
|
trackingUrl?: string | null
|
||||||
|
createdAt: string
|
||||||
|
lineItems: Array<{ id?: number; title?: string; quantity?: number; price?: string; sku?: string; variant_title?: string; product_id?: number }>
|
||||||
|
customer?: { first_name?: string; last_name?: string; email?: string; phone?: string } | null
|
||||||
|
shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||||
|
billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||||
|
store: { id: string; businessName: string; shopifyStoreName: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplierOrderDetailsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams()
|
||||||
|
const orderId = params?.orderId as string
|
||||||
|
|
||||||
|
const [order, setOrder] = useState<SupplierOrder | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const [fulfillmentStatus, setFulfillmentStatus] = useState("UNFULFILLED")
|
||||||
|
const [trackingNumber, setTrackingNumber] = useState("")
|
||||||
|
const [trackingCompany, setTrackingCompany] = useState("")
|
||||||
|
const [trackingUrl, setTrackingUrl] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderId) return
|
||||||
|
const fetchOrder = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/orders/${orderId}`)
|
||||||
|
if (!res.ok) throw new Error("Failed to load order")
|
||||||
|
const data = await res.json()
|
||||||
|
const o = data.order as SupplierOrder
|
||||||
|
setOrder(o)
|
||||||
|
setFulfillmentStatus(o.fulfillmentStatus || "UNFULFILLED")
|
||||||
|
setTrackingNumber(o.trackingNumber || "")
|
||||||
|
setTrackingCompany(o.trackingCompany || "")
|
||||||
|
setTrackingUrl(o.trackingUrl || "")
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load order details.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchOrder()
|
||||||
|
}, [orderId])
|
||||||
|
|
||||||
|
const changed = useMemo(() => {
|
||||||
|
if (!order) return false
|
||||||
|
return (
|
||||||
|
fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") ||
|
||||||
|
trackingNumber !== (order.trackingNumber || "") ||
|
||||||
|
trackingCompany !== (order.trackingCompany || "") ||
|
||||||
|
trackingUrl !== (order.trackingUrl || "")
|
||||||
|
)
|
||||||
|
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!order) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/orders/${order.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fulfillmentStatus,
|
||||||
|
trackingNumber,
|
||||||
|
trackingCompany,
|
||||||
|
trackingUrl,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to save")
|
||||||
|
toast.success("Order updated successfully")
|
||||||
|
setOrder({
|
||||||
|
...order,
|
||||||
|
fulfillmentStatus,
|
||||||
|
trackingNumber,
|
||||||
|
trackingCompany,
|
||||||
|
trackingUrl,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update order")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !order) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center gap-4 text-red-400">
|
||||||
|
<AlertCircle className="w-8 h-8" />
|
||||||
|
<p>{error || "Order not found"}</p>
|
||||||
|
<button onClick={() => router.back()} className="px-4 py-2 rounded-lg bg-white/10 text-white">Back</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => router.back()} className="p-2 rounded-lg hover:bg-white/10 text-slate-300">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold text-white">Order {order.orderNumber}</h1>
|
||||||
|
<p className="text-slate-400 text-sm">{order.store.businessName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!changed || saving}
|
||||||
|
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-bold inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<div className="xl:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||||
|
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-indigo-400" />
|
||||||
|
Order Snapshot
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||||
|
<p className="text-xs uppercase text-slate-400 font-bold">Created</p>
|
||||||
|
<p className="text-white font-semibold mt-1">{new Date(order.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||||
|
<p className="text-xs uppercase text-slate-400 font-bold">Total</p>
|
||||||
|
<p className="text-white font-semibold mt-1">{order.totalPrice} {order.currency || ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||||
|
<p className="text-xs uppercase text-slate-400 font-bold">Payment</p>
|
||||||
|
<p className="text-white font-semibold mt-1">{order.financialStatus || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5 space-y-4">
|
||||||
|
<h2 className="text-white font-bold inline-flex items-center gap-2">
|
||||||
|
<Truck className="w-4 h-4 text-indigo-400" />
|
||||||
|
Fulfillment Update
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Order Status</label>
|
||||||
|
<select
|
||||||
|
value={fulfillmentStatus}
|
||||||
|
onChange={(e) => setFulfillmentStatus(e.target.value)}
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||||
|
>
|
||||||
|
<option value="UNFULFILLED">Unfulfilled</option>
|
||||||
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
|
<option value="SHIPPED">Shipped</option>
|
||||||
|
<option value="FULFILLED">Fulfilled</option>
|
||||||
|
<option value="CANCELLED">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking Platform</label>
|
||||||
|
<select
|
||||||
|
value={trackingCompany}
|
||||||
|
onChange={(e) => setTrackingCompany(e.target.value)}
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||||
|
>
|
||||||
|
<option value="">Select platform</option>
|
||||||
|
{SHOPIFY_TRACKING_COMPANIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking ID</label>
|
||||||
|
<input
|
||||||
|
value={trackingNumber}
|
||||||
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||||
|
placeholder="e.g. 1Z999AA10123456784"
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking URL (optional)</label>
|
||||||
|
<input
|
||||||
|
value={trackingUrl}
|
||||||
|
onChange={(e) => setTrackingUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changed && (
|
||||||
|
<p className="text-emerald-400 text-xs font-semibold inline-flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Unsaved changes
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||||
|
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4 text-indigo-400" />
|
||||||
|
Customer
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<p className="text-white font-semibold">
|
||||||
|
{[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-300 inline-flex items-center gap-2 break-all">
|
||||||
|
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{order.customer?.email || order.email || "Email: --"}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-300 inline-flex items-center gap-2">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
{order.customer?.phone || "Phone: --"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||||
|
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||||
|
Shipping Address
|
||||||
|
</h2>
|
||||||
|
{order.shippingAddress ? (
|
||||||
|
<div className="text-sm text-slate-300 space-y-1.5">
|
||||||
|
<p className="text-white font-semibold">
|
||||||
|
{[order.shippingAddress.first_name, order.shippingAddress.last_name].filter(Boolean).join(" ")}
|
||||||
|
</p>
|
||||||
|
<p>{order.shippingAddress.address1 || "--"}</p>
|
||||||
|
{order.shippingAddress.address2 ? <p>{order.shippingAddress.address2}</p> : null}
|
||||||
|
<p>{[order.shippingAddress.city, order.shippingAddress.province, order.shippingAddress.zip].filter(Boolean).join(", ") || "--"}</p>
|
||||||
|
<p>{order.shippingAddress.country || "--"}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500 text-sm">No shipping address available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||||
|
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||||
|
Billing Address
|
||||||
|
</h2>
|
||||||
|
{order.billingAddress ? (
|
||||||
|
<div className="text-sm text-slate-300 space-y-1.5">
|
||||||
|
<p className="text-white font-semibold">
|
||||||
|
{[order.billingAddress.first_name, order.billingAddress.last_name].filter(Boolean).join(" ")}
|
||||||
|
</p>
|
||||||
|
<p>{order.billingAddress.address1 || "--"}</p>
|
||||||
|
{order.billingAddress.address2 ? <p>{order.billingAddress.address2}</p> : null}
|
||||||
|
<p>{[order.billingAddress.city, order.billingAddress.province, order.billingAddress.zip].filter(Boolean).join(", ") || "--"}</p>
|
||||||
|
<p>{order.billingAddress.country || "--"}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500 text-sm">No billing address available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||||
|
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||||
|
<Package className="w-4 h-4 text-indigo-400" />
|
||||||
|
Order Items
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{order.lineItems.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-sm">No items available.</p>
|
||||||
|
) : order.lineItems.map((item, idx) => (
|
||||||
|
<div key={`${item.id || idx}`} className="bg-white/5 border border-white/10 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-white font-semibold break-words">{item.title || "Item"}</p>
|
||||||
|
{item.variant_title ? <p className="text-slate-400 text-xs mt-1">{item.variant_title}</p> : null}
|
||||||
|
<p className="text-slate-400 text-xs mt-1">Qty: {item.quantity || 0}{item.sku ? ` · SKU: ${item.sku}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-semibold whitespace-nowrap">
|
||||||
|
{((Number(item.price || 0) || 0) * (item.quantity || 0)).toFixed(2)} {order.currency || ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,18 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Search,
|
Search,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Package,
|
Package,
|
||||||
CheckCircle2,
|
ChevronRight
|
||||||
Clock,
|
|
||||||
XCircle
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
export default function SupplierOrdersPage() {
|
export default function SupplierOrdersPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [orders, setOrders] = useState<any[]>([])
|
const [orders, setOrders] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@ -106,7 +106,7 @@ export default function SupplierOrdersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
<div className="bg-white/5 rounded-xl border border-white/10 overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-white/5 border-b border-white/10">
|
<thead className="bg-white/5 border-b border-white/10">
|
||||||
@ -116,11 +116,16 @@ export default function SupplierOrdersPage() {
|
|||||||
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Date</th>
|
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Date</th>
|
||||||
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Status</th>
|
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Status</th>
|
||||||
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Total</th>
|
<th className="px-6 py-4 text-left text-xs font-bold text-slate-400 uppercase tracking-wider">Total</th>
|
||||||
|
<th className="px-6 py-4 text-right text-xs font-bold text-slate-400 uppercase tracking-wider">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/10">
|
<tbody className="divide-y divide-white/10">
|
||||||
{filteredOrders.map((order) => (
|
{filteredOrders.map((order) => (
|
||||||
<tr key={order.id} className="hover:bg-white/5 transition-colors">
|
<tr
|
||||||
|
key={order.id}
|
||||||
|
className="hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
onClick={() => router.push(`/supplier/orders/${order.id}`)}
|
||||||
|
>
|
||||||
<td className="px-6 py-4 font-medium text-white">{order.orderNumber}</td>
|
<td className="px-6 py-4 font-medium text-white">{order.orderNumber}</td>
|
||||||
<td className="px-6 py-4 text-sm text-slate-300">{order.vendorName}</td>
|
<td className="px-6 py-4 text-sm text-slate-300">{order.vendorName}</td>
|
||||||
<td className="px-6 py-4 text-sm text-slate-300">
|
<td className="px-6 py-4 text-sm text-slate-300">
|
||||||
@ -128,14 +133,20 @@ export default function SupplierOrdersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
order.status === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
(order.status || "").toUpperCase() === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
||||||
order.status === "PENDING" ? "bg-yellow-500/20 text-yellow-400" :
|
(order.status || "").toUpperCase() === "SHIPPED" || (order.status || "").toUpperCase() === "IN_PROGRESS" ? "bg-yellow-500/20 text-yellow-400" :
|
||||||
"bg-red-500/20 text-red-400"
|
(order.status || "").toUpperCase() === "CANCELLED" ? "bg-red-500/20 text-red-400" :
|
||||||
|
"bg-blue-500/20 text-blue-400"
|
||||||
}`}>
|
}`}>
|
||||||
{order.status}
|
{order.status || "UNFULFILLED"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-white">${Number(order.total || 0).toFixed(2)}</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<span className="inline-flex items-center gap-1 text-indigo-300 text-sm font-semibold">
|
||||||
|
Manage <ChevronRight className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm font-medium text-white">${order.total?.toFixed(2)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
56
src/app/uploads/[...path]/route.ts
Normal file
56
src/app/uploads/[...path]/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
function contentTypeFor(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".ico":
|
||||||
|
return "image/x-icon"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { path: segments } = await params
|
||||||
|
if (!segments?.length) {
|
||||||
|
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsRoot = path.resolve("public", "uploads")
|
||||||
|
const requestedPath = path.resolve(uploadsRoot, ...segments)
|
||||||
|
|
||||||
|
// Prevent path traversal outside public/uploads.
|
||||||
|
if (!requestedPath.startsWith(uploadsRoot)) {
|
||||||
|
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fs.readFile(requestedPath)
|
||||||
|
return new NextResponse(new Uint8Array(data), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentTypeFor(requestedPath),
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -95,19 +95,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
const user = userId
|
const user = userId
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: sessionEmail
|
: sessionEmail
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { email: sessionEmail },
|
where: { email: sessionEmail },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
session.user.id = user.id
|
session.user.id = user.id
|
||||||
session.user.role = user.role
|
session.user.role = user.role
|
||||||
session.user.chatPublicKey = user.chatPublicKey ?? undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@ -89,19 +89,18 @@ const nextAuth = NextAuth({
|
|||||||
const user = userId
|
const user = userId
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: sessionEmail
|
: sessionEmail
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { email: sessionEmail },
|
where: { email: sessionEmail },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
session.user.id = user.id
|
session.user.id = user.id
|
||||||
session.user.role = user.role
|
session.user.role = user.role
|
||||||
session.user.chatPublicKey = user.chatPublicKey ?? undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@ -95,19 +95,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
const user = userId
|
const user = userId
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: sessionEmail
|
: sessionEmail
|
||||||
? await prisma.user.findUnique({
|
? await prisma.user.findUnique({
|
||||||
where: { email: sessionEmail },
|
where: { email: sessionEmail },
|
||||||
select: { id: true, role: true, chatPublicKey: true },
|
select: { id: true, role: true },
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
session.user.id = user.id
|
session.user.id = user.id
|
||||||
session.user.role = user.role
|
session.user.role = user.role
|
||||||
session.user.chatPublicKey = user.chatPublicKey ?? undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|||||||
@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { getSession, useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { useChatCrypto, PRIVATE_KEY_STORAGE_KEY, PUBLIC_KEY_STORAGE_KEY } from "@/hooks/useChatCrypto"
|
|
||||||
import { ChatCrypto } from "@/lib/chat-crypto"
|
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
import {
|
import {
|
||||||
Send,
|
Send,
|
||||||
@ -12,7 +10,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Lock,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Package,
|
Package,
|
||||||
@ -28,7 +25,6 @@ interface Contact {
|
|||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
image: string | null
|
image: string | null
|
||||||
publicKey: string | null
|
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,8 +67,6 @@ export function ChatView() {
|
|||||||
const isLight = theme === "light"
|
const isLight = theme === "light"
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const userId = (session?.user as { id?: string })?.id
|
const userId = (session?.user as { id?: string })?.id
|
||||||
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto(userId)
|
|
||||||
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([])
|
const [contacts, setContacts] = useState<Contact[]>([])
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
@ -92,57 +86,11 @@ export function ChatView() {
|
|||||||
const attachRef = useRef<HTMLDivElement>(null)
|
const attachRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const autoSetupAttemptedRef = useRef(false)
|
|
||||||
|
|
||||||
const handleSetup = async () => {
|
|
||||||
try {
|
|
||||||
setContactsError(null)
|
|
||||||
// Ensure we have a user id before trying to set up keys
|
|
||||||
if (!userId) {
|
|
||||||
setContactsError("Session not ready. Please refresh the page and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await setupKeys()
|
|
||||||
await getSession()
|
|
||||||
fetchContacts()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Setup failed", error)
|
|
||||||
setContactsError("Failed to set up encryption. Please try again.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-setup chat keys when user has none (e.g. supplier who just accepted invitation)
|
|
||||||
useEffect(() => {
|
|
||||||
if (cryptoLoading || hasKeys || autoSetupAttemptedRef.current || !userId) return
|
|
||||||
autoSetupAttemptedRef.current = true
|
|
||||||
setContactsError(null)
|
|
||||||
setupKeys()
|
|
||||||
.then(() => getSession())
|
|
||||||
.then(() => fetchContacts())
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Auto setup failed", err)
|
|
||||||
autoSetupAttemptedRef.current = false
|
|
||||||
setContactsError("Encryption setup failed. Click below to try again.")
|
|
||||||
})
|
|
||||||
}, [cryptoLoading, hasKeys, setupKeys])
|
|
||||||
|
|
||||||
const fetchContacts = async () => {
|
const fetchContacts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingContacts(true)
|
setLoadingContacts(true)
|
||||||
setContactsError(null)
|
setContactsError(null)
|
||||||
// Sync current user's public key to DB so the other party always has our latest key (fixes decrypt when they send to us)
|
|
||||||
const storedPublic = getStoredPublicKey?.() ?? null
|
|
||||||
if (storedPublic) {
|
|
||||||
try {
|
|
||||||
await fetch("/api/user/chat-setup", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ publicKey: storedPublic })
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Non-blocking; contacts can still load
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const res = await fetch("/api/chat/contacts")
|
const res = await fetch("/api/chat/contacts")
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@ -159,7 +107,6 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchMessages = async (contact: Contact, silent = false) => {
|
const fetchMessages = async (contact: Contact, silent = false) => {
|
||||||
if (!privateKey) return
|
|
||||||
try {
|
try {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoadingMessages(true)
|
setLoadingMessages(true)
|
||||||
@ -168,39 +115,14 @@ export function ChatView() {
|
|||||||
const res = await fetch(`/api/chat/messages?contactId=${contact.id}`)
|
const res = await fetch(`/api/chat/messages?contactId=${contact.id}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
const decryptedMessages = await Promise.all(
|
const plainMessages = data.messages.map((m: { id: string; senderId: string; receiverId: string; content: string; createdAt: string }) => ({
|
||||||
data.messages.map(async (m: { id: string; senderId: string; receiverId: string; encryptedContent: string; encryptedKeyForSender: string; encryptedKeyForReceiver: string; iv: string; createdAt: string }) => {
|
id: m.id,
|
||||||
const primaryKey = m.senderId === contact.id ? m.encryptedKeyForReceiver : m.encryptedKeyForSender
|
senderId: m.senderId,
|
||||||
const fallbackKey = m.senderId === contact.id ? m.encryptedKeyForSender : m.encryptedKeyForReceiver
|
receiverId: m.receiverId,
|
||||||
for (const keyBlob of [primaryKey, fallbackKey]) {
|
content: m.content,
|
||||||
try {
|
createdAt: m.createdAt
|
||||||
const decrypted = await ChatCrypto.decryptMessage(
|
}))
|
||||||
m.encryptedContent,
|
setMessages(plainMessages)
|
||||||
keyBlob,
|
|
||||||
m.iv,
|
|
||||||
privateKey
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
senderId: m.senderId,
|
|
||||||
receiverId: m.receiverId,
|
|
||||||
content: decrypted,
|
|
||||||
createdAt: m.createdAt
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: m.id,
|
|
||||||
senderId: m.senderId,
|
|
||||||
receiverId: m.receiverId,
|
|
||||||
content: "[Encrypted Message - Error Decrypting]",
|
|
||||||
createdAt: m.createdAt
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
setMessages(decryptedMessages)
|
|
||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
setMessagesError("Failed to load messages. Please try again.")
|
setMessagesError("Failed to load messages. Please try again.")
|
||||||
}
|
}
|
||||||
@ -213,8 +135,8 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasKeys) fetchContacts()
|
fetchContacts()
|
||||||
}, [hasKeys])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedContact) {
|
if (selectedContact) {
|
||||||
@ -222,17 +144,17 @@ export function ChatView() {
|
|||||||
} else {
|
} else {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
}
|
}
|
||||||
}, [selectedContact, privateKey])
|
}, [selectedContact])
|
||||||
|
|
||||||
// Poll for new messages when a contact is selected
|
// Poll for new messages when a contact is selected
|
||||||
const POLL_INTERVAL_MS = 8000
|
const POLL_INTERVAL_MS = 8000
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedContact || !privateKey) return
|
if (!selectedContact) return
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchMessages(selectedContact, true)
|
fetchMessages(selectedContact, true)
|
||||||
}, POLL_INTERVAL_MS)
|
}, POLL_INTERVAL_MS)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [selectedContact?.id, privateKey])
|
}, [selectedContact?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
@ -303,50 +225,17 @@ export function ChatView() {
|
|||||||
|
|
||||||
const handleSendMessage = async (e: React.FormEvent) => {
|
const handleSendMessage = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newMessage.trim() || !selectedContact || !selectedContact.publicKey || !privateKey) return
|
if (!newMessage.trim() || !selectedContact) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSending(true)
|
setSending(true)
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
let meData: { user?: { chatPublicKey?: string } } = {}
|
|
||||||
try {
|
|
||||||
const session = await getSession()
|
|
||||||
meData = { user: session?.user ? { chatPublicKey: (session.user as { chatPublicKey?: string }).chatPublicKey } : {} }
|
|
||||||
} catch {
|
|
||||||
const res = await fetch("/api/auth/session")
|
|
||||||
meData = await res.json()
|
|
||||||
}
|
|
||||||
let myPublicKey = meData.user?.chatPublicKey
|
|
||||||
|
|
||||||
if (!myPublicKey && getStoredPublicKey) {
|
|
||||||
const storedPublic = getStoredPublicKey()
|
|
||||||
if (storedPublic) {
|
|
||||||
const syncRes = await fetch("/api/user/chat-setup", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ publicKey: storedPublic })
|
|
||||||
})
|
|
||||||
if (syncRes.ok) myPublicKey = storedPublic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!myPublicKey) {
|
|
||||||
setSendError("Encryption key not ready. Please refresh the page and try again.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted = await ChatCrypto.encryptMessage(
|
|
||||||
newMessage,
|
|
||||||
myPublicKey,
|
|
||||||
selectedContact.publicKey
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await fetch("/api/chat/messages", {
|
const res = await fetch("/api/chat/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
receiverId: selectedContact.id,
|
receiverId: selectedContact.id,
|
||||||
...encrypted
|
content: newMessage
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -372,44 +261,6 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cryptoLoading) {
|
|
||||||
return (
|
|
||||||
<div className={cn("h-full flex flex-col items-center justify-center gap-4", isLight ? "bg-slate-50" : "")}>
|
|
||||||
<Loader2 className="w-10 h-10 animate-spin text-indigo-500" />
|
|
||||||
<p className={cn("font-medium", isLight ? "text-slate-600" : "text-slate-400")}>Initializing JUNO CHAT...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasKeys) {
|
|
||||||
return (
|
|
||||||
<div className={cn("h-full flex flex-col items-center justify-center p-6 text-center", isLight ? "bg-slate-50" : "")}>
|
|
||||||
<div className={cn(
|
|
||||||
"w-20 h-20 rounded-3xl flex items-center justify-center mb-8 animate-pulse shadow-2xl",
|
|
||||||
isLight ? "bg-indigo-100 border border-indigo-200 shadow-indigo-200" : "bg-indigo-500/20 border border-indigo-500/30 shadow-indigo-500/20"
|
|
||||||
)}>
|
|
||||||
<ShieldCheck className={cn("w-10 h-10", isLight ? "text-indigo-600" : "text-indigo-400")} />
|
|
||||||
</div>
|
|
||||||
<h1 className={cn("text-3xl font-black mb-4 tracking-tight", isLight ? "text-slate-900" : "text-white")}>Enable JUNO CHAT</h1>
|
|
||||||
<p className={cn("max-w-sm mb-10 leading-relaxed font-medium", isLight ? "text-slate-600" : "text-slate-400")}>
|
|
||||||
JUNO CHAT uses industry-standard <strong>AES-256 E2E Encryption</strong>.
|
|
||||||
Set up your secure keys to start chatting with your connections.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleSetup}
|
|
||||||
disabled={sending}
|
|
||||||
className="px-10 py-5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl font-black transition-all shadow-xl shadow-indigo-500/30 flex items-center gap-3 active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{sending && <Loader2 className="w-5 h-5 animate-spin" />}
|
|
||||||
Initialize Encryption
|
|
||||||
</button>
|
|
||||||
<p className={cn("mt-8 text-[10px] uppercase tracking-widest font-black flex items-center gap-2", isLight ? "text-slate-500" : "text-slate-500")}>
|
|
||||||
<Lock className="w-3 h-3" /> Private keys never leave your browser
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredContacts = contacts.filter(c =>
|
const filteredContacts = contacts.filter(c =>
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
c.email.toLowerCase().includes(searchQuery.toLowerCase())
|
c.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
@ -512,10 +363,7 @@ export function ChatView() {
|
|||||||
{contact.role}
|
{contact.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(
|
<div className="w-2 h-2 rounded-full bg-green-500/50" />
|
||||||
"w-2 h-2 rounded-full",
|
|
||||||
contact.publicKey ? "bg-green-500/50" : "bg-red-500/50"
|
|
||||||
)} />
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -533,7 +381,7 @@ export function ChatView() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className={cn("text-xs font-black truncate", isLight ? "text-slate-900" : "text-white")}>YOU</p>
|
<p className={cn("text-xs font-black truncate", isLight ? "text-slate-900" : "text-white")}>YOU</p>
|
||||||
<p className={cn("text-[10px] font-bold flex items-center gap-1.5 uppercase tracking-tighter", isLight ? "text-emerald-600" : "text-green-400")}>
|
<p className={cn("text-[10px] font-bold flex items-center gap-1.5 uppercase tracking-tighter", isLight ? "text-emerald-600" : "text-green-400")}>
|
||||||
<ShieldCheck className="w-3 h-3" /> Encrypted Ready
|
<ShieldCheck className="w-3 h-3" /> Ready
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Settings className={cn("w-4 h-4 cursor-pointer transition-colors", isLight ? "text-slate-500 hover:text-slate-700" : "text-slate-600 hover:text-white")} />
|
<Settings className={cn("w-4 h-4 cursor-pointer transition-colors", isLight ? "text-slate-500 hover:text-slate-700" : "text-slate-600 hover:text-white")} />
|
||||||
@ -575,7 +423,7 @@ export function ChatView() {
|
|||||||
<span className={cn("text-[10px] font-black uppercase tracking-widest", isLight ? "text-slate-500" : "text-slate-500")}>Active Now</span>
|
<span className={cn("text-[10px] font-black uppercase tracking-widest", isLight ? "text-slate-500" : "text-slate-500")}>Active Now</span>
|
||||||
<div className={cn("h-3 w-[1px] mx-1", isLight ? "bg-slate-200" : "bg-white/10")} />
|
<div className={cn("h-3 w-[1px] mx-1", isLight ? "bg-slate-200" : "bg-white/10")} />
|
||||||
<span className={cn("text-[10px] font-black uppercase tracking-widest flex items-center gap-1", isLight ? "text-indigo-600" : "text-indigo-400")}>
|
<span className={cn("text-[10px] font-black uppercase tracking-widest flex items-center gap-1", isLight ? "text-indigo-600" : "text-indigo-400")}>
|
||||||
<Lock className="w-2.5 h-2.5" /> End-to-End
|
Chat
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -597,8 +445,8 @@ export function ChatView() {
|
|||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center max-w-xs mx-auto opacity-80">
|
<div className="h-full flex flex-col items-center justify-center text-center max-w-xs mx-auto opacity-80">
|
||||||
<ShieldCheck className={cn("w-16 h-16 mb-4", isLight ? "text-indigo-500" : "text-indigo-500")} />
|
<ShieldCheck className={cn("w-16 h-16 mb-4", isLight ? "text-indigo-500" : "text-indigo-500")} />
|
||||||
<h4 className={cn("text-lg font-black mb-2", isLight ? "text-slate-900" : "text-white")}>Secure Connection Started</h4>
|
<h4 className={cn("text-lg font-black mb-2", isLight ? "text-slate-900" : "text-white")}>Conversation Started</h4>
|
||||||
<p className={cn("text-sm font-medium", isLight ? "text-slate-500" : "text-slate-400")}>Messages are encrypted before leaving your device.</p>
|
<p className={cn("text-sm font-medium", isLight ? "text-slate-500" : "text-slate-400")}>Start chatting with your connection.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg, idx) => {
|
messages.map((msg, idx) => {
|
||||||
@ -647,52 +495,19 @@ export function ChatView() {
|
|||||||
<div className="mb-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm font-medium flex flex-wrap items-center justify-between gap-2">
|
||||||
<span>{sendError}</span>
|
<span>{sendError}</span>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{sendError.includes("Encryption key not ready") && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
setSendError(null)
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(PRIVATE_KEY_STORAGE_KEY)
|
|
||||||
localStorage.removeItem(PUBLIC_KEY_STORAGE_KEY)
|
|
||||||
await setupKeys()
|
|
||||||
await getSession()
|
|
||||||
fetchContacts()
|
|
||||||
} catch {
|
|
||||||
setSendError("Re-setup failed. Please refresh the page.")
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-200 text-xs font-bold uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Re-setup encryption
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" onClick={() => setSendError(null)} className="text-red-400 hover:text-red-300" aria-label="Dismiss">×</button>
|
<button type="button" onClick={() => setSendError(null)} className="text-red-400 hover:text-red-300" aria-label="Dismiss">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!selectedContact.publicKey ? (
|
<form
|
||||||
<div className="p-6 bg-red-500/10 border border-red-500/20 rounded-2xl text-center">
|
onSubmit={handleSendMessage}
|
||||||
<p className="text-sm text-red-400 font-black uppercase tracking-tighter flex items-center justify-center gap-2">
|
className={cn(
|
||||||
<AlertCircle className="w-4 h-4" /> Connection Not Ready
|
"relative flex items-end gap-3 p-3 rounded-2xl transition-all shadow-lg",
|
||||||
</p>
|
isLight
|
||||||
<p className="text-[11px] text-slate-500 mt-2 font-medium">
|
? "bg-white border border-slate-200 focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/20"
|
||||||
This contact hasn't set up their JUNO CHAT keys yet.
|
: "glass-panel bg-white/5 border border-white/10 focus-within:border-indigo-500/50 focus-within:bg-white/10"
|
||||||
</p>
|
)}
|
||||||
<p className="text-[11px] text-slate-500 mt-1 font-medium">
|
>
|
||||||
Ask them to open <strong>JUNO CHAT</strong> once; keys are set up automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form
|
|
||||||
onSubmit={handleSendMessage}
|
|
||||||
className={cn(
|
|
||||||
"relative flex items-end gap-3 p-3 rounded-2xl transition-all shadow-lg",
|
|
||||||
isLight
|
|
||||||
? "bg-white border border-slate-200 focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-500/20"
|
|
||||||
: "glass-panel bg-white/5 border border-white/10 focus-within:border-indigo-500/50 focus-within:bg-white/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="relative" ref={attachRef}>
|
<div className="relative" ref={attachRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -778,7 +593,7 @@ export function ChatView() {
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder="Type encrypted message..."
|
placeholder="Type a message..."
|
||||||
value={newMessage}
|
value={newMessage}
|
||||||
onChange={(e) => setNewMessage(e.target.value)}
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@ -804,10 +619,9 @@ export function ChatView() {
|
|||||||
>
|
>
|
||||||
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
<p className={cn("mt-3 text-[9px] font-black uppercase tracking-[0.2em] text-center", isLight ? "text-slate-500" : "text-slate-600")}>
|
<p className={cn("mt-3 text-[9px] font-black uppercase tracking-[0.2em] text-center", isLight ? "text-slate-500" : "text-slate-600")}>
|
||||||
<Lock className="inline w-2.5 h-2.5 mr-1 mb-0.5" /> Military-Grade AES-256 Protocol
|
Real-time chat enabled
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -819,8 +633,8 @@ export function ChatView() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className={cn("text-2xl font-black mb-3 tracking-tight", isLight ? "text-slate-900" : "text-white")}>Your Direct Line</h3>
|
<h3 className={cn("text-2xl font-black mb-3 tracking-tight", isLight ? "text-slate-900" : "text-white")}>Your Direct Line</h3>
|
||||||
<p className={cn("text-sm font-medium leading-relaxed", isLight ? "text-slate-500" : "text-slate-500")}>
|
<p className={cn("text-sm font-medium leading-relaxed", isLight ? "text-slate-500" : "text-slate-500")}>
|
||||||
Encrypted workspace for vendors and suppliers.
|
Messaging workspace for vendors and suppliers.
|
||||||
Select a contact to start a secure session.
|
Select a contact to start a session.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
|
||||||
import { ChatCrypto } from "@/lib/chat-crypto"
|
|
||||||
|
|
||||||
export const PRIVATE_KEY_STORAGE_KEY = "juno_chat_private_key"
|
|
||||||
export const PUBLIC_KEY_STORAGE_KEY = "juno_chat_public_key"
|
|
||||||
|
|
||||||
export function useChatCrypto(userId?: string) {
|
|
||||||
const [privateKey, setPrivateKey] = useState<string | null>(null)
|
|
||||||
const [hasKeys, setHasKeys] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
const privateKeyKey = userId ? `${PRIVATE_KEY_STORAGE_KEY}_${userId}` : PRIVATE_KEY_STORAGE_KEY
|
|
||||||
const publicKeyKey = userId ? `${PUBLIC_KEY_STORAGE_KEY}_${userId}` : PUBLIC_KEY_STORAGE_KEY
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userId) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = typeof window !== "undefined" ? localStorage.getItem(privateKeyKey) : null
|
|
||||||
if (stored) {
|
|
||||||
setPrivateKey(stored)
|
|
||||||
setHasKeys(true)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// No local key – try to restore from server backup
|
|
||||||
; (async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/user/chat-key", { cache: "no-store" })
|
|
||||||
if (!res.ok) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
if (data.hasBackup && data.privateKey && typeof window !== "undefined") {
|
|
||||||
localStorage.setItem(privateKeyKey, data.privateKey as string)
|
|
||||||
localStorage.setItem(publicKeyKey, (data.publicKey as string | undefined) ?? localStorage.getItem(publicKeyKey) ?? "")
|
|
||||||
setPrivateKey(data.privateKey as string)
|
|
||||||
setHasKeys(true)
|
|
||||||
} else {
|
|
||||||
setPrivateKey(null)
|
|
||||||
setHasKeys(false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("restore chat key failed", error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}, [userId, privateKeyKey, publicKeyKey])
|
|
||||||
|
|
||||||
const setupKeys = useCallback(async () => {
|
|
||||||
if (!userId) throw new Error("User ID required for chat setup")
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const keys = await ChatCrypto.generateKeyPair()
|
|
||||||
|
|
||||||
localStorage.setItem(privateKeyKey, keys.privateKey)
|
|
||||||
localStorage.setItem(publicKeyKey, keys.publicKey)
|
|
||||||
setPrivateKey(keys.privateKey)
|
|
||||||
setHasKeys(true)
|
|
||||||
|
|
||||||
const res = await fetch("/api/user/chat-setup", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ publicKey: keys.publicKey, privateKey: keys.privateKey })
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error("Failed to save public key")
|
|
||||||
|
|
||||||
return keys.publicKey
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Setup keys error:", error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [userId, privateKeyKey, publicKeyKey])
|
|
||||||
|
|
||||||
const getStoredPublicKey = useCallback((): string | null => {
|
|
||||||
if (typeof window === "undefined" || !userId) return null
|
|
||||||
return localStorage.getItem(publicKeyKey)
|
|
||||||
}, [userId, publicKeyKey])
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey,
|
|
||||||
hasKeys,
|
|
||||||
loading,
|
|
||||||
setupKeys,
|
|
||||||
getStoredPublicKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* JUNO CHAT E2E Encryption Utility
|
|
||||||
* uses Web Crypto API (SubtleCrypto)
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Each user generates an RSA-OAEP key pair.
|
|
||||||
* 2. Public key is shared via JUNO API (stored in User table).
|
|
||||||
* 3. Private key is stored ONLY in the user's browser (localStorage).
|
|
||||||
*
|
|
||||||
* Message Sending:
|
|
||||||
* 1. Generate a random AES-GCM (256-bit) "Session Key".
|
|
||||||
* 2. Encrypt the message body with the Session Key and a random IV.
|
|
||||||
* 3. Encrypt the Session Key twice:
|
|
||||||
* - Once with the Sender's RSA Public Key.
|
|
||||||
* - Once with the Receiver's RSA Public Key.
|
|
||||||
* 4. Send [EncryptedBody, EncryptedKeyForSender, EncryptedKeyForReceiver, IV] to JUNO.
|
|
||||||
*
|
|
||||||
* Message Receiving:
|
|
||||||
* 1. Fetch [EncryptedBody, EncryptedKeyForReceiver, IV].
|
|
||||||
* 2. Decrypt the EncryptedKeyForReceiver using the Receiver's RSA Private Key.
|
|
||||||
* 3. Use the decrypted Session Key + IV to decrypt the Message Body.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ChatCrypto {
|
|
||||||
static async generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
|
||||||
const keyPair = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "RSA-OAEP",
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
|
||||||
hash: "SHA-256",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportedPublic = await window.crypto.subtle.exportKey("spki", keyPair.publicKey);
|
|
||||||
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: btoa(String.fromCharCode(...new Uint8Array(exportedPublic))),
|
|
||||||
privateKey: btoa(String.fromCharCode(...new Uint8Array(exportedPrivate))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async encryptMessage(
|
|
||||||
message: string,
|
|
||||||
senderPublicKeyStr: string,
|
|
||||||
receiverPublicKeyStr: string
|
|
||||||
): Promise<{ encryptedContent: string; encryptedKeyForSender: string; encryptedKeyForReceiver: string; iv: string }> {
|
|
||||||
// 1. Generate AES Session Key
|
|
||||||
const sessionKey = await window.crypto.subtle.generateKey(
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
true,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Encrypt Content with AES
|
|
||||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const encodedContent = new TextEncoder().encode(message);
|
|
||||||
const encryptedContentBuffer = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-GCM", iv },
|
|
||||||
sessionKey,
|
|
||||||
encodedContent
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Encrypt AES Key with both Public Keys
|
|
||||||
const senderPublicKey = await this.importPublicKey(senderPublicKeyStr);
|
|
||||||
const receiverPublicKey = await this.importPublicKey(receiverPublicKeyStr);
|
|
||||||
|
|
||||||
const exportedSessionKey = await window.crypto.subtle.exportKey("raw", sessionKey);
|
|
||||||
|
|
||||||
const encryptedKeyForSender = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: "RSA-OAEP" },
|
|
||||||
senderPublicKey,
|
|
||||||
exportedSessionKey
|
|
||||||
);
|
|
||||||
|
|
||||||
const encryptedKeyForReceiver = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: "RSA-OAEP" },
|
|
||||||
receiverPublicKey,
|
|
||||||
exportedSessionKey
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
encryptedContent: this.bufferToBase64(encryptedContentBuffer),
|
|
||||||
encryptedKeyForSender: this.bufferToBase64(encryptedKeyForSender),
|
|
||||||
encryptedKeyForReceiver: this.bufferToBase64(encryptedKeyForReceiver),
|
|
||||||
iv: this.bufferToBase64(iv),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static async decryptMessage(
|
|
||||||
encryptedContentBase64: string,
|
|
||||||
encryptedSessionKeyBase64: string,
|
|
||||||
ivBase64: string,
|
|
||||||
privateKeyStr: string
|
|
||||||
): Promise<string> {
|
|
||||||
const privateKey = await this.importPrivateKey(privateKeyStr);
|
|
||||||
const encryptedSessionKey = this.base64ToBuffer(encryptedSessionKeyBase64);
|
|
||||||
const iv = this.base64ToBuffer(ivBase64);
|
|
||||||
const encryptedContent = this.base64ToBuffer(encryptedContentBase64);
|
|
||||||
|
|
||||||
// 1. Decrypt AES Key using RSA Private Key
|
|
||||||
const decryptedSessionKeyBuffer = await window.crypto.subtle.decrypt(
|
|
||||||
{ name: "RSA-OAEP" },
|
|
||||||
privateKey,
|
|
||||||
encryptedSessionKey.buffer as ArrayBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
const sessionKey = await window.crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
decryptedSessionKeyBuffer,
|
|
||||||
"AES-GCM",
|
|
||||||
true,
|
|
||||||
["decrypt"]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Decrypt Content using AES Session Key
|
|
||||||
const decryptedContentBuffer = await window.crypto.subtle.decrypt(
|
|
||||||
{ name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
|
|
||||||
sessionKey,
|
|
||||||
encryptedContent.buffer as ArrayBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
return new TextDecoder().decode(decryptedContentBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async importPublicKey(keyStr: string): Promise<CryptoKey> {
|
|
||||||
const binary = this.base64ToBuffer(keyStr);
|
|
||||||
return window.crypto.subtle.importKey(
|
|
||||||
"spki",
|
|
||||||
binary.buffer as ArrayBuffer,
|
|
||||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
||||||
true,
|
|
||||||
["encrypt"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async importPrivateKey(keyStr: string): Promise<CryptoKey> {
|
|
||||||
const binary = this.base64ToBuffer(keyStr);
|
|
||||||
return window.crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
binary.buffer as ArrayBuffer,
|
|
||||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
|
||||||
true,
|
|
||||||
["decrypt"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
|
|
||||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
||||||
return btoa(String.fromCharCode(...bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static base64ToBuffer(base64: string): Uint8Array {
|
|
||||||
const binaryString = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
src/types/next-auth.d.ts
vendored
1
src/types/next-auth.d.ts
vendored
@ -6,7 +6,6 @@ declare module "next-auth" {
|
|||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
chatPublicKey?: string | null
|
|
||||||
} & DefaultSession["user"]
|
} & DefaultSession["user"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user