fix: supplier order side & removed chat encryption

This commit is contained in:
talatops 2026-03-31 18:16:30 +05:00
parent 036ad53acb
commit 28ec17409f
21 changed files with 712 additions and 620 deletions

View File

@ -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";

View File

@ -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

View File

@ -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"
})) }))
} }

View File

@ -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) {

View 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 })
}
}

View File

@ -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,

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View 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>
)
}

View File

@ -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>

View 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 })
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>
)} )}

View File

@ -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
}
}

View File

@ -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;
}
}

View File

@ -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"]
} }