diff --git a/prisma/migrations/20260330130000_chat_plaintext_cleanup/migration.sql b/prisma/migrations/20260330130000_chat_plaintext_cleanup/migration.sql new file mode 100644 index 0000000..46d81d6 --- /dev/null +++ b/prisma/migrations/20260330130000_chat_plaintext_cleanup/migration.sql @@ -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"; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 980bd3f..a2dd13b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,9 +27,6 @@ model User { mfaEnabled Boolean @default(false) mfaSecret String? // Encrypted TOTP secret - chatPublicKey String? - chatPrivateKeyEncrypted String? @db.Text - // Relations sentMessages ChatMessage[] @relation("SentMessages") receivedMessages ChatMessage[] @relation("ReceivedMessages") @@ -157,6 +154,9 @@ model CachedOrder { currency String? financialStatus String? fulfillmentStatus String? + trackingNumber String? + trackingCompany String? + trackingUrl String? lineItems Json // Store line items as JSON customer Json? // Store customer info as JSON createdAt DateTime @default(now()) @@ -559,10 +559,7 @@ model ChatMessage { id String @id @default(cuid()) senderId String receiverId String - encryptedContent String @db.Text - encryptedKeyForSender String @db.Text - encryptedKeyForReceiver String @db.Text - iv String + content String @db.Text sender User @relation("SentMessages", fields: [senderId], references: [id]) receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) diff --git a/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/3204a4a4-4580-44fa-8320-c0d3f2d93125.png b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/3204a4a4-4580-44fa-8320-c0d3f2d93125.png new file mode 100644 index 0000000..5c52822 Binary files /dev/null and b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/3204a4a4-4580-44fa-8320-c0d3f2d93125.png differ diff --git a/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/40791d23-ead0-4ab9-b104-5f0a1b30d2b3.ico b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/40791d23-ead0-4ab9-b104-5f0a1b30d2b3.ico new file mode 100644 index 0000000..dd5a126 Binary files /dev/null and b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/40791d23-ead0-4ab9-b104-5f0a1b30d2b3.ico differ diff --git a/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/f266e026-e6c5-458e-87f0-1727a7c55ba1.png b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/f266e026-e6c5-458e-87f0-1727a7c55ba1.png new file mode 100644 index 0000000..b767110 Binary files /dev/null and b/public/uploads/supplier-products/cmna11jb0000d4lpe2hklimdf/f266e026-e6c5-458e-87f0-1727a7c55ba1.png differ diff --git a/src/app/api/chat/contacts/route.ts b/src/app/api/chat/contacts/route.ts index 80e0ec5..d34ac02 100644 --- a/src/app/api/chat/contacts/route.ts +++ b/src/app/api/chat/contacts/route.ts @@ -25,7 +25,6 @@ export async function GET() { name: string | null email: string | null image: string | null - publicKey: string | null role: string } let contacts: Contact[] = [] @@ -46,7 +45,6 @@ export async function GET() { name: true, email: true, image: true, - chatPublicKey: true, } } } @@ -59,7 +57,6 @@ export async function GET() { name: c.supplier.companyName || c.supplier.user.name, email: c.supplier.user.email, image: c.supplier.user.image, - publicKey: c.supplier.user.chatPublicKey, role: "SUPPLIER" })) } else if (user.role === "SUPPLIER" && user.supplierProfile) { @@ -78,7 +75,6 @@ export async function GET() { name: true, email: true, image: true, - chatPublicKey: true, } } } @@ -91,7 +87,6 @@ export async function GET() { name: c.store.businessName || c.store.user.name, email: c.store.user.email, image: c.store.user.image, - publicKey: c.store.user.chatPublicKey, role: "VENDOR" })) } diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index c37ced0..1a5d4ea 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -10,9 +10,9 @@ export async function POST(req: Request) { 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 }) } @@ -20,18 +20,15 @@ export async function POST(req: Request) { data: { senderId: session.user.id, receiverId, - encryptedContent, - encryptedKeyForSender, - encryptedKeyForReceiver, - iv, + content, } }) // Send push notification to receiver try { await sendPushNotification(receiverId, { - title: "New Encrypted Message", - body: `${session.user.name || "A user"} sent you a secure message.`, + title: "New Message", + body: `${session.user.name || "A user"} sent you a message.`, url: "/dashboard/chat" // Correct to your chat dashboard path }) } catch (pushError) { diff --git a/src/app/api/supplier/orders/[orderId]/route.ts b/src/app/api/supplier/orders/[orderId]/route.ts new file mode 100644 index 0000000..a1ba7ca --- /dev/null +++ b/src/app/api/supplier/orders/[orderId]/route.ts @@ -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>() + 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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null +} + +function isSupplierAllowedForOrder( + syncsByStore: Map>, + 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 | 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 } + 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, + 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 }) + } +} + diff --git a/src/app/api/supplier/orders/route.ts b/src/app/api/supplier/orders/route.ts index 7e95ddc..ac1ff37 100644 --- a/src/app/api/supplier/orders/route.ts +++ b/src/app/api/supplier/orders/route.ts @@ -151,9 +151,15 @@ export async function GET() { orderNumber: order.orderNumber, email: order.email, totalPrice: order.totalPrice, + total: Number.parseFloat(order.totalPrice || "0"), currency: order.currency, financialStatus: order.financialStatus, 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, store: order.store, lineItems: syncedLineItems, diff --git a/src/app/api/user/chat-key/route.ts b/src/app/api/user/chat-key/route.ts deleted file mode 100644 index 8179f3c..0000000 --- a/src/app/api/user/chat-key/route.ts +++ /dev/null @@ -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 }) - } -} - diff --git a/src/app/api/user/chat-setup/route.ts b/src/app/api/user/chat-setup/route.ts deleted file mode 100644 index dfe34ce..0000000 --- a/src/app/api/user/chat-setup/route.ts +++ /dev/null @@ -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 }) - } -} diff --git a/src/app/supplier/orders/[orderId]/page.tsx b/src/app/supplier/orders/[orderId]/page.tsx new file mode 100644 index 0000000..b399487 --- /dev/null +++ b/src/app/supplier/orders/[orderId]/page.tsx @@ -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(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 ( +
+ +
+ ) + } + + if (error || !order) { + return ( +
+ +

{error || "Order not found"}

+ +
+ ) + } + + return ( +
+
+
+ +
+

Order {order.orderNumber}

+

{order.store.businessName}

+
+
+ +
+ +
+
+
+

+ + Order Snapshot +

+
+
+

Created

+

{new Date(order.createdAt).toLocaleString()}

+
+
+

Total

+

{order.totalPrice} {order.currency || ""}

+
+
+

Payment

+

{order.financialStatus || "N/A"}

+
+
+
+
+ +
+

+ + Fulfillment Update +

+ +
+ + +
+ +
+ + +
+ +
+ + 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" + /> +
+ +
+ + setTrackingUrl(e.target.value)} + placeholder="https://..." + className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3" + /> +
+ + {changed && ( +

+ Unsaved changes +

+ )} +
+
+ +
+
+

