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",
|
||||
"framer-motion": "^12.33.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otplib": "^13.2.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^7.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@ -2212,6 +2214,176 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@ -4222,6 +4394,14 @@
|
||||
"uncrypto": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@ -4545,6 +4725,25 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||
@ -4561,6 +4760,11 @@
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
@ -4857,6 +5061,11 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -5160,6 +5369,11 @@
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@ -5183,6 +5397,14 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -6482,6 +6704,11 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
@ -6516,6 +6743,11 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@ -7058,6 +7290,17 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@ -7098,6 +7341,14 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@ -7386,6 +7637,16 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@ -7425,6 +7686,45 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz",
|
||||
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bluebird": "~3.4.0",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -7873,6 +8173,11 @@
|
||||
"node >= 0.4.6"
|
||||
]
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@ -7958,6 +8263,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@ -7978,6 +8288,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@ -7997,6 +8315,36 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
@ -8267,6 +8615,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@ -8431,6 +8784,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@ -8653,6 +9025,11 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
@ -8766,6 +9143,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@ -8940,6 +9322,11 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
@ -8972,6 +9359,14 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@ -9487,6 +9882,11 @@
|
||||
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@ -9573,6 +9973,11 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz",
|
||||
|
||||
@ -27,10 +27,12 @@
|
||||
"dotenv": "^17.2.4",
|
||||
"framer-motion": "^12.33.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"nodemailer": "^7.0.7",
|
||||
"otplib": "^13.2.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^7.3.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
@ -9,6 +9,6 @@ export default defineConfig({
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
url: process.env["DATABASE_URL"]!,
|
||||
},
|
||||
});
|
||||
|
||||
@ -27,7 +27,11 @@ model User {
|
||||
mfaEnabled Boolean @default(false)
|
||||
mfaSecret String? // Encrypted TOTP secret
|
||||
|
||||
chatPublicKey String?
|
||||
|
||||
// Relations
|
||||
sentMessages ChatMessage[] @relation("SentMessages")
|
||||
receivedMessages ChatMessage[] @relation("ReceivedMessages")
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
stores Store[] // Changed from singular to support multiple stores
|
||||
@ -109,6 +113,7 @@ model Store {
|
||||
faqItems FaqItem[]
|
||||
knowledgeChunks KnowledgeChunk[]
|
||||
emailLogs EmailLog[]
|
||||
flaggedEmails FlaggedEmail[]
|
||||
templates Template[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@ -383,6 +388,28 @@ model EmailLog {
|
||||
@@index([userId, sentAt])
|
||||
}
|
||||
|
||||
enum FlaggedEmailPriority {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
model FlaggedEmail {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
subject String @db.Text
|
||||
from String // Sender email
|
||||
reason String @db.Text
|
||||
priority FlaggedEmailPriority @default(MEDIUM)
|
||||
messageId String? // Gmail message id for future sync
|
||||
flaggedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([storeId])
|
||||
@@index([storeId, flaggedAt])
|
||||
}
|
||||
|
||||
enum KnowledgeDocumentStatus {
|
||||
UPLOADED
|
||||
PROCESSING
|
||||
@ -482,3 +509,21 @@ model Template {
|
||||
|
||||
@@index([storeId])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
senderId String
|
||||
receiverId String
|
||||
encryptedContent String @db.Text
|
||||
encryptedKeyForSender String @db.Text
|
||||
encryptedKeyForReceiver String @db.Text
|
||||
iv String
|
||||
|
||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([senderId, receiverId])
|
||||
@@index([receiverId, senderId])
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Link2 } from "lucide-react"
|
||||
|
||||
interface AdminConnection {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
store: {
|
||||
businessName: string
|
||||
shopifyStoreName: string
|
||||
@ -17,12 +18,14 @@ interface AdminConnection {
|
||||
}
|
||||
|
||||
async function getConnections(): Promise<AdminConnection[]> {
|
||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, {
|
||||
cache: "no-store",
|
||||
const connections = await prisma.connection.findMany({
|
||||
include: {
|
||||
store: { select: { businessName: true, shopifyStoreName: true } },
|
||||
supplier: { select: { companyName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { connections: AdminConnection[] }
|
||||
return data.connections
|
||||
return connections as unknown as AdminConnection[]
|
||||
}
|
||||
|
||||
export default async function AdminConnectionsPage() {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Mail } from "lucide-react"
|
||||
|
||||
@ -12,7 +13,7 @@ interface AdminEmailLog {
|
||||
status: string
|
||||
providerMessageId: string | null
|
||||
errorMessage: string | null
|
||||
sentAt: string
|
||||
sentAt: Date
|
||||
store: {
|
||||
businessName: string
|
||||
shopifyStoreName: string
|
||||
@ -20,15 +21,17 @@ interface AdminEmailLog {
|
||||
}
|
||||
|
||||
async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> {
|
||||
// Use a relative URL so the admin's session cookies are forwarded
|
||||
const res = await fetch("/api/admin/emails?limit=100", {
|
||||
cache: "no-store",
|
||||
})
|
||||
if (!res.ok) {
|
||||
return { emails: [], total: 0 }
|
||||
}
|
||||
const data = (await res.json()) as { emails: AdminEmailLog[]; total: number }
|
||||
return data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prismaAny = prisma as any
|
||||
const [emails, total] = await Promise.all([
|
||||
prismaAny.emailLog.findMany({
|
||||
include: { store: { select: { businessName: true, shopifyStoreName: true } } },
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: 100,
|
||||
}),
|
||||
prismaAny.emailLog.count(),
|
||||
])
|
||||
return { emails: emails as AdminEmailLog[], total }
|
||||
}
|
||||
|
||||
export default async function AdminEmailsPage() {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Activity, Link2, Radar, Shuffle } from "lucide-react"
|
||||
|
||||
@ -14,26 +15,59 @@ interface AdminEvent {
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
async function getEvents(type?: AdminEventType): Promise<AdminEvent[]> {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set("limit", "50")
|
||||
if (type) {
|
||||
searchParams.set("type", type)
|
||||
}
|
||||
const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([
|
||||
prisma.connection.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: LIMIT,
|
||||
include: { store: true, supplier: true },
|
||||
}),
|
||||
prisma.productSync.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: LIMIT,
|
||||
include: { store: true, supplier: true },
|
||||
}),
|
||||
prisma.syncMetadata.findMany({
|
||||
orderBy: { lastSyncAt: "desc" },
|
||||
take: LIMIT,
|
||||
include: { store: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
)
|
||||
const events: AdminEvent[] = [
|
||||
...connectionEvents.map((c) => ({
|
||||
id: c.id,
|
||||
type: "CONNECTION" as AdminEventType,
|
||||
status: c.status,
|
||||
timestamp: c.createdAt.toISOString(),
|
||||
storeName: c.store.shopifyStoreName,
|
||||
supplierName: c.supplier.companyName,
|
||||
metadata: {},
|
||||
})),
|
||||
...productSyncEvents.map((p) => ({
|
||||
id: p.id,
|
||||
type: "PRODUCT_SYNC" as AdminEventType,
|
||||
status: p.status,
|
||||
timestamp: p.createdAt.toISOString(),
|
||||
storeName: p.store.shopifyStoreName,
|
||||
supplierName: p.supplier.companyName,
|
||||
metadata: { productTitle: p.shopifyProductTitle, shopifyProductId: p.shopifyProductId },
|
||||
})),
|
||||
...syncHealthEvents.map((s) => ({
|
||||
id: s.id,
|
||||
type: "SYNC_HEALTH" as AdminEventType,
|
||||
status: s.syncStatus,
|
||||
timestamp: s.lastSyncAt.toISOString(),
|
||||
storeName: s.store.shopifyStoreName,
|
||||
supplierName: null as string | null,
|
||||
metadata: { resourceType: s.resourceType, recordCount: s.recordCount },
|
||||
})),
|
||||
].filter((e) => !type || e.type === type)
|
||||
|
||||
if (!res.ok) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { events: AdminEvent[] }
|
||||
return data.events
|
||||
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
return events.slice(0, LIMIT)
|
||||
}
|
||||
|
||||
function typeBadge(eventType: AdminEventType) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { ShoppingCart } from "lucide-react"
|
||||
|
||||
@ -10,7 +11,7 @@ interface AdminOrder {
|
||||
currency: string | null
|
||||
financialStatus: string | null
|
||||
fulfillmentStatus: string | null
|
||||
createdAt: string
|
||||
createdAt: Date
|
||||
store: {
|
||||
businessName: string
|
||||
shopifyStoreName: string
|
||||
@ -18,12 +19,57 @@ interface AdminOrder {
|
||||
}
|
||||
|
||||
async function getOrders(): Promise<AdminOrder[]> {
|
||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, {
|
||||
cache: "no-store",
|
||||
const acceptedSyncs = await prisma.productSync.findMany({
|
||||
where: { status: "ACCEPTED" },
|
||||
select: { shopifyProductId: true, storeId: true },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { orders: AdminOrder[] }
|
||||
return data.orders
|
||||
if (acceptedSyncs.length === 0) return []
|
||||
|
||||
const syncsByStore = new Map<string, Set<string>>()
|
||||
acceptedSyncs.forEach((sync) => {
|
||||
if (!syncsByStore.has(sync.storeId)) syncsByStore.set(sync.storeId, new Set())
|
||||
syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId)
|
||||
})
|
||||
const storeIds = Array.from(syncsByStore.keys())
|
||||
|
||||
const cachedOrders = await prisma.cachedOrder.findMany({
|
||||
where: { storeId: { in: storeIds } },
|
||||
include: {
|
||||
store: { select: { id: true, businessName: true, shopifyStoreName: true } },
|
||||
},
|
||||
orderBy: { shopifyCreatedAt: "desc" },
|
||||
})
|
||||
|
||||
const relevantOrders = cachedOrders.filter((order) => {
|
||||
const syncedProductIds = syncsByStore.get(order.storeId)
|
||||
if (!syncedProductIds?.size) return false
|
||||
try {
|
||||
const lineItems =
|
||||
typeof order.lineItems === "string" ? JSON.parse(order.lineItems) : order.lineItems
|
||||
if (!Array.isArray(lineItems)) return false
|
||||
return lineItems.some(
|
||||
(item: { product_id?: number }) =>
|
||||
item.product_id != null && syncedProductIds.has(String(item.product_id))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return relevantOrders.map((order) => {
|
||||
const store = order.store as { id: string; businessName: string; shopifyStoreName: string }
|
||||
return {
|
||||
id: order.shopifyOrderId,
|
||||
orderNumber: order.orderNumber,
|
||||
email: order.email,
|
||||
totalPrice: order.totalPrice,
|
||||
currency: order.currency,
|
||||
financialStatus: order.financialStatus,
|
||||
fulfillmentStatus: order.fulfillmentStatus,
|
||||
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||
store: { businessName: store.businessName, shopifyStoreName: store.shopifyStoreName },
|
||||
}
|
||||
}) as AdminOrder[]
|
||||
}
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
@ -109,7 +155,7 @@ export default async function AdminOrdersPage() {
|
||||
{order.fulfillmentStatus || "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||
{new Date(order.createdAt).toLocaleString("en-US", {
|
||||
{new Date(order.createdAt as Date).toLocaleString("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
|
||||
@ -69,11 +69,10 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}) as unknown as Promise<ConnectionGroup[]>,
|
||||
// Use `as any` to be compatible with the current Prisma client setup
|
||||
(prisma as any).productSync.groupBy({
|
||||
prisma.productSync.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}) as Promise<ProductSyncGroup[]>,
|
||||
} as Parameters<typeof prisma.productSync.groupBy>[0]) as unknown as Promise<ProductSyncGroup[]>,
|
||||
prisma.cachedOrder.count(),
|
||||
prisma.syncMetadata.findMany({
|
||||
where: { syncStatus: { in: ["failed"] } },
|
||||
@ -81,7 +80,7 @@ async function getAdminOverview(): Promise<AdminOverviewResult> {
|
||||
take: 5,
|
||||
include: { store: true },
|
||||
}) as Promise<RecentSyncError[]>,
|
||||
(prisma as any).productSync.findMany({
|
||||
prisma.productSync.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
include: {
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Shuffle } from "lucide-react"
|
||||
|
||||
interface AdminProductSync {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string
|
||||
acceptedAt: string | null
|
||||
rejectedAt: string | null
|
||||
createdAt: Date
|
||||
acceptedAt: Date | null
|
||||
rejectedAt: Date | null
|
||||
shopifyProductId: string
|
||||
shopifyProductTitle: string
|
||||
store: {
|
||||
@ -20,12 +21,14 @@ interface AdminProductSync {
|
||||
}
|
||||
|
||||
async function getProductSyncs(): Promise<AdminProductSync[]> {
|
||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, {
|
||||
cache: "no-store",
|
||||
const syncs = await prisma.productSync.findMany({
|
||||
include: {
|
||||
store: { select: { businessName: true, shopifyStoreName: true } },
|
||||
supplier: { select: { companyName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { syncs: AdminProductSync[] }
|
||||
return data.syncs
|
||||
return syncs as unknown as AdminProductSync[]
|
||||
}
|
||||
|
||||
export default async function AdminProductSyncsPage() {
|
||||
@ -113,9 +116,7 @@ export default async function AdminProductSyncsPage() {
|
||||
</td>
|
||||
<td className="py-3 pr-4 align-top text-xs text-slate-400">
|
||||
{sync.acceptedAt || sync.rejectedAt
|
||||
? new Date(
|
||||
(sync.acceptedAt || sync.rejectedAt) as string,
|
||||
).toLocaleString("en-US", {
|
||||
? new Date(sync.acceptedAt || sync.rejectedAt!).toLocaleString("en-US", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { redirect } from "next/navigation"
|
||||
import { Activity, Radar } from "lucide-react"
|
||||
|
||||
@ -16,12 +17,15 @@ interface AdminSyncMetadata {
|
||||
}
|
||||
|
||||
async function getSyncHealth(): Promise<AdminSyncMetadata[]> {
|
||||
const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, {
|
||||
cache: "no-store",
|
||||
const rows = await prisma.syncMetadata.findMany({
|
||||
include: {
|
||||
store: {
|
||||
select: { businessName: true, shopifyStoreName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { lastSyncAt: "desc" },
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as { syncs: AdminSyncMetadata[] }
|
||||
return data.syncs
|
||||
return rows as unknown as AdminSyncMetadata[]
|
||||
}
|
||||
|
||||
export default async function AdminSyncHealthPage() {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { ConnectionStatus } from "@prisma/client"
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
@ -15,7 +16,7 @@ export async function GET(req: Request) {
|
||||
|
||||
const connections = await prisma.connection.findMany({
|
||||
where: {
|
||||
...(status && { status: status as any }),
|
||||
...(status && { status: status as ConnectionStatus }),
|
||||
},
|
||||
include: {
|
||||
store: true,
|
||||
|
||||
@ -25,7 +25,7 @@ export async function GET(req: Request) {
|
||||
supplier: true,
|
||||
},
|
||||
}),
|
||||
(prisma as any).productSync.findMany({
|
||||
prisma.productSync.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
@ -52,7 +52,7 @@ export async function GET(req: Request) {
|
||||
supplierName: c.supplier.companyName,
|
||||
metadata: {},
|
||||
})),
|
||||
...productSyncEvents.map((p: any) => ({
|
||||
...productSyncEvents.map((p: (typeof productSyncEvents)[number]) => ({
|
||||
id: p.id,
|
||||
type: "PRODUCT_SYNC" as AdminEventType,
|
||||
status: p.status,
|
||||
|
||||
@ -11,7 +11,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
// Get all accepted product syncs
|
||||
const acceptedSyncs = await (prisma as any).productSync.findMany({
|
||||
const acceptedSyncs = await prisma.productSync.findMany({
|
||||
where: {
|
||||
status: "ACCEPTED",
|
||||
},
|
||||
@ -31,7 +31,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const syncsByStore = new Map<string, Set<string>>()
|
||||
acceptedSyncs.forEach((sync: any) => {
|
||||
acceptedSyncs.forEach((sync: (typeof acceptedSyncs)[number]) => {
|
||||
if (!syncsByStore.has(sync.storeId)) {
|
||||
syncsByStore.set(sync.storeId, new Set())
|
||||
}
|
||||
@ -69,7 +69,7 @@ export async function GET() {
|
||||
|
||||
if (!Array.isArray(lineItems)) return false
|
||||
|
||||
return lineItems.some((item: any) => {
|
||||
return lineItems.some((item: { product_id?: number }) => {
|
||||
const productId = item.product_id?.toString()
|
||||
return productId && syncedProductIds.has(productId)
|
||||
})
|
||||
@ -80,7 +80,7 @@ export async function GET() {
|
||||
|
||||
const transformedOrders = relevantOrders.map((order: typeof relevantOrders[0]) => {
|
||||
const syncedProductIds = syncsByStore.get(order.storeId) || new Set()
|
||||
let lineItems: any[] = []
|
||||
let lineItems: { product_id?: number }[] = []
|
||||
|
||||
try {
|
||||
lineItems =
|
||||
@ -91,7 +91,7 @@ export async function GET() {
|
||||
lineItems = []
|
||||
}
|
||||
|
||||
const syncedLineItems = lineItems.filter((item: any) => {
|
||||
const syncedLineItems = lineItems.filter((item: { product_id?: number }) => {
|
||||
const productId = item.product_id?.toString()
|
||||
return productId && syncedProductIds.has(productId)
|
||||
})
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { ProductSyncStatus } from "@prisma/client"
|
||||
|
||||
const VALID_STATUSES: ProductSyncStatus[] = [
|
||||
ProductSyncStatus.PENDING,
|
||||
ProductSyncStatus.ACCEPTED,
|
||||
ProductSyncStatus.REJECTED,
|
||||
]
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
@ -11,9 +18,13 @@ export async function GET(req: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const status = searchParams.get("status")
|
||||
const statusParam = searchParams.get("status")
|
||||
const status =
|
||||
statusParam && VALID_STATUSES.includes(statusParam as ProductSyncStatus)
|
||||
? (statusParam as ProductSyncStatus)
|
||||
: undefined
|
||||
|
||||
const syncs = await (prisma as any).productSync.findMany({
|
||||
const syncs = await prisma.productSync.findMany({
|
||||
where: {
|
||||
...(status && { status }),
|
||||
},
|
||||
|
||||
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 code = searchParams.get("code")
|
||||
const error = searchParams.get("error")
|
||||
const state = searchParams.get("state")
|
||||
const _state = searchParams.get("state")
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
|
||||
@ -9,7 +9,7 @@ import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/em
|
||||
* Manually trigger email polling and automated replies
|
||||
* This can be called periodically via cron job or manually
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(_req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
|
||||
@ -13,7 +13,7 @@ import { getGmailAccessToken } from "@/lib/gmail"
|
||||
*
|
||||
* Note: Gmail watch requests expire after 7 days and need to be renewed.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(_req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
|
||||
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"),
|
||||
"description" = COALESCE(${description ?? null}, "description"),
|
||||
"body" = COALESCE(${templateBody}, "body"),
|
||||
"variables" = COALESCE(${variables as any}, "variables"),
|
||||
"variables" = COALESCE(${variables as Record<string, unknown> | null}, "variables"),
|
||||
"isHtml" = COALESCE(${isHtml}, "isHtml"),
|
||||
"updatedAt" = NOW()
|
||||
WHERE "id" = ${existing.id}
|
||||
|
||||
@ -150,8 +150,7 @@ export async function POST(req: Request) {
|
||||
INSERT INTO "Template"
|
||||
("id", "storeId", "name", "intent", "mood", "description", "body", "variables", "isHtml", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as
|
||||
any}, ${isHtml ?? true}, NOW(), NOW())
|
||||
(${id}, ${activeStore.id}, ${name}, ${intent}, ${mood}, ${description ?? null}, ${templateBody}, ${variables as Record<string, unknown> | null}, ${isHtml ?? true}, NOW(), NOW())
|
||||
RETURNING
|
||||
"id",
|
||||
"storeId",
|
||||
|
||||
@ -10,7 +10,7 @@ function normalizeSubject(subject: string | null): string {
|
||||
// Remove repeated "Re:" / "Fwd:" prefixes
|
||||
// e.g. "Re: Re: Hello" -> "Hello"
|
||||
// Case-insensitive, allow localized variants in future by trimming until no prefix matches
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
const match = s.match(/^(re|fw|fwd):\s*/i)
|
||||
if (!match) break
|
||||
|
||||
@ -15,7 +15,7 @@ export async function DELETE(
|
||||
const { sessionId } = await params
|
||||
|
||||
// Verify the session belongs to the user
|
||||
const userSession = await (prisma as any).session.findFirst({
|
||||
const userSession = await prisma.session.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
userId: session.user.id
|
||||
@ -27,7 +27,7 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
// Delete the session
|
||||
await (prisma as any).session.delete({
|
||||
await prisma.session.delete({
|
||||
where: { id: sessionId }
|
||||
})
|
||||
|
||||
|
||||
@ -16,13 +16,13 @@ export async function GET() {
|
||||
cookieStore.get("__Secure-authjs.session-token")?.value
|
||||
|
||||
// Fetch all sessions for the user
|
||||
const userSessions = await (prisma as any).session.findMany({
|
||||
const userSessions = await prisma.session.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { expires: "desc" }
|
||||
})
|
||||
|
||||
// Transform sessions to include additional info
|
||||
const sessions = userSessions.map((s: { id: string; sessionToken: string; expires: Date }) => ({
|
||||
const sessions = userSessions.map((s: (typeof userSessions)[number]) => ({
|
||||
id: s.id,
|
||||
sessionToken: s.sessionToken,
|
||||
expires: s.expires.toISOString(),
|
||||
|
||||
@ -3,6 +3,7 @@ import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { embedBatch } from "@/lib/kb/bedrockEmbeddings"
|
||||
import type { FaqItem } from "@prisma/client"
|
||||
|
||||
type FaqImportItem = {
|
||||
question: string
|
||||
@ -91,7 +92,7 @@ export async function POST(
|
||||
|
||||
const createdFaqs = await prisma.$transaction(
|
||||
validItems.map((item: FaqImportItem) =>
|
||||
(prisma as any).faqItem.create({
|
||||
prisma.faqItem.create({
|
||||
data: {
|
||||
storeId: store.id,
|
||||
question: item.question,
|
||||
@ -103,12 +104,12 @@ export async function POST(
|
||||
)
|
||||
|
||||
const texts = createdFaqs.map(
|
||||
(faq) => `${faq.question}\n${faq.answer}`,
|
||||
(faq: { question: string; answer: string }) => `${faq.question}\n${faq.answer}`,
|
||||
)
|
||||
const embeddings = await embedBatch(texts)
|
||||
|
||||
await Promise.all(
|
||||
createdFaqs.map((faq, index) => {
|
||||
createdFaqs.map((faq: FaqItem, index: number) => {
|
||||
const embedding = embeddings[index]
|
||||
if (!embedding || !embedding.length) return Promise.resolve()
|
||||
|
||||
@ -129,7 +130,7 @@ export async function POST(
|
||||
)
|
||||
`,
|
||||
faq.storeId,
|
||||
faq.id,
|
||||
faq.id as string,
|
||||
`${faq.question}\n${faq.answer}`,
|
||||
{
|
||||
question: faq.question,
|
||||
|
||||
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",
|
||||
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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Flag,
|
||||
Search,
|
||||
Filter,
|
||||
Mail,
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import {
|
||||
Flag,
|
||||
Search,
|
||||
Filter,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Archive,
|
||||
CheckCircle2
|
||||
@ -25,7 +23,46 @@ interface FlaggedEmail {
|
||||
export default function FlaggedEmailPage() {
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [flaggedEmails, setFlaggedEmails] = useState<FlaggedEmail[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const fetchFlaggedEmails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("/api/juno-engine/flagged-emails")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFlaggedEmails(data.flaggedEmails ?? [])
|
||||
}
|
||||
} catch {
|
||||
setFlaggedEmails([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchFlaggedEmails()
|
||||
}, [fetchFlaggedEmails])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id)
|
||||
try {
|
||||
const res = await fetch(`/api/juno-engine/flagged-emails/${id}`, { method: "DELETE" })
|
||||
if (res.ok) {
|
||||
setFlaggedEmails((prev) => prev.filter((e) => e.id !== id))
|
||||
}
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = flaggedEmails.filter(
|
||||
(e) =>
|
||||
e.subject.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.from.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.reason.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 md:space-y-6">
|
||||
@ -55,7 +92,7 @@ export default function FlaggedEmailPage() {
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-400" />
|
||||
<p className="font-medium animate-pulse">Loading flagged emails...</p>
|
||||
</div>
|
||||
) : flaggedEmails.length === 0 ? (
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||
<Flag className="w-10 h-10 opacity-20" />
|
||||
<p className="font-medium">No flagged emails found.</p>
|
||||
@ -73,7 +110,7 @@ export default function FlaggedEmailPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{flaggedEmails.map((email) => (
|
||||
{filtered.map((email) => (
|
||||
<tr key={email.id} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="px-4 md:px-8 py-3 md:py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -98,7 +135,12 @@ export default function FlaggedEmailPage() {
|
||||
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-indigo-400 transition-all" title="Archive">
|
||||
<Archive className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all" title="Delete">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(email.id)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg text-slate-400 hover:text-red-400 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -36,8 +36,7 @@ export default async function DashboardLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
// Root container fills viewport and only hides horizontal overflow
|
||||
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-x-hidden">
|
||||
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-slate-900/70 border-r border-white/10 backdrop-blur-xl">
|
||||
<Sidebar />
|
||||
@ -71,9 +70,8 @@ export default async function DashboardLayout({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Fixed-height content area; inner pages manage their own scrolling */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-x-hidden p-4 md:p-8">
|
||||
<PageTransition>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import {
|
||||
Package,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
Layers
|
||||
} from "lucide-react"
|
||||
|
||||
// Sample data for this legacy view. For live inventory data, use the Inventory page.
|
||||
const mockInventory = [
|
||||
{ id: "1", title: "Neo Wireless Controller", supplier: "Tokyo Tech Hub", price: "$45.00", stock: 120, status: "SYNCED" },
|
||||
{ id: "2", title: "Smart Home Hub v2", supplier: "Shenzhen Direct", price: "$89.50", stock: 15, status: "LOW STOCK" },
|
||||
@ -101,7 +103,7 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Footer Placeholder */}
|
||||
{/* Sample analytics – real data is on the Inventory page */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="glass p-6 rounded-2xl border-white/5 flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-emerald-accent/10 text-emerald-accent">
|
||||
@ -131,6 +133,12 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-zinc-500 mt-4">
|
||||
Sample data. View live inventory and sync status on the{" "}
|
||||
<Link href="/dashboard/inventory" className="text-indigo-400 hover:underline font-medium">
|
||||
Inventory page
|
||||
</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -59,7 +59,7 @@ export default function GlobalSuppliersPage() {
|
||||
}, 5000) // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [suppliers]) // Re-run when suppliers change
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -61,7 +61,7 @@ export default function MySuppliersPage() {
|
||||
}, 5000) // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [connections]) // Re-run when connections change
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 text-right">
|
||||
<Link href="/forgot-password" className="text-xs text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresMFA && (
|
||||
|
||||
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 (
|
||||
<div className="h-screen relative font-sans dashboard-glass-dark overflow-x-hidden">
|
||||
<div className="flex h-screen w-full relative font-sans dashboard-glass-dark overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden h-full md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 z-[80] bg-slate-900/70 border-r border-white/10 backdrop-blur-xl">
|
||||
<SupplierSidebar />
|
||||
</div>
|
||||
|
||||
<main className="md:pl-72 h-full flex flex-col">
|
||||
|
||||
<main className="md:pl-72 flex flex-col flex-1 h-full min-w-0">
|
||||
<header className="h-16 px-4 md:px-8 border-b border-white/10 bg-slate-900/50 backdrop-blur-xl flex items-center justify-between flex-shrink-0 z-50">
|
||||
<div className="flex items-center gap-2 md:gap-4 flex-1 min-w-0">
|
||||
<MobileSupplierSidebar />
|
||||
@ -61,9 +61,11 @@ export default async function SupplierLayout({
|
||||
<UserAccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 md:p-8 scroll-smooth" data-scroll-container>
|
||||
<PageTransition>
|
||||
{children}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<PageTransition className="p-4 md:p-8 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -14,6 +14,15 @@ import {
|
||||
AlertCircle
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell
|
||||
} from "recharts"
|
||||
|
||||
export default function SupplierDashboardPage() {
|
||||
const [stats, setStats] = useState({
|
||||
@ -230,12 +239,35 @@ export default function SupplierDashboardPage() {
|
||||
<div className="bg-white/5 rounded-xl p-6 border border-white/10 backdrop-blur-sm">
|
||||
<h3 className="text-lg font-bold text-white mb-4">Performance Overview</h3>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Track your supplier performance metrics and growth over time.
|
||||
Key metrics at a glance.
|
||||
</p>
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<TrendingUp className="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">Detailed charts coming soon</p>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={[
|
||||
{ label: "Products", value: stats.totalProducts, color: "rgb(52 211 153)" },
|
||||
{ label: "Vendors", value: stats.connectedVendors, color: "rgb(147 51 234)" },
|
||||
{ label: "Pending", value: stats.pendingConnections, color: "rgb(251 191 36)" },
|
||||
{ label: "New (7d)", value: stats.recentProducts + stats.recentConnections, color: "rgb(59 130 246)" }
|
||||
]}
|
||||
margin={{ top: 12, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<XAxis dataKey="label" tick={{ fill: "rgb(148 163 184)", fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: "rgb(148 163 184)", fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgb(30 41 59)", border: "1px solid rgb(51 65 85)", borderRadius: "8px" }}
|
||||
labelStyle={{ color: "rgb(248 250 252)" }}
|
||||
formatter={(value: number | undefined) => [value ?? 0, "Count"]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Cell key={i} fill={["rgb(52 211 153)", "rgb(147 51 234)", "rgb(251 191 36)", "rgb(59 130 246)"][i]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">Products, connected vendors, pending connections, and new activity (last 7 days)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
5
src/app/supplier/vendors/page.tsx
vendored
5
src/app/supplier/vendors/page.tsx
vendored
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import {
|
||||
Users,
|
||||
CheckCircle2,
|
||||
@ -54,6 +54,7 @@ interface ProductSync {
|
||||
|
||||
function SupplierVendorsContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const [connections, setConnections] = useState<Connection[]>([])
|
||||
const [productSyncs, setProductSyncs] = useState<ProductSync[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -201,6 +202,8 @@ function SupplierVendorsContent() {
|
||||
setInvitationToken("")
|
||||
setShowTokenInput(false)
|
||||
fetchConnections()
|
||||
// Send supplier to JUNO CHAT so encryption keys auto-setup on first open
|
||||
setTimeout(() => router.push("/supplier/chat"), 1500)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to accept invitation")
|
||||
} finally {
|
||||
|
||||
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) {
|
||||
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
|
||||
},
|
||||
|
||||
@ -18,12 +18,14 @@ export function MobileSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const timer = setTimeout(() => setMounted(true), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
// Close sidebar when route changes
|
||||
useEffect(() => {
|
||||
setIsOpen(false)
|
||||
const timer = setTimeout(() => setIsOpen(false), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pathname])
|
||||
|
||||
// Prevent body scroll when sidebar is open
|
||||
|
||||
@ -12,7 +12,8 @@ export function MobileSupplierSidebar() {
|
||||
|
||||
// Close sidebar when route changes
|
||||
useEffect(() => {
|
||||
setIsOpen(false)
|
||||
const timer = setTimeout(() => setIsOpen(false), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [pathname])
|
||||
|
||||
// Prevent body scroll when sidebar is open
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PageTransition({ children }: PageTransitionProps) {
|
||||
export default function PageTransition({ children, className }: PageTransitionProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
@ -22,6 +24,7 @@ export default function PageTransition({ children }: PageTransitionProps) {
|
||||
transform: 'translateZ(0)',
|
||||
WebkitTransform: 'translateZ(0)'
|
||||
}}
|
||||
className={cn("flex-1 flex flex-col min-h-0", className)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
|
||||
@ -73,6 +73,12 @@ const routes = [
|
||||
{ label: "Flagged Emails", href: "/dashboard/juno-engine/flagged-email", icon: Flag },
|
||||
{ label: "Gmail", href: "/dashboard/juno-engine/gmail-connect", icon: Mail },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "JUNO CHAT",
|
||||
icon: Mail,
|
||||
href: "/dashboard/chat",
|
||||
color: "text-pink-500",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@ -7,10 +7,8 @@ import {
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
Settings,
|
||||
Mail,
|
||||
Zap,
|
||||
FileText,
|
||||
TrendingUp
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@ -40,11 +38,11 @@ const supplierRoutes = [
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
icon: Settings,
|
||||
href: "/supplier/settings",
|
||||
color: "text-slate-400",
|
||||
}
|
||||
label: "JUNO CHAT",
|
||||
icon: Mail,
|
||||
href: "/supplier/chat",
|
||||
color: "text-pink-500",
|
||||
},
|
||||
]
|
||||
|
||||
export function SupplierSidebar() {
|
||||
|
||||
@ -40,8 +40,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
| ThemeMode
|
||||
| null
|
||||
if (stored === "light" || stored === "default") {
|
||||
setThemeState(stored)
|
||||
applyTheme(stored)
|
||||
const timer = setTimeout(() => {
|
||||
setThemeState(stored)
|
||||
applyTheme(stored)
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
applyTheme("default")
|
||||
}
|
||||
|
||||
@ -31,7 +31,8 @@ export default function UserAccountMenu() {
|
||||
|
||||
const handleSettings = () => {
|
||||
setIsOpen(false)
|
||||
router.push("/dashboard/settings")
|
||||
const settingsPath = session?.user?.role === "SUPPLIER" ? "/supplier/settings" : "/dashboard/settings"
|
||||
router.push(settingsPath)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
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 path from "path"
|
||||
import { PDFParse } from "pdf-parse"
|
||||
import mammoth from "mammoth"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
export type RawDocument = {
|
||||
@ -20,15 +22,31 @@ export type TextChunk = {
|
||||
const DEFAULT_CHUNK_SIZE = 1000
|
||||
const DEFAULT_CHUNK_OVERLAP = 200
|
||||
|
||||
// Placeholder PDF/DOCX extraction – swap in real libraries (e.g. pdf-parse, docx) later.
|
||||
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
|
||||
try {
|
||||
const buffer = await fs.readFile(storagePath)
|
||||
const text = buffer.toString("utf8")
|
||||
const PDF_MIME = "application/pdf"
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
const DOC_MIME = "application/msword"
|
||||
|
||||
// In a real implementation, branch on mimeType and use appropriate parsers.
|
||||
// For now, treat as UTF-8 text for a first iteration.
|
||||
return text
|
||||
async function extractTextFromFile(storagePath: string, mimeType: string): Promise<string> {
|
||||
const absolutePath = path.resolve(storagePath)
|
||||
try {
|
||||
if (mimeType === PDF_MIME) {
|
||||
const buffer = await fs.readFile(absolutePath)
|
||||
const parser = new PDFParse({ data: buffer })
|
||||
try {
|
||||
const result = await parser.getText()
|
||||
await parser.destroy()
|
||||
return (result.text ?? "").trim()
|
||||
} finally {
|
||||
await parser.destroy().catch(() => {})
|
||||
}
|
||||
}
|
||||
if (mimeType === DOCX_MIME || mimeType === DOC_MIME) {
|
||||
const result = await mammoth.extractRawText({ path: absolutePath })
|
||||
return (result.value ?? "").trim()
|
||||
}
|
||||
// Fallback for plain text or unknown types
|
||||
const buffer = await fs.readFile(absolutePath)
|
||||
return buffer.toString("utf8").trim()
|
||||
} catch (error) {
|
||||
logger.error("Failed to extract text from file", { storagePath, mimeType, error })
|
||||
throw error
|
||||
|
||||
@ -8,20 +8,20 @@ function log(level: LogLevel, message: string, meta?: unknown) {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
if (!isProd) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.debug(...payload)
|
||||
}
|
||||
break
|
||||
case "info":
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.info(...payload)
|
||||
break
|
||||
case "warn":
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.warn(...payload)
|
||||
break
|
||||
case "error":
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
console.error(...payload)
|
||||
break
|
||||
}
|
||||
|
||||
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: {
|
||||
id: string
|
||||
role: UserRole
|
||||
chatPublicKey?: string | null
|
||||
} & DefaultSession["user"]
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user