fix: supplier order side & removed chat encryption
This commit is contained in:
parent
036ad53acb
commit
28ec17409f
@ -0,0 +1,15 @@
|
||||
-- Remove obsolete chat key columns from User.
|
||||
ALTER TABLE "User"
|
||||
DROP COLUMN IF EXISTS "chatPublicKey",
|
||||
DROP COLUMN IF EXISTS "chatPrivateKeyEncrypted";
|
||||
|
||||
-- Rename encrypted message payload to plain content.
|
||||
ALTER TABLE "ChatMessage"
|
||||
RENAME COLUMN "encryptedContent" TO "content";
|
||||
|
||||
-- Remove obsolete encryption metadata columns.
|
||||
ALTER TABLE "ChatMessage"
|
||||
DROP COLUMN IF EXISTS "encryptedKeyForSender",
|
||||
DROP COLUMN IF EXISTS "encryptedKeyForReceiver",
|
||||
DROP COLUMN IF EXISTS "iv";
|
||||
|
||||
@ -27,9 +27,6 @@ model User {
|
||||
mfaEnabled Boolean @default(false)
|
||||
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])
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@ -25,7 +25,6 @@ export async function GET() {
|
||||
name: string | null
|
||||
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"
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
216
src/app/api/supplier/orders/[orderId]/route.ts
Normal file
216
src/app/api/supplier/orders/[orderId]/route.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
|
||||
async function getSupplierContext(userId: string) {
|
||||
const profile = await prisma.supplierProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!profile) return null
|
||||
|
||||
const acceptedSyncs = await prisma.productSync.findMany({
|
||||
where: { supplierId: profile.id, status: "ACCEPTED" },
|
||||
select: { storeId: true, shopifyProductId: true },
|
||||
})
|
||||
|
||||
const syncsByStore = new Map<string, Set<string>>()
|
||||
for (const sync of acceptedSyncs) {
|
||||
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
|
||||
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||
}
|
||||
|
||||
return { profile, syncsByStore }
|
||||
}
|
||||
|
||||
function extractLineItems(lineItems: unknown): Array<{ product_id?: number }> {
|
||||
try {
|
||||
const parsed = typeof lineItems === "string" ? JSON.parse(lineItems) : lineItems
|
||||
return Array.isArray(parsed) ? (parsed as Array<{ product_id?: number }>) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
function isSupplierAllowedForOrder(
|
||||
syncsByStore: Map<string, Set<string>>,
|
||||
storeId: string,
|
||||
lineItems: unknown,
|
||||
) {
|
||||
const syncedProductIds = syncsByStore.get(storeId)
|
||||
if (!syncedProductIds || syncedProductIds.size === 0) return false
|
||||
const items = extractLineItems(lineItems)
|
||||
return items.some((item) => {
|
||||
const pid = item.product_id?.toString()
|
||||
return !!pid && syncedProductIds.has(pid)
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ orderId: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { orderId } = await params
|
||||
const ctx = await getSupplierContext(session.user.id)
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const order = await prisma.cachedOrder.findFirst({
|
||||
where: { shopifyOrderId: orderId },
|
||||
include: {
|
||||
store: {
|
||||
select: { id: true, businessName: true, shopifyStoreName: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
||||
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
||||
}
|
||||
|
||||
let liveOrder: Record<string, unknown> | null = null
|
||||
try {
|
||||
const storeCredentials = await prisma.store.findUnique({
|
||||
where: { id: order.storeId },
|
||||
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||
})
|
||||
if (storeCredentials?.shopifyDomain && storeCredentials?.shopifyAccessToken) {
|
||||
const cleanDomain = storeCredentials.shopifyDomain
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "")
|
||||
|
||||
const response = await fetch(
|
||||
`https://${cleanDomain}/admin/api/2024-01/orders/${orderId}.json`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": storeCredentials.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as { order?: Record<string, unknown> }
|
||||
liveOrder = toObject(payload.order)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SUPPLIER_ORDER_GET_SHOPIFY]", e)
|
||||
}
|
||||
|
||||
const cachedCustomer = toObject(order.customer)
|
||||
const customer = toObject(liveOrder?.customer) || cachedCustomer
|
||||
const shippingAddress = toObject(liveOrder?.shipping_address)
|
||||
const billingAddress = toObject(liveOrder?.billing_address)
|
||||
const lineItems = Array.isArray(liveOrder?.line_items)
|
||||
? (liveOrder?.line_items as Array<{ product_id?: number }>)
|
||||
: extractLineItems(order.lineItems)
|
||||
|
||||
return NextResponse.json({
|
||||
order: {
|
||||
id: order.shopifyOrderId,
|
||||
orderNumber: order.orderNumber,
|
||||
email: (typeof liveOrder?.email === "string" && liveOrder.email) || order.email,
|
||||
totalPrice: order.totalPrice,
|
||||
currency: order.currency,
|
||||
financialStatus: order.financialStatus,
|
||||
fulfillmentStatus: order.fulfillmentStatus || "UNFULFILLED",
|
||||
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
||||
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
||||
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
||||
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||
lineItems,
|
||||
customer,
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
store: order.store,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[SUPPLIER_ORDER_GET]", error)
|
||||
return NextResponse.json({ message: "Failed to fetch order" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ orderId: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || session.user.role !== "SUPPLIER") {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
const { orderId } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const allowedStatuses = new Set(["UNFULFILLED", "IN_PROGRESS", "SHIPPED", "FULFILLED", "CANCELLED"])
|
||||
const fulfillmentStatus = String(body.fulfillmentStatus || "").toUpperCase()
|
||||
if (!allowedStatuses.has(fulfillmentStatus)) {
|
||||
return NextResponse.json({ message: "Invalid fulfillment status" }, { status: 400 })
|
||||
}
|
||||
|
||||
const trackingNumber = String(body.trackingNumber || "").trim() || null
|
||||
const trackingCompany = String(body.trackingCompany || "").trim() || null
|
||||
const trackingUrl = String(body.trackingUrl || "").trim() || null
|
||||
|
||||
const ctx = await getSupplierContext(session.user.id)
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const order = await prisma.cachedOrder.findFirst({
|
||||
where: { shopifyOrderId: orderId },
|
||||
select: { id: true, storeId: true, lineItems: true },
|
||||
})
|
||||
if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
||||
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
||||
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
||||
}
|
||||
|
||||
const updated = await prisma.cachedOrder.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
fulfillmentStatus,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
} as unknown as Record<string, unknown>,
|
||||
select: {
|
||||
shopifyOrderId: true,
|
||||
fulfillmentStatus: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
order: {
|
||||
...updated,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[SUPPLIER_ORDER_PATCH]", error)
|
||||
return NextResponse.json({ message: "Failed to update order" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,9 +151,15 @@ export async function GET() {
|
||||
orderNumber: order.orderNumber,
|
||||
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,
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { decryptString } from "@/lib/encryption"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id }
|
||||
}) as { chatPrivateKeyEncrypted?: string | null } | null
|
||||
|
||||
if (!user?.chatPrivateKeyEncrypted) {
|
||||
return NextResponse.json(
|
||||
{ hasBackup: false },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const decrypted = decryptString(user.chatPrivateKeyEncrypted)
|
||||
if (!decrypted) {
|
||||
return NextResponse.json(
|
||||
{ hasBackup: false },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ hasBackup: true, privateKey: decrypted },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[CHAT_KEY_GET]", error)
|
||||
return new NextResponse("Internal Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { encryptString } from "@/lib/encryption"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = await req.json() as {
|
||||
publicKey?: string
|
||||
privateKey?: string
|
||||
}
|
||||
|
||||
if (!publicKey) {
|
||||
return new NextResponse("Missing publicKey", { status: 400 })
|
||||
}
|
||||
|
||||
let chatPrivateKeyEncrypted: string | undefined
|
||||
if (privateKey) {
|
||||
try {
|
||||
chatPrivateKeyEncrypted = encryptString(privateKey)
|
||||
} catch (err) {
|
||||
console.error("[CHAT_SETUP] Failed to encrypt private key", err)
|
||||
return new NextResponse("Failed to encrypt private key", { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: {
|
||||
chatPublicKey: publicKey,
|
||||
...(chatPrivateKeyEncrypted ? { chatPrivateKeyEncrypted } : {})
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("[CHAT_SETUP]", error)
|
||||
return new NextResponse("Internal Error", { status: 500 })
|
||||
}
|
||||
}
|
||||
348
src/app/supplier/orders/[orderId]/page.tsx
Normal file
348
src/app/supplier/orders/[orderId]/page.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin } from "lucide-react"
|
||||
import toast from "react-hot-toast"
|
||||
|
||||
const SHOPIFY_TRACKING_COMPANIES = [
|
||||
"4PX",
|
||||
"Amazon Logistics UK",
|
||||
"An Post",
|
||||
"Australia Post",
|
||||
"UPS",
|
||||
"USPS",
|
||||
"FedEx",
|
||||
"DHL Express",
|
||||
"DHL eCommerce",
|
||||
"Canada Post",
|
||||
"China Post",
|
||||
"DPD",
|
||||
"Royal Mail",
|
||||
"Purolator",
|
||||
"OnTrac",
|
||||
"GLS",
|
||||
"PostNL",
|
||||
"Singapore Post",
|
||||
"La Poste",
|
||||
"Yodel",
|
||||
"Evri",
|
||||
"Japan Post",
|
||||
"New Zealand Post",
|
||||
"Aramex",
|
||||
"Other",
|
||||
]
|
||||
|
||||
type SupplierOrder = {
|
||||
id: string
|
||||
orderNumber: string
|
||||
email?: string | null
|
||||
totalPrice: string
|
||||
currency?: string | null
|
||||
financialStatus?: string | null
|
||||
fulfillmentStatus: string
|
||||
trackingNumber?: string | null
|
||||
trackingCompany?: string | null
|
||||
trackingUrl?: string | null
|
||||
createdAt: string
|
||||
lineItems: Array<{ id?: number; title?: string; quantity?: number; price?: string; sku?: string; variant_title?: string; product_id?: number }>
|
||||
customer?: { first_name?: string; last_name?: string; email?: string; phone?: string } | null
|
||||
shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||
billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||
store: { id: string; businessName: string; shopifyStoreName: string }
|
||||
}
|
||||
|
||||
export default function SupplierOrderDetailsPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const orderId = params?.orderId as string
|
||||
|
||||
const [order, setOrder] = useState<SupplierOrder | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const [fulfillmentStatus, setFulfillmentStatus] = useState("UNFULFILLED")
|
||||
const [trackingNumber, setTrackingNumber] = useState("")
|
||||
const [trackingCompany, setTrackingCompany] = useState("")
|
||||
const [trackingUrl, setTrackingUrl] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return
|
||||
const fetchOrder = async () => {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
try {
|
||||
const res = await fetch(`/api/supplier/orders/${orderId}`)
|
||||
if (!res.ok) throw new Error("Failed to load order")
|
||||
const data = await res.json()
|
||||
const o = data.order as SupplierOrder
|
||||
setOrder(o)
|
||||
setFulfillmentStatus(o.fulfillmentStatus || "UNFULFILLED")
|
||||
setTrackingNumber(o.trackingNumber || "")
|
||||
setTrackingCompany(o.trackingCompany || "")
|
||||
setTrackingUrl(o.trackingUrl || "")
|
||||
} catch {
|
||||
setError("Failed to load order details.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchOrder()
|
||||
}, [orderId])
|
||||
|
||||
const changed = useMemo(() => {
|
||||
if (!order) return false
|
||||
return (
|
||||
fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") ||
|
||||
trackingNumber !== (order.trackingNumber || "") ||
|
||||
trackingCompany !== (order.trackingCompany || "") ||
|
||||
trackingUrl !== (order.trackingUrl || "")
|
||||
)
|
||||
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl])
|
||||
|
||||
const save = async () => {
|
||||
if (!order) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`/api/supplier/orders/${order.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fulfillmentStatus,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
toast.success("Order updated successfully")
|
||||
setOrder({
|
||||
...order,
|
||||
fulfillmentStatus,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
})
|
||||
} catch {
|
||||
toast.error("Failed to update order")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-red-400">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<p>{error || "Order not found"}</p>
|
||||
<button onClick={() => router.back()} className="px-4 py-2 rounded-lg bg-white/10 text-white">Back</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="p-2 rounded-lg hover:bg-white/10 text-slate-300">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold text-white">Order {order.orderNumber}</h1>
|
||||
<p className="text-slate-400 text-sm">{order.store.businessName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!changed || saving}
|
||||
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-bold inline-flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-indigo-400" />
|
||||
Order Snapshot
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-slate-400 font-bold">Created</p>
|
||||
<p className="text-white font-semibold mt-1">{new Date(order.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-slate-400 font-bold">Total</p>
|
||||
<p className="text-white font-semibold mt-1">{order.totalPrice} {order.currency || ""}</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<p className="text-xs uppercase text-slate-400 font-bold">Payment</p>
|
||||
<p className="text-white font-semibold mt-1">{order.financialStatus || "N/A"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-white font-bold inline-flex items-center gap-2">
|
||||
<Truck className="w-4 h-4 text-indigo-400" />
|
||||
Fulfillment Update
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Order Status</label>
|
||||
<select
|
||||
value={fulfillmentStatus}
|
||||
onChange={(e) => setFulfillmentStatus(e.target.value)}
|
||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||
>
|
||||
<option value="UNFULFILLED">Unfulfilled</option>
|
||||
<option value="IN_PROGRESS">In Progress</option>
|
||||
<option value="SHIPPED">Shipped</option>
|
||||
<option value="FULFILLED">Fulfilled</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking Platform</label>
|
||||
<select
|
||||
value={trackingCompany}
|
||||
onChange={(e) => setTrackingCompany(e.target.value)}
|
||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||
>
|
||||
<option value="">Select platform</option>
|
||||
{SHOPIFY_TRACKING_COMPANIES.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking ID</label>
|
||||
<input
|
||||
value={trackingNumber}
|
||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||
placeholder="e.g. 1Z999AA10123456784"
|
||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking URL (optional)</label>
|
||||
<input
|
||||
value={trackingUrl}
|
||||
onChange={(e) => setTrackingUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{changed && (
|
||||
<p className="text-emerald-400 text-xs font-semibold inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Unsaved changes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-indigo-400" />
|
||||
Customer
|
||||
</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white font-semibold">
|
||||
{[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}
|
||||
</p>
|
||||
<p className="text-slate-300 inline-flex items-center gap-2 break-all">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
{order.customer?.email || order.email || "Email: --"}
|
||||
</p>
|
||||
<p className="text-slate-300 inline-flex items-center gap-2">
|
||||
<Phone className="w-3.5 h-3.5 text-slate-500" />
|
||||
{order.customer?.phone || "Phone: --"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||
Shipping Address
|
||||
</h2>
|
||||
{order.shippingAddress ? (
|
||||
<div className="text-sm text-slate-300 space-y-1.5">
|
||||
<p className="text-white font-semibold">
|
||||
{[order.shippingAddress.first_name, order.shippingAddress.last_name].filter(Boolean).join(" ")}
|
||||
</p>
|
||||
<p>{order.shippingAddress.address1 || "--"}</p>
|
||||
{order.shippingAddress.address2 ? <p>{order.shippingAddress.address2}</p> : null}
|
||||
<p>{[order.shippingAddress.city, order.shippingAddress.province, order.shippingAddress.zip].filter(Boolean).join(", ") || "--"}</p>
|
||||
<p>{order.shippingAddress.country || "--"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-sm">No shipping address available.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||
Billing Address
|
||||
</h2>
|
||||
{order.billingAddress ? (
|
||||
<div className="text-sm text-slate-300 space-y-1.5">
|
||||
<p className="text-white font-semibold">
|
||||
{[order.billingAddress.first_name, order.billingAddress.last_name].filter(Boolean).join(" ")}
|
||||
</p>
|
||||
<p>{order.billingAddress.address1 || "--"}</p>
|
||||
{order.billingAddress.address2 ? <p>{order.billingAddress.address2}</p> : null}
|
||||
<p>{[order.billingAddress.city, order.billingAddress.province, order.billingAddress.zip].filter(Boolean).join(", ") || "--"}</p>
|
||||
<p>{order.billingAddress.country || "--"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-500 text-sm">No billing address available.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-indigo-400" />
|
||||
Order Items
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{order.lineItems.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">No items available.</p>
|
||||
) : order.lineItems.map((item, idx) => (
|
||||
<div key={`${item.id || idx}`} className="bg-white/5 border border-white/10 rounded-lg p-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white font-semibold break-words">{item.title || "Item"}</p>
|
||||
{item.variant_title ? <p className="text-slate-400 text-xs mt-1">{item.variant_title}</p> : null}
|
||||
<p className="text-slate-400 text-xs mt-1">Qty: {item.quantity || 0}{item.sku ? ` · SKU: ${item.sku}` : ""}</p>
|
||||
</div>
|
||||
<p className="text-white font-semibold whitespace-nowrap">
|
||||
{((Number(item.price || 0) || 0) * (item.quantity || 0)).toFixed(2)} {order.currency || ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
|
||||
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<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState("")
|
||||
@ -106,7 +106,7 @@ export default function SupplierOrdersPage() {
|
||||
</p>
|
||||
</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">
|
||||
<table className="w-full">
|
||||
<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">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-right text-xs font-bold text-slate-400 uppercase tracking-wider">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{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 text-sm text-slate-300">{order.vendorName}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-300">
|
||||
@ -128,14 +133,20 @@ export default function SupplierOrdersPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
order.status === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
||||
order.status === "PENDING" ? "bg-yellow-500/20 text-yellow-400" :
|
||||
"bg-red-500/20 text-red-400"
|
||||
(order.status || "").toUpperCase() === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
||||
(order.status || "").toUpperCase() === "SHIPPED" || (order.status || "").toUpperCase() === "IN_PROGRESS" ? "bg-yellow-500/20 text-yellow-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>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-white">${order.total?.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
56
src/app/uploads/[...path]/route.ts
Normal file
56
src/app/uploads/[...path]/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
function contentTypeFor(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params
|
||||
if (!segments?.length) {
|
||||
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const uploadsRoot = path.resolve("public", "uploads")
|
||||
const requestedPath = path.resolve(uploadsRoot, ...segments)
|
||||
|
||||
// Prevent path traversal outside public/uploads.
|
||||
if (!requestedPath.startsWith(uploadsRoot)) {
|
||||
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const data = await fs.readFile(requestedPath)
|
||||
return new NextResponse(new Uint8Array(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypeFor(requestedPath),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Not found" }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,19 +95,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
const user = userId
|
||||
? 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Contact[]>([])
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
@ -92,57 +86,11 @@ export function ChatView() {
|
||||
const attachRef = 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 () => {
|
||||
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 (
|
||||
<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 =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@ -512,10 +363,7 @@ export function ChatView() {
|
||||
{contact.role}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
contact.publicKey ? "bg-green-500/50" : "bg-red-500/50"
|
||||
)} />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500/50" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -533,7 +381,7 @@ export function ChatView() {
|
||||
<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-[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>
|
||||
</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")} />
|
||||
@ -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>
|
||||
<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")}>
|
||||
<Lock className="w-2.5 h-2.5" /> End-to-End
|
||||
Chat
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -597,8 +445,8 @@ export function ChatView() {
|
||||
) : messages.length === 0 ? (
|
||||
<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")} />
|
||||
<h4 className={cn("text-lg font-black mb-2", isLight ? "text-slate-900" : "text-white")}>Secure Connection Started</h4>
|
||||
<p className={cn("text-sm font-medium", isLight ? "text-slate-500" : "text-slate-400")}>Messages are encrypted before leaving your device.</p>
|
||||
<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")}>Start chatting with your connection.</p>
|
||||
</div>
|
||||
) : (
|
||||
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">
|
||||
<span>{sendError}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedContact.publicKey ? (
|
||||
<div className="p-6 bg-red-500/10 border border-red-500/20 rounded-2xl text-center">
|
||||
<p className="text-sm text-red-400 font-black uppercase tracking-tighter flex items-center justify-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" /> Connection Not Ready
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-500 mt-2 font-medium">
|
||||
This contact hasn't set up their JUNO CHAT keys yet.
|
||||
</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"
|
||||
)}
|
||||
>
|
||||
<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}>
|
||||
<button
|
||||
type="button"
|
||||
@ -778,7 +593,7 @@ export function ChatView() {
|
||||
</div>
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="Type encrypted message..."
|
||||
placeholder="Type a message..."
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@ -804,10 +619,9 @@ export function ChatView() {
|
||||
>
|
||||
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||
</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")}>
|
||||
<Lock className="inline w-2.5 h-2.5 mr-1 mb-0.5" /> Military-Grade AES-256 Protocol
|
||||
Real-time chat enabled
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@ -819,8 +633,8 @@ export function ChatView() {
|
||||
</div>
|
||||
<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")}>
|
||||
Encrypted workspace for vendors and suppliers.
|
||||
Select a contact to start a secure session.
|
||||
Messaging workspace for vendors and suppliers.
|
||||
Select a contact to start a session.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { ChatCrypto } from "@/lib/chat-crypto"
|
||||
|
||||
export const PRIVATE_KEY_STORAGE_KEY = "juno_chat_private_key"
|
||||
export const PUBLIC_KEY_STORAGE_KEY = "juno_chat_public_key"
|
||||
|
||||
export function useChatCrypto(userId?: string) {
|
||||
const [privateKey, setPrivateKey] = useState<string | null>(null)
|
||||
const [hasKeys, setHasKeys] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const privateKeyKey = userId ? `${PRIVATE_KEY_STORAGE_KEY}_${userId}` : PRIVATE_KEY_STORAGE_KEY
|
||||
const publicKeyKey = userId ? `${PUBLIC_KEY_STORAGE_KEY}_${userId}` : PUBLIC_KEY_STORAGE_KEY
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const stored = typeof window !== "undefined" ? localStorage.getItem(privateKeyKey) : null
|
||||
if (stored) {
|
||||
setPrivateKey(stored)
|
||||
setHasKeys(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// No local key – try to restore from server backup
|
||||
; (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/user/chat-key", { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.hasBackup && data.privateKey && typeof window !== "undefined") {
|
||||
localStorage.setItem(privateKeyKey, data.privateKey as string)
|
||||
localStorage.setItem(publicKeyKey, (data.publicKey as string | undefined) ?? localStorage.getItem(publicKeyKey) ?? "")
|
||||
setPrivateKey(data.privateKey as string)
|
||||
setHasKeys(true)
|
||||
} else {
|
||||
setPrivateKey(null)
|
||||
setHasKeys(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("restore chat key failed", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [userId, privateKeyKey, publicKeyKey])
|
||||
|
||||
const setupKeys = useCallback(async () => {
|
||||
if (!userId) throw new Error("User ID required for chat setup")
|
||||
setLoading(true)
|
||||
try {
|
||||
const keys = await ChatCrypto.generateKeyPair()
|
||||
|
||||
localStorage.setItem(privateKeyKey, keys.privateKey)
|
||||
localStorage.setItem(publicKeyKey, keys.publicKey)
|
||||
setPrivateKey(keys.privateKey)
|
||||
setHasKeys(true)
|
||||
|
||||
const res = await fetch("/api/user/chat-setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ publicKey: keys.publicKey, privateKey: keys.privateKey })
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error("Failed to save public key")
|
||||
|
||||
return keys.publicKey
|
||||
} catch (error) {
|
||||
console.error("Setup keys error:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [userId, privateKeyKey, publicKeyKey])
|
||||
|
||||
const getStoredPublicKey = useCallback((): string | null => {
|
||||
if (typeof window === "undefined" || !userId) return null
|
||||
return localStorage.getItem(publicKeyKey)
|
||||
}, [userId, publicKeyKey])
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
hasKeys,
|
||||
loading,
|
||||
setupKeys,
|
||||
getStoredPublicKey
|
||||
}
|
||||
}
|
||||
@ -1,164 +0,0 @@
|
||||
/**
|
||||
* JUNO CHAT E2E Encryption Utility
|
||||
* uses Web Crypto API (SubtleCrypto)
|
||||
*
|
||||
* Flow:
|
||||
* 1. Each user generates an RSA-OAEP key pair.
|
||||
* 2. Public key is shared via JUNO API (stored in User table).
|
||||
* 3. Private key is stored ONLY in the user's browser (localStorage).
|
||||
*
|
||||
* Message Sending:
|
||||
* 1. Generate a random AES-GCM (256-bit) "Session Key".
|
||||
* 2. Encrypt the message body with the Session Key and a random IV.
|
||||
* 3. Encrypt the Session Key twice:
|
||||
* - Once with the Sender's RSA Public Key.
|
||||
* - Once with the Receiver's RSA Public Key.
|
||||
* 4. Send [EncryptedBody, EncryptedKeyForSender, EncryptedKeyForReceiver, IV] to JUNO.
|
||||
*
|
||||
* Message Receiving:
|
||||
* 1. Fetch [EncryptedBody, EncryptedKeyForReceiver, IV].
|
||||
* 2. Decrypt the EncryptedKeyForReceiver using the Receiver's RSA Private Key.
|
||||
* 3. Use the decrypted Session Key + IV to decrypt the Message Body.
|
||||
*/
|
||||
|
||||
export class ChatCrypto {
|
||||
static async generateKeyPair(): Promise<{ publicKey: string; privateKey: string }> {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
const exportedPublic = await window.crypto.subtle.exportKey("spki", keyPair.publicKey);
|
||||
const exportedPrivate = await window.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
|
||||
|
||||
return {
|
||||
publicKey: btoa(String.fromCharCode(...new Uint8Array(exportedPublic))),
|
||||
privateKey: btoa(String.fromCharCode(...new Uint8Array(exportedPrivate))),
|
||||
};
|
||||
}
|
||||
|
||||
static async encryptMessage(
|
||||
message: string,
|
||||
senderPublicKeyStr: string,
|
||||
receiverPublicKeyStr: string
|
||||
): Promise<{ encryptedContent: string; encryptedKeyForSender: string; encryptedKeyForReceiver: string; iv: string }> {
|
||||
// 1. Generate AES Session Key
|
||||
const sessionKey = await window.crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// 2. Encrypt Content with AES
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encodedContent = new TextEncoder().encode(message);
|
||||
const encryptedContentBuffer = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
sessionKey,
|
||||
encodedContent
|
||||
);
|
||||
|
||||
// 3. Encrypt AES Key with both Public Keys
|
||||
const senderPublicKey = await this.importPublicKey(senderPublicKeyStr);
|
||||
const receiverPublicKey = await this.importPublicKey(receiverPublicKeyStr);
|
||||
|
||||
const exportedSessionKey = await window.crypto.subtle.exportKey("raw", sessionKey);
|
||||
|
||||
const encryptedKeyForSender = await window.crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
senderPublicKey,
|
||||
exportedSessionKey
|
||||
);
|
||||
|
||||
const encryptedKeyForReceiver = await window.crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
receiverPublicKey,
|
||||
exportedSessionKey
|
||||
);
|
||||
|
||||
return {
|
||||
encryptedContent: this.bufferToBase64(encryptedContentBuffer),
|
||||
encryptedKeyForSender: this.bufferToBase64(encryptedKeyForSender),
|
||||
encryptedKeyForReceiver: this.bufferToBase64(encryptedKeyForReceiver),
|
||||
iv: this.bufferToBase64(iv),
|
||||
};
|
||||
}
|
||||
|
||||
static async decryptMessage(
|
||||
encryptedContentBase64: string,
|
||||
encryptedSessionKeyBase64: string,
|
||||
ivBase64: string,
|
||||
privateKeyStr: string
|
||||
): Promise<string> {
|
||||
const privateKey = await this.importPrivateKey(privateKeyStr);
|
||||
const encryptedSessionKey = this.base64ToBuffer(encryptedSessionKeyBase64);
|
||||
const iv = this.base64ToBuffer(ivBase64);
|
||||
const encryptedContent = this.base64ToBuffer(encryptedContentBase64);
|
||||
|
||||
// 1. Decrypt AES Key using RSA Private Key
|
||||
const decryptedSessionKeyBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
encryptedSessionKey.buffer as ArrayBuffer
|
||||
);
|
||||
|
||||
const sessionKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
decryptedSessionKeyBuffer,
|
||||
"AES-GCM",
|
||||
true,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// 2. Decrypt Content using AES Session Key
|
||||
const decryptedContentBuffer = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
|
||||
sessionKey,
|
||||
encryptedContent.buffer as ArrayBuffer
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedContentBuffer);
|
||||
}
|
||||
|
||||
private static async importPublicKey(keyStr: string): Promise<CryptoKey> {
|
||||
const binary = this.base64ToBuffer(keyStr);
|
||||
return window.crypto.subtle.importKey(
|
||||
"spki",
|
||||
binary.buffer as ArrayBuffer,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
true,
|
||||
["encrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
private static async importPrivateKey(keyStr: string): Promise<CryptoKey> {
|
||||
const binary = this.base64ToBuffer(keyStr);
|
||||
return window.crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
binary.buffer as ArrayBuffer,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
true,
|
||||
["decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
private static bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
private static base64ToBuffer(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
1
src/types/next-auth.d.ts
vendored
1
src/types/next-auth.d.ts
vendored
@ -6,7 +6,6 @@ declare module "next-auth" {
|
||||
user: {
|
||||
id: string
|
||||
role: UserRole
|
||||
chatPublicKey?: string | null
|
||||
} & DefaultSession["user"]
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user