+ + Customer +

+
+

+ {[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"} +

+

+ + {order.customer?.email || order.email || "Email: --"} +

+

+ + {order.customer?.phone || "Phone: --"} +

+
+
+ +
+

+ + Shipping Address +

+ {order.shippingAddress ? ( +
+

+ {[order.shippingAddress.first_name, order.shippingAddress.last_name].filter(Boolean).join(" ")} +

+

{order.shippingAddress.address1 || "--"}

+ {order.shippingAddress.address2 ?

{order.shippingAddress.address2}

: null} +

{[order.shippingAddress.city, order.shippingAddress.province, order.shippingAddress.zip].filter(Boolean).join(", ") || "--"}

+

{order.shippingAddress.country || "--"}

+
+ ) : ( +

No shipping address available.

+ )} +
+ +
+

+ + Billing Address +

+ {order.billingAddress ? ( +
+

+ {[order.billingAddress.first_name, order.billingAddress.last_name].filter(Boolean).join(" ")} +

+

{order.billingAddress.address1 || "--"}

+ {order.billingAddress.address2 ?

{order.billingAddress.address2}

: null} +

{[order.billingAddress.city, order.billingAddress.province, order.billingAddress.zip].filter(Boolean).join(", ") || "--"}

+

{order.billingAddress.country || "--"}

+
+ ) : ( +

No billing address available.

+ )} +
+
+ +
+

+ + Order Items +

+
+ {order.lineItems.length === 0 ? ( +

No items available.

+ ) : order.lineItems.map((item, idx) => ( +
+
+

{item.title || "Item"}

+ {item.variant_title ?

{item.variant_title}

: null} +

Qty: {item.quantity || 0}{item.sku ? ` · SKU: ${item.sku}` : ""}

+
+

+ {((Number(item.price || 0) || 0) * (item.quantity || 0)).toFixed(2)} {order.currency || ""} +

+
+ ))} +
+
+
+ ) +} + diff --git a/src/app/supplier/orders/page.tsx b/src/app/supplier/orders/page.tsx index 9f0cd27..8949189 100644 --- a/src/app/supplier/orders/page.tsx +++ b/src/app/supplier/orders/page.tsx @@ -1,18 +1,18 @@ "use client" import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" import { ShoppingCart, Search, Loader2, AlertCircle, Package, - CheckCircle2, - Clock, - XCircle + ChevronRight } from "lucide-react" export default function SupplierOrdersPage() { + const router = useRouter() const [orders, setOrders] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState("") @@ -106,7 +106,7 @@ export default function SupplierOrdersPage() {

) : ( -
+
@@ -116,11 +116,16 @@ export default function SupplierOrdersPage() { + {filteredOrders.map((order) => ( - + router.push(`/supplier/orders/${order.id}`)} + > + + - ))} diff --git a/src/app/uploads/[...path]/route.ts b/src/app/uploads/[...path]/route.ts new file mode 100644 index 0000000..8dfa97c --- /dev/null +++ b/src/app/uploads/[...path]/route.ts @@ -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 }) + } +} + diff --git a/src/auth.supplier.ts b/src/auth.supplier.ts index bcbf472..a50e810 100644 --- a/src/auth.supplier.ts +++ b/src/auth.supplier.ts @@ -95,19 +95,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const user = userId ? await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : sessionEmail ? await prisma.user.findUnique({ where: { email: sessionEmail }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : null if (user) { session.user.id = user.id session.user.role = user.role - session.user.chatPublicKey = user.chatPublicKey ?? undefined } return session diff --git a/src/auth.ts b/src/auth.ts index a7e8267..bc3884c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -89,19 +89,18 @@ const nextAuth = NextAuth({ const user = userId ? await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : sessionEmail ? await prisma.user.findUnique({ where: { email: sessionEmail }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : null if (user) { session.user.id = user.id session.user.role = user.role - session.user.chatPublicKey = user.chatPublicKey ?? undefined } return session diff --git a/src/auth.vendor.ts b/src/auth.vendor.ts index 40757d1..b320b7f 100644 --- a/src/auth.vendor.ts +++ b/src/auth.vendor.ts @@ -95,19 +95,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const user = userId ? await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : sessionEmail ? await prisma.user.findUnique({ where: { email: sessionEmail }, - select: { id: true, role: true, chatPublicKey: true }, + select: { id: true, role: true }, }) : null if (user) { session.user.id = user.id session.user.role = user.role - session.user.chatPublicKey = user.chatPublicKey ?? undefined } return session diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 0dff808..b31e144 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -2,9 +2,7 @@ import { useState, useEffect, useRef } from "react" import { usePathname } from "next/navigation" -import { getSession, 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 { useSession } from "next-auth/react" import { useTheme } from "@/components/ThemeProvider" import { Send, @@ -12,7 +10,6 @@ import { ShieldCheck, Loader2, Plus, - Lock, MessageSquare, CheckCheck, Package, @@ -28,7 +25,6 @@ interface Contact { name: string email: string image: string | null - publicKey: string | null role: string } @@ -71,8 +67,6 @@ export function ChatView() { const isLight = theme === "light" const { data: session } = useSession() const userId = (session?.user as { id?: string })?.id - const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto(userId) - const [contacts, setContacts] = useState([]) const [selectedContact, setSelectedContact] = useState(null) const [messages, setMessages] = useState([]) @@ -92,57 +86,11 @@ export function ChatView() { const attachRef = useRef(null) const messagesEndRef = useRef(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 () => { try { setLoadingContacts(true) 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") if (res.ok) { const data = await res.json() @@ -159,7 +107,6 @@ export function ChatView() { } const fetchMessages = async (contact: Contact, silent = false) => { - if (!privateKey) return try { if (!silent) { setLoadingMessages(true) @@ -168,39 +115,14 @@ export function ChatView() { const res = await fetch(`/api/chat/messages?contactId=${contact.id}`) if (res.ok) { const data = await res.json() - const decryptedMessages = await Promise.all( - data.messages.map(async (m: { id: string; senderId: string; receiverId: string; encryptedContent: string; encryptedKeyForSender: string; encryptedKeyForReceiver: string; iv: string; createdAt: string }) => { - const primaryKey = m.senderId === contact.id ? m.encryptedKeyForReceiver : m.encryptedKeyForSender - const fallbackKey = m.senderId === contact.id ? m.encryptedKeyForSender : m.encryptedKeyForReceiver - for (const keyBlob of [primaryKey, fallbackKey]) { - try { - const decrypted = await ChatCrypto.decryptMessage( - m.encryptedContent, - 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) + const plainMessages = data.messages.map((m: { id: string; senderId: string; receiverId: string; content: string; createdAt: string }) => ({ + id: m.id, + senderId: m.senderId, + receiverId: m.receiverId, + content: m.content, + createdAt: m.createdAt + })) + setMessages(plainMessages) } else if (!silent) { setMessagesError("Failed to load messages. Please try again.") } @@ -213,8 +135,8 @@ export function ChatView() { } useEffect(() => { - if (hasKeys) fetchContacts() - }, [hasKeys]) + fetchContacts() + }, []) useEffect(() => { if (selectedContact) { @@ -222,17 +144,17 @@ export function ChatView() { } else { setMessages([]) } - }, [selectedContact, privateKey]) + }, [selectedContact]) // Poll for new messages when a contact is selected const POLL_INTERVAL_MS = 8000 useEffect(() => { - if (!selectedContact || !privateKey) return + if (!selectedContact) return const interval = setInterval(() => { fetchMessages(selectedContact, true) }, POLL_INTERVAL_MS) return () => clearInterval(interval) - }, [selectedContact?.id, privateKey]) + }, [selectedContact?.id]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) @@ -303,50 +225,17 @@ export function ChatView() { const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault() - if (!newMessage.trim() || !selectedContact || !selectedContact.publicKey || !privateKey) return + if (!newMessage.trim() || !selectedContact) return try { setSending(true) 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", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ receiverId: selectedContact.id, - ...encrypted + content: newMessage }) }) @@ -372,44 +261,6 @@ export function ChatView() { } } - if (cryptoLoading) { - return ( -
- -

Initializing JUNO CHAT...

-
- ) - } - - if (!hasKeys) { - return ( -
-
- -
-

Enable JUNO CHAT

-

- JUNO CHAT uses industry-standard AES-256 E2E Encryption. - Set up your secure keys to start chatting with your connections. -

- -

- Private keys never leave your browser -

-
- ) - } - const filteredContacts = contacts.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.email.toLowerCase().includes(searchQuery.toLowerCase()) @@ -512,10 +363,7 @@ export function ChatView() { {contact.role}

-
+
))}
@@ -533,7 +381,7 @@ export function ChatView() {

YOU

- Encrypted Ready + Ready

@@ -575,7 +423,7 @@ export function ChatView() { Active Now
- End-to-End + Chat
@@ -597,8 +445,8 @@ export function ChatView() { ) : messages.length === 0 ? (
-

Secure Connection Started

-

Messages are encrypted before leaving your device.

+

Conversation Started

+

Start chatting with your connection.

) : ( messages.map((msg, idx) => { @@ -647,52 +495,19 @@ export function ChatView() {
{sendError}
- {sendError.includes("Encryption key not ready") && ( - - )}
)} - {!selectedContact.publicKey ? ( -
-

- Connection Not Ready -

-

- This contact hasn't set up their JUNO CHAT keys yet. -

-

- Ask them to open JUNO CHAT once; keys are set up automatically. -

-
- ) : ( -
+
Date Status TotalAction
{order.orderNumber} {order.vendorName} @@ -128,14 +133,20 @@ export default function SupplierOrdersPage() { - {order.status} + {order.status || "UNFULFILLED"} + + ${Number(order.total || 0).toFixed(2)} + + Manage ${order.total?.toFixed(2)}