Features: Juno Chat & E2E encryption & forgot password
This commit is contained in:
parent
4ad0ed7dbc
commit
947d9c8af8
405
package-lock.json
generated
405
package-lock.json
generated
@ -25,10 +25,12 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"framer-motion": "^12.33.0",
|
"framer-motion": "^12.33.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"otplib": "^13.2.1",
|
"otplib": "^13.2.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@ -2212,6 +2214,176 @@
|
|||||||
"node": ">=16"
|
"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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@ -4222,6 +4394,14 @@
|
|||||||
"uncrypto": "^0.1.3"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@ -4545,6 +4725,25 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
@ -4561,6 +4760,11 @@
|
|||||||
"bcrypt": "bin/bcrypt"
|
"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": {
|
"node_modules/bowser": {
|
||||||
"version": "2.14.1",
|
"version": "2.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||||
@ -4857,6 +5061,11 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
"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": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@ -5183,6 +5397,14 @@
|
|||||||
"url": "https://dotenvx.com"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -6482,6 +6704,11 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/immer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
@ -6516,6 +6743,11 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@ -7058,6 +7290,17 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -7098,6 +7341,14 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.2",
|
"version": "1.30.2",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
@ -7386,6 +7637,16 @@
|
|||||||
"loose-envify": "cli.js"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@ -7425,6 +7686,45 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@ -7873,6 +8173,11 @@
|
|||||||
"node >= 0.4.6"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -7958,6 +8263,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -7978,6 +8288,14 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
"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": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@ -8653,6 +9025,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@ -8766,6 +9143,11 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@ -8940,6 +9322,11 @@
|
|||||||
"node": ">= 10.x"
|
"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": {
|
"node_modules/sqlstring": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
@ -8972,6 +9359,14 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"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"
|
"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": {
|
"node_modules/valibot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||||
|
|||||||
@ -27,10 +27,12 @@
|
|||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"framer-motion": "^12.33.0",
|
"framer-motion": "^12.33.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
|
"mammoth": "^1.11.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^5.0.0-beta.30",
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"nodemailer": "^7.0.7",
|
"nodemailer": "^7.0.7",
|
||||||
"otplib": "^13.2.1",
|
"otplib": "^13.2.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@ -9,6 +9,6 @@ export default defineConfig({
|
|||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DATABASE_URL"],
|
url: process.env["DATABASE_URL"]!,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -27,7 +27,11 @@ model User {
|
|||||||
mfaEnabled Boolean @default(false)
|
mfaEnabled Boolean @default(false)
|
||||||
mfaSecret String? // Encrypted TOTP secret
|
mfaSecret String? // Encrypted TOTP secret
|
||||||
|
|
||||||
|
chatPublicKey String?
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
sentMessages ChatMessage[] @relation("SentMessages")
|
||||||
|
receivedMessages ChatMessage[] @relation("ReceivedMessages")
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
stores Store[] // Changed from singular to support multiple stores
|
stores Store[] // Changed from singular to support multiple stores
|
||||||
@ -109,6 +113,7 @@ model Store {
|
|||||||
faqItems FaqItem[]
|
faqItems FaqItem[]
|
||||||
knowledgeChunks KnowledgeChunk[]
|
knowledgeChunks KnowledgeChunk[]
|
||||||
emailLogs EmailLog[]
|
emailLogs EmailLog[]
|
||||||
|
flaggedEmails FlaggedEmail[]
|
||||||
templates Template[]
|
templates Template[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -383,6 +388,28 @@ model EmailLog {
|
|||||||
@@index([userId, sentAt])
|
@@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 {
|
enum KnowledgeDocumentStatus {
|
||||||
UPLOADED
|
UPLOADED
|
||||||
PROCESSING
|
PROCESSING
|
||||||
@ -482,3 +509,21 @@ model Template {
|
|||||||
|
|
||||||
@@index([storeId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Link2 } from "lucide-react"
|
import { Link2 } from "lucide-react"
|
||||||
|
|
||||||
interface AdminConnection {
|
interface AdminConnection {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
createdAt: string
|
createdAt: Date
|
||||||
updatedAt: string
|
updatedAt: Date
|
||||||
store: {
|
store: {
|
||||||
businessName: string
|
businessName: string
|
||||||
shopifyStoreName: string
|
shopifyStoreName: string
|
||||||
@ -17,12 +18,14 @@ interface AdminConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getConnections(): Promise<AdminConnection[]> {
|
async function getConnections(): Promise<AdminConnection[]> {
|
||||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, {
|
const connections = await prisma.connection.findMany({
|
||||||
cache: "no-store",
|
include: {
|
||||||
|
store: { select: { businessName: true, shopifyStoreName: true } },
|
||||||
|
supplier: { select: { companyName: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
return connections as unknown as AdminConnection[]
|
||||||
const data = (await res.json()) as { connections: AdminConnection[] }
|
|
||||||
return data.connections
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminConnectionsPage() {
|
export default async function AdminConnectionsPage() {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Mail } from "lucide-react"
|
import { Mail } from "lucide-react"
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ interface AdminEmailLog {
|
|||||||
status: string
|
status: string
|
||||||
providerMessageId: string | null
|
providerMessageId: string | null
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
sentAt: string
|
sentAt: Date
|
||||||
store: {
|
store: {
|
||||||
businessName: string
|
businessName: string
|
||||||
shopifyStoreName: string
|
shopifyStoreName: string
|
||||||
@ -20,15 +21,17 @@ interface AdminEmailLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> {
|
async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> {
|
||||||
// Use a relative URL so the admin's session cookies are forwarded
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const res = await fetch("/api/admin/emails?limit=100", {
|
const prismaAny = prisma as any
|
||||||
cache: "no-store",
|
const [emails, total] = await Promise.all([
|
||||||
})
|
prismaAny.emailLog.findMany({
|
||||||
if (!res.ok) {
|
include: { store: { select: { businessName: true, shopifyStoreName: true } } },
|
||||||
return { emails: [], total: 0 }
|
orderBy: { sentAt: "desc" },
|
||||||
}
|
take: 100,
|
||||||
const data = (await res.json()) as { emails: AdminEmailLog[]; total: number }
|
}),
|
||||||
return data
|
prismaAny.emailLog.count(),
|
||||||
|
])
|
||||||
|
return { emails: emails as AdminEmailLog[], total }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminEmailsPage() {
|
export default async function AdminEmailsPage() {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Activity, Link2, Radar, Shuffle } from "lucide-react"
|
import { Activity, Link2, Radar, Shuffle } from "lucide-react"
|
||||||
|
|
||||||
@ -14,26 +15,59 @@ interface AdminEvent {
|
|||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LIMIT = 50
|
||||||
|
|
||||||
async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
||||||
const searchParams = new URLSearchParams()
|
const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([
|
||||||
searchParams.set("limit", "50")
|
prisma.connection.findMany({
|
||||||
if (type) {
|
orderBy: { createdAt: "desc" },
|
||||||
searchParams.set("type", type)
|
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(
|
const events: AdminEvent[] = [
|
||||||
`${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`,
|
...connectionEvents.map((c) => ({
|
||||||
{
|
id: c.id,
|
||||||
cache: "no-store",
|
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) {
|
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
return []
|
return events.slice(0, LIMIT)
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { events: AdminEvent[] }
|
|
||||||
return data.events
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeBadge(eventType: AdminEventType) {
|
function typeBadge(eventType: AdminEventType) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { ShoppingCart } from "lucide-react"
|
import { ShoppingCart } from "lucide-react"
|
||||||
|
|
||||||
@ -10,7 +11,7 @@ interface AdminOrder {
|
|||||||
currency: string | null
|
currency: string | null
|
||||||
financialStatus: string | null
|
financialStatus: string | null
|
||||||
fulfillmentStatus: string | null
|
fulfillmentStatus: string | null
|
||||||
createdAt: string
|
createdAt: Date
|
||||||
store: {
|
store: {
|
||||||
businessName: string
|
businessName: string
|
||||||
shopifyStoreName: string
|
shopifyStoreName: string
|
||||||
@ -18,12 +19,57 @@ interface AdminOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getOrders(): Promise<AdminOrder[]> {
|
async function getOrders(): Promise<AdminOrder[]> {
|
||||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, {
|
const acceptedSyncs = await prisma.productSync.findMany({
|
||||||
cache: "no-store",
|
where: { status: "ACCEPTED" },
|
||||||
|
select: { shopifyProductId: true, storeId: true },
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
if (acceptedSyncs.length === 0) return []
|
||||||
const data = (await res.json()) as { orders: AdminOrder[] }
|
|
||||||
return data.orders
|
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() {
|
export default async function AdminOrdersPage() {
|
||||||
@ -109,7 +155,7 @@ export default async function AdminOrdersPage() {
|
|||||||
{order.fulfillmentStatus || "—"}
|
{order.fulfillmentStatus || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
<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",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -69,11 +69,10 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
|
|||||||
by: ["status"],
|
by: ["status"],
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}) as unknown as Promise<ConnectionGroup[]>,
|
}) as unknown as Promise<ConnectionGroup[]>,
|
||||||
// Use `as any` to be compatible with the current Prisma client setup
|
prisma.productSync.groupBy({
|
||||||
(prisma as any).productSync.groupBy({
|
|
||||||
by: ["status"],
|
by: ["status"],
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}) as Promise<ProductSyncGroup[]>,
|
} as Parameters<typeof prisma.productSync.groupBy>[0]) as unknown as Promise<ProductSyncGroup[]>,
|
||||||
prisma.cachedOrder.count(),
|
prisma.cachedOrder.count(),
|
||||||
prisma.syncMetadata.findMany({
|
prisma.syncMetadata.findMany({
|
||||||
where: { syncStatus: { in: ["failed"] } },
|
where: { syncStatus: { in: ["failed"] } },
|
||||||
@ -81,7 +80,7 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
|
|||||||
take: 5,
|
take: 5,
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
}) as Promise<RecentSyncError[]>,
|
}) as Promise<RecentSyncError[]>,
|
||||||
(prisma as any).productSync.findMany({
|
prisma.productSync.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 8,
|
take: 8,
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Shuffle } from "lucide-react"
|
import { Shuffle } from "lucide-react"
|
||||||
|
|
||||||
interface AdminProductSync {
|
interface AdminProductSync {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
createdAt: string
|
createdAt: Date
|
||||||
acceptedAt: string | null
|
acceptedAt: Date | null
|
||||||
rejectedAt: string | null
|
rejectedAt: Date | null
|
||||||
shopifyProductId: string
|
shopifyProductId: string
|
||||||
shopifyProductTitle: string
|
shopifyProductTitle: string
|
||||||
store: {
|
store: {
|
||||||
@ -20,12 +21,14 @@ interface AdminProductSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getProductSyncs(): Promise<AdminProductSync[]> {
|
async function getProductSyncs(): Promise<AdminProductSync[]> {
|
||||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, {
|
const syncs = await prisma.productSync.findMany({
|
||||||
cache: "no-store",
|
include: {
|
||||||
|
store: { select: { businessName: true, shopifyStoreName: true } },
|
||||||
|
supplier: { select: { companyName: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
return syncs as unknown as AdminProductSync[]
|
||||||
const data = (await res.json()) as { syncs: AdminProductSync[] }
|
|
||||||
return data.syncs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminProductSyncsPage() {
|
export default async function AdminProductSyncsPage() {
|
||||||
@ -113,9 +116,7 @@ export default async function AdminProductSyncsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||||
{sync.acceptedAt || sync.rejectedAt
|
{sync.acceptedAt || sync.rejectedAt
|
||||||
? new Date(
|
? new Date(sync.acceptedAt || sync.rejectedAt!).toLocaleString("en-US", {
|
||||||
(sync.acceptedAt || sync.rejectedAt) as string,
|
|
||||||
).toLocaleString("en-US", {
|
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Activity, Radar } from "lucide-react"
|
import { Activity, Radar } from "lucide-react"
|
||||||
|
|
||||||
@ -16,12 +17,15 @@ interface AdminSyncMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getSyncHealth(): Promise<AdminSyncMetadata[]> {
|
async function getSyncHealth(): Promise<AdminSyncMetadata[]> {
|
||||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, {
|
const rows = await prisma.syncMetadata.findMany({
|
||||||
cache: "no-store",
|
include: {
|
||||||
|
store: {
|
||||||
|
select: { businessName: true, shopifyStoreName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { lastSyncAt: "desc" },
|
||||||
})
|
})
|
||||||
if (!res.ok) return []
|
return rows as unknown as AdminSyncMetadata[]
|
||||||
const data = (await res.json()) as { syncs: AdminSyncMetadata[] }
|
|
||||||
return data.syncs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminSyncHealthPage() {
|
export default async function AdminSyncHealthPage() {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { ConnectionStatus } from "@prisma/client"
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -15,7 +16,7 @@ export async function GET(req: Request) {
|
|||||||
|
|
||||||
const connections = await prisma.connection.findMany({
|
const connections = await prisma.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(status && { status: status as any }),
|
...(status && { status: status as ConnectionStatus }),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
store: true,
|
store: true,
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export async function GET(req: Request) {
|
|||||||
supplier: true,
|
supplier: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
(prisma as any).productSync.findMany({
|
prisma.productSync.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
@ -52,7 +52,7 @@ export async function GET(req: Request) {
|
|||||||
supplierName: c.supplier.companyName,
|
supplierName: c.supplier.companyName,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
})),
|
})),
|
||||||
...productSyncEvents.map((p: any) => ({
|
...productSyncEvents.map((p: (typeof productSyncEvents)[number]) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
type: "PRODUCT_SYNC" as AdminEventType,
|
type: "PRODUCT_SYNC" as AdminEventType,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all accepted product syncs
|
// Get all accepted product syncs
|
||||||
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
const acceptedSyncs = await prisma.productSync.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: "ACCEPTED",
|
status: "ACCEPTED",
|
||||||
},
|
},
|
||||||
@ -31,7 +31,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncsByStore = new Map<string, Set<string>>()
|
const syncsByStore = new Map<string, Set<string>>()
|
||||||
acceptedSyncs.forEach((sync: any) => {
|
acceptedSyncs.forEach((sync: (typeof acceptedSyncs)[number]) => {
|
||||||
if (!syncsByStore.has(sync.storeId)) {
|
if (!syncsByStore.has(sync.storeId)) {
|
||||||
syncsByStore.set(sync.storeId, new Set())
|
syncsByStore.set(sync.storeId, new Set())
|
||||||
}
|
}
|
||||||
@ -69,7 +69,7 @@ export async function GET() {
|
|||||||
|
|
||||||
if (!Array.isArray(lineItems)) return false
|
if (!Array.isArray(lineItems)) return false
|
||||||
|
|
||||||
return lineItems.some((item: any) => {
|
return lineItems.some((item: { product_id?: number }) => {
|
||||||
const productId = item.product_id?.toString()
|
const productId = item.product_id?.toString()
|
||||||
return productId && syncedProductIds.has(productId)
|
return productId && syncedProductIds.has(productId)
|
||||||
})
|
})
|
||||||
@ -80,7 +80,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const transformedOrders = relevantOrders.map((order: typeof relevantOrders[0]) => {
|
const transformedOrders = relevantOrders.map((order: typeof relevantOrders[0]) => {
|
||||||
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
||||||
let lineItems: any[] = []
|
let lineItems: { product_id?: number }[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
lineItems =
|
lineItems =
|
||||||
@ -91,7 +91,7 @@ export async function GET() {
|
|||||||
lineItems = []
|
lineItems = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncedLineItems = lineItems.filter((item: any) => {
|
const syncedLineItems = lineItems.filter((item: { product_id?: number }) => {
|
||||||
const productId = item.product_id?.toString()
|
const productId = item.product_id?.toString()
|
||||||
return productId && syncedProductIds.has(productId)
|
return productId && syncedProductIds.has(productId)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
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) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
@ -11,9 +18,13 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
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: {
|
where: {
|
||||||
...(status && { status }),
|
...(status && { status }),
|
||||||
},
|
},
|
||||||
|
|||||||
104
src/app/api/chat/attach-orders/route.ts
Normal file
104
src/app/api/chat/attach-orders/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/app/api/chat/attach-products/route.ts
Normal file
92
src/app/api/chat/attach-products/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/app/api/chat/contacts/route.ts
Normal file
102
src/app/api/chat/contacts/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/chat/messages/route.ts
Normal file
67
src/app/api/chat/messages/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,7 +23,7 @@ export async function GET(req: Request) {
|
|||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const code = searchParams.get("code")
|
const code = searchParams.get("code")
|
||||||
const error = searchParams.get("error")
|
const error = searchParams.get("error")
|
||||||
const state = searchParams.get("state")
|
const _state = searchParams.get("state")
|
||||||
|
|
||||||
// Handle OAuth errors
|
// Handle OAuth errors
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/em
|
|||||||
* Manually trigger email polling and automated replies
|
* Manually trigger email polling and automated replies
|
||||||
* This can be called periodically via cron job or manually
|
* This can be called periodically via cron job or manually
|
||||||
*/
|
*/
|
||||||
export async function POST(req: Request) {
|
export async function POST(_req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { getGmailAccessToken } from "@/lib/gmail"
|
|||||||
*
|
*
|
||||||
* Note: Gmail watch requests expire after 7 days and need to be renewed.
|
* 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 {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
|||||||
50
src/app/api/juno-engine/flagged-emails/[id]/route.ts
Normal file
50
src/app/api/juno-engine/flagged-emails/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/app/api/juno-engine/flagged-emails/route.ts
Normal file
119
src/app/api/juno-engine/flagged-emails/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -160,7 +160,7 @@ export async function PUT(
|
|||||||
"mood" = COALESCE(${mood}, "mood"),
|
"mood" = COALESCE(${mood}, "mood"),
|
||||||
"description" = COALESCE(${description ?? null}, "description"),
|
"description" = COALESCE(${description ?? null}, "description"),
|
||||||
"body" = COALESCE(${templateBody}, "body"),
|
"body" = COALESCE(${templateBody}, "body"),
|
||||||
"variables" = COALESCE(${variables as any}, "variables"),
|
"variables" = COALESCE(${variables as Record<string, unknown> | null}, "variables"),
|
||||||
"isHtml" = COALESCE(${isHtml}, "isHtml"),
|
"isHtml" = COALESCE(${isHtml}, "isHtml"),
|
||||||
"updatedAt" = NOW()
|
"updatedAt" = NOW()
|
||||||
WHERE "id" = ${existing.id}
|
WHERE "id" = ${existing.id}
|
||||||
|
|||||||
@ -150,8 +150,7 @@ export async function POST(req: Request) {
|
|||||||
INSERT INTO "Template"
|
INSERT INTO "Template"
|
||||||
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
|
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
|
||||||
VALUES
|
VALUES
|
||||||
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as
|
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as Record<string, unknown> | null}, ${isHtml ?? true}, NOW(), NOW())
|
||||||
any}, ${isHtml ?? true}, NOW(), NOW())
|
|
||||||
RETURNING
|
RETURNING
|
||||||
"id",
|
"id",
|
||||||
"storeId",
|
"storeId",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ function normalizeSubject(subject: string | null): string {
|
|||||||
// Remove repeated "Re:" / "Fwd:" prefixes
|
// Remove repeated "Re:" / "Fwd:" prefixes
|
||||||
// e.g. "Re: Re: Hello" -> "Hello"
|
// e.g. "Re: Re: Hello" -> "Hello"
|
||||||
// Case-insensitive, allow localized variants in future by trimming until no prefix matches
|
// Case-insensitive, allow localized variants in future by trimming until no prefix matches
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const match = s.match(/^(re|fw|fwd):\s*/i)
|
const match = s.match(/^(re|fw|fwd):\s*/i)
|
||||||
if (!match) break
|
if (!match) break
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export async function DELETE(
|
|||||||
const { sessionId } = await params
|
const { sessionId } = await params
|
||||||
|
|
||||||
// Verify the session belongs to the user
|
// Verify the session belongs to the user
|
||||||
const userSession = await (prisma as any).session.findFirst({
|
const userSession = await prisma.session.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
userId: session.user.id
|
userId: session.user.id
|
||||||
@ -27,7 +27,7 @@ export async function DELETE(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete the session
|
// Delete the session
|
||||||
await (prisma as any).session.delete({
|
await prisma.session.delete({
|
||||||
where: { id: sessionId }
|
where: { id: sessionId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -16,13 +16,13 @@ export async function GET() {
|
|||||||
cookieStore.get("__Secure-authjs.session-token")?.value
|
cookieStore.get("__Secure-authjs.session-token")?.value
|
||||||
|
|
||||||
// Fetch all sessions for the user
|
// 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 },
|
where: { userId: session.user.id },
|
||||||
orderBy: { expires: "desc" }
|
orderBy: { expires: "desc" }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Transform sessions to include additional info
|
// 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,
|
id: s.id,
|
||||||
sessionToken: s.sessionToken,
|
sessionToken: s.sessionToken,
|
||||||
expires: s.expires.toISOString(),
|
expires: s.expires.toISOString(),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { auth } from "@/auth"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||||
|
import type { FaqItem } from "@prisma/client"
|
||||||
|
|
||||||
type FaqImportItem = {
|
type FaqImportItem = {
|
||||||
question: string
|
question: string
|
||||||
@ -91,7 +92,7 @@ export async function POST(
|
|||||||
|
|
||||||
const createdFaqs = await prisma.$transaction(
|
const createdFaqs = await prisma.$transaction(
|
||||||
validItems.map((item: FaqImportItem) =>
|
validItems.map((item: FaqImportItem) =>
|
||||||
(prisma as any).faqItem.create({
|
prisma.faqItem.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
question: item.question,
|
question: item.question,
|
||||||
@ -103,12 +104,12 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const texts = createdFaqs.map(
|
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)
|
const embeddings = await embedBatch(texts)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
createdFaqs.map((faq, index) => {
|
createdFaqs.map((faq: FaqItem, index: number) => {
|
||||||
const embedding = embeddings[index]
|
const embedding = embeddings[index]
|
||||||
if (!embedding || !embedding.length) return Promise.resolve()
|
if (!embedding || !embedding.length) return Promise.resolve()
|
||||||
|
|
||||||
@ -129,7 +130,7 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
faq.storeId,
|
faq.storeId,
|
||||||
faq.id,
|
faq.id as string,
|
||||||
`${faq.question}\n${faq.answer}`,
|
`${faq.question}\n${faq.answer}`,
|
||||||
{
|
{
|
||||||
question: faq.question,
|
question: faq.question,
|
||||||
|
|||||||
28
src/app/api/user/chat-setup/route.ts
Normal file
28
src/app/api/user/chat-setup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/dashboard/chat/page.tsx
Normal file
11
src/app/dashboard/chat/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -64,7 +64,7 @@ const faqs: FAQItem[] = [
|
|||||||
{
|
{
|
||||||
id: "6",
|
id: "6",
|
||||||
question: "How do I export my data?",
|
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"
|
category: "general"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import {
|
import {
|
||||||
Flag,
|
Flag,
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
Mail,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
CheckCircle2
|
CheckCircle2
|
||||||
@ -25,7 +23,46 @@ interface FlaggedEmail {
|
|||||||
export default function FlaggedEmailPage() {
|
export default function FlaggedEmailPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
const [flaggedEmails, setFlaggedEmails] = useState<FlaggedEmail[]>([])
|
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 (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
<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" />
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||||
<p className="font-medium animate-pulse">Loading flagged emails...</p>
|
<p className="font-medium animate-pulse">Loading flagged emails...</p>
|
||||||
</div>
|
</div>
|
||||||
) : flaggedEmails.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||||
<Flag className="w-10 h-10 opacity-20" />
|
<Flag className="w-10 h-10 opacity-20" />
|
||||||
<p className="font-medium">No flagged emails found.</p>
|
<p className="font-medium">No flagged emails found.</p>
|
||||||
@ -73,7 +110,7 @@ export default function FlaggedEmailPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<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">
|
<tr key={email.id} className="hover:bg-white/5 transition-colors group">
|
||||||
<td className="px-4 md:px-8 py-3 md:py-4">
|
<td className="px-4 md:px-8 py-3 md:py-4">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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" />
|
<Archive className="w-4 h-4" />
|
||||||
</button>
|
</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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -36,8 +36,7 @@ export default async function DashboardLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Root container fills viewport and only hides horizontal overflow
|
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-hidden">
|
||||||
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-x-hidden">
|
|
||||||
{/* Desktop Sidebar */}
|
{/* 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">
|
<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 />
|
<Sidebar />
|
||||||
@ -71,9 +70,8 @@ export default async function DashboardLayout({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Fixed-height content area; inner pages manage their own scrolling */}
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-x-hidden p-4 md:p-8">
|
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
|
||||||
<PageTransition>
|
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
Layers
|
Layers
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
|
// Sample data for this legacy view. For live inventory data, use the Inventory page.
|
||||||
const mockInventory = [
|
const mockInventory = [
|
||||||
{ id: "1", title: "Neo Wireless Controller", supplier: "Tokyo Tech Hub", price: "$45.00", stock: 120, status: "SYNCED" },
|
{ 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" },
|
{ 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>
|
||||||
</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="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="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">
|
<div className="p-3 rounded-xl bg-emerald-accent/10 text-emerald-accent">
|
||||||
@ -131,6 +133,12 @@ export default function InventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,7 @@ export default function GlobalSuppliersPage() {
|
|||||||
}, 5000) // Poll every 5 seconds
|
}, 5000) // Poll every 5 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [suppliers]) // Re-run when suppliers change
|
}, [suppliers]) // Re-run when suppliers change
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export default function MySuppliersPage() {
|
|||||||
}, 5000) // Poll every 5 seconds
|
}, 5000) // Poll every 5 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [connections]) // Re-run when connections change
|
}, [connections]) // Re-run when connections change
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
122
src/app/forgot-password/page.tsx
Normal file
122
src/app/forgot-password/page.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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"
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{requiresMFA && (
|
{requiresMFA && (
|
||||||
|
|||||||
175
src/app/reset-password/page.tsx
Normal file
175
src/app/reset-password/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/app/supplier/chat/page.tsx
Normal file
7
src/app/supplier/chat/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ChatView } from "@/components/chat/ChatView"
|
||||||
|
|
||||||
|
export default function SupplierChatPage() {
|
||||||
|
return <ChatView />
|
||||||
|
}
|
||||||
@ -40,13 +40,13 @@ export default async function SupplierLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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">
|
<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 />
|
<SupplierSidebar />
|
||||||
</div>
|
</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">
|
<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">
|
<div className="flex items-center gap-2 md:gap-4 flex-1 min-w-0">
|
||||||
<MobileSupplierSidebar />
|
<MobileSupplierSidebar />
|
||||||
@ -61,9 +61,11 @@ export default async function SupplierLayout({
|
|||||||
<UserAccountMenu />
|
<UserAccountMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 md:p-8 scroll-smooth" data-scroll-container>
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<PageTransition>
|
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
|
||||||
{children}
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -14,6 +14,15 @@ import {
|
|||||||
AlertCircle
|
AlertCircle
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell
|
||||||
|
} from "recharts"
|
||||||
|
|
||||||
export default function SupplierDashboardPage() {
|
export default function SupplierDashboardPage() {
|
||||||
const [stats, setStats] = useState({
|
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">
|
<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>
|
<h3 className="text-lg font-bold text-white mb-4">Performance Overview</h3>
|
||||||
<p className="text-sm text-slate-400 mb-4">
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
Track your supplier performance metrics and growth over time.
|
Key metrics at a glance.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-center py-8 text-slate-500">
|
<div className="h-64 w-full">
|
||||||
<TrendingUp className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<p className="text-sm">Detailed charts coming soon</p>
|
<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>
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">Products, connected vendors, pending connections, and new activity (last 7 days)</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
5
src/app/supplier/vendors/page.tsx
vendored
5
src/app/supplier/vendors/page.tsx
vendored
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from "react"
|
import { useEffect, useState, Suspense } from "react"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@ -54,6 +54,7 @@ interface ProductSync {
|
|||||||
|
|
||||||
function SupplierVendorsContent() {
|
function SupplierVendorsContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
const [connections, setConnections] = useState<Connection[]>([])
|
const [connections, setConnections] = useState<Connection[]>([])
|
||||||
const [productSyncs, setProductSyncs] = useState<ProductSync[]>([])
|
const [productSyncs, setProductSyncs] = useState<ProductSync[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@ -201,6 +202,8 @@ function SupplierVendorsContent() {
|
|||||||
setInvitationToken("")
|
setInvitationToken("")
|
||||||
setShowTokenInput(false)
|
setShowTokenInput(false)
|
||||||
fetchConnections()
|
fetchConnections()
|
||||||
|
// Send supplier to JUNO CHAT so encryption keys auto-setup on first open
|
||||||
|
setTimeout(() => router.push("/supplier/chat"), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to accept invitation")
|
setError(err instanceof Error ? err.message : "Failed to accept invitation")
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
113
src/app/verify-email/page.tsx
Normal file
113
src/app/verify-email/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -26,6 +26,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
if (token.role) {
|
if (token.role) {
|
||||||
session.user.role = token.role as "VENDOR" | "SUPPLIER" | "ADMIN"
|
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
|
return session
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,12 +18,14 @@ export function MobileSidebar() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
const timer = setTimeout(() => setMounted(true), 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Close sidebar when route changes
|
// Close sidebar when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(false)
|
const timer = setTimeout(() => setIsOpen(false), 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
// Prevent body scroll when sidebar is open
|
// Prevent body scroll when sidebar is open
|
||||||
|
|||||||
@ -12,7 +12,8 @@ export function MobileSupplierSidebar() {
|
|||||||
|
|
||||||
// Close sidebar when route changes
|
// Close sidebar when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsOpen(false)
|
const timer = setTimeout(() => setIsOpen(false), 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
// Prevent body scroll when sidebar is open
|
// Prevent body scroll when sidebar is open
|
||||||
|
|||||||
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { ReactNode } from "react"
|
import { ReactNode } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface PageTransitionProps {
|
interface PageTransitionProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageTransition({ children }: PageTransitionProps) {
|
export default function PageTransition({ children, className }: PageTransitionProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
@ -22,6 +24,7 @@ export default function PageTransition({ children }: PageTransitionProps) {
|
|||||||
transform: 'translateZ(0)',
|
transform: 'translateZ(0)',
|
||||||
WebkitTransform: 'translateZ(0)'
|
WebkitTransform: 'translateZ(0)'
|
||||||
}}
|
}}
|
||||||
|
className={cn("flex-1 flex flex-col min-h-0", className)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -73,6 +73,12 @@ const routes = [
|
|||||||
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
|
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
|
||||||
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },
|
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "JUNO CHAT",
|
||||||
|
icon: Mail,
|
||||||
|
href: "/dashboard/chat",
|
||||||
|
color: "text-pink-500",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,8 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Mail,
|
||||||
Zap,
|
Zap,
|
||||||
FileText,
|
|
||||||
TrendingUp
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -40,11 +38,11 @@ const supplierRoutes = [
|
|||||||
color: "text-purple-500",
|
color: "text-purple-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: "JUNO CHAT",
|
||||||
icon: Settings,
|
icon: Mail,
|
||||||
href: "/supplier/settings",
|
href: "/supplier/chat",
|
||||||
color: "text-slate-400",
|
color: "text-pink-500",
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SupplierSidebar() {
|
export function SupplierSidebar() {
|
||||||
|
|||||||
@ -40,8 +40,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
| ThemeMode
|
| ThemeMode
|
||||||
| null
|
| null
|
||||||
if (stored === "light" || stored === "default") {
|
if (stored === "light" || stored === "default") {
|
||||||
setThemeState(stored)
|
const timer = setTimeout(() => {
|
||||||
applyTheme(stored)
|
setThemeState(stored)
|
||||||
|
applyTheme(stored)
|
||||||
|
}, 0)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
} else {
|
} else {
|
||||||
applyTheme("default")
|
applyTheme("default")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,8 @@ export default function UserAccountMenu() {
|
|||||||
|
|
||||||
const handleSettings = () => {
|
const handleSettings = () => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
router.push("/dashboard/settings")
|
const settingsPath = session?.user?.role === "SUPPLIER" ? "/supplier/settings" : "/dashboard/settings"
|
||||||
|
router.push(settingsPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
805
src/components/chat/ChatView.tsx
Normal file
805
src/components/chat/ChatView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
src/hooks/useChatCrypto.ts
Normal file
62
src/hooks/useChatCrypto.ts
Normal 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
164
src/lib/chat-crypto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { PDFParse } from "pdf-parse"
|
||||||
|
import mammoth from "mammoth"
|
||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
|
|
||||||
export type RawDocument = {
|
export type RawDocument = {
|
||||||
@ -20,15 +22,31 @@ export type TextChunk = {
|
|||||||
const DEFAULT_CHUNK_SIZE = 1000
|
const DEFAULT_CHUNK_SIZE = 1000
|
||||||
const DEFAULT_CHUNK_OVERLAP = 200
|
const DEFAULT_CHUNK_OVERLAP = 200
|
||||||
|
|
||||||
// Placeholder PDF/DOCX extraction – swap in real libraries (e.g. pdf-parse, docx) later.
|
const PDF_MIME = "application/pdf"
|
||||||
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
|
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
try {
|
const DOC_MIME = "application/msword"
|
||||||
const buffer = await fs.readFile(storagePath)
|
|
||||||
const text = buffer.toString("utf8")
|
|
||||||
|
|
||||||
// In a real implementation, branch on mimeType and use appropriate parsers.
|
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
|
||||||
// For now, treat as UTF-8 text for a first iteration.
|
const absolutePath = path.resolve(storagePath)
|
||||||
return text
|
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) {
|
} catch (error) {
|
||||||
logger.error("Failed to extract text from file", { storagePath, mimeType, error })
|
logger.error("Failed to extract text from file", { storagePath, mimeType, error })
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@ -8,20 +8,20 @@ function log(level: LogLevel, message: string, meta?: unknown) {
|
|||||||
switch (level) {
|
switch (level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
if (!isProd) {
|
if (!isProd) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.debug(...payload)
|
console.debug(...payload)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case "info":
|
case "info":
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.info(...payload)
|
console.info(...payload)
|
||||||
break
|
break
|
||||||
case "warn":
|
case "warn":
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(...payload)
|
console.warn(...payload)
|
||||||
break
|
break
|
||||||
case "error":
|
case "error":
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(...payload)
|
console.error(...payload)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/types/next-auth.d.ts
vendored
1
src/types/next-auth.d.ts
vendored
@ -6,6 +6,7 @@ declare module "next-auth" {
|
|||||||
user: {
|
user: {
|
||||||
id: string
|
id: string
|
||||||
role: UserRole
|
role: UserRole
|
||||||
|
chatPublicKey?: string | null
|
||||||
} & DefaultSession["user"]
|
} & DefaultSession["user"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user