Features: Juno Chat & E2E encryption & forgot password

This commit is contained in:
root 2026-02-14 21:05:39 +05:00
parent 4ad0ed7dbc
commit 947d9c8af8
60 changed files with 2849 additions and 145 deletions

405
package-lock.json generated
View File

@ -25,10 +25,12 @@
"dotenv": "^17.2.4",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.7",
"otplib": "^13.2.1",
"pdf-parse": "^2.4.5",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"qrcode": "^1.5.4",
@ -2212,6 +2214,176 @@
"node": ">=16"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -4222,6 +4394,14 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -4545,6 +4725,25 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -4561,6 +4760,11 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
@ -4857,6 +5061,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5160,6 +5369,11 @@
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -5183,6 +5397,14 @@
"url": "https://dotenvx.com"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -6482,6 +6704,11 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@ -6516,6 +6743,11 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -7058,6 +7290,17 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -7098,6 +7341,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@ -7386,6 +7637,16 @@
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -7425,6 +7686,45 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mammoth": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/mammoth/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/mammoth/node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -7873,6 +8173,11 @@
"node >= 0.4.6"
]
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -7958,6 +8263,11 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -7978,6 +8288,14 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -7997,6 +8315,36 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@ -8267,6 +8615,11 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -8431,6 +8784,25 @@
}
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -8653,6 +9025,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@ -8766,6 +9143,11 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@ -8940,6 +9322,11 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@ -8972,6 +9359,14 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -9487,6 +9882,11 @@
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
},
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -9573,6 +9973,11 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valibot": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",

View File

@ -27,10 +27,12 @@
"dotenv": "^17.2.4",
"framer-motion": "^12.33.0",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.7",
"otplib": "^13.2.1",
"pdf-parse": "^2.4.5",
"pg": "^8.18.0",
"prisma": "^7.3.0",
"qrcode": "^1.5.4",

View File

@ -9,6 +9,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
url: process.env["DATABASE_URL"]!,
},
});

View File

@ -27,7 +27,11 @@ model User {
mfaEnabled Boolean @default(false)
mfaSecret String? // Encrypted TOTP secret
chatPublicKey String?
// Relations
sentMessages ChatMessage[] @relation("SentMessages")
receivedMessages ChatMessage[] @relation("ReceivedMessages")
accounts Account[]
sessions Session[]
stores Store[] // Changed from singular to support multiple stores
@ -109,6 +113,7 @@ model Store {
faqItems FaqItem[]
knowledgeChunks KnowledgeChunk[]
emailLogs EmailLog[]
flaggedEmails FlaggedEmail[]
templates Template[]
createdAt DateTime @default(now())
@ -383,6 +388,28 @@ model EmailLog {
@@index([userId, sentAt])
}
enum FlaggedEmailPriority {
LOW
MEDIUM
HIGH
}
model FlaggedEmail {
id String @id @default(cuid())
storeId String
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
subject String @db.Text
from String // Sender email
reason String @db.Text
priority FlaggedEmailPriority @default(MEDIUM)
messageId String? // Gmail message id for future sync
flaggedAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([storeId])
@@index([storeId, flaggedAt])
}
enum KnowledgeDocumentStatus {
UPLOADED
PROCESSING
@ -482,3 +509,21 @@ model Template {
@@index([storeId])
}
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
sender User @relation("SentMessages", fields: [senderId], references: [id])
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
createdAt DateTime @default(now())
@@index([senderId, receiverId])
@@index([receiverId, senderId])
}

View File

@ -1,12 +1,13 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Link2 } from "lucide-react"
interface AdminConnection {
id: string
status: string
createdAt: string
updatedAt: string
createdAt: Date
updatedAt: Date
store: {
businessName: string
shopifyStoreName: string
@ -17,12 +18,14 @@ interface AdminConnection {
}
async function getConnections(): Promise<AdminConnection[]> {
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, {
cache: "no-store",
const connections = await prisma.connection.findMany({
include: {
store: { select: { businessName: true, shopifyStoreName: true } },
supplier: { select: { companyName: true } },
},
orderBy: { createdAt: "desc" },
})
if (!res.ok) return []
const data = (await res.json()) as { connections: AdminConnection[] }
return data.connections
return connections as unknown as AdminConnection[]
}
export default async function AdminConnectionsPage() {

View File

@ -1,4 +1,5 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Mail } from "lucide-react"
@ -12,7 +13,7 @@ interface AdminEmailLog {
status: string
providerMessageId: string | null
errorMessage: string | null
sentAt: string
sentAt: Date
store: {
businessName: string
shopifyStoreName: string
@ -20,15 +21,17 @@ interface AdminEmailLog {
}
async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> {
// Use a relative URL so the admin's session cookies are forwarded
const res = await fetch("/api/admin/emails?limit=100", {
cache: "no-store",
})
if (!res.ok) {
return { emails: [], total: 0 }
}
const data = (await res.json()) as { emails: AdminEmailLog[]; total: number }
return data
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaAny = prisma as any
const [emails, total] = await Promise.all([
prismaAny.emailLog.findMany({
include: { store: { select: { businessName: true, shopifyStoreName: true } } },
orderBy: { sentAt: "desc" },
take: 100,
}),
prismaAny.emailLog.count(),
])
return { emails: emails as AdminEmailLog[], total }
}
export default async function AdminEmailsPage() {

View File

@ -1,4 +1,5 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Activity, Link2, Radar, Shuffle } from "lucide-react"
@ -14,26 +15,59 @@ interface AdminEvent {
metadata?: Record<string, unknown>
}
const LIMIT = 50
async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
const searchParams = new URLSearchParams()
searchParams.set("limit", "50")
if (type) {
searchParams.set("type", type)
}
const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([
prisma.connection.findMany({
orderBy: { createdAt: "desc" },
take: LIMIT,
include: { store: true, supplier: true },
}),
prisma.productSync.findMany({
orderBy: { createdAt: "desc" },
take: LIMIT,
include: { store: true, supplier: true },
}),
prisma.syncMetadata.findMany({
orderBy: { lastSyncAt: "desc" },
take: LIMIT,
include: { store: true },
}),
])
const res = await fetch(
`${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`,
{
cache: "no-store",
},
)
const events: AdminEvent[] = [
...connectionEvents.map((c) => ({
id: c.id,
type: "CONNECTION" as AdminEventType,
status: c.status,
timestamp: c.createdAt.toISOString(),
storeName: c.store.shopifyStoreName,
supplierName: c.supplier.companyName,
metadata: {},
})),
...productSyncEvents.map((p) => ({
id: p.id,
type: "PRODUCT_SYNC" as AdminEventType,
status: p.status,
timestamp: p.createdAt.toISOString(),
storeName: p.store.shopifyStoreName,
supplierName: p.supplier.companyName,
metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId },
})),
...syncHealthEvents.map((s) => ({
id: s.id,
type: "SYNC_HEALTH" as AdminEventType,
status: s.syncStatus,
timestamp: s.lastSyncAt.toISOString(),
storeName: s.store.shopifyStoreName,
supplierName: null as string | null,
metadata: { resourceType: s.resourceType, recordCount: s.recordCount },
})),
].filter((e) => !type || e.type === type)
if (!res.ok) {
return []
}
const data = (await res.json()) as { events: AdminEvent[] }
return data.events
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
return events.slice(0, LIMIT)
}
function typeBadge(eventType: AdminEventType) {

View File

@ -1,4 +1,5 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { ShoppingCart } from "lucide-react"
@ -10,7 +11,7 @@ interface AdminOrder {
currency: string | null
financialStatus: string | null
fulfillmentStatus: string | null
createdAt: string
createdAt: Date
store: {
businessName: string
shopifyStoreName: string
@ -18,12 +19,57 @@ interface AdminOrder {
}
async function getOrders(): Promise<AdminOrder[]> {
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, {
cache: "no-store",
const acceptedSyncs = await prisma.productSync.findMany({
where: { status: "ACCEPTED" },
select: { shopifyProductId: true, storeId: true },
})
if (!res.ok) return []
const data = (await res.json()) as { orders: AdminOrder[] }
return data.orders
if (acceptedSyncs.length === 0) return []
const syncsByStore = new Map<string, Set<string>>()
acceptedSyncs.forEach((sync) => {
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
})
const storeIds = Array.from(syncsByStore.keys())
const cachedOrders = await prisma.cachedOrder.findMany({
where: { storeId: { in: storeIds } },
include: {
store: { select: { id: true, businessName: true, shopifyStoreName: true } },
},
orderBy: { shopifyCreatedAt: "desc" },
})
const relevantOrders = cachedOrders.filter((order) => {
const syncedProductIds = syncsByStore.get(order.storeId)
if (!syncedProductIds?.size) return false
try {
const lineItems =
typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems
if (!Array.isArray(lineItems)) return false
return lineItems.some(
(item: { product_id?: number }) =>
item.product_id != null && syncedProductIds.has(String(item.product_id))
)
} catch {
return false
}
})
return relevantOrders.map((order) => {
const store = order.store as { id: string; businessName: string; shopifyStoreName: string }
return {
id: order.shopifyOrderId,
orderNumber: order.orderNumber,
email: order.email,
totalPrice: order.totalPrice,
currency: order.currency,
financialStatus: order.financialStatus,
fulfillmentStatus: order.fulfillmentStatus,
createdAt: order.shopifyCreatedAt || order.createdAt,
store: { businessName: store.businessName, shopifyStoreName: store.shopifyStoreName },
}
}) as AdminOrder[]
}
export default async function AdminOrdersPage() {
@ -109,7 +155,7 @@ export default async function AdminOrdersPage() {
{order.fulfillmentStatus || "—"}
</td>
<td className="py-3 pr-4 align-top text-xs text-slate-400">
{new Date(order.createdAt).toLocaleString("en-US", {
{new Date(order.createdAt as Date).toLocaleString("en-US", {
dateStyle: "short",
timeStyle: "short",
})}

View File

@ -69,11 +69,10 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
by: ["status"],
_count: { _all: true },
}) as unknown as Promise<ConnectionGroup[]>,
// Use `as any` to be compatible with the current Prisma client setup
(prisma as any).productSync.groupBy({
prisma.productSync.groupBy({
by: ["status"],
_count: { _all: true },
}) as Promise<ProductSyncGroup[]>,
} as Parameters<typeof prisma.productSync.groupBy>[0]) as unknown as Promise<ProductSyncGroup[]>,
prisma.cachedOrder.count(),
prisma.syncMetadata.findMany({
where: { syncStatus: { in: ["failed"] } },
@ -81,7 +80,7 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
take: 5,
include: { store: true },
}) as Promise<RecentSyncError[]>,
(prisma as any).productSync.findMany({
prisma.productSync.findMany({
orderBy: { createdAt: "desc" },
take: 8,
include: {

View File

@ -1,13 +1,14 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Shuffle } from "lucide-react"
interface AdminProductSync {
id: string
status: string
createdAt: string
acceptedAt: string | null
rejectedAt: string | null
createdAt: Date
acceptedAt: Date | null
rejectedAt: Date | null
shopifyProductId: string
shopifyProductTitle: string
store: {
@ -20,12 +21,14 @@ interface AdminProductSync {
}
async function getProductSyncs(): Promise<AdminProductSync[]> {
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, {
cache: "no-store",
const syncs = await prisma.productSync.findMany({
include: {
store: { select: { businessName: true, shopifyStoreName: true } },
supplier: { select: { companyName: true } },
},
orderBy: { createdAt: "desc" },
})
if (!res.ok) return []
const data = (await res.json()) as { syncs: AdminProductSync[] }
return data.syncs
return syncs as unknown as AdminProductSync[]
}
export default async function AdminProductSyncsPage() {
@ -113,9 +116,7 @@ export default async function AdminProductSyncsPage() {
</td>
<td className="py-3 pr-4 align-top text-xs text-slate-400">
{sync.acceptedAt || sync.rejectedAt
? new Date(
(sync.acceptedAt || sync.rejectedAt) as string,
).toLocaleString("en-US", {
? new Date(sync.acceptedAt || sync.rejectedAt!).toLocaleString("en-US", {
dateStyle: "short",
timeStyle: "short",
})

View File

@ -1,4 +1,5 @@
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Activity, Radar } from "lucide-react"
@ -16,12 +17,15 @@ interface AdminSyncMetadata {
}
async function getSyncHealth(): Promise<AdminSyncMetadata[]> {
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, {
cache: "no-store",
const rows = await prisma.syncMetadata.findMany({
include: {
store: {
select: { businessName: true, shopifyStoreName: true },
},
},
orderBy: { lastSyncAt: "desc" },
})
if (!res.ok) return []
const data = (await res.json()) as { syncs: AdminSyncMetadata[] }
return data.syncs
return rows as unknown as AdminSyncMetadata[]
}
export default async function AdminSyncHealthPage() {

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { ConnectionStatus } from "@prisma/client"
export async function GET(req: Request) {
try {
@ -15,7 +16,7 @@ export async function GET(req: Request) {
const connections = await prisma.connection.findMany({
where: {
...(status && { status: status as any }),
...(status && { status: status as ConnectionStatus }),
},
include: {
store: true,

View File

@ -25,7 +25,7 @@ export async function GET(req: Request) {
supplier: true,
},
}),
(prisma as any).productSync.findMany({
prisma.productSync.findMany({
orderBy: { createdAt: "desc" },
take: limit,
include: {
@ -52,7 +52,7 @@ export async function GET(req: Request) {
supplierName: c.supplier.companyName,
metadata: {},
})),
...productSyncEvents.map((p: any) => ({
...productSyncEvents.map((p: (typeof productSyncEvents)[number]) => ({
id: p.id,
type: "PRODUCT_SYNC" as AdminEventType,
status: p.status,

View File

@ -11,7 +11,7 @@ export async function GET() {
}
// Get all accepted product syncs
const acceptedSyncs = await (prisma as any).productSync.findMany({
const acceptedSyncs = await prisma.productSync.findMany({
where: {
status: "ACCEPTED",
},
@ -31,7 +31,7 @@ export async function GET() {
}
const syncsByStore = new Map<string, Set<string>>()
acceptedSyncs.forEach((sync: any) => {
acceptedSyncs.forEach((sync: (typeof acceptedSyncs)[number]) => {
if (!syncsByStore.has(sync.storeId)) {
syncsByStore.set(sync.storeId, new Set())
}
@ -69,7 +69,7 @@ export async function GET() {
if (!Array.isArray(lineItems)) return false
return lineItems.some((item: any) => {
return lineItems.some((item: { product_id?: number }) => {
const productId = item.product_id?.toString()
return productId && syncedProductIds.has(productId)
})
@ -80,7 +80,7 @@ export async function GET() {
const transformedOrders = relevantOrders.map((order: typeof relevantOrders[0]) => {
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
let lineItems: any[] = []
let lineItems: { product_id?: number }[] = []
try {
lineItems =
@ -91,7 +91,7 @@ export async function GET() {
lineItems = []
}
const syncedLineItems = lineItems.filter((item: any) => {
const syncedLineItems = lineItems.filter((item: { product_id?: number }) => {
const productId = item.product_id?.toString()
return productId && syncedProductIds.has(productId)
})

View File

@ -1,6 +1,13 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { ProductSyncStatus } from "@prisma/client"
const VALID_STATUSES: ProductSyncStatus[] = [
ProductSyncStatus.PENDING,
ProductSyncStatus.ACCEPTED,
ProductSyncStatus.REJECTED,
]
export async function GET(req: Request) {
try {
@ -11,9 +18,13 @@ export async function GET(req: Request) {
}
const { searchParams } = new URL(req.url)
const status = searchParams.get("status")
const statusParam = searchParams.get("status")
const status =
statusParam && VALID_STATUSES.includes(statusParam as ProductSyncStatus)
? (statusParam as ProductSyncStatus)
: undefined
const syncs = await (prisma as any).productSync.findMany({
const syncs = await prisma.productSync.findMany({
where: {
...(status && { status }),
},

View File

@ -0,0 +1,104 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { getCachedOrders, isCacheFresh, updateOrderCache } from "@/lib/cache"
const PER_STORE_LIMIT = 25
/**
* GET /api/chat/attach-orders
* Returns orders from all vendor stores (for chat attach). Each item includes storeId and storeName.
* Vendor-only.
*/
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const stores = await prisma.store.findMany({
where: { userId: session.user.id },
select: {
id: true,
businessName: true,
shopifyDomain: true,
shopifyAccessToken: true
}
})
if (stores.length === 0) {
return NextResponse.json({ orders: [] })
}
type OrderItem = {
id: number
name: string
total_price: string
currency?: string
storeId: string
storeName: string
}
const fetchOrdersForStore = async (
store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null }
): Promise<OrderItem[]> => {
const cleanDomain = (store.shopifyDomain || "").trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
if (!cleanDomain || !store.shopifyAccessToken) return []
const storeName = store.businessName || store.shopifyDomain || "Store"
const cacheFresh = await isCacheFresh(store.id, "orders")
if (cacheFresh) {
const cached = await getCachedOrders(store.id)
return cached.slice(0, PER_STORE_LIMIT).map((order) => ({
id: parseInt(order.shopifyOrderId),
name: order.orderNumber,
total_price: order.totalPrice,
currency: order.currency || undefined,
storeId: store.id,
storeName
}))
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders.json?status=any&limit=${PER_STORE_LIMIT}`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken!,
"Content-Type": "application/json"
},
signal: controller.signal
}
)
clearTimeout(timeoutId)
if (!response.ok) return []
const data = await response.json()
if (data.orders?.length) {
await updateOrderCache(store.id, data.orders).catch(() => {})
}
return (data.orders || []).map((o: { id: number; name?: string; total_price?: string; currency?: string }) => ({
id: o.id,
name: o.name || `#${o.id}`,
total_price: o.total_price || "0",
currency: o.currency,
storeId: store.id,
storeName
}))
} catch {
return []
}
}
const results = await Promise.allSettled(stores.map((s) => fetchOrdersForStore(s)))
const allOrders: OrderItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
allOrders.sort((a, b) => (b.id > a.id ? 1 : -1))
return NextResponse.json({ orders: allOrders.slice(0, 50) })
} catch (error) {
console.error("Chat attach orders error:", error)
return NextResponse.json({ message: "Failed to load orders", orders: [] }, { status: 500 })
}
}

View File

@ -0,0 +1,92 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { getCachedProducts, isCacheFresh, updateProductCache } from "@/lib/cache"
const PER_STORE_LIMIT = 25
/**
* GET /api/chat/attach-products
* Returns products from all vendor stores (for chat attach). Each item includes storeId and storeName.
* Vendor-only.
*/
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id || session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
const stores = await prisma.store.findMany({
where: { userId: session.user.id },
select: {
id: true,
businessName: true,
shopifyDomain: true,
shopifyAccessToken: true
}
})
if (stores.length === 0) {
return NextResponse.json({ products: [] })
}
type ProductItem = { id: number; title: string; storeId: string; storeName: string }
const fetchProductsForStore = async (
store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null }
): Promise<ProductItem[]> => {
const cleanDomain = (store.shopifyDomain || "").trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim()
if (!cleanDomain || !store.shopifyAccessToken) return []
const storeName = store.businessName || store.shopifyDomain || "Store"
const cacheFresh = await isCacheFresh(store.id, "products")
if (cacheFresh) {
const cached = await getCachedProducts(store.id)
return cached.slice(0, PER_STORE_LIMIT).map((product) => ({
id: parseInt(product.shopifyProductId),
title: product.title || "Product",
storeId: store.id,
storeName
}))
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(
`https://${cleanDomain}/admin/api/2024-01/products.json?limit=${PER_STORE_LIMIT}`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken!,
"Content-Type": "application/json"
},
signal: controller.signal
}
)
clearTimeout(timeoutId)
if (!response.ok) return []
const data = await response.json()
if (data.products?.length) {
await updateProductCache(store.id, data.products).catch(() => {})
}
return (data.products || []).map((p: { id: number; title?: string }) => ({
id: p.id,
title: p.title || "Product",
storeId: store.id,
storeName
}))
} catch {
return []
}
}
const results = await Promise.allSettled(stores.map((s) => fetchProductsForStore(s)))
const allProducts: ProductItem[] = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
return NextResponse.json({ products: allProducts.slice(0, 50) })
} catch (error) {
console.error("Chat attach products error:", error)
return NextResponse.json({ message: "Failed to load products", products: [] }, { status: 500 })
}
}

View File

@ -0,0 +1,102 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
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 },
include: {
supplierProfile: true,
}
})
if (!user) {
return new NextResponse("User not found", { status: 404 })
}
interface Contact {
id: string
name: string | null
email: string | null
image: string | null
publicKey: string | null
role: string
}
let contacts: Contact[] = []
if (user.role === "VENDOR") {
// Fetch connected suppliers
const connections = await prisma.connection.findMany({
where: {
store: { userId: user.id },
status: "CONNECTED"
},
include: {
supplier: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
chatPublicKey: true,
}
}
}
}
}
})
contacts = connections.map(c => ({
id: c.supplier.user.id,
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) {
// Fetch connected vendors
const connections = await prisma.connection.findMany({
where: {
supplierId: user.supplierProfile.id,
status: "CONNECTED"
},
include: {
store: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true,
chatPublicKey: true,
}
}
}
}
}
})
contacts = connections.map(c => ({
id: c.store.user.id,
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"
}))
}
return NextResponse.json({ contacts })
} catch (error) {
console.error("[CHAT_CONTACTS]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@ -0,0 +1,67 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 })
}
const { receiverId, encryptedContent, encryptedKeyForSender, encryptedKeyForReceiver, iv } = await req.json()
if (!receiverId || !encryptedContent || !encryptedKeyForSender || !encryptedKeyForReceiver || !iv) {
return new NextResponse("Missing fields", { status: 400 })
}
const message = await prisma.chatMessage.create({
data: {
senderId: session.user.id,
receiverId,
encryptedContent,
encryptedKeyForSender,
encryptedKeyForReceiver,
iv,
}
})
return NextResponse.json(message)
} catch (error) {
console.error("[CHAT_MESSAGES_POST]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}
export async function GET(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 })
}
const { searchParams } = new URL(req.url)
const contactId = searchParams.get("contactId")
if (!contactId) {
return new NextResponse("Missing contactId", { status: 400 })
}
const messages = await prisma.chatMessage.findMany({
where: {
OR: [
{ senderId: session.user.id, receiverId: contactId },
{ senderId: contactId, receiverId: session.user.id }
]
},
orderBy: {
createdAt: "asc"
}
})
return NextResponse.json({ messages })
} catch (error) {
console.error("[CHAT_MESSAGES_GET]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@ -23,7 +23,7 @@ export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const code = searchParams.get("code")
const error = searchParams.get("error")
const state = searchParams.get("state")
const _state = searchParams.get("state")
// Handle OAuth errors
if (error) {

View File

@ -9,7 +9,7 @@ import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/em
* Manually trigger email polling and automated replies
* This can be called periodically via cron job or manually
*/
export async function POST(req: Request) {
export async function POST(_req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {

View File

@ -13,7 +13,7 @@ import { getGmailAccessToken } from "@/lib/gmail"
*
* Note: Gmail watch requests expire after 7 days and need to be renewed.
*/
export async function POST(req: Request) {
export async function POST(_req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {

View File

@ -0,0 +1,50 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
async function getActiveStoreId(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
stores: {
where: { isActive: true },
take: 1,
select: { id: true },
},
},
})
return user?.stores[0]?.id ?? null
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json({ message: "No active store found" }, { status: 400 })
}
const { id } = await params
await prisma.flaggedEmail.deleteMany({
where: { id, storeId },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("[FLAGGED_EMAILS_DELETE]", error)
return NextResponse.json(
{ message: "Failed to delete flagged email" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,119 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
async function getActiveStoreId(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
stores: {
where: { isActive: true },
take: 1,
select: { id: true },
},
},
})
return user?.stores[0]?.id ?? null
}
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json({ flaggedEmails: [] })
}
const flaggedEmails = await prisma.flaggedEmail.findMany({
where: { storeId },
orderBy: { flaggedAt: "desc" },
})
return NextResponse.json({
flaggedEmails: flaggedEmails.map((e) => ({
id: e.id,
subject: e.subject,
from: e.from,
reason: e.reason,
priority: e.priority.toLowerCase() as "low" | "medium" | "high",
flaggedAt: e.flaggedAt.toISOString(),
})),
})
} catch (error) {
console.error("[FLAGGED_EMAILS_GET]", error)
return NextResponse.json(
{ message: "Failed to fetch flagged emails" },
{ status: 500 }
)
}
}
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}
if (session.user.role !== "VENDOR") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}
const storeId = await getActiveStoreId(session.user.id)
if (!storeId) {
return NextResponse.json({ message: "No active store found" }, { status: 400 })
}
const body = await req.json()
const { subject, from, reason, priority, messageId } = body as {
subject?: string
from?: string
reason?: string
priority?: "LOW" | "MEDIUM" | "HIGH"
messageId?: string
}
if (!subject || !from || !reason) {
return NextResponse.json(
{ message: "subject, from, and reason are required" },
{ status: 400 }
)
}
const priorityVal = priority && ["LOW", "MEDIUM", "HIGH"].includes(priority)
? priority
: "MEDIUM"
const created = await prisma.flaggedEmail.create({
data: {
storeId,
subject,
from,
reason,
priority: priorityVal,
messageId: messageId ?? null,
},
})
return NextResponse.json({
id: created.id,
subject: created.subject,
from: created.from,
reason: created.reason,
priority: created.priority.toLowerCase(),
flaggedAt: created.flaggedAt.toISOString(),
})
} catch (error) {
console.error("[FLAGGED_EMAILS_POST]", error)
return NextResponse.json(
{ message: "Failed to create flagged email" },
{ status: 500 }
)
}
}

View File

@ -160,7 +160,7 @@ export async function PUT(
"mood" = COALESCE(${mood}, "mood"),
"description" = COALESCE(${description ?? null}, "description"),
"body" = COALESCE(${templateBody}, "body"),
"variables" = COALESCE(${variables as any}, "variables"),
"variables" = COALESCE(${variables as Record<string, unknown> | null}, "variables"),
"isHtml" = COALESCE(${isHtml}, "isHtml"),
"updatedAt" = NOW()
WHERE "id" = ${existing.id}

View File

@ -150,8 +150,7 @@ export async function POST(req: Request) {
INSERT INTO "Template"
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
VALUES
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as
any}, ${isHtml ?? true}, NOW(), NOW())
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as Record<string, unknown> | null}, ${isHtml ?? true}, NOW(), NOW())
RETURNING
"id",
"storeId",

View File

@ -10,7 +10,7 @@ function normalizeSubject(subject: string | null): string {
// Remove repeated "Re:" / "Fwd:" prefixes
// e.g. "Re: Re: Hello" -> "Hello"
// Case-insensitive, allow localized variants in future by trimming until no prefix matches
// eslint-disable-next-line no-constant-condition
while (true) {
const match = s.match(/^(re|fw|fwd):\s*/i)
if (!match) break

View File

@ -15,7 +15,7 @@ export async function DELETE(
const { sessionId } = await params
// Verify the session belongs to the user
const userSession = await (prisma as any).session.findFirst({
const userSession = await prisma.session.findFirst({
where: {
id: sessionId,
userId: session.user.id
@ -27,7 +27,7 @@ export async function DELETE(
}
// Delete the session
await (prisma as any).session.delete({
await prisma.session.delete({
where: { id: sessionId }
})

View File

@ -16,13 +16,13 @@ export async function GET() {
cookieStore.get("__Secure-authjs.session-token")?.value
// Fetch all sessions for the user
const userSessions = await (prisma as any).session.findMany({
const userSessions = await prisma.session.findMany({
where: { userId: session.user.id },
orderBy: { expires: "desc" }
})
// Transform sessions to include additional info
const sessions = userSessions.map((s: { id: string; sessionToken: string; expires: Date }) => ({
const sessions = userSessions.map((s: (typeof userSessions)[number]) => ({
id: s.id,
sessionToken: s.sessionToken,
expires: s.expires.toISOString(),

View File

@ -3,6 +3,7 @@ import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { logger } from "@/lib/logger"
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
import type { FaqItem } from "@prisma/client"
type FaqImportItem = {
question: string
@ -91,7 +92,7 @@ export async function POST(
const createdFaqs = await prisma.$transaction(
validItems.map((item: FaqImportItem) =>
(prisma as any).faqItem.create({
prisma.faqItem.create({
data: {
storeId: store.id,
question: item.question,
@ -103,12 +104,12 @@ export async function POST(
)
const texts = createdFaqs.map(
(faq) => `${faq.question}\n${faq.answer}`,
(faq: { question: string; answer: string }) => `${faq.question}\n${faq.answer}`,
)
const embeddings = await embedBatch(texts)
await Promise.all(
createdFaqs.map((faq, index) => {
createdFaqs.map((faq: FaqItem, index: number) => {
const embedding = embeddings[index]
if (!embedding || !embedding.length) return Promise.resolve()
@ -129,7 +130,7 @@ export async function POST(
)
`,
faq.storeId,
faq.id,
faq.id as string,
`${faq.question}\n${faq.answer}`,
{
question: faq.question,

View File

@ -0,0 +1,28 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
export async function POST(req: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return new NextResponse("Unauthorized", { status: 401 })
}
const { publicKey } = await req.json()
if (!publicKey) {
return new NextResponse("Missing publicKey", { status: 400 })
}
await prisma.user.update({
where: { id: session.user.id },
data: { chatPublicKey: publicKey }
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("[CHAT_SETUP]", error)
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@ -0,0 +1,11 @@
"use client"
import { ChatView } from "@/components/chat/ChatView"
export default function ChatPage() {
return (
<div className="flex-1 flex flex-col min-h-0 h-full">
<ChatView />
</div>
)
}

View File

@ -64,7 +64,7 @@ const faqs: FAQItem[] = [
{
id: "6",
question: "How do I export my data?",
answer: "You can export reports from various pages using the Export button. Currently, you can export order data as CSV. More export options are coming soon.",
answer: "You can export order data as CSV from the Orders page. Additional export options are planned.",
category: "general"
}
]

View File

@ -1,13 +1,11 @@
"use client"
import { useState } from "react"
import {
Flag,
Search,
Filter,
Mail,
import { useState, useEffect, useCallback } from "react"
import {
Flag,
Search,
Filter,
Loader2,
AlertCircle,
Trash2,
Archive,
CheckCircle2
@ -25,7 +23,46 @@ interface FlaggedEmail {
export default function FlaggedEmailPage() {
const [searchTerm, setSearchTerm] = useState("")
const [flaggedEmails, setFlaggedEmails] = useState<FlaggedEmail[]>([])
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
const fetchFlaggedEmails = useCallback(async () => {
setLoading(true)
try {
const res = await fetch("/api/juno-engine/flagged-emails")
if (res.ok) {
const data = await res.json()
setFlaggedEmails(data.flaggedEmails ?? [])
}
} catch {
setFlaggedEmails([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchFlaggedEmails()
}, [fetchFlaggedEmails])
const handleDelete = async (id: string) => {
setDeletingId(id)
try {
const res = await fetch(`/api/juno-engine/flagged-emails/${id}`, { method: "DELETE" })
if (res.ok) {
setFlaggedEmails((prev) => prev.filter((e) => e.id !== id))
}
} finally {
setDeletingId(null)
}
}
const filtered = flaggedEmails.filter(
(e) =>
e.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.from.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.reason.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="h-full flex flex-col space-y-4 md:space-y-6">
@ -55,7 +92,7 @@ export default function FlaggedEmailPage() {
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
<p className="font-medium animate-pulse">Loading flagged emails...</p>
</div>
) : flaggedEmails.length === 0 ? (
) : filtered.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
<Flag className="w-10 h-10 opacity-20" />
<p className="font-medium">No flagged emails found.</p>
@ -73,7 +110,7 @@ export default function FlaggedEmailPage() {
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{flaggedEmails.map((email) => (
{filtered.map((email) => (
<tr key={email.id} className="hover:bg-white/5 transition-colors group">
<td className="px-4 md:px-8 py-3 md:py-4">
<div className="flex items-center gap-2">
@ -98,7 +135,12 @@ export default function FlaggedEmailPage() {
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all" title="Archive">
<Archive className="w-4 h-4" />
</button>
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all" title="Delete">
<button
type="button"
onClick={() => handleDelete(email.id)}
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>

View File

@ -36,8 +36,7 @@ export default async function DashboardLayout({
}
return (
// Root container fills viewport and only hides horizontal overflow
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-x-hidden">
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-hidden">
{/* Desktop Sidebar */}
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-slate-900/70 border-r border-white/10 backdrop-blur-xl">
<Sidebar />
@ -71,9 +70,8 @@ export default async function DashboardLayout({
</div>
</header>
{/* Fixed-height content area; inner pages manage their own scrolling */}
<div className="flex-1 flex flex-col min-h-0 overflow-x-hidden p-4 md:p-8">
<PageTransition>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
<div className="flex-1 flex flex-col min-h-0">
{children}
</div>

View File

@ -1,5 +1,6 @@
"use client"
import Link from "next/link"
import { motion } from "framer-motion"
import {
Package,
@ -12,6 +13,7 @@ import {
Layers
} from "lucide-react"
// Sample data for this legacy view. For live inventory data, use the Inventory page.
const mockInventory = [
{ id: "1", title: "Neo Wireless Controller", supplier: "Tokyo Tech Hub", price: "$45.00", stock: 120, status: "SYNCED" },
{ id: "2", title: "Smart Home Hub v2", supplier: "Shenzhen Direct", price: "$89.50", stock: 15, status: "LOW STOCK" },
@ -101,7 +103,7 @@ export default function InventoryPage() {
</div>
</div>
{/* Analytics Footer Placeholder */}
{/* Sample analytics – real data is on the Inventory page */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="glass p-6 rounded-2xl border-white/5 flex items-center gap-4">
<div className="p-3 rounded-xl bg-emerald-accent/10 text-emerald-accent">
@ -131,6 +133,12 @@ export default function InventoryPage() {
</div>
</div>
</div>
<p className="text-center text-xs text-zinc-500 mt-4">
Sample data. View live inventory and sync status on the{" "}
<Link href="/dashboard/inventory" className="text-indigo-400 hover:underline font-medium">
Inventory page
</Link>.
</p>
</div>
)
}

View File

@ -59,7 +59,7 @@ export default function GlobalSuppliersPage() {
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [suppliers]) // Re-run when suppliers change
useEffect(() => {

View File

@ -61,7 +61,7 @@ export default function MySuppliersPage() {
}, 5000) // Poll every 5 seconds
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connections]) // Re-run when connections change
useEffect(() => {

View File

@ -0,0 +1,122 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
import { Mail, Loader2, Zap, ArrowLeft } from "lucide-react"
import Link from "next/link"
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
setSuccess(false)
const trimmed = email.trim()
if (!trimmed) {
setError("Please enter your email address.")
setLoading(false)
return
}
try {
const res = await fetch("/api/auth/password-reset/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: trimmed }),
})
const data = await res.json()
if (res.ok) {
setSuccess(true)
} else {
setError(data.message || "Something went wrong. Please try again.")
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-6 relative overflow-hidden auth-glass-dark">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="w-full max-w-md relative z-10"
>
<div className="bg-white/5 backdrop-blur-xl rounded-2xl md:rounded-3xl p-6 md:p-8 border border-white/10">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
<Zap className="w-6 h-6 text-white fill-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
</div>
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
Forgot <span className="text-indigo-400">Password</span>
</h2>
<p className="text-slate-400 text-sm">
Enter your email and we&apos;ll send you a link to reset your password.
</p>
</div>
{success ? (
<div className="space-y-4 text-center">
<p className="text-slate-200 font-medium">
If an account with that email exists, a password reset link has been sent. Check your inbox and spam folder.
</p>
<Link
href="/login"
className="inline-flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-bold text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back to login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">
Email Address
</label>
<div className="relative">
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/>
</div>
</div>
{error && (
<p className="text-red-400 text-xs font-medium bg-red-500/10 p-2 rounded-lg border border-red-500/20">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-12 bg-indigo-500 hover:bg-indigo-600 text-white font-bold rounded-xl transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed border border-indigo-400/20"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Send reset link"}
</button>
</form>
)}
<p className="text-center mt-6 text-sm text-slate-400">
<Link href="/login" className="text-indigo-400 hover:underline font-medium">
Back to login
</Link>
</p>
</div>
</motion.div>
</div>
)
}

View File

@ -160,6 +160,11 @@ export default function LoginPage() {
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200 disabled:opacity-50"
/>
</div>
<div className="mt-1.5 text-right">
<Link href="/forgot-password" className="text-xs text-indigo-400 hover:text-indigo-300 font-medium">
Forgot password?
</Link>
</div>
</div>
{requiresMFA && (

View File

@ -0,0 +1,175 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion } from "framer-motion"
import { Lock, Loader2, Zap, CheckCircle2 } from "lucide-react"
import Link from "next/link"
function ResetPasswordForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const token = searchParams.get("token") ?? ""
const email = searchParams.get("email") ?? ""
useEffect(() => {
if (!token || !email) {
setError("Invalid or missing reset link. Please request a new password reset.")
}
}, [token, email])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
if (password !== confirmPassword) {
setError("Passwords do not match.")
setLoading(false)
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters.")
setLoading(false)
return
}
try {
const res = await fetch("/api/auth/password-reset/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, email, password }),
})
const data = await res.json()
if (res.ok) {
setSuccess(true)
setTimeout(() => router.push("/login"), 2000)
} else {
setError(data.message || "Failed to reset password. The link may have expired.")
}
} catch {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="text-center space-y-4">
<div className="w-14 h-14 rounded-2xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center mx-auto">
<CheckCircle2 className="w-7 h-7 text-emerald-400" />
</div>
<p className="text-slate-200 font-medium">Password has been reset successfully. Redirecting to login...</p>
<Link href="/login" className="inline-block text-indigo-400 hover:underline font-medium text-sm">
Go to login now
</Link>
</div>
)
}
if (!token || !email) {
return (
<div className="text-center space-y-4">
<p className="text-red-400 font-medium">{error}</p>
<Link href="/forgot-password" className="inline-block text-indigo-400 hover:underline font-medium text-sm">
Request a new reset link
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">
New Password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 uppercase tracking-widest mb-1.5 ml-1">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200"
/>
</div>
</div>
{error && (
<p className="text-red-400 text-xs font-medium bg-red-500/10 p-2 rounded-lg border border-red-500/20">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-12 bg-indigo-500 hover:bg-indigo-600 text-white font-bold rounded-xl transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed border border-indigo-400/20"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : "Reset password"}
</button>
</form>
)
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-6 relative overflow-hidden auth-glass-dark">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="w-full max-w-md relative z-10"
>
<div className="bg-white/5 backdrop-blur-xl rounded-2xl md:rounded-3xl p-6 md:p-8 border border-white/10">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
<Zap className="w-6 h-6 text-white fill-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
</div>
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
Reset <span className="text-indigo-400">Password</span>
</h2>
<p className="text-slate-400 text-sm">
Enter your new password below.
</p>
</div>
<Suspense fallback={<div className="text-slate-400 text-center py-4">Loading...</div>}>
<ResetPasswordForm />
</Suspense>
<p className="text-center mt-6 text-sm text-slate-400">
<Link href="/login" className="text-indigo-400 hover:underline font-medium">
Back to login
</Link>
</p>
</div>
</motion.div>
</div>
)
}

View File

@ -0,0 +1,7 @@
"use client"
import { ChatView } from "@/components/chat/ChatView"
export default function SupplierChatPage() {
return <ChatView />
}

View File

@ -40,13 +40,13 @@ export default async function SupplierLayout({
}
return (
<div className="h-screen relative font-sans dashboard-glass-dark overflow-x-hidden">
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-hidden">
{/* Desktop Sidebar */}
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-slate-900/70 border-r border-white/10 backdrop-blur-xl">
<SupplierSidebar />
</div>
<main className="md:pl-72 h-full flex flex-col">
<main className="md:pl-72 flex flex-col flex-1 h-full min-w-0">
<header className="h-16 px-4 md:px-8 border-b border-white/10 bg-slate-900/50 backdrop-blur-xl flex items-center justify-between flex-shrink-0 z-50">
<div className="flex items-center gap-2 md:gap-4 flex-1 min-w-0">
<MobileSupplierSidebar />
@ -61,9 +61,11 @@ export default async function SupplierLayout({
<UserAccountMenu />
</div>
</header>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 md:p-8 scroll-smooth" data-scroll-container>
<PageTransition>
{children}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
<div className="flex-1 flex flex-col min-h-0">
{children}
</div>
</PageTransition>
</div>
</main>

View File

@ -14,6 +14,15 @@ import {
AlertCircle
} from "lucide-react"
import Link from "next/link"
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell
} from "recharts"
export default function SupplierDashboardPage() {
const [stats, setStats] = useState({
@ -230,12 +239,35 @@ export default function SupplierDashboardPage() {
<div className="bg-white/5 rounded-xl p-6 border border-white/10 backdrop-blur-sm">
<h3 className="text-lg font-bold text-white mb-4">Performance Overview</h3>
<p className="text-sm text-slate-400 mb-4">
Track your supplier performance metrics and growth over time.
Key metrics at a glance.
</p>
<div className="text-center py-8 text-slate-500">
<TrendingUp className="w-12 h-12 mx-auto mb-2 opacity-30" />
<p className="text-sm">Detailed charts coming soon</p>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{ label: "Products", value: stats.totalProducts, color: "rgb(52 211 153)" },
{ label: "Vendors", value: stats.connectedVendors, color: "rgb(147 51 234)" },
{ label: "Pending", value: stats.pendingConnections, color: "rgb(251 191 36)" },
{ label: "New (7d)", value: stats.recentProducts + stats.recentConnections, color: "rgb(59 130 246)" }
]}
margin={{ top: 12, right: 12, left: 0, bottom: 0 }}
>
<XAxis dataKey="label" tick={{ fill: "rgb(148 163 184)", fontSize: 12 }} />
<YAxis tick={{ fill: "rgb(148 163 184)", fontSize: 12 }} />
<Tooltip
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
labelStyle={{ color: "rgb(248 250 252)" }}
formatter={(value: number | undefined) => [value ?? 0, "Count"]}
/>
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{[0, 1, 2, 3].map((i) => (
<Cell key={i} fill={["rgb(52 211 153)", "rgb(147 51 234)", "rgb(251 191 36)", "rgb(59 130 246)"][i]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-slate-500 mt-2">Products, connected vendors, pending connections, and new activity (last 7 days)</p>
</div>
</>
)}

View File

@ -1,7 +1,7 @@
"use client"
import { useEffect, useState, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { useSearchParams, useRouter } from "next/navigation"
import {
Users,
CheckCircle2,
@ -54,6 +54,7 @@ interface ProductSync {
function SupplierVendorsContent() {
const searchParams = useSearchParams()
const router = useRouter()
const [connections, setConnections] = useState<Connection[]>([])
const [productSyncs, setProductSyncs] = useState<ProductSync[]>([])
const [loading, setLoading] = useState(true)
@ -201,6 +202,8 @@ function SupplierVendorsContent() {
setInvitationToken("")
setShowTokenInput(false)
fetchConnections()
// Send supplier to JUNO CHAT so encryption keys auto-setup on first open
setTimeout(() => router.push("/supplier/chat"), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to accept invitation")
} finally {

View File

@ -0,0 +1,113 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { motion } from "framer-motion"
import { Loader2, Zap, CheckCircle2, XCircle } from "lucide-react"
import Link from "next/link"
function VerifyEmailContent() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [message, setMessage] = useState("")
const searchParams = useSearchParams()
const token = searchParams.get("token") ?? ""
const email = searchParams.get("email") ?? ""
useEffect(() => {
if (!token || !email) {
setStatus("error")
setMessage("Invalid or missing verification link.")
return
}
setStatus("loading")
fetch("/api/auth/verify-email/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, email }),
})
.then((res) => res.json())
.then((data) => {
if (data.message === "Email verified successfully") {
setStatus("success")
} else {
setStatus("error")
setMessage(data.message || "Verification failed.")
}
})
.catch(() => {
setStatus("error")
setMessage("An error occurred. Please try again.")
})
}, [token, email])
if (status === "loading" || status === "idle") {
return (
<div className="text-center py-8">
<Loader2 className="w-10 h-10 animate-spin text-indigo-400 mx-auto mb-4" />
<p className="text-slate-400 font-medium">Verifying your email...</p>
</div>
)
}
if (status === "success") {
return (
<div className="text-center space-y-4">
<div className="w-14 h-14 rounded-2xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center mx-auto">
<CheckCircle2 className="w-7 h-7 text-emerald-400" />
</div>
<h3 className="text-xl font-bold text-white">Email verified</h3>
<p className="text-slate-400 text-sm">Your email has been verified successfully. You can now sign in.</p>
<Link
href="/login"
className="inline-flex items-center justify-center h-12 px-6 bg-indigo-500 hover:bg-indigo-600 text-white font-bold rounded-xl transition-all"
>
Go to login
</Link>
</div>
)
}
return (
<div className="text-center space-y-4">
<div className="w-14 h-14 rounded-2xl bg-red-500/20 border border-red-500/30 flex items-center justify-center mx-auto">
<XCircle className="w-7 h-7 text-red-400" />
</div>
<h3 className="text-xl font-bold text-white">Verification failed</h3>
<p className="text-slate-400 text-sm">{message}</p>
<Link href="/login" className="inline-block text-indigo-400 hover:underline font-medium text-sm">
Back to login
</Link>
</div>
)
}
export default function VerifyEmailPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-6 relative overflow-hidden auth-glass-dark">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="w-full max-w-md relative z-10"
>
<div className="bg-white/5 backdrop-blur-xl rounded-2xl md:rounded-3xl p-6 md:p-8 border border-white/10">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
<Zap className="w-6 h-6 text-white fill-white" />
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
</div>
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
Verify <span className="text-indigo-400">Email</span>
</h2>
</div>
<Suspense fallback={<div className="text-slate-400 text-center py-4">Loading...</div>}>
<VerifyEmailContent />
</Suspense>
</div>
</motion.div>
</div>
)
}

View File

@ -26,6 +26,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (token.role) {
session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN"
}
// Load chatPublicKey from DB so it stays current after chat-setup
const user = await prisma.user.findUnique({
where: { id: token.id as string },
select: { chatPublicKey: true },
})
if (user) session.user.chatPublicKey = user.chatPublicKey ?? undefined
}
return session
},

View File

@ -18,12 +18,14 @@ export function MobileSidebar() {
const pathname = usePathname()
useEffect(() => {
setMounted(true)
const timer = setTimeout(() => setMounted(true), 0)
return () => clearTimeout(timer)
}, [])
// Close sidebar when route changes
useEffect(() => {
setIsOpen(false)
const timer = setTimeout(() => setIsOpen(false), 0)
return () => clearTimeout(timer)
}, [pathname])
// Prevent body scroll when sidebar is open

View File

@ -12,7 +12,8 @@ export function MobileSupplierSidebar() {
// Close sidebar when route changes
useEffect(() => {
setIsOpen(false)
const timer = setTimeout(() => setIsOpen(false), 0)
return () => clearTimeout(timer)
}, [pathname])
// Prevent body scroll when sidebar is open

View File

@ -2,12 +2,14 @@
import { motion } from "framer-motion"
import { ReactNode } from "react"
import { cn } from "@/lib/utils"
interface PageTransitionProps {
children: ReactNode
className?: string
}
export default function PageTransition({ children }: PageTransitionProps) {
export default function PageTransition({ children, className }: PageTransitionProps) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
@ -22,6 +24,7 @@ export default function PageTransition({ children }: PageTransitionProps) {
transform: 'translateZ(0)',
WebkitTransform: 'translateZ(0)'
}}
className={cn("flex-1 flex flex-col min-h-0", className)}
>
{children}
</motion.div>

View File

@ -73,6 +73,12 @@ const routes = [
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },
]
},
{
label: "JUNO CHAT",
icon: Mail,
href: "/dashboard/chat",
color: "text-pink-500",
}
]

View File

@ -7,10 +7,8 @@ import {
Package,
ShoppingCart,
Users,
Settings,
Mail,
Zap,
FileText,
TrendingUp
} from "lucide-react"
import { cn } from "@/lib/utils"
@ -40,11 +38,11 @@ const supplierRoutes = [
color: "text-purple-500",
},
{
label: "Settings",
icon: Settings,
href: "/supplier/settings",
color: "text-slate-400",
}
label: "JUNO CHAT",
icon: Mail,
href: "/supplier/chat",
color: "text-pink-500",
},
]
export function SupplierSidebar() {

View File

@ -40,8 +40,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
| ThemeMode
| null
if (stored === "light" || stored === "default") {
setThemeState(stored)
applyTheme(stored)
const timer = setTimeout(() => {
setThemeState(stored)
applyTheme(stored)
}, 0)
return () => clearTimeout(timer)
} else {
applyTheme("default")
}

View File

@ -31,7 +31,8 @@ export default function UserAccountMenu() {
const handleSettings = () => {
setIsOpen(false)
router.push("/dashboard/settings")
const settingsPath = session?.user?.role === "SUPPLIER" ? "/supplier/settings" : "/dashboard/settings"
router.push(settingsPath)
}
const handleLogout = async () => {

View File

@ -0,0 +1,805 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import { getSession } 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 {
Send,
Search,
ShieldCheck,
Loader2,
Plus,
Lock,
MessageSquare,
CheckCheck,
Package,
ShoppingCart,
X,
Settings
} from "lucide-react"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
interface Contact {
id: string
name: string
email: string
image: string | null
publicKey: string | null
role: string
}
interface Message {
id: string
senderId: string
receiverId: string
content: string
createdAt: string
}
function AlertCircle(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)
}
type AttachOrder = { id: number; name: string; total_price: string; currency?: string; storeId?: string; storeName?: string }
type AttachProduct = { id: number | string; title: string; storeId?: string; storeName?: string }
export function ChatView() {
const pathname = usePathname()
const isSupplier = pathname.startsWith("/supplier")
const { theme } = useTheme()
const isLight = theme === "light"
const { hasKeys, loading: cryptoLoading, setupKeys, privateKey, getStoredPublicKey } = useChatCrypto()
const [contacts, setContacts] = useState<Contact[]>([])
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [newMessage, setNewMessage] = useState("")
const [loadingContacts, setLoadingContacts] = useState(true)
const [loadingMessages, setLoadingMessages] = useState(false)
const [sending, setSending] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [contactsError, setContactsError] = useState<string | null>(null)
const [messagesError, setMessagesError] = useState<string | null>(null)
const [sendError, setSendError] = useState<string | null>(null)
const [attachOpen, setAttachOpen] = useState(false)
const [attachLoading, setAttachLoading] = useState(false)
const [attachOrders, setAttachOrders] = useState<AttachOrder[]>([])
const [attachProducts, setAttachProducts] = useState<AttachProduct[]>([])
const attachRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const autoSetupAttemptedRef = useRef(false)
const handleSetup = async () => {
try {
setContactsError(null)
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) 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)
const res = await fetch("/api/chat/contacts")
if (res.ok) {
const data = await res.json()
setContacts(data.contacts)
} else {
setContactsError("Failed to load contacts. Please try again.")
}
} catch (error) {
console.error("Fetch contacts error", error)
setContactsError("Failed to load contacts. Please try again.")
} finally {
setLoadingContacts(false)
}
}
const fetchMessages = async (contact: Contact, silent = false) => {
if (!privateKey) return
try {
if (!silent) {
setLoadingMessages(true)
setMessagesError(null)
}
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: any) => {
try {
const decrypted = await ChatCrypto.decryptMessage(
m.encryptedContent,
m.senderId === contact.id ? m.encryptedKeyForReceiver : m.encryptedKeyForSender,
m.iv,
privateKey
)
return {
id: m.id,
senderId: m.senderId,
receiverId: m.receiverId,
content: decrypted,
createdAt: m.createdAt
}
} catch (err) {
return {
id: m.id,
senderId: m.senderId,
receiverId: m.receiverId,
content: "[Encrypted Message - Error Decrypting]",
createdAt: m.createdAt
}
}
})
)
setMessages(decryptedMessages)
} else if (!silent) {
setMessagesError("Failed to load messages. Please try again.")
}
} catch (error) {
console.error("Fetch messages error", error)
if (!silent) setMessagesError("Failed to load messages. Please try again.")
} finally {
if (!silent) setLoadingMessages(false)
}
}
useEffect(() => {
if (hasKeys) fetchContacts()
}, [hasKeys])
useEffect(() => {
if (selectedContact) {
fetchMessages(selectedContact)
} else {
setMessages([])
}
}, [selectedContact, privateKey])
// Poll for new messages when a contact is selected
const POLL_INTERVAL_MS = 8000
useEffect(() => {
if (!selectedContact || !privateKey) return
const interval = setInterval(() => {
fetchMessages(selectedContact, true)
}, POLL_INTERVAL_MS)
return () => clearInterval(interval)
}, [selectedContact?.id, privateKey])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (attachRef.current && !attachRef.current.contains(e.target as Node)) {
setAttachOpen(false)
}
}
if (attachOpen) {
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}
}, [attachOpen])
const openAttach = async () => {
setAttachOpen(true)
setAttachLoading(true)
setAttachOrders([])
setAttachProducts([])
try {
if (isSupplier) {
const res = await fetch("/api/supplier/products")
if (res.ok) {
const data = await res.json()
const list = (data.products || []).map((p: { id: string; title?: string; shopifyProductTitle?: string }) => ({
id: p.id,
title: p.title || p.shopifyProductTitle || "Product"
}))
setAttachProducts(list)
}
} else {
const [ordersRes, productsRes] = await Promise.all([
fetch("/api/chat/attach-orders"),
fetch("/api/chat/attach-products")
])
if (ordersRes.ok) {
const ordersData = await ordersRes.json()
setAttachOrders(ordersData.orders || [])
}
if (productsRes.ok) {
const productsData = await productsRes.json()
setAttachProducts(productsData.products || [])
}
}
} catch {
// Silent fail
} finally {
setAttachLoading(false)
}
}
const insertOrder = (order: AttachOrder) => {
const storePart = order.storeName ? ` · ${order.storeName}` : ""
const line = `[Order ${order.name}${storePart} - ${order.currency || ""} ${order.total_price}]\n`
setNewMessage(prev => (prev ? prev + line : line.trim()))
setAttachOpen(false)
}
const insertProduct = (product: AttachProduct) => {
const storePart = product.storeName ? ` · ${product.storeName}` : ""
const line = `[Product: ${product.title}${storePart}${typeof product.id === "number" ? ` (#${product.id})` : ""}]\n`
setNewMessage(prev => (prev ? prev + line : line.trim()))
setAttachOpen(false)
}
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (!newMessage.trim() || !selectedContact || !selectedContact.publicKey || !privateKey) 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
})
})
if (res.ok) {
const msg = await res.json()
const optimiticMsg: Message = {
id: msg.id,
senderId: msg.senderId,
receiverId: msg.receiverId,
content: newMessage,
createdAt: msg.createdAt
}
setMessages(prev => [...prev, optimiticMsg])
setNewMessage("")
} else {
setSendError("Failed to send message. Please try again.")
}
} catch (error) {
console.error("Send message error", error)
setSendError("Failed to send message. Please try again.")
} finally {
setSending(false)
}
}
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())
)
return (
<div className={cn(
"h-full flex flex-col md:flex-row overflow-hidden min-h-0",
isLight ? "bg-slate-50" : "bg-white/5 md:bg-transparent"
)}>
{/* Sidebar - Contacts (fixed; only inner list scrolls) */}
<div className={cn(
"w-full md:w-80 flex flex-col flex-shrink-0 min-h-0 border-r backdrop-blur-xl transition-all",
selectedContact ? "hidden md:flex" : "flex",
isLight ? "bg-white border-slate-200 shadow-sm" : "border-white/10 glass-panel bg-black/20"
)}>
<div className={cn(
"p-6 border-b flex items-center justify-between flex-shrink-0",
isLight ? "border-slate-200" : "border-white/10"
)}>
<h2 className={cn("text-xl font-black tracking-tighter", isLight ? "text-slate-900" : "text-white")}>JUNO CHAT</h2>
<div className="flex gap-2">
<div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center",
isLight ? "bg-emerald-500/10 border border-emerald-500/20" : "bg-green-500/20 border border-green-500/30"
)}>
<ShieldCheck className={cn("w-4 h-4", isLight ? "text-emerald-600" : "text-green-400")} />
</div>
</div>
</div>
<div className="p-4 flex-shrink-0">
<div className="relative group">
<Search className={cn("absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 transition-colors", isLight ? "text-slate-400 group-focus-within:text-indigo-600" : "text-slate-500 group-focus-within:text-indigo-400")} />
<input
type="text"
placeholder="Find connections..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all font-medium",
isLight ? "bg-slate-50 border border-slate-200 text-slate-900 placeholder:text-slate-400" : "bg-white/5 border border-white/10 text-white placeholder:text-slate-600"
)}
/>
</div>
{contactsError && (
<div className="mt-3 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-red-600 text-xs font-medium flex items-center justify-between gap-2">
<span>{contactsError}</span>
<button type="button" onClick={() => setContactsError(null)} className="shrink-0 text-red-500 hover:text-red-700" aria-label="Dismiss">×</button>
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
{loadingContacts ? (
<div className={cn("p-10 flex flex-col items-center gap-2", isLight ? "text-slate-500" : "text-slate-500")}>
<Loader2 className="w-6 h-6 animate-spin" />
<span className="text-xs font-bold uppercase tracking-widest">Loading...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="p-10 text-center opacity-50">
<MessageSquare className={cn("w-10 h-10 mx-auto mb-3", isLight ? "text-slate-400" : "text-slate-600")} />
<p className={cn("text-sm font-bold uppercase tracking-widest", isLight ? "text-slate-500" : "text-slate-500")}>No Contacts</p>
</div>
) : (
<div className="px-2 space-y-1">
{filteredContacts.map(contact => (
<button
key={contact.id}
onClick={() => setSelectedContact(contact)}
className={cn(
"w-full flex items-center gap-3 p-4 rounded-2xl transition-all group relative",
selectedContact?.id === contact.id
? isLight ? "bg-indigo-600 shadow-lg text-white" : "bg-indigo-600 shadow-lg shadow-indigo-600/20"
: isLight ? "hover:bg-slate-100" : "hover:bg-white/5"
)}
>
<div className="relative">
<div className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center font-black transition-all",
selectedContact?.id === contact.id
? "bg-white text-indigo-600"
: isLight ? "bg-slate-100 text-slate-600 group-hover:bg-indigo-100 group-hover:text-indigo-600" : "bg-white/10 text-slate-300 group-hover:bg-indigo-500/20 group-hover:text-indigo-400"
)}>
{contact.name?.[0].toUpperCase() || "?"}
</div>
<div className={cn("absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 bg-green-500", isLight ? "border-white" : "border-slate-900")} />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className={cn(
"text-sm font-black truncate tracking-tight",
selectedContact?.id === contact.id ? "text-white" : isLight ? "text-slate-900" : "text-slate-200"
)}>
{contact.name}
</h4>
<p className={cn(
"text-[10px] uppercase font-black tracking-widest mt-0.5",
selectedContact?.id === contact.id ? "text-indigo-100/70" : isLight ? "text-slate-500" : "text-slate-500"
)}>
{contact.role}
</p>
</div>
<div className={cn(
"w-2 h-2 rounded-full",
contact.publicKey ? "bg-green-500/50" : "bg-red-500/50"
)} />
</button>
))}
</div>
)}
</div>
<div className={cn(
"p-6 border-t flex-shrink-0",
isLight ? "border-slate-200 bg-slate-50" : "border-white/10 bg-black/40"
)}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-pink-600 to-indigo-600 flex items-center justify-center text-white font-black text-sm">
Y
</div>
<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
</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")} />
</div>
</div>
</div>
{/* Chat Area - fixed header and input; only messages scroll */}
<div className={cn(
"flex-1 flex flex-col min-h-0 overflow-hidden",
isLight ? "bg-slate-100" : "bg-slate-950/20",
!selectedContact ? "hidden md:flex items-center justify-center" : "flex"
)}>
{selectedContact ? (
<>
<div className={cn(
"p-4 md:p-6 border-b flex items-center justify-between flex-shrink-0 z-10",
isLight ? "bg-white border-slate-200 shadow-sm" : "border-white/10 glass-panel backdrop-blur-md"
)}>
<div className="flex items-center gap-4">
<button
onClick={() => setSelectedContact(null)}
className={cn("md:hidden w-10 h-10 rounded-xl border flex items-center justify-center", isLight ? "border-slate-200 text-slate-600" : "border-white/10 text-slate-400")}
>
<Plus className="w-5 h-5 rotate-45" />
</button>
<div className={cn(
"w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center font-black",
isLight ? "bg-indigo-100 border border-indigo-200 text-indigo-600" : "bg-indigo-500/20 border border-indigo-500/30 text-indigo-400"
)}>
{selectedContact.name[0].toUpperCase()}
</div>
<div>
<h3 className={cn("font-black text-sm md:text-lg tracking-tight leading-none mb-1", isLight ? "text-slate-900" : "text-white")}>
{selectedContact.name}
</h3>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<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
</span>
</div>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-4 md:p-8 space-y-6 custom-scrollbar scroll-smooth">
{messagesError && (
<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 items-center justify-between gap-2">
<span>{messagesError}</span>
<button type="button" onClick={() => setMessagesError(null)} className="shrink-0 text-red-400 hover:text-red-300" aria-label="Dismiss">×</button>
</div>
)}
{loadingMessages ? (
<div className="h-full flex flex-col items-center justify-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
<p className="text-xs font-black uppercase tracking-widest text-slate-600">Syncing History...</p>
</div>
) : 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>
</div>
) : (
messages.map((msg, idx) => {
const isMe = msg.senderId !== selectedContact.id
const prevMsg = idx > 0 ? messages[idx - 1] : null
const showTime = !prevMsg || format(new Date(msg.createdAt), "HH:mm") !== format(new Date(prevMsg.createdAt), "HH:mm")
return (
<div
key={msg.id}
className={cn(
"flex flex-col animate-in fade-in slide-in-from-bottom-2 duration-500",
isMe ? "items-end" : "items-start"
)}
>
<div className={cn(
"max-w-[85%] md:max-w-[70%] px-5 py-4 rounded-2xl text-sm font-medium relative group",
isMe
? "bg-indigo-600 text-white rounded-tr-none shadow-lg shadow-indigo-600/10"
: isLight
? "bg-white border border-slate-200 text-slate-800 rounded-tl-none shadow-md"
: "bg-white/5 border border-white/10 text-slate-200 rounded-tl-none backdrop-blur-sm shadow-xl"
)}>
{msg.content}
<div className={cn(
"absolute bottom-2 -right-6 flex items-center gap-1 transition-opacity opacity-0 group-hover:opacity-100",
isMe ? "-left-8 right-auto flex-row-reverse" : ""
)}>
{isMe && <CheckCheck className={cn("w-3.5 h-3.5", isLight ? "text-indigo-300" : "text-indigo-400")} />}
</div>
</div>
{showTime && (
<span className={cn("text-[10px] font-black mt-2 uppercase tracking-widest px-1", isLight ? "text-slate-500" : "text-slate-600")}>
{format(new Date(msg.createdAt), "HH:mm")}
</span>
)}
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
<div className="p-6 flex-shrink-0">
{sendError && (
<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"
)}
>
<div className="relative" ref={attachRef}>
<button
type="button"
onClick={openAttach}
className={cn("p-3 rounded-xl transition-all", isLight ? "text-slate-500 hover:bg-slate-100 hover:text-indigo-600" : "hover:bg-white/5 text-slate-500 hover:text-indigo-400")}
aria-label="Attach order or product"
>
<Plus className="w-5 h-5" />
</button>
{attachOpen && (
<div
className={cn(
"absolute bottom-full left-0 mb-2 w-80 max-h-72 overflow-hidden rounded-2xl shadow-xl border z-50 flex flex-col",
isLight ? "bg-white border-slate-200" : "bg-slate-800/95 border-white/10 backdrop-blur-xl"
)}
>
<div className={cn("p-2 border-b flex items-center justify-between flex-shrink-0", isLight ? "border-slate-200" : "border-white/10")}>
<span className={cn("text-xs font-bold uppercase tracking-wider", isLight ? "text-slate-500" : "text-slate-400")}>
{isSupplier ? "Attach product" : "Attach order or product"}
</span>
<button type="button" onClick={() => setAttachOpen(false)} className="p-1.5 rounded-lg hover:bg-white/10 text-slate-500" aria-label="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{attachLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className={cn("w-6 h-6 animate-spin", isLight ? "text-indigo-500" : "text-indigo-400")} />
</div>
) : (
<>
{!isSupplier && attachOrders.length > 0 && (
<div className="mb-3">
<p className={cn("text-[10px] font-bold uppercase tracking-wider mb-1.5 flex items-center gap-1", isLight ? "text-slate-500" : "text-slate-400")}>
<ShoppingCart className="w-3 h-3" /> Orders
</p>
<div className="space-y-1">
{attachOrders.map((order) => (
<button
key={`${order.storeId}-${order.id}`}
type="button"
onClick={() => insertOrder(order)}
className={cn("w-full text-left px-3 py-2 rounded-xl text-sm font-medium transition-colors", isLight ? "hover:bg-slate-100 text-slate-800" : "hover:bg-white/10 text-slate-200")}
>
<span className="font-semibold">{order.name}</span>
{order.storeName && <span className={cn("ml-1.5 text-[10px] uppercase tracking-wider", isLight ? "text-slate-500" : "text-slate-400")}>· {order.storeName}</span>}
<span className="block text-xs opacity-80">{order.currency || ""}{order.total_price}</span>
</button>
))}
</div>
</div>
)}
{attachProducts.length > 0 && (
<div>
<p className={cn("text-[10px] font-bold uppercase tracking-wider mb-1.5 flex items-center gap-1", isLight ? "text-slate-500" : "text-slate-400")}>
<Package className="w-3 h-3" /> Products
</p>
<div className="space-y-1">
{attachProducts.map((product) => (
<button
key={product.storeId ? `${product.storeId}-${product.id}` : String(product.id)}
type="button"
onClick={() => insertProduct(product)}
className={cn("w-full text-left px-3 py-2 rounded-xl text-sm font-medium transition-colors", isLight ? "hover:bg-slate-100 text-slate-800" : "hover:bg-white/10 text-slate-200")}
>
<span className="line-clamp-1">{product.title}</span>
{product.storeName && <span className={cn("text-[10px] uppercase tracking-wider block mt-0.5", isLight ? "text-slate-500" : "text-slate-400")}>{product.storeName}</span>}
</button>
))}
</div>
</div>
)}
{!attachLoading && attachOrders.length === 0 && attachProducts.length === 0 && (
<p className={cn("py-6 text-center text-sm", isLight ? "text-slate-500" : "text-slate-500")}>
{isSupplier ? "No products to attach." : "No orders or products found."}
</p>
)}
</>
)}
</div>
</div>
)}
</div>
<textarea
rows={1}
placeholder="Type encrypted message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage(e)
}
}}
className={cn(
"flex-1 max-h-40 py-3 bg-transparent border-none focus:outline-none focus:ring-0 text-sm resize-none font-medium custom-scrollbar",
isLight ? "text-slate-900 placeholder:text-slate-400" : "text-white placeholder:text-slate-600"
)}
/>
<button
type="submit"
disabled={!newMessage.trim() || sending}
className={cn(
"p-4 rounded-xl transition-all shadow-lg active:scale-90 disabled:opacity-50",
newMessage.trim()
? "bg-indigo-600 text-white shadow-indigo-600/20"
: isLight ? "bg-slate-100 text-slate-500" : "bg-white/5 text-slate-700"
)}
>
{sending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
</button>
</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
</p>
</div>
</>
) : (
<div className="flex flex-col items-center text-center p-12 max-w-sm">
<div className={cn("w-24 h-24 rounded-full flex items-center justify-center mb-8 relative", isLight ? "bg-indigo-100" : "bg-indigo-500/5")}>
<div className={cn("absolute inset-0 rounded-full animate-ping opacity-20", isLight ? "bg-indigo-300" : "bg-indigo-500/10")} />
<MessageSquare className={cn("w-10 h-10", isLight ? "text-indigo-400" : "text-indigo-500/40")} />
</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.
</p>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
"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() {
const [privateKey, setPrivateKey] = useState<string | null>(null)
const [hasKeys, setHasKeys] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
const stored = localStorage.getItem(PRIVATE_KEY_STORAGE_KEY)
if (stored) {
setPrivateKey(stored)
setHasKeys(true)
}
setLoading(false)
}, [])
const setupKeys = useCallback(async () => {
setLoading(true)
try {
const keys = await ChatCrypto.generateKeyPair()
localStorage.setItem(PRIVATE_KEY_STORAGE_KEY, keys.privateKey)
localStorage.setItem(PUBLIC_KEY_STORAGE_KEY, 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 })
})
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)
}
}, [])
const getStoredPublicKey = useCallback((): string | null => {
if (typeof window === "undefined") return null
return localStorage.getItem(PUBLIC_KEY_STORAGE_KEY)
}, [])
return {
privateKey,
hasKeys,
loading,
setupKeys,
getStoredPublicKey
}
}

164
src/lib/chat-crypto.ts Normal file
View File

@ -0,0 +1,164 @@
/**
* 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

@ -1,5 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { PDFParse } from "pdf-parse"
import mammoth from "mammoth"
import { logger } from "@/lib/logger"
export type RawDocument = {
@ -20,15 +22,31 @@ export type TextChunk = {
const DEFAULT_CHUNK_SIZE = 1000
const DEFAULT_CHUNK_OVERLAP = 200
// Placeholder PDF/DOCX extraction – swap in real libraries (e.g. pdf-parse, docx) later.
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
try {
const buffer = await fs.readFile(storagePath)
const text = buffer.toString("utf8")
const PDF_MIME = "application/pdf"
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
const DOC_MIME = "application/msword"
// In a real implementation, branch on mimeType and use appropriate parsers.
// For now, treat as UTF-8 text for a first iteration.
return text
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
const absolutePath = path.resolve(storagePath)
try {
if (mimeType === PDF_MIME) {
const buffer = await fs.readFile(absolutePath)
const parser = new PDFParse({ data: buffer })
try {
const result = await parser.getText()
await parser.destroy()
return (result.text ?? "").trim()
} finally {
await parser.destroy().catch(() => {})
}
}
if (mimeType === DOCX_MIME || mimeType === DOC_MIME) {
const result = await mammoth.extractRawText({ path: absolutePath })
return (result.value ?? "").trim()
}
// Fallback for plain text or unknown types
const buffer = await fs.readFile(absolutePath)
return buffer.toString("utf8").trim()
} catch (error) {
logger.error("Failed to extract text from file", { storagePath, mimeType, error })
throw error

View File

@ -8,20 +8,20 @@ function log(level: LogLevel, message: string, meta?: unknown) {
switch (level) {
case "debug":
if (!isProd) {
// eslint-disable-next-line no-console
console.debug(...payload)
}
break
case "info":
// eslint-disable-next-line no-console
console.info(...payload)
break
case "warn":
// eslint-disable-next-line no-console
console.warn(...payload)
break
case "error":
// eslint-disable-next-line no-console
console.error(...payload)
break
}

View File

@ -6,6 +6,7 @@ declare module "next-auth" {
user: {
id: string
role: UserRole
chatPublicKey?: string | null
} & DefaultSession["user"]
}