Final_prod
This commit is contained in:
parent
765c469975
commit
9c19ca9dd7
325
Channel-Backend/package-lock.json
generated
325
Channel-Backend/package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@ -20,6 +21,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@ -31,6 +33,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"prisma": "^7.8.0",
|
||||
@ -1055,6 +1058,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@ -1291,6 +1304,12 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
@ -1469,6 +1488,48 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@ -1610,6 +1671,34 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@ -1690,6 +1779,61 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@ -1761,6 +1905,43 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
@ -2172,6 +2353,37 @@
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@ -2621,6 +2833,15 @@
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
@ -2685,6 +2906,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -2725,6 +2958,55 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@ -3574,6 +3856,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@ -3635,6 +3926,40 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
@ -21,6 +22,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@ -32,6 +34,7 @@
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"prisma": "^7.8.0",
|
||||
|
||||
@ -37,6 +37,11 @@ model User {
|
||||
inviteTokenExp DateTime?
|
||||
organizationId String?
|
||||
onboardingStatus String @default("PENDING_ONBOARDING")
|
||||
partnerGroup String?
|
||||
assignedNdaId String?
|
||||
assignedMsaId String?
|
||||
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
|
||||
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@ -62,10 +67,14 @@ model Asset {
|
||||
githubUrl String?
|
||||
status String @default("published")
|
||||
isDownloadable Boolean @default(true)
|
||||
thumbnailUrl String?
|
||||
problemStatement String? @db.Text
|
||||
solution String? @db.Text
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
sharedWith SharedAsset[]
|
||||
downloadRequests DownloadRequest[]
|
||||
assetGroups AssetGroup[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@ -106,14 +115,16 @@ model Folder {
|
||||
}
|
||||
|
||||
model LegalDocument {
|
||||
id String @id @default(uuid())
|
||||
type DocumentType
|
||||
version String
|
||||
content String
|
||||
isActive Boolean @default(false)
|
||||
pdfUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
acceptances LegalAcceptance[]
|
||||
id String @id @default(uuid())
|
||||
type DocumentType
|
||||
version String
|
||||
content String
|
||||
isActive Boolean @default(false)
|
||||
pdfUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
acceptances LegalAcceptance[]
|
||||
assignedNdaUsers User[] @relation("AssignedNda")
|
||||
assignedMsaUsers User[] @relation("AssignedMsa")
|
||||
}
|
||||
|
||||
model LegalAcceptance {
|
||||
@ -152,3 +163,12 @@ model BlogPost {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AssetGroup {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
assets Asset[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@ -73,67 +73,7 @@ async function seed() {
|
||||
console.log(`Admin User already exists: ${adminEmail}`);
|
||||
}
|
||||
|
||||
// 4. Seed Partner 1: Awaiting onboarding
|
||||
const onboardingEmail = 'pending-onboarding@partner.com';
|
||||
let onboardingUser = await prisma.user.findUnique({ where: { email: onboardingEmail } });
|
||||
if (!onboardingUser) {
|
||||
onboardingUser = await prisma.user.create({
|
||||
data: {
|
||||
email: onboardingEmail,
|
||||
passwordHash,
|
||||
role: 'PARTNER_USER',
|
||||
onboardingStatus: 'PENDING_ONBOARDING',
|
||||
mfaEnabled: false,
|
||||
organizationId: partnerOrg.id
|
||||
}
|
||||
});
|
||||
console.log(`Seeded Partner (Pending Onboarding): ${onboardingEmail}`);
|
||||
} else {
|
||||
console.log(`Partner (Pending Onboarding) already exists: ${onboardingEmail}`);
|
||||
}
|
||||
|
||||
// 5. Seed Partner 2: Awaiting admin approval (Signed NDA & MSA)
|
||||
const pendingApprovalEmail = 'pending-approval@partner.com';
|
||||
let pendingApprovalUser = await prisma.user.findUnique({ where: { email: pendingApprovalEmail } });
|
||||
if (!pendingApprovalUser) {
|
||||
pendingApprovalUser = await prisma.user.create({
|
||||
data: {
|
||||
email: pendingApprovalEmail,
|
||||
passwordHash,
|
||||
role: 'PARTNER_USER',
|
||||
onboardingStatus: 'PENDING_APPROVAL',
|
||||
mfaEnabled: false,
|
||||
organizationId: partnerOrg.id
|
||||
}
|
||||
});
|
||||
console.log(`Seeded Partner (Pending Approval): ${pendingApprovalEmail}`);
|
||||
|
||||
// Seed LegalAcceptance for NDA
|
||||
await prisma.legalAcceptance.create({
|
||||
data: {
|
||||
docId: ndaDoc.id,
|
||||
userId: pendingApprovalUser.id,
|
||||
ipAddress: '127.0.0.1',
|
||||
signatureHash: 'sha256-dummyndaaccept1234567890abcdef',
|
||||
acceptedAt: new Date()
|
||||
}
|
||||
});
|
||||
// Seed LegalAcceptance for MSA
|
||||
await prisma.legalAcceptance.create({
|
||||
data: {
|
||||
docId: msaDoc.id,
|
||||
userId: pendingApprovalUser.id,
|
||||
ipAddress: '127.0.0.1',
|
||||
signatureHash: 'sha256-dummymsaaccept1234567890abcdef',
|
||||
acceptedAt: new Date()
|
||||
}
|
||||
});
|
||||
console.log('Seeded Legal Acceptances for NDA/MSA for pending-approval partner.');
|
||||
} else {
|
||||
console.log(`Partner (Pending Approval) already exists: ${pendingApprovalEmail}`);
|
||||
}
|
||||
|
||||
// 6. Seed Partner 3: Fully active & approved
|
||||
// 4. Seed Partner: Fully active & approved
|
||||
const activeEmail = 'active-partner@partner.com';
|
||||
let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } });
|
||||
if (!activeUser) {
|
||||
|
||||
@ -18,6 +18,7 @@ import legalRoutes from './routes/legal.routes';
|
||||
import blogRoutes from './routes/blog.routes';
|
||||
|
||||
import { ensureBucketExists } from './utils/s3';
|
||||
import { originStorage } from './utils/origin-storage';
|
||||
|
||||
const app: Express = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
@ -38,6 +39,30 @@ app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Capture request origin and run in AsyncLocalStorage context
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
let clientOrigin: string | undefined;
|
||||
const originHeader = req.get('origin');
|
||||
if (originHeader) {
|
||||
clientOrigin = originHeader;
|
||||
} else {
|
||||
const refererHeader = req.get('referer');
|
||||
if (refererHeader) {
|
||||
try {
|
||||
clientOrigin = new URL(refererHeader).origin;
|
||||
} catch {
|
||||
// ignore invalid urls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (clientOrigin) {
|
||||
originStorage.run(clientOrigin, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Dynamic MinIO object delivery handler
|
||||
app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@ -3,14 +3,27 @@ import path from 'path';
|
||||
import { PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { s3Client, BUCKET_NAME } from '../utils/s3';
|
||||
import { AssetService } from '../services/asset.service';
|
||||
import { ScraperService } from '../services/scraper.service';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
|
||||
export class AssetController {
|
||||
private assetService = new AssetService();
|
||||
private scraperService = new ScraperService();
|
||||
|
||||
public scrapeCaseStudy = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const url = req.query.url as string;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
const data = await this.scraperService.scrapeCaseStudy(url);
|
||||
res.status(200).json(data);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url';
|
||||
const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url' || req.body.type === 'case_study';
|
||||
|
||||
if (!isUrlAsset && !req.file) {
|
||||
throw new Error('No file uploaded');
|
||||
@ -18,6 +31,11 @@ export class AssetController {
|
||||
|
||||
const uploaderId = req.user?.userId || 'system';
|
||||
|
||||
const description = req.body.description;
|
||||
if (!description || !description.trim()) {
|
||||
return res.status(400).json({ error: 'Description is required' });
|
||||
}
|
||||
|
||||
// Parse shares if present
|
||||
let shares = req.body.shares;
|
||||
if (typeof shares === 'string' && shares.trim()) {
|
||||
@ -43,7 +61,7 @@ export class AssetController {
|
||||
|
||||
const assetData = {
|
||||
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
|
||||
type: isUrlAsset ? 'url' : req.file!.mimetype,
|
||||
type: req.body.type || (isUrlAsset ? 'url' : req.file!.mimetype),
|
||||
size: isUrlAsset ? 0 : req.file!.size,
|
||||
url: fileUrl,
|
||||
uploadedBy: uploaderId,
|
||||
@ -54,6 +72,9 @@ export class AssetController {
|
||||
githubUrl: req.body.githubUrl || null,
|
||||
status: req.body.status || 'published',
|
||||
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
|
||||
thumbnailUrl: req.body.thumbnailUrl || null,
|
||||
problemStatement: req.body.problemStatement || null,
|
||||
solution: req.body.solution || null,
|
||||
shares,
|
||||
sharedOrgIds: req.body.sharedOrgIds || null,
|
||||
};
|
||||
@ -160,4 +181,51 @@ export class AssetController {
|
||||
res.status(200).json(request);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public createAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { name, description, assetIds } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Group name is required' });
|
||||
}
|
||||
const group = await this.assetService.createAssetGroup(name, description, assetIds || []);
|
||||
res.status(201).json(group);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public listAssetGroups = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const groups = await this.assetService.getAssetGroups();
|
||||
res.status(200).json(groups);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public deleteAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await this.assetService.deleteAssetGroup(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public addAssetsToGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { assetIds } = req.body;
|
||||
if (!Array.isArray(assetIds)) {
|
||||
return res.status(400).json({ error: 'assetIds must be an array' });
|
||||
}
|
||||
const group = await this.assetService.addAssetsToGroup(req.params.id, assetIds);
|
||||
res.status(200).json(group);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
public removeAssetsFromGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { assetIds } = req.body;
|
||||
if (!Array.isArray(assetIds)) {
|
||||
return res.status(400).json({ error: 'assetIds must be an array' });
|
||||
}
|
||||
const group = await this.assetService.removeAssetsFromGroup(req.params.id, assetIds);
|
||||
res.status(200).json(group);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,12 +25,58 @@ export class AuthController {
|
||||
|
||||
public invitePartner = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { email, organizationId } = z.object({ email: z.string().email(), organizationId: z.string().uuid().optional() }).parse(req.body);
|
||||
const result = await this.authService.invitePartner(email, organizationId);
|
||||
const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({
|
||||
email: z.string().email(),
|
||||
organizationId: z.string().uuid().optional(),
|
||||
partnerGroup: z.string().optional().nullable(),
|
||||
assignedNdaId: z.string().optional().nullable(),
|
||||
assignedMsaId: z.string().optional().nullable(),
|
||||
sharedAssetIds: z.array(z.string().uuid()).optional(),
|
||||
mfaEnabled: z.boolean().optional(),
|
||||
}).parse(req.body);
|
||||
|
||||
const result = await this.authService.invitePartner(email, {
|
||||
organizationId,
|
||||
partnerGroup: partnerGroup || undefined,
|
||||
assignedNdaId: assignedNdaId || undefined,
|
||||
assignedMsaId: assignedMsaId || undefined,
|
||||
sharedAssetIds,
|
||||
mfaEnabled,
|
||||
});
|
||||
res.status(201).json({ message: 'Invite created', token: result.inviteToken });
|
||||
} catch(err) { next(err); }
|
||||
}
|
||||
|
||||
public updatePartner = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { partnerId } = req.params;
|
||||
const { partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({
|
||||
partnerGroup: z.string().optional().nullable(),
|
||||
assignedNdaId: z.string().optional().nullable(),
|
||||
assignedMsaId: z.string().optional().nullable(),
|
||||
sharedAssetIds: z.array(z.string().uuid()).optional(),
|
||||
mfaEnabled: z.boolean().optional(),
|
||||
}).parse(req.body);
|
||||
|
||||
const result = await this.authService.updatePartner(partnerId, {
|
||||
partnerGroup: partnerGroup === null ? undefined : partnerGroup,
|
||||
assignedNdaId: assignedNdaId === null ? undefined : assignedNdaId,
|
||||
assignedMsaId: assignedMsaId === null ? undefined : assignedMsaId,
|
||||
sharedAssetIds,
|
||||
mfaEnabled,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch(err) { next(err); }
|
||||
}
|
||||
|
||||
public resendInvite = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { partnerId } = req.params;
|
||||
const result = await this.authService.resendInvite(partnerId);
|
||||
res.status(200).json(result);
|
||||
} catch(err) { next(err); }
|
||||
}
|
||||
|
||||
public validateInvite = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
@ -25,8 +25,12 @@ export class LegalController {
|
||||
public getActive = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const type = req.params.type.toUpperCase() as DocumentType;
|
||||
const doc = await this.legalService.getActiveDocument(type);
|
||||
const userId = (req as any).user?.userId;
|
||||
const doc = await this.legalService.getActiveDocument(type, userId);
|
||||
if (!doc) {
|
||||
if (userId) {
|
||||
return res.status(200).json({ skipped: true });
|
||||
}
|
||||
return res.status(404).json({ error: 'No active document found' });
|
||||
}
|
||||
res.status(200).json(doc);
|
||||
@ -56,7 +60,7 @@ export class LegalController {
|
||||
|
||||
// Get active document for this type
|
||||
const type = documentType.toUpperCase() as DocumentType;
|
||||
const activeDoc = await this.legalService.getActiveDocument(type);
|
||||
const activeDoc = await this.legalService.getActiveDocument(type, userId);
|
||||
|
||||
if (!activeDoc) {
|
||||
return res.status(404).json({ error: 'No active document found to sign' });
|
||||
@ -107,6 +111,13 @@ export class LegalController {
|
||||
} catch(err) { next(err); }
|
||||
}
|
||||
|
||||
public listAll = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const docs = await this.legalService.getAllDocuments();
|
||||
res.status(200).json(docs);
|
||||
} catch(err) { next(err); }
|
||||
}
|
||||
|
||||
public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) throw new Error('No file uploaded');
|
||||
|
||||
@ -9,8 +9,17 @@ const assetController = new AssetController();
|
||||
router.use(authenticate);
|
||||
|
||||
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
|
||||
router.get('/scrape-case-study', requireRole('ADMIN'), assetController.scrapeCaseStudy);
|
||||
router.get('/', assetController.listAssets);
|
||||
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
|
||||
|
||||
// Asset groups endpoints
|
||||
router.get('/groups/list', assetController.listAssetGroups);
|
||||
router.post('/groups/create', requireRole('ADMIN'), assetController.createAssetGroup);
|
||||
router.delete('/groups/:id', requireRole('ADMIN'), assetController.deleteAssetGroup);
|
||||
router.patch('/groups/:id/add-assets', requireRole('ADMIN'), assetController.addAssetsToGroup);
|
||||
router.patch('/groups/:id/remove-assets', requireRole('ADMIN'), assetController.removeAssetsFromGroup);
|
||||
|
||||
router.get('/:id', assetController.getAsset);
|
||||
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
|
||||
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);
|
||||
|
||||
@ -16,5 +16,7 @@ router.post('/invite', authenticate, requireRole('ADMIN'), authController.invite
|
||||
router.get('/invite/:token', authController.validateInvite);
|
||||
router.post('/invite/accept', authController.acceptInvite);
|
||||
router.get('/partners', authenticate, requireRole('ADMIN'), authController.listPartners);
|
||||
router.put('/partners/:partnerId', authenticate, requireRole('ADMIN'), authController.updatePartner);
|
||||
router.post('/partners/:partnerId/resend-invite', authenticate, requireRole('ADMIN'), authController.resendInvite);
|
||||
|
||||
export default router;
|
||||
|
||||
@ -10,6 +10,7 @@ router.use(authenticate);
|
||||
|
||||
// Admins create documents
|
||||
router.post('/documents', requireRole('ADMIN'), legalController.create);
|
||||
router.get('/documents', requireRole('ADMIN'), legalController.listAll);
|
||||
|
||||
// Anyone can view active docs and sign them
|
||||
router.get('/documents/active/:type', legalController.getActive);
|
||||
|
||||
@ -1,6 +1,57 @@
|
||||
import prisma from '../utils/db';
|
||||
import crypto from 'crypto';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
export class AssetService {
|
||||
private mailService = new MailService();
|
||||
|
||||
public async triggerOnboardingForEligiblePartners(orgIds: string[], userIds: string[]) {
|
||||
// Find all users in PENDING_ASSETS state who belong to either the userIds list or orgIds list
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
onboardingStatus: 'PENDING_ASSETS',
|
||||
OR: [
|
||||
{ id: { in: userIds } },
|
||||
{ organizationId: { in: orgIds } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
// Find count of shared assets for this user (either user-specific or organization-level)
|
||||
const count = await prisma.sharedAsset.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: user.id },
|
||||
{ organizationId: user.organizationId || undefined, userId: null }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
let inviteToken = user.inviteToken;
|
||||
let inviteTokenExp = user.inviteTokenExp;
|
||||
|
||||
if (!inviteToken) {
|
||||
inviteToken = crypto.randomBytes(32).toString('hex');
|
||||
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
onboardingStatus: 'PENDING_ONBOARDING',
|
||||
inviteToken,
|
||||
inviteTokenExp,
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger the SMTP email
|
||||
await this.mailService.sendInviteEmail(user.email, inviteToken!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async createAsset(data: any) {
|
||||
const { sharedOrgIds, shares, tags, ...rest } = data;
|
||||
|
||||
@ -24,6 +75,9 @@ export class AssetService {
|
||||
});
|
||||
|
||||
// Handle immediate sharing
|
||||
const orgIdsToCheck: string[] = [];
|
||||
const userIdsToCheck: string[] = [];
|
||||
|
||||
if (shares) {
|
||||
let parsedShares: any[] = [];
|
||||
if (Array.isArray(shares)) {
|
||||
@ -45,6 +99,10 @@ export class AssetService {
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
parsedShares.forEach(s => {
|
||||
if (s.organizationId) orgIdsToCheck.push(s.organizationId);
|
||||
if (s.userId) userIdsToCheck.push(s.userId);
|
||||
});
|
||||
}
|
||||
} else if (sharedOrgIds) {
|
||||
let orgIds: string[] = [];
|
||||
@ -67,9 +125,14 @@ export class AssetService {
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
orgIdsToCheck.push(...orgIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) {
|
||||
await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck);
|
||||
}
|
||||
|
||||
return this.getAssetById(asset.id);
|
||||
}
|
||||
|
||||
@ -193,6 +256,9 @@ export class AssetService {
|
||||
data: updateData
|
||||
});
|
||||
|
||||
const orgIdsToCheck: string[] = [];
|
||||
const userIdsToCheck: string[] = [];
|
||||
|
||||
if (shares !== undefined) {
|
||||
let parsedShares: any[] = [];
|
||||
if (Array.isArray(shares)) {
|
||||
@ -215,6 +281,10 @@ export class AssetService {
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
parsedShares.forEach(s => {
|
||||
if (s.organizationId) orgIdsToCheck.push(s.organizationId);
|
||||
if (s.userId) userIdsToCheck.push(s.userId);
|
||||
});
|
||||
}
|
||||
} else if (sharedOrgIds !== undefined) {
|
||||
let orgIds: string[] = [];
|
||||
@ -238,9 +308,14 @@ export class AssetService {
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
orgIdsToCheck.push(...orgIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) {
|
||||
await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck);
|
||||
}
|
||||
|
||||
return this.getAssetById(id);
|
||||
}
|
||||
|
||||
@ -253,6 +328,9 @@ export class AssetService {
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
await this.triggerOnboardingForEligiblePartners(organizationIds, []);
|
||||
|
||||
return this.getAssetById(assetId);
|
||||
}
|
||||
|
||||
@ -283,6 +361,10 @@ export class AssetService {
|
||||
skipDuplicates: true
|
||||
})
|
||||
]);
|
||||
|
||||
const orgIds = shares.map(s => s.organizationId).filter(Boolean);
|
||||
const userIds = shares.map(s => s.userId).filter(Boolean);
|
||||
await this.triggerOnboardingForEligiblePartners(orgIds, userIds);
|
||||
}
|
||||
|
||||
public async incrementDownloadCount(id: string) {
|
||||
@ -331,4 +413,62 @@ export class AssetService {
|
||||
data: { status: 'REJECTED' }
|
||||
});
|
||||
}
|
||||
|
||||
public async createAssetGroup(name: string, description?: string, assetIds: string[] = []) {
|
||||
return await prisma.assetGroup.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
assets: {
|
||||
connect: assetIds.map(id => ({ id }))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
assets: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async addAssetsToGroup(groupId: string, assetIds: string[]) {
|
||||
return await prisma.assetGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
assets: {
|
||||
connect: assetIds.map(id => ({ id }))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
assets: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async removeAssetsFromGroup(groupId: string, assetIds: string[]) {
|
||||
return await prisma.assetGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
assets: {
|
||||
disconnect: assetIds.map(id => ({ id }))
|
||||
}
|
||||
},
|
||||
include: {
|
||||
assets: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getAssetGroups() {
|
||||
return await prisma.assetGroup.findMany({
|
||||
include: {
|
||||
assets: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAssetGroup(id: string) {
|
||||
return await prisma.assetGroup.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,17 @@ import prisma from '../utils/db';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { AppError } from '../utils/errors';
|
||||
import crypto from 'crypto';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
export class AuthService {
|
||||
private mailService = new MailService();
|
||||
|
||||
private async getOrCreateOrganizationForEmail(email: string) {
|
||||
const domain = email.split('@')[1];
|
||||
if (!domain) return null;
|
||||
|
||||
// Ignore generic/public emails or treat them as their own organization
|
||||
const name = domain.split('.')[0].toUpperCase();
|
||||
const name = domain.toUpperCase();
|
||||
if (!name) return null;
|
||||
|
||||
let org = await prisma.organization.findFirst({
|
||||
@ -47,20 +50,50 @@ export class AuthService {
|
||||
return this.getUserById(user.id);
|
||||
}
|
||||
|
||||
public async invitePartner(email: string, organizationId?: string) {
|
||||
public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean } = {}) {
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new AppError('Email already in use', 400);
|
||||
|
||||
let orgId = organizationId || null;
|
||||
let orgId = options.organizationId || null;
|
||||
if (!orgId) {
|
||||
orgId = await this.getOrCreateOrganizationForEmail(email);
|
||||
}
|
||||
|
||||
const crypto = require('crypto');
|
||||
const inviteToken = crypto.randomBytes(32).toString('hex');
|
||||
const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
await prisma.user.create({
|
||||
const hasAssets = options.sharedAssetIds && options.sharedAssetIds.length > 0;
|
||||
const onboardingStatus = hasAssets ? 'PENDING_ONBOARDING' : 'PENDING_ASSETS';
|
||||
|
||||
let assignedNdaId: string | null = null;
|
||||
if (options.assignedNdaId === 'NONE') {
|
||||
assignedNdaId = null;
|
||||
} else if (options.assignedNdaId === undefined || options.assignedNdaId === '') {
|
||||
// Resolve active default NDA
|
||||
const activeNda = await prisma.legalDocument.findFirst({
|
||||
where: { type: 'NDA', isActive: true },
|
||||
select: { id: true }
|
||||
});
|
||||
assignedNdaId = activeNda?.id || null;
|
||||
} else {
|
||||
assignedNdaId = options.assignedNdaId;
|
||||
}
|
||||
|
||||
let assignedMsaId: string | null = null;
|
||||
if (options.assignedMsaId === 'NONE') {
|
||||
assignedMsaId = null;
|
||||
} else if (options.assignedMsaId === undefined || options.assignedMsaId === '') {
|
||||
// Resolve active default MSA
|
||||
const activeMsa = await prisma.legalDocument.findFirst({
|
||||
where: { type: 'MSA', isActive: true },
|
||||
select: { id: true }
|
||||
});
|
||||
assignedMsaId = activeMsa?.id || null;
|
||||
} else {
|
||||
assignedMsaId = options.assignedMsaId;
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: '', // Set on accept
|
||||
@ -68,13 +101,202 @@ export class AuthService {
|
||||
organizationId: orgId,
|
||||
inviteToken,
|
||||
inviteTokenExp,
|
||||
onboardingStatus: 'PENDING_ONBOARDING',
|
||||
mfaEnabled: true,
|
||||
onboardingStatus,
|
||||
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : true,
|
||||
partnerGroup: options.partnerGroup || null,
|
||||
assignedNdaId,
|
||||
assignedMsaId,
|
||||
}
|
||||
});
|
||||
|
||||
// In a real app, send email here
|
||||
return { inviteToken };
|
||||
if (hasAssets && orgId) {
|
||||
await prisma.sharedAsset.createMany({
|
||||
data: options.sharedAssetIds!.map(assetId => ({
|
||||
assetId,
|
||||
organizationId: orgId!,
|
||||
userId: user.id,
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
let emailSent = false;
|
||||
let emailError: string | undefined = undefined;
|
||||
|
||||
// Trigger SMTP mail if assets are assigned
|
||||
if (onboardingStatus === 'PENDING_ONBOARDING') {
|
||||
try {
|
||||
await this.mailService.sendInviteEmail(email, inviteToken);
|
||||
emailSent = true;
|
||||
} catch (err) {
|
||||
console.error(`[SMTP ERROR] Non-fatal invitation mail failure: ${(err as Error).message}`);
|
||||
emailError = (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
return { inviteToken, emailSent, emailError };
|
||||
}
|
||||
|
||||
public async updatePartner(partnerId: string, options: { partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean }) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: partnerId }
|
||||
});
|
||||
if (!user) throw new AppError('Partner not found', 404);
|
||||
|
||||
const oldStatus = user.onboardingStatus;
|
||||
const orgId = user.organizationId;
|
||||
|
||||
let assignedNdaId = user.assignedNdaId;
|
||||
if (options.assignedNdaId !== undefined) {
|
||||
if (options.assignedNdaId === 'NONE') {
|
||||
assignedNdaId = null;
|
||||
} else if (options.assignedNdaId === '') {
|
||||
const activeNda = await prisma.legalDocument.findFirst({
|
||||
where: { type: 'NDA', isActive: true },
|
||||
select: { id: true }
|
||||
});
|
||||
assignedNdaId = activeNda?.id || null;
|
||||
} else {
|
||||
assignedNdaId = options.assignedNdaId;
|
||||
}
|
||||
}
|
||||
|
||||
let assignedMsaId = user.assignedMsaId;
|
||||
if (options.assignedMsaId !== undefined) {
|
||||
if (options.assignedMsaId === 'NONE') {
|
||||
assignedMsaId = null;
|
||||
} else if (options.assignedMsaId === '') {
|
||||
const activeMsa = await prisma.legalDocument.findFirst({
|
||||
where: { type: 'MSA', isActive: true },
|
||||
select: { id: true }
|
||||
});
|
||||
assignedMsaId = activeMsa?.id || null;
|
||||
} else {
|
||||
assignedMsaId = options.assignedMsaId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update basic fields
|
||||
await prisma.user.update({
|
||||
where: { id: partnerId },
|
||||
data: {
|
||||
partnerGroup: options.partnerGroup !== undefined ? options.partnerGroup : user.partnerGroup,
|
||||
assignedNdaId,
|
||||
assignedMsaId,
|
||||
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : user.mfaEnabled,
|
||||
}
|
||||
});
|
||||
|
||||
// Update shared assets if provided
|
||||
if (options.sharedAssetIds !== undefined) {
|
||||
// Clear existing shared assets for this user (both user-specific and org-wide)
|
||||
if (orgId) {
|
||||
await prisma.sharedAsset.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: partnerId },
|
||||
{ organizationId: orgId, userId: null }
|
||||
]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.sharedAsset.deleteMany({
|
||||
where: { userId: partnerId }
|
||||
});
|
||||
}
|
||||
|
||||
// Create new shared assets if any
|
||||
if (options.sharedAssetIds.length > 0 && orgId) {
|
||||
await prisma.sharedAsset.createMany({
|
||||
data: options.sharedAssetIds.map(assetId => ({
|
||||
assetId,
|
||||
organizationId: orgId,
|
||||
userId: partnerId,
|
||||
})),
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
// Check transition from PENDING_ASSETS to PENDING_ONBOARDING
|
||||
if (oldStatus === 'PENDING_ASSETS' && options.sharedAssetIds.length > 0) {
|
||||
let inviteToken = user.inviteToken;
|
||||
let inviteTokenExp = user.inviteTokenExp;
|
||||
|
||||
if (!inviteToken) {
|
||||
inviteToken = crypto.randomBytes(32).toString('hex');
|
||||
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: partnerId },
|
||||
data: {
|
||||
onboardingStatus: 'PENDING_ONBOARDING',
|
||||
inviteToken,
|
||||
inviteTokenExp,
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Trigger the SMTP email since assets are now assigned!
|
||||
await this.mailService.sendInviteEmail(user.email, inviteToken!);
|
||||
} catch (err) {
|
||||
console.error(`[SMTP ERROR] Non-fatal update transition email failure: ${(err as Error).message}`);
|
||||
}
|
||||
} else if (oldStatus === 'PENDING_ONBOARDING' && options.sharedAssetIds.length === 0) {
|
||||
// Transition back if all assets are removed before onboarding starts
|
||||
await prisma.user.update({
|
||||
where: { id: partnerId },
|
||||
data: {
|
||||
onboardingStatus: 'PENDING_ASSETS'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getUserById(partnerId);
|
||||
}
|
||||
|
||||
public async resendInvite(partnerId: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: partnerId }
|
||||
});
|
||||
if (!user) throw new AppError('Partner not found', 404);
|
||||
if (user.role === 'ADMIN') throw new AppError('Cannot send invitation to admins', 400);
|
||||
if (user.onboardingStatus === 'APPROVED') {
|
||||
throw new AppError('Partner is already active/approved', 400);
|
||||
}
|
||||
|
||||
let inviteToken = user.inviteToken;
|
||||
let inviteTokenExp = user.inviteTokenExp;
|
||||
|
||||
// Generate token if missing or expired
|
||||
if (!inviteToken || !inviteTokenExp || inviteTokenExp < new Date()) {
|
||||
inviteToken = crypto.randomBytes(32).toString('hex');
|
||||
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
await prisma.user.update({
|
||||
where: { id: partnerId },
|
||||
data: {
|
||||
inviteToken,
|
||||
inviteTokenExp,
|
||||
onboardingStatus: 'PENDING_ONBOARDING'
|
||||
}
|
||||
});
|
||||
} else if (user.onboardingStatus === 'PENDING_ASSETS') {
|
||||
// Force onboarding status to PENDING_ONBOARDING
|
||||
await prisma.user.update({
|
||||
where: { id: partnerId },
|
||||
data: {
|
||||
onboardingStatus: 'PENDING_ONBOARDING'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await this.mailService.sendInviteEmail(user.email, inviteToken);
|
||||
} catch (err) {
|
||||
throw new AppError(`Failed to send invitation email: ${(err as Error).message}`, 500);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async validateInvite(token: string) {
|
||||
@ -92,12 +314,16 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(passwordString, 10);
|
||||
const noAgreements = !user.assignedNdaId && !user.assignedMsaId;
|
||||
const newOnboardingStatus = noAgreements ? 'APPROVED' : user.onboardingStatus;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash,
|
||||
inviteToken: null,
|
||||
inviteTokenExp: null,
|
||||
onboardingStatus: newOnboardingStatus,
|
||||
}
|
||||
});
|
||||
|
||||
@ -144,7 +370,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
public async listPartners() {
|
||||
return await prisma.user.findMany({
|
||||
const partners = await prisma.user.findMany({
|
||||
where: { role: 'PARTNER_USER' },
|
||||
select: {
|
||||
id: true,
|
||||
@ -154,9 +380,55 @@ export class AuthService {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
organizationId: true,
|
||||
partnerGroup: true,
|
||||
assignedNdaId: true,
|
||||
assignedMsaId: true,
|
||||
assignedNda: {
|
||||
select: { id: true, version: true }
|
||||
},
|
||||
assignedMsa: {
|
||||
select: { id: true, version: true }
|
||||
},
|
||||
inviteToken: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const partnersWithSharedAssets = await Promise.all(
|
||||
partners.map(async (partner) => {
|
||||
let sharedAssets: any[] = [];
|
||||
if (partner.organizationId) {
|
||||
sharedAssets = await prisma.sharedAsset.findMany({
|
||||
where: {
|
||||
organizationId: partner.organizationId,
|
||||
OR: [
|
||||
{ userId: null },
|
||||
{ userId: partner.id }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
assetId: true,
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
categoryId: true,
|
||||
subcategory: true,
|
||||
url: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
...partner,
|
||||
sharedAssets
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return partnersWithSharedAssets;
|
||||
}
|
||||
|
||||
public async getUserById(id: string) {
|
||||
@ -171,6 +443,8 @@ export class AuthService {
|
||||
onboardingStatus: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
assignedMsaId: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
@ -200,6 +474,8 @@ export class AuthService {
|
||||
onboardingStatus: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
assignedNdaId: true,
|
||||
assignedMsaId: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@ -21,7 +21,25 @@ export class LegalService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getActiveDocument(type: DocumentType) {
|
||||
public async getActiveDocument(type: DocumentType, userId?: string) {
|
||||
if (userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assignedNdaId: true, assignedMsaId: true }
|
||||
});
|
||||
if (user) {
|
||||
const assignedId = type === 'NDA' ? user.assignedNdaId : user.assignedMsaId;
|
||||
if (assignedId) {
|
||||
const doc = await prisma.legalDocument.findUnique({
|
||||
where: { id: assignedId }
|
||||
});
|
||||
if (doc) return doc;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.legalDocument.findFirst({
|
||||
where: { type, isActive: true },
|
||||
});
|
||||
@ -44,17 +62,21 @@ export class LegalService {
|
||||
}
|
||||
|
||||
public async checkOnboardingCompletion(userId: string) {
|
||||
// Check if both NDA and MSA have been accepted
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { assignedNdaId: true, assignedMsaId: true }
|
||||
});
|
||||
|
||||
const acceptances = await prisma.legalAcceptance.findMany({
|
||||
where: { userId },
|
||||
include: { document: true }
|
||||
});
|
||||
|
||||
const hasNDA = acceptances.some(a => a.document.type === 'NDA');
|
||||
const hasMSA = acceptances.some(a => a.document.type === 'MSA');
|
||||
const hasNDA = !user?.assignedNdaId || acceptances.some(a => a.document.type === 'NDA');
|
||||
const hasMSA = !user?.assignedMsaId || acceptances.some(a => a.document.type === 'MSA');
|
||||
|
||||
if (hasNDA && hasMSA) {
|
||||
// Both signed, ready for admin approval. We don't automatically set to APPROVED.
|
||||
// Both signed or skipped, ready for admin approval. We don't automatically set to APPROVED.
|
||||
// But we can ensure it's PENDING_APPROVAL.
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
@ -87,6 +109,8 @@ export class LegalService {
|
||||
id: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
assignedNdaId: true,
|
||||
assignedMsaId: true,
|
||||
acceptances: {
|
||||
select: {
|
||||
id: true,
|
||||
@ -102,6 +126,12 @@ export class LegalService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAllDocuments() {
|
||||
return await prisma.legalDocument.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
public async approvePartner(userId: string) {
|
||||
return await prisma.user.update({
|
||||
where: { id: userId },
|
||||
|
||||
69
Channel-Backend/src/services/mail.service.ts
Normal file
69
Channel-Backend/src/services/mail.service.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { originStorage } from '../utils/origin-storage';
|
||||
|
||||
export class MailService {
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor() {
|
||||
const host = process.env.SMTP_HOST || 'smtp.mailtrap.io';
|
||||
const port = parseInt(process.env.SMTP_PORT || '2525', 10);
|
||||
const user = process.env.SMTP_USER || '';
|
||||
const pass = process.env.SMTP_PASS || '';
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async sendInviteEmail(email: string, inviteToken: string) {
|
||||
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||
const inviteUrl = `${origin}/invite?token=${inviteToken}`;
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'),
|
||||
to: email,
|
||||
subject: 'Tech4Biz Partner Portal Invitation',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 30px; border: 1px solid #e2e8f0; border-radius: 12px; background-color: #ffffff;">
|
||||
<div style="text-align: left; margin-bottom: 24px;">
|
||||
<h2 style="color: #1e293b; margin: 0; font-size: 20px; font-weight: bold;">Tech4Biz Partner Portal</h2>
|
||||
<p style="color: #64748b; font-size: 13px; margin: 4px 0 0 0;">Authorized Collaborative Access Gateway</p>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #e2e8f0; padding-top: 24px; margin-top: 16px;">
|
||||
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 16px 0;">Hello,</p>
|
||||
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 16px 0;">You have been invited to register for the <strong>Tech4Biz Partner Portal</strong> as a collaborative member. This portal allows your organization to view shared resources, asset files, and manage onboarding documentation.</p>
|
||||
|
||||
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 24px 0;">To set up your account and access the platform, please proceed to the link below to configure your credentials and review the partner documentation:</p>
|
||||
|
||||
<div style="text-align: left; margin: 24px 0;">
|
||||
<a href="${inviteUrl}" style="background-color: #0f172a; color: #ffffff; padding: 12px 24px; font-size: 14px; font-weight: bold; text-decoration: none; border-radius: 6px; display: inline-block;">Access Partner Portal</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 12px; color: #64748b; line-height: 1.6; margin: 24px 0 8px 0;">If you are unable to click the button above, please copy and paste the URL below into your web browser:</p>
|
||||
<p style="font-size: 12px; color: #334155; font-family: monospace; background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 6px; word-break: break-all; margin: 0 0 24px 0;">${inviteUrl}</p>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid #e2e8f0; padding-top: 20px; margin-top: 30px; text-align: center;">
|
||||
<p style="font-size: 11px; color: #94a3b8; margin: 0;">© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
console.log(`[SMTP] Invitation email successfully sent to ${email}. Message ID: ${info.messageId}`);
|
||||
return info;
|
||||
} catch (err) {
|
||||
console.error(`[SMTP ERROR] Failed to send invitation email to ${email}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
229
Channel-Backend/src/services/scraper.service.ts
Normal file
229
Channel-Backend/src/services/scraper.service.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
interface ScrapeResult {
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
problemStatement: string;
|
||||
solution: string;
|
||||
}
|
||||
|
||||
interface PageElement {
|
||||
type: 'heading' | 'content';
|
||||
tagName: string;
|
||||
text: string;
|
||||
problemScore: number;
|
||||
solutionScore: number;
|
||||
}
|
||||
|
||||
export class ScraperService {
|
||||
private problemKeywords: Record<string, number> = {
|
||||
'problem statement': 1.0,
|
||||
'challenges': 0.9,
|
||||
'challenge': 0.9,
|
||||
'problem': 0.9,
|
||||
'pain point': 0.9,
|
||||
'issue': 0.8,
|
||||
'background': 0.6,
|
||||
'context': 0.5,
|
||||
'objective': 0.6,
|
||||
};
|
||||
|
||||
private solutionKeywords: Record<string, number> = {
|
||||
'suggested solution': 1.0,
|
||||
'solution': 0.9,
|
||||
'approach': 0.8,
|
||||
'implementation': 0.7,
|
||||
'architecture': 0.8,
|
||||
'technology': 0.6,
|
||||
};
|
||||
|
||||
private stopKeywords: string[] = [
|
||||
'result',
|
||||
'conclusion',
|
||||
'roi',
|
||||
'future',
|
||||
'roadmap',
|
||||
'benefit',
|
||||
'collaboration',
|
||||
'support'
|
||||
];
|
||||
|
||||
private getCategoryScore(text: string, keywords: Record<string, number>): number {
|
||||
const lowerText = text.toLowerCase();
|
||||
let maxScore = 0;
|
||||
for (const [kw, score] of Object.entries(keywords)) {
|
||||
if (lowerText.includes(kw)) {
|
||||
maxScore = Math.max(maxScore, score);
|
||||
}
|
||||
}
|
||||
return maxScore;
|
||||
}
|
||||
|
||||
public async scrapeCaseStudy(url: string): Promise<ScrapeResult> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch page. Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Extract Title
|
||||
let title = $('meta[property="og:title"]').attr('content') ||
|
||||
$('meta[name="twitter:title"]').attr('content') ||
|
||||
$('title').text() ||
|
||||
$('h1').first().text() ||
|
||||
'';
|
||||
|
||||
title = title.replace(/\s*[-|]\s*Tech4Biz.*$/i, '').trim();
|
||||
|
||||
// Extract Banner Image/Thumbnail
|
||||
const thumbnailUrl = $('meta[property="og:image"]').attr('content') ||
|
||||
$('meta[name="twitter:image"]').attr('content') ||
|
||||
'';
|
||||
|
||||
const elements = $('h1, h2, h3, h4, h5, h6, p, ul, ol');
|
||||
const pageElements: PageElement[] = [];
|
||||
|
||||
elements.each((_, el) => {
|
||||
const $el = $(el);
|
||||
const tagName = el.name.toLowerCase();
|
||||
const text = $el.text().trim();
|
||||
if (!text) return;
|
||||
|
||||
// Skip copyright and common footer elements
|
||||
if (text.toLowerCase().includes('copyright reserved')) return;
|
||||
|
||||
if (tagName.startsWith('h') && text.length < 65) {
|
||||
const problemScore = this.getCategoryScore(text, this.problemKeywords);
|
||||
const solutionScore = this.getCategoryScore(text, this.solutionKeywords);
|
||||
pageElements.push({
|
||||
type: 'heading',
|
||||
tagName,
|
||||
text,
|
||||
problemScore,
|
||||
solutionScore
|
||||
});
|
||||
} else {
|
||||
let formattedText = '';
|
||||
if (tagName === 'ul' || tagName === 'ol') {
|
||||
const listItems: string[] = [];
|
||||
$el.find('li').each((_, li) => {
|
||||
const liText = $(li).text().trim();
|
||||
if (liText) {
|
||||
listItems.push(`• ${liText}`);
|
||||
}
|
||||
});
|
||||
if (listItems.length > 0) {
|
||||
formattedText = listItems.join('\n');
|
||||
}
|
||||
} else {
|
||||
if (text.length > 10) {
|
||||
formattedText = text;
|
||||
}
|
||||
}
|
||||
|
||||
if (formattedText) {
|
||||
pageElements.push({
|
||||
type: 'content',
|
||||
tagName,
|
||||
text: formattedText,
|
||||
problemScore: 0,
|
||||
solutionScore: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the best indices
|
||||
let bestProblemIdx = -1;
|
||||
let maxProblemScore = 0.4;
|
||||
let bestSolutionIdx = -1;
|
||||
let maxSolutionScore = 0.4;
|
||||
|
||||
pageElements.forEach((el, idx) => {
|
||||
if (el.type === 'heading') {
|
||||
if (el.problemScore > maxProblemScore) {
|
||||
maxProblemScore = el.problemScore;
|
||||
bestProblemIdx = idx;
|
||||
}
|
||||
if (el.solutionScore > maxSolutionScore) {
|
||||
maxSolutionScore = el.solutionScore;
|
||||
bestSolutionIdx = idx;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback searches
|
||||
if (bestProblemIdx === -1) {
|
||||
bestProblemIdx = pageElements.findIndex(el =>
|
||||
el.type === 'heading' &&
|
||||
(el.text.toLowerCase().includes('background') || el.text.toLowerCase().includes('overview'))
|
||||
);
|
||||
}
|
||||
|
||||
if (bestSolutionIdx === -1) {
|
||||
bestSolutionIdx = pageElements.findIndex(el =>
|
||||
el.type === 'heading' &&
|
||||
(el.text.toLowerCase().includes('implementation') || el.text.toLowerCase().includes('results') || el.text.toLowerCase().includes('benefits') || el.text.toLowerCase().includes('components'))
|
||||
);
|
||||
}
|
||||
|
||||
const gatherContent = (startIdx: number, otherIdx: number, isProblem: boolean): string => {
|
||||
if (startIdx === -1) return '';
|
||||
|
||||
const collected: string[] = [];
|
||||
let contentCount = 0;
|
||||
|
||||
for (let i = startIdx + 1; i < pageElements.length; i++) {
|
||||
if (i === otherIdx) break;
|
||||
|
||||
const el = pageElements[i];
|
||||
if (el.type === 'heading') {
|
||||
const lowerText = el.text.toLowerCase();
|
||||
|
||||
// Stop if we hit the other major category heading
|
||||
if (isProblem && el.solutionScore >= 0.8) break;
|
||||
if (!isProblem && el.problemScore >= 0.8) break;
|
||||
|
||||
// Stop if we hit a stop keyword
|
||||
const matchesStop = this.stopKeywords.some(kw => lowerText.includes(kw));
|
||||
if (matchesStop) break;
|
||||
|
||||
// Include subheading formatting
|
||||
collected.push(`**${el.text}**`);
|
||||
} else {
|
||||
// Simple de-duplication for consecutive identical elements
|
||||
if (collected[collected.length - 1] !== el.text) {
|
||||
collected.push(el.text);
|
||||
contentCount++;
|
||||
}
|
||||
if (contentCount >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
return collected.join('\n\n');
|
||||
};
|
||||
|
||||
const problemStatement = gatherContent(bestProblemIdx, bestSolutionIdx, true);
|
||||
const solution = gatherContent(bestSolutionIdx, bestProblemIdx, false);
|
||||
|
||||
return {
|
||||
title,
|
||||
thumbnailUrl,
|
||||
problemStatement,
|
||||
solution
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SCRAPE ERROR] Failed to scrape case study:', error.message);
|
||||
throw new Error(`Scraping failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Channel-Backend/src/utils/origin-storage.ts
Normal file
3
Channel-Backend/src/utils/origin-storage.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export const originStorage = new AsyncLocalStorage<string>();
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2375BF46' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z' /%3E%3C/svg%3E" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tech4Biz Client & Admin Portal</title>
|
||||
</head>
|
||||
|
||||
@ -177,11 +177,11 @@ export const AdminLayout: React.FC = () => {
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
|
||||
<Link to="/admin" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
||||
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm">
|
||||
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span className="text-sm font-extrabold tracking-tight text-ink-900">
|
||||
Admin Console
|
||||
Tech4Biz
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
@ -211,7 +211,12 @@ export const AdminLayout: React.FC = () => {
|
||||
className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
||||
<span className="font-extrabold text-ink-900">Menu</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-ink-0 border border-ink-200 p-0.5 shadow-sm">
|
||||
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span className="font-extrabold text-ink-900 text-sm">Tech4Biz</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="p-2 rounded-lg bg-ink-100"
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useThemeStore } from '../../hooks/use-theme';
|
||||
import { useAuthStore } from '../../hooks/use-auth';
|
||||
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const navItems = [
|
||||
@ -128,10 +128,10 @@ export const ClientLayout: React.FC = () => {
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
|
||||
<Link to="/client" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
||||
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm">
|
||||
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span className="text-sm font-extrabold tracking-tight text-ink-900">Client Portal</span>
|
||||
<span className="text-sm font-extrabold tracking-tight text-ink-900">Tech4Biz</span>
|
||||
</Link>
|
||||
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
|
||||
<Menu className="w-5 h-5" />
|
||||
@ -145,7 +145,12 @@ export const ClientLayout: React.FC = () => {
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
|
||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
|
||||
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
||||
<span className="font-extrabold text-ink-900">Menu</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-ink-0 border border-ink-200 p-0.5 shadow-sm">
|
||||
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<span className="font-extrabold text-ink-900 text-sm">Tech4Biz</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@ -51,6 +51,11 @@ const ClientAgreementsPage = React.lazy(() =>
|
||||
default: m.ClientAgreementsPage,
|
||||
})),
|
||||
);
|
||||
const GroupDetailsPage = React.lazy(() =>
|
||||
import("../../pages/admin/GroupDetailsPage").then((m) => ({
|
||||
default: m.GroupDetailsPage,
|
||||
})),
|
||||
);
|
||||
|
||||
// Dummy Components for routing
|
||||
const LoadingFallback = () => (
|
||||
@ -196,6 +201,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "groups/:groupId",
|
||||
element: (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<GroupDetailsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@ -45,6 +45,8 @@ interface DocumentPreviewModalProps {
|
||||
onVerify: (docType: "NDA" | "MSA") => void;
|
||||
onApprovePartner: () => void;
|
||||
isApproving: boolean;
|
||||
assignedNdaId?: string | null;
|
||||
assignedMsaId?: string | null;
|
||||
}
|
||||
|
||||
export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
@ -58,6 +60,8 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
onVerify,
|
||||
onApprovePartner,
|
||||
isApproving,
|
||||
assignedNdaId,
|
||||
assignedMsaId,
|
||||
}) => {
|
||||
const [currentTab, setCurrentTab] = useState<"NDA" | "MSA">("NDA");
|
||||
const [zoom, setZoom] = useState(100);
|
||||
@ -212,7 +216,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
const fileHost = (
|
||||
import.meta.env.VITE_API_URL || "/api/v1"
|
||||
).replace("/api/v1", "");
|
||||
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
|
||||
const isNdaVerified = !assignedNdaId || verifiedDocs.nda;
|
||||
const isMsaVerified = !assignedMsaId || verifiedDocs.msa;
|
||||
const isBothVerified = isNdaVerified && isMsaVerified;
|
||||
const isCurrentVerified =
|
||||
currentTab === "NDA" ? verifiedDocs.nda : verifiedDocs.msa;
|
||||
|
||||
@ -312,7 +318,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Warning Banner if Unsigned */}
|
||||
{!activeAcceptance && (
|
||||
{!activeAcceptance && (currentTab === "NDA" ? assignedNdaId : assignedMsaId) && (
|
||||
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
|
||||
<p className="text-xs font-bold text-amber-800">
|
||||
@ -546,11 +552,11 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
|
||||
{activeAcceptance.signatureBase64 ? (
|
||||
// Render the exact drawn signature as-is — no modification
|
||||
<img
|
||||
<img
|
||||
src={activeAcceptance.signatureBase64}
|
||||
alt="Client drawn signature"
|
||||
className="max-w-full max-h-[160px] object-contain"
|
||||
style={{ imageRendering: "crisp-edges" }}
|
||||
style={{ imageRendering: "crisp-edges", filter: "brightness(0)" }}
|
||||
/>
|
||||
) : (
|
||||
// Upload-based signing — no drawn image stored
|
||||
@ -634,11 +640,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
|
||||
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
|
||||
<h4 className="text-base font-extrabold text-ink-900">
|
||||
Document Unavailable
|
||||
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) ? "Agreement Not Required" : "Document Unavailable"}
|
||||
</h4>
|
||||
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
|
||||
The partner has not yet submitted or signed the {currentTab}{" "}
|
||||
document.
|
||||
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId)
|
||||
? "This partner onboarding configuration does not require signing this agreement."
|
||||
: `The partner has not yet submitted or signed the ${currentTab} document.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -747,14 +754,32 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeAcceptance?.signatureBase64 && (
|
||||
<div className="border-t border-ink-100 pt-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">
|
||||
Captured Signature
|
||||
</h4>
|
||||
<div className="border border-ink-200 rounded-lg p-2 bg-white flex items-center justify-center min-h-[60px] shadow-sm">
|
||||
<img
|
||||
src={activeAcceptance.signatureBase64}
|
||||
alt="Signature"
|
||||
className="max-h-12 object-contain"
|
||||
style={{ filter: 'brightness(0)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-ink-100 space-y-2">
|
||||
<Button
|
||||
onClick={() => onVerify(currentTab)}
|
||||
disabled={!activeAcceptance}
|
||||
variant={isCurrentVerified ? "secondary" : "primary"}
|
||||
disabled={!activeAcceptance || !(currentTab === "NDA" ? assignedNdaId : assignedMsaId)}
|
||||
variant={!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) || isCurrentVerified ? "secondary" : "primary"}
|
||||
className="w-full flex justify-center items-center gap-1.5"
|
||||
>
|
||||
{isCurrentVerified ? (
|
||||
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) ? (
|
||||
<span>No Verification Needed</span>
|
||||
) : isCurrentVerified ? (
|
||||
<>
|
||||
<div className="flex gap-2 items-center">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
@ -766,8 +791,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
|
||||
Marking this verified unlocks approval options for the
|
||||
administrator.
|
||||
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId)
|
||||
? "This agreement is not required for this partner."
|
||||
: "Marking this verified unlocks approval options for the administrator."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -776,15 +802,15 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
{/* Sticky Footer */}
|
||||
<div className="px-6 py-4 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 shrink-0">
|
||||
<div className="text-xs font-semibold text-ink-500">
|
||||
{verifiedDocs.nda && verifiedDocs.msa ? (
|
||||
{isBothVerified ? (
|
||||
<span className="text-emerald-700 font-bold flex items-center gap-1.5">
|
||||
<CheckCircle className="w-4.5 h-4.5" /> Both agreements
|
||||
<CheckCircle className="w-4.5 h-4.5" /> Required agreements
|
||||
verified. Access approval unlocked.
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please
|
||||
review and verify both the NDA and MSA documents.
|
||||
review and verify the pending required documents.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -795,7 +821,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onApprovePartner}
|
||||
disabled={!isBothVerified || isApproving}
|
||||
disabled={isApproving}
|
||||
variant="primary"
|
||||
className="font-bold shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
@ -34,17 +35,19 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
|
||||
const sizeClass = sizeClasses[size] || sizeClasses.md;
|
||||
|
||||
return (
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center items-start pt-[76px] sm:pt-[80px] px-4 pb-4 sm:pb-6 overflow-hidden">
|
||||
<div className="fixed inset-0 z-[9999] flex justify-center items-start pt-[76px] sm:pt-[80px] px-4 pb-4 sm:pb-6 overflow-hidden">
|
||||
{/* Backdrop overlay */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-ink-950/40 backdrop-blur-sm z-[100]"
|
||||
className="fixed inset-0 bg-ink-950/40 backdrop-blur-sm z-[9999]"
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
@ -53,7 +56,7 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
|
||||
className={`relative w-full ${sizeClass} bg-ink-0 border border-ink-200 rounded-2xl shadow-xl flex flex-col overflow-hidden focus:outline-none z-[101] ${className}`}
|
||||
className={`relative w-full ${sizeClass} bg-ink-0 border border-ink-200 rounded-2xl shadow-xl flex flex-col overflow-hidden focus:outline-none z-[10000] ${className}`}
|
||||
style={{
|
||||
maxHeight: '100%'
|
||||
}}
|
||||
@ -86,7 +89,8 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Detect theme for stroke color
|
||||
// Detect theme for stroke color (visible on black/dark or white screens)
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
ctx.strokeStyle = isDark ? '#fafafa' : '#09090b';
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Globe,
|
||||
FileText,
|
||||
@ -13,7 +14,8 @@ import {
|
||||
ExternalLink,
|
||||
Download,
|
||||
Clock,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { User } from '../../../types/auth';
|
||||
@ -33,6 +35,8 @@ interface AssetCardProps {
|
||||
onRequestDownload: (asset: Asset) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (assetId: string) => void;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
@ -48,7 +52,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
onDownload,
|
||||
onRequestDownload,
|
||||
isSelected = false,
|
||||
onToggleSelect
|
||||
onToggleSelect,
|
||||
isExpanded = false,
|
||||
onToggleExpand
|
||||
}) => {
|
||||
const isMenuOpen = activeMenuId === asset.id;
|
||||
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
|
||||
@ -64,6 +70,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
};
|
||||
|
||||
const getAssetIcon = (type: string) => {
|
||||
if (type === 'case_study') return BookOpen;
|
||||
if (type === 'url') return Globe;
|
||||
if (type.includes('pdf')) return FileText;
|
||||
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
|
||||
@ -104,10 +111,28 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
const isWord = asset.type.includes('word') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc');
|
||||
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
|
||||
const isSpreadsheet = asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv');
|
||||
|
||||
const hasBanner = isImage || (asset.type === 'case_study' && !!asset.thumbnailUrl);
|
||||
const bannerSrc = (asset.type === 'case_study' && asset.thumbnailUrl) ? asset.thumbnailUrl : asset.url;
|
||||
|
||||
return (
|
||||
<div className={`group relative bg-ink-0 border rounded-xl p-4 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : 'border-ink-200 hover:border-ink-300'}`}>
|
||||
<div>
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('.toggle-expand-btn')) {
|
||||
return;
|
||||
}
|
||||
onViewDetails(asset);
|
||||
}}
|
||||
className={`group bg-ink-0 border rounded-xl p-4 transition-all duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
|
||||
isExpanded
|
||||
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
|
||||
: 'relative w-full h-full border-ink-200 hover:border-ink-300 hover:shadow-md hover:-translate-y-0.5'
|
||||
} ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : ''}`}
|
||||
>
|
||||
<div className="flex-grow flex flex-col">
|
||||
{/* Visual Thumbnail Area */}
|
||||
<div className="w-full h-36 bg-ink-50 border border-ink-200 rounded-lg mb-3 flex items-center justify-center overflow-hidden relative select-none group-hover:border-ink-300 transition-colors bg-gradient-to-br from-ink-50 to-ink-100/50">
|
||||
{/* Absolute overlays for controls */}
|
||||
@ -196,9 +221,9 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Preview Area Content */}
|
||||
{isImage ? (
|
||||
{hasBanner ? (
|
||||
<img
|
||||
src={getFullAssetUrl(asset.url)}
|
||||
src={getFullAssetUrl(bannerSrc)}
|
||||
alt={asset.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
@ -213,7 +238,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
<div
|
||||
id={`fallback-${asset.id}`}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{ display: isImage ? 'none' : 'flex' }}
|
||||
style={{ display: hasBanner ? 'none' : 'flex' }}
|
||||
>
|
||||
{isPdf ? (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative">
|
||||
@ -285,7 +310,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
<div className="w-12 h-12 rounded-xl bg-ink-100 border border-ink-200 flex items-center justify-center shadow-inner relative group-hover:scale-105 transition-transform">
|
||||
<Icon className="w-6 h-6 text-ink-650" />
|
||||
<div className="absolute -bottom-1 -right-1 bg-ink-800 border border-ink-700 rounded px-1 py-0.5 text-[7px] font-bold text-ink-0 uppercase">
|
||||
{asset.type === 'url' ? 'LINK' : 'FILE'}
|
||||
{asset.type === 'case_study' ? 'CASE STUDY' : asset.type === 'url' ? 'LINK' : 'FILE'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -293,7 +318,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-4">
|
||||
<div className="space-y-1 mb-4 flex-grow flex flex-col">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
|
||||
{asset.categoryId || 'General'}
|
||||
@ -309,29 +334,88 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
|
||||
{asset.title}
|
||||
</h3>
|
||||
<p className="text-[11px] font-medium text-ink-400 mt-1">
|
||||
{asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} • {new Date(asset.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{asset.description && (
|
||||
<p className="text-xs text-ink-500 line-clamp-2 mt-1.5 leading-relaxed" title={asset.description}>
|
||||
{asset.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-auto pt-2.5">
|
||||
<p className="text-[11px] font-medium text-ink-400">
|
||||
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} • {new Date(asset.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{asset.type === 'case_study' && (asset.problemStatement || asset.solution) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand?.();
|
||||
}}
|
||||
className="toggle-expand-btn text-[10px] font-bold text-ink-650 hover:text-ink-900 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2 py-0.5 rounded transition-all cursor-pointer"
|
||||
>
|
||||
{isExpanded ? 'Hide Summary' : 'Show Summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{asset.type === 'case_study' && isExpanded && (asset.problemStatement || asset.solution) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
className="mt-3.5 space-y-2.5 border-t border-ink-100 pt-3 text-[11px] overflow-hidden"
|
||||
>
|
||||
{asset.problemStatement && (
|
||||
<div>
|
||||
<span className="font-bold text-red-650 uppercase tracking-wider text-[9px] block mb-0.5">Problem / Challenge</span>
|
||||
<p className="text-ink-700 leading-relaxed bg-red-500/[0.01] border border-red-500/5 p-2 rounded" title={asset.problemStatement}>
|
||||
{asset.problemStatement}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{asset.solution && (
|
||||
<div>
|
||||
<span className="font-bold text-emerald-650 uppercase tracking-wider text-[9px] block mb-0.5">Our Approach</span>
|
||||
<p className="text-ink-700 leading-relaxed bg-emerald-500/[0.01] border border-emerald-500/5 p-2 rounded" title={asset.solution}>
|
||||
{asset.solution}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<a
|
||||
href={asset.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-bold text-ink-600 hover:text-ink-900 transition-colors bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-md"
|
||||
>
|
||||
<span>Read Full Case Study</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-ink-100 flex items-center justify-between mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-ink-400">
|
||||
{asset.type === 'url' ? 'Type' : 'Downloads'}
|
||||
{asset.type === 'url' || asset.type === 'case_study' ? 'Type' : 'Downloads'}
|
||||
</span>
|
||||
<span className="text-xs font-extrabold text-ink-900 mt-0.5">
|
||||
{asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
|
||||
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{asset.type === 'url' ? (
|
||||
{asset.type === 'url' || asset.type === 'case_study' ? (
|
||||
<a
|
||||
href={asset.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-7 h-7 rounded-md bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
|
||||
title="Open External URL"
|
||||
title={asset.type === 'case_study' ? 'Open Case Study' : 'Open External URL'}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
@ -366,7 +450,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -55,6 +55,37 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asset.type === 'case_study' && asset.thumbnailUrl && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
|
||||
<div className="w-full h-40 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
|
||||
<img
|
||||
src={asset.thumbnailUrl}
|
||||
alt="Case Study Banner"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asset.type === 'case_study' && asset.problemStatement && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-600 font-sans mb-1">Problem / Challenge</h4>
|
||||
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-red-500/[0.02] border border-red-500/10 p-3 rounded-lg shadow-sm">
|
||||
{asset.problemStatement}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{asset.type === 'case_study' && asset.solution && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-emerald-650 font-sans mb-1">Suggested Solution</h4>
|
||||
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-emerald-500/[0.02] border border-emerald-500/10 p-3 rounded-lg shadow-sm">
|
||||
{asset.solution}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
|
||||
|
||||
@ -49,7 +49,12 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
|
||||
const isLocalUrl = (url: string) => {
|
||||
const resolved = getFullAssetUrl(url);
|
||||
return resolved.includes('localhost') || resolved.includes('127.0.0.1');
|
||||
return (
|
||||
resolved.includes('localhost') ||
|
||||
resolved.includes('127.0.0.1') ||
|
||||
resolved.includes('ngrok-free.dev') ||
|
||||
resolved.includes('ngrok.io')
|
||||
);
|
||||
};
|
||||
|
||||
const getGithubRawUrl = (url: string) => {
|
||||
@ -79,19 +84,60 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
const isTextOrCodeAsset = (url: string, type: string) => {
|
||||
const assetUrl = url.toLowerCase();
|
||||
return (
|
||||
assetUrl.endsWith('.md') ||
|
||||
assetUrl.endsWith('.txt') ||
|
||||
assetUrl.endsWith('.json') ||
|
||||
assetUrl.endsWith('.js') ||
|
||||
assetUrl.endsWith('.ts') ||
|
||||
assetUrl.endsWith('.tsx') ||
|
||||
assetUrl.endsWith('.jsx') ||
|
||||
assetUrl.endsWith('.py') ||
|
||||
assetUrl.endsWith('.yaml') ||
|
||||
assetUrl.endsWith('.yml') ||
|
||||
assetUrl.endsWith('.css') ||
|
||||
assetUrl.endsWith('.html') ||
|
||||
assetUrl.endsWith('.sh') ||
|
||||
type.includes('text') ||
|
||||
type.includes('markdown') ||
|
||||
type.includes('json') ||
|
||||
type.includes('javascript')
|
||||
);
|
||||
};
|
||||
|
||||
const isAudioAsset = (url: string, type: string) => {
|
||||
const assetUrl = url.toLowerCase();
|
||||
return (
|
||||
assetUrl.endsWith('.mp3') ||
|
||||
assetUrl.endsWith('.wav') ||
|
||||
assetUrl.endsWith('.ogg') ||
|
||||
assetUrl.endsWith('.m4a') ||
|
||||
assetUrl.endsWith('.webm') ||
|
||||
type.includes('audio/')
|
||||
);
|
||||
};
|
||||
|
||||
const isVideoAsset = (url: string, type: string) => {
|
||||
const assetUrl = url.toLowerCase();
|
||||
return (
|
||||
assetUrl.endsWith('.mp4') ||
|
||||
assetUrl.endsWith('.webm') ||
|
||||
assetUrl.endsWith('.ogg') ||
|
||||
assetUrl.endsWith('.mov') ||
|
||||
type.includes('video/')
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && asset) {
|
||||
setTextPreviewContent('');
|
||||
setIsLoadingText(false);
|
||||
|
||||
const assetUrl = asset.url.toLowerCase();
|
||||
|
||||
if (
|
||||
asset.type !== 'url' &&
|
||||
(assetUrl.endsWith('.md') ||
|
||||
assetUrl.endsWith('.txt') ||
|
||||
asset.type.includes('text') ||
|
||||
asset.type.includes('markdown'))
|
||||
isTextOrCodeAsset(asset.url, asset.type)
|
||||
) {
|
||||
setIsLoadingText(true);
|
||||
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' })
|
||||
@ -172,7 +218,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
size="full"
|
||||
className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-5xl !w-full'}
|
||||
className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
@ -267,7 +313,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
title={asset.title}
|
||||
/>
|
||||
</div>
|
||||
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? (
|
||||
) : isTextOrCodeAsset(asset.url, asset.type) ? (
|
||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
||||
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||
@ -283,12 +329,12 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="border-b border-ink-200 pb-4 mb-6">
|
||||
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
||||
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
|
||||
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Source Code Format</p>
|
||||
</div>
|
||||
{asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
|
||||
<MarkdownViewer markdown={textPreviewContent} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-ink-800 bg-ink-50 border border-ink-200 rounded-lg p-4 break-words leading-relaxed overflow-x-auto">
|
||||
{textPreviewContent}
|
||||
</pre>
|
||||
)}
|
||||
@ -296,6 +342,36 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isAudioAsset(asset.url, asset.type) ? (
|
||||
<div className="w-full h-full flex flex-col justify-center items-center bg-ink-0 p-8 space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
|
||||
<span className="text-2xl">🎵</span>
|
||||
</div>
|
||||
<div className="text-center max-w-sm">
|
||||
<h4 className="text-sm font-bold text-ink-900 font-sans">Audio Player</h4>
|
||||
<p className="text-xs text-ink-500 mt-1 font-sans">You can listen to this audio asset directly in the browser.</p>
|
||||
</div>
|
||||
<audio
|
||||
src={getFullAssetUrl(asset.url)}
|
||||
controls
|
||||
className="w-full max-w-md mt-4 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : isVideoAsset(asset.url, asset.type) ? (
|
||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-950">
|
||||
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none w-full shrink-0">
|
||||
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
|
||||
<span className="font-sans text-ink-900">Video Player</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center min-h-0 relative p-4">
|
||||
<video
|
||||
src={getFullAssetUrl(asset.url)}
|
||||
controls
|
||||
className="max-w-full max-h-full rounded-xl shadow-lg border border-ink-800"
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
|
||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
||||
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
|
||||
@ -313,7 +389,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
||||
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
|
||||
</p>
|
||||
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left font-sans">
|
||||
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally.
|
||||
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost or ngrok tunnel), external Microsoft services cannot fetch your local files. Please download this asset using the button below to view it locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,293 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Asset } from '../../../types/assets';
|
||||
import type { AssetGroup } from '../../../services/assets-api';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import { Folder, Plus, Trash2, Search, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { createAssetGroup, deleteAssetGroup } from '../../../services/assets-api';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
|
||||
interface ManageGroupsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
assets: Asset[];
|
||||
groups: AssetGroup[];
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const ManageGroupsModal: React.FC<ManageGroupsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
assets,
|
||||
groups,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { success, error } = useToast();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const filteredAssets = assets.filter(asset =>
|
||||
asset.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const handleToggleAsset = (id: string) => {
|
||||
setSelectedAssetIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedAssetIds.length === assets.length) {
|
||||
setSelectedAssetIds([]);
|
||||
} else {
|
||||
setSelectedAssetIds(assets.map(a => a.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createAssetGroup({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assetIds: selectedAssetIds,
|
||||
});
|
||||
success('Asset group created', `"${name}" group was successfully registered.`);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSelectedAssetIds([]);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error('Failed to create group', err.response?.data?.error || 'Something went wrong.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (id: string, groupName: string) => {
|
||||
if (!window.confirm(`Are you sure you want to permanently delete the asset bundle "${groupName}"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAssetGroup(id);
|
||||
success('Asset group deleted', `"${groupName}" group has been deleted.`);
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error('Failed to delete group', err.response?.data?.error || 'Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string, title: string) => {
|
||||
const lowerType = type.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) {
|
||||
return (
|
||||
<div className="w-6 h-6 rounded bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-650 shrink-0">
|
||||
<span className="text-[8px] font-bold">PDF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) {
|
||||
return (
|
||||
<div className="w-6 h-6 rounded bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-650 shrink-0">
|
||||
<span className="text-[8px] font-bold">DOC</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) {
|
||||
return (
|
||||
<div className="w-6 h-6 rounded bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-650 shrink-0">
|
||||
<span className="text-[8px] font-bold">PPT</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-6 h-6 rounded bg-ink-500/10 border border-ink-500/20 flex items-center justify-center text-ink-600 shrink-0">
|
||||
<span className="text-[8px] font-bold">URL</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Manage Asset Groups & Bundles"
|
||||
subtitle="Bundle a fixed group of collateral/resources to easily assign them to partner users in one click."
|
||||
size="xl"
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-[450px]">
|
||||
{/* Left Side: Existing Groups */}
|
||||
<div className="border-r border-ink-200 pr-0 lg:pr-6 flex flex-col min-h-0">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-500 mb-4 flex items-center gap-1.5 font-sans">
|
||||
<Folder className="w-4 h-4 text-ink-750" />
|
||||
<span>Active Asset Groups ({groups.length})</span>
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3 max-h-[380px] pr-1">
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-center py-12 bg-ink-50/55 rounded-xl border border-dashed border-ink-200">
|
||||
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
|
||||
<p className="text-xs font-bold text-ink-900 font-sans">No Asset Groups Created</p>
|
||||
<p className="text-[10px] text-ink-450 mt-1 font-sans">Use the panel on the right to bundle your first set of resources.</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(group => (
|
||||
<div key={group.id} className="p-3 bg-ink-0 border border-ink-200 rounded-xl shadow-sm hover:border-ink-305 transition-all flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1 font-sans">
|
||||
<p className="text-xs font-bold text-ink-900 truncate">{group.name}</p>
|
||||
{group.description && (
|
||||
<p className="text-[10px] text-ink-500 mt-0.5 font-medium">{group.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 items-center">
|
||||
<Link
|
||||
to={`/admin/groups/${group.id}`}
|
||||
onClick={onClose}
|
||||
className="text-[9px] px-2 py-0.5 rounded bg-ink-900 hover:bg-ink-800 text-ink-0 font-bold flex items-center gap-1 transition-all border border-transparent shadow-sm cursor-pointer"
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
<span>View Page</span>
|
||||
</Link>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-ink-100 border border-ink-200 text-ink-700 font-bold">
|
||||
{group.assets.length} Assets
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteGroup(group.id, group.name)}
|
||||
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0"
|
||||
title="Delete Group"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Create Group Form */}
|
||||
<form onSubmit={handleCreateGroup} className="flex flex-col min-h-0">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-500 mb-4 flex items-center gap-1.5 font-sans">
|
||||
<Plus className="w-4 h-4 text-ink-750" />
|
||||
<span>Create New Bundle</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 flex-1 overflow-y-auto max-h-[380px] pr-1">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block font-sans">
|
||||
Bundle Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Sales Onboarding Pack"
|
||||
required
|
||||
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block font-sans">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe what assets are bundled in this group."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold resize-none font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1.5 font-sans">
|
||||
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide">
|
||||
Select Assets to Include
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="text-[9px] font-bold text-ink-900 hover:underline cursor-pointer"
|
||||
>
|
||||
{selectedAssetIds.length === assets.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Asset search and list */}
|
||||
<div className="border border-ink-200 rounded-lg overflow-hidden flex flex-col max-h-[200px]">
|
||||
<div className="relative border-b border-ink-200 shrink-0">
|
||||
<Search className="w-3.5 h-3.5 text-ink-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search catalog assets..."
|
||||
className="w-full bg-ink-50/50 py-1.5 pl-8 pr-3 text-[11px] font-medium outline-none text-ink-900 font-sans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto divide-y divide-ink-200 bg-ink-0">
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="p-4 text-center text-[10px] text-ink-400 font-medium font-sans">
|
||||
No assets found
|
||||
</div>
|
||||
) : (
|
||||
filteredAssets.map(asset => {
|
||||
const isSelected = selectedAssetIds.includes(asset.id);
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => handleToggleAsset(asset.id)}
|
||||
className={`flex items-center gap-3 p-2 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-100 ${
|
||||
isSelected ? 'bg-ink-100/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'border-ink-900 bg-ink-900 text-ink-0'
|
||||
: 'border-ink-200 bg-ink-50'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-2.5 h-2.5 stroke-[3]" />}
|
||||
</div>
|
||||
{getFileIcon(asset.type, asset.title)}
|
||||
<div className="min-w-0 flex-1 font-sans">
|
||||
<p className="truncate text-ink-900 text-[11px]">{asset.title}</p>
|
||||
{asset.categoryId && (
|
||||
<span className="text-[8px] px-1 py-0.2 rounded bg-ink-100 text-ink-600 font-bold uppercase tracking-wider">
|
||||
{asset.categoryId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="w-full flex justify-center py-2"
|
||||
>
|
||||
{isSubmitting ? 'Creating Bundle...' : 'Create Group / Bundle'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { updateAsset, bulkShareAssets } from '../../../services/assets-api';
|
||||
import { updateAsset, bulkShareAssets, getAssetGroups, addAssetsToGroup } from '../../../services/assets-api';
|
||||
import type { AssetGroup } from '../../../services/assets-api';
|
||||
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
@ -28,6 +29,15 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
||||
const [isSavingShare, setIsSavingShare] = useState(false);
|
||||
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
||||
const [groups, setGroups] = useState<AssetGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
getAssetGroups().then(setGroups).catch(console.error);
|
||||
setSelectedGroupId('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (asset) {
|
||||
@ -87,7 +97,15 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
} else if (assetIds && assetIds.length > 0) {
|
||||
await bulkShareAssets(assetIds, sharesList);
|
||||
}
|
||||
success('Share permissions updated', 'The asset visibility settings have been updated.');
|
||||
|
||||
if (selectedGroupId) {
|
||||
const ids = asset ? [asset.id] : (assetIds || []);
|
||||
if (ids.length > 0) {
|
||||
await addAssetsToGroup(selectedGroupId, ids);
|
||||
}
|
||||
}
|
||||
|
||||
success('Share permissions updated', 'The asset visibility and group membership settings have been updated.');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
@ -211,6 +229,29 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="pt-2 border-t border-ink-200">
|
||||
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block">
|
||||
Add to Asset Group / Bundle (Optional)
|
||||
</label>
|
||||
<select
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 font-semibold"
|
||||
>
|
||||
<option value="">-- Select Group --</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[9px] text-ink-450 mt-1 font-medium">
|
||||
This will automatically register the shared asset(s) as part of the selected bundle.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
||||
import { uploadAsset } from '../../../services/assets-api';
|
||||
import { uploadAsset, scrapeCaseStudy } from '../../../services/assets-api';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
@ -17,11 +17,16 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
onSuccess
|
||||
}) => {
|
||||
const { success, error } = useToast();
|
||||
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
||||
const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
|
||||
const [uploadUrl, setUploadUrl] = useState('');
|
||||
const [caseStudyUrl, setCaseStudyUrl] = useState('');
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
||||
const [problemStatement, setProblemStatement] = useState('');
|
||||
const [solution, setSolution] = useState('');
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const [uploadTitle, setUploadTitle] = useState('');
|
||||
const [uploadDescription, setUploadDescription] = useState('');
|
||||
const [uploadCategory, setUploadCategory] = useState('Marketing');
|
||||
@ -50,6 +55,46 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
}
|
||||
}, [uploadFile]);
|
||||
|
||||
const handleFetchCaseStudy = async () => {
|
||||
if (!caseStudyUrl) {
|
||||
error('URL Required', 'Please enter a case study URL first.');
|
||||
return;
|
||||
}
|
||||
if (!caseStudyUrl.startsWith('http')) {
|
||||
error('Invalid URL', 'Please enter a valid URL starting with http:// or https://');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const data = await scrapeCaseStudy(caseStudyUrl);
|
||||
setUploadTitle(data.title);
|
||||
setThumbnailUrl(data.thumbnailUrl);
|
||||
setProblemStatement(data.problemStatement);
|
||||
setSolution(data.solution);
|
||||
|
||||
// Auto-set tag as case-study
|
||||
setUploadTags(prev => {
|
||||
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (!tags.includes('case-study')) {
|
||||
tags.push('case-study');
|
||||
}
|
||||
return tags.join(', ');
|
||||
});
|
||||
|
||||
// Auto-set category and subcategory
|
||||
setUploadCategory('Resources');
|
||||
setUploadSubcategory('Case Study');
|
||||
|
||||
success('Case Study Details Fetched', 'Title, banner, problem, and solution successfully retrieved.');
|
||||
} catch (err: any) {
|
||||
console.error('Failed to scrape case study', err);
|
||||
error('Failed to retrieve case study details', err.response?.data?.error || 'Make sure the URL is a valid showcase case study.');
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@ -63,6 +108,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
e.preventDefault();
|
||||
if (uploadTab === 'file' && !uploadFile) return;
|
||||
if (uploadTab === 'url' && !uploadUrl) return;
|
||||
if (uploadTab === 'case_study' && !caseStudyUrl) return;
|
||||
|
||||
setIsUploading(true);
|
||||
const formData = new FormData();
|
||||
@ -70,9 +116,10 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
formData.append('file', uploadFile);
|
||||
} else {
|
||||
formData.append('isUrlAsset', 'true');
|
||||
formData.append('url', uploadUrl);
|
||||
formData.append('url', uploadTab === 'case_study' ? caseStudyUrl : uploadUrl);
|
||||
}
|
||||
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : uploadUrl));
|
||||
formData.append('type', uploadTab === 'case_study' ? 'case_study' : (uploadTab === 'url' ? 'url' : ''));
|
||||
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : (uploadTab === 'case_study' ? caseStudyUrl : uploadUrl)));
|
||||
formData.append('description', uploadDescription);
|
||||
formData.append('categoryId', uploadCategory);
|
||||
formData.append('subcategory', uploadSubcategory);
|
||||
@ -80,10 +127,22 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
formData.append('githubUrl', uploadGithubUrl);
|
||||
formData.append('isDownloadable', String(uploadIsDownloadable));
|
||||
|
||||
if (thumbnailUrl) {
|
||||
formData.append('thumbnailUrl', thumbnailUrl);
|
||||
}
|
||||
if (uploadTab === 'case_study') {
|
||||
formData.append('problemStatement', problemStatement);
|
||||
formData.append('solution', solution);
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadAsset(formData);
|
||||
setUploadFile(null);
|
||||
setUploadUrl('');
|
||||
setCaseStudyUrl('');
|
||||
setThumbnailUrl('');
|
||||
setProblemStatement('');
|
||||
setSolution('');
|
||||
setUploadTitle('');
|
||||
setUploadDescription('');
|
||||
setUploadCategory('Marketing');
|
||||
@ -147,6 +206,13 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
>
|
||||
External Web URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadTab('case_study')}
|
||||
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'case_study' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
|
||||
>
|
||||
Showcase Case Study
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="upload-asset-form" onSubmit={handleUploadSubmit} className="space-y-4 mt-4">
|
||||
@ -249,7 +315,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : uploadTab === 'url' ? (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
|
||||
<input
|
||||
@ -261,6 +327,82 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Case Study URL</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={caseStudyUrl}
|
||||
onChange={(e) => setCaseStudyUrl(e.target.value)}
|
||||
placeholder="https://showcase.tech4bizsolutions.com/ai-forecasting-pricing-for-fmcg-tech4biz-case-study/"
|
||||
required={uploadTab === 'case_study'}
|
||||
className="flex-1 bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFetchCaseStudy}
|
||||
disabled={isScraping || !caseStudyUrl}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{isScraping ? 'Fetching...' : 'Fetch Details'}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-450 mt-1 block">
|
||||
Fetches title, banner image, problem statement, and solution from the showcase platform.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Scraped Problem Statement</label>
|
||||
<textarea
|
||||
value={problemStatement}
|
||||
onChange={(e) => setProblemStatement(e.target.value)}
|
||||
placeholder="Problem Statement will appear here..."
|
||||
rows={4}
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-y font-sans leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Scraped Solution</label>
|
||||
<textarea
|
||||
value={solution}
|
||||
onChange={(e) => setSolution(e.target.value)}
|
||||
placeholder="Solution details will appear here..."
|
||||
rows={4}
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-y font-sans leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Thumbnail Image URL (Optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={thumbnailUrl}
|
||||
onChange={(e) => setThumbnailUrl(e.target.value)}
|
||||
placeholder="https://example.com/image.png"
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{thumbnailUrl && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
|
||||
<div className="w-full h-32 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt="Thumbnail Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
@ -302,12 +444,13 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
|
||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description <span className="text-red-500">*</span></label>
|
||||
<textarea
|
||||
value={uploadDescription}
|
||||
onChange={(e) => setUploadDescription(e.target.value)}
|
||||
placeholder="Enter short description about this asset..."
|
||||
rows={3}
|
||||
required
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,18 +1,52 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { invitePartner } from "../services/auth-api";
|
||||
import type { InviteResponse } from "../services/auth-api";
|
||||
import { invitePartner, updatePartner, resendInvitePartner } from "../services/auth-api";
|
||||
import type { InviteResponse, InviteParams, UpdatePartnerParams, Partner } from "../services/auth-api";
|
||||
import { PARTNERS_QUERY_KEYS } from "./use-partners-query";
|
||||
|
||||
export const useInvitePartnerMutation = (): UseMutationResult<
|
||||
InviteResponse,
|
||||
Error,
|
||||
string
|
||||
InviteParams
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<InviteResponse, Error, string>({
|
||||
mutationFn: (email: string) => invitePartner(email),
|
||||
return useMutation<InviteResponse, Error, InviteParams>({
|
||||
mutationFn: (params: InviteParams) => invitePartner(params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePartnerMutation = (): UseMutationResult<
|
||||
Partner,
|
||||
Error,
|
||||
{ partnerId: string; params: UpdatePartnerParams }
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Partner, Error, { partnerId: string; params: UpdatePartnerParams }>({
|
||||
mutationFn: ({ partnerId, params }) => updatePartner(partnerId, params),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useResendInviteMutation = (): UseMutationResult<
|
||||
{ success: boolean },
|
||||
Error,
|
||||
string
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean }, Error, string>({
|
||||
mutationFn: (partnerId: string) => resendInvitePartner(partnerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: PARTNERS_QUERY_KEYS.list(),
|
||||
|
||||
@ -22,8 +22,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
|
||||
}),
|
||||
initTheme: () => set(() => {
|
||||
const saved = localStorage.getItem('theme-preference');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = saved ? (saved as Theme) : (prefersDark ? 'dark' : 'light');
|
||||
const initialTheme = saved ? (saved as Theme) : 'dark';
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { Variants } from "framer-motion";
|
||||
import { UploadCloud, Search, File, CheckCircle, Share2 } from "lucide-react";
|
||||
import { UploadCloud, Search, File, CheckCircle, Share2, Folder } from "lucide-react";
|
||||
import { useAuthStore } from "../hooks/use-auth";
|
||||
import { axiosInstance } from "../services/axios";
|
||||
import {
|
||||
@ -12,7 +12,9 @@ import {
|
||||
approveDownloadRequest,
|
||||
rejectDownloadRequest,
|
||||
downloadAssetFile,
|
||||
getAssetGroups,
|
||||
} from "../services/assets-api";
|
||||
import type { AssetGroup } from "../services/assets-api";
|
||||
import { useToast } from "../hooks/use-toast";
|
||||
|
||||
// Subcomponents
|
||||
@ -23,6 +25,7 @@ import { ShareAssetModal } from "../features/assets/components/ShareAssetModal";
|
||||
import { AssetDetailsModal } from "../features/assets/components/AssetDetailsModal";
|
||||
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
|
||||
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
|
||||
import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
|
||||
import { PageHeader } from "../components/ui/PageHeader";
|
||||
import Button from "../components/ui/Button";
|
||||
import { PageLayout } from "../components/layout/PageLayout";
|
||||
@ -52,6 +55,8 @@ export const AssetsPage = () => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [groups, setGroups] = useState<AssetGroup[]>([]);
|
||||
|
||||
// Search & Filter
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
|
||||
@ -63,15 +68,26 @@ export const AssetsPage = () => {
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
|
||||
const [isGroupsOpen, setIsGroupsOpen] = useState(false);
|
||||
|
||||
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const availableCategories = new Set(
|
||||
assets.map((asset) => asset.categoryId || "General")
|
||||
);
|
||||
if (selectedCategory !== "ALL" && !availableCategories.has(selectedCategory)) {
|
||||
setSelectedCategory("ALL");
|
||||
}
|
||||
}, [assets, selectedCategory]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -82,6 +98,9 @@ export const AssetsPage = () => {
|
||||
if (user?.role === "ADMIN") {
|
||||
const orgsData = await getOrganizations();
|
||||
setOrganizations(orgsData);
|
||||
|
||||
const groupsData = await getAssetGroups();
|
||||
setGroups(groupsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch assets data", err);
|
||||
@ -201,11 +220,13 @@ export const AssetsPage = () => {
|
||||
|
||||
const CATEGORIES = [
|
||||
"ALL",
|
||||
"Marketing",
|
||||
"Presentations",
|
||||
"Branding",
|
||||
"Resources",
|
||||
"Technical",
|
||||
...Array.from(
|
||||
new Set(
|
||||
assets
|
||||
.map((asset) => asset.categoryId || "General")
|
||||
.filter((catId): catId is string => !!catId)
|
||||
)
|
||||
).sort()
|
||||
];
|
||||
|
||||
const filteredAssets = assets.filter((asset) => {
|
||||
@ -222,7 +243,9 @@ export const AssetsPage = () => {
|
||||
asset.tags.some((tag) => tag.toLowerCase().includes(query));
|
||||
|
||||
const matchesCategory =
|
||||
selectedCategory === "ALL" || asset.categoryId === selectedCategory;
|
||||
selectedCategory === "ALL" ||
|
||||
asset.categoryId === selectedCategory ||
|
||||
(!asset.categoryId && selectedCategory === "General");
|
||||
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
@ -313,6 +336,17 @@ export const AssetsPage = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === "ADMIN" && (
|
||||
<Button
|
||||
onClick={() => setIsGroupsOpen(true)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Folder className="w-3.5 h-3.5" />}
|
||||
>
|
||||
Manage Groups
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === "ADMIN" && (
|
||||
<Button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
@ -347,10 +381,10 @@ export const AssetsPage = () => {
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"
|
||||
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 items-start"
|
||||
>
|
||||
{filteredAssets.map((asset) => (
|
||||
<motion.div key={asset.id} variants={itemVariants}>
|
||||
<motion.div key={asset.id} variants={itemVariants} className="relative h-[410px] w-full flex flex-col">
|
||||
<AssetCard
|
||||
asset={asset}
|
||||
user={user}
|
||||
@ -365,6 +399,8 @@ export const AssetsPage = () => {
|
||||
onRequestDownload={handleRequestDownload}
|
||||
isSelected={selectedAssetIds.includes(asset.id)}
|
||||
onToggleSelect={handleToggleSelectAsset}
|
||||
isExpanded={expandedAssetId === asset.id}
|
||||
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
@ -430,6 +466,14 @@ export const AssetsPage = () => {
|
||||
onApprove={handleApproveRequest}
|
||||
onReject={handleRejectRequest}
|
||||
/>
|
||||
|
||||
<ManageGroupsModal
|
||||
isOpen={isGroupsOpen}
|
||||
onClose={() => setIsGroupsOpen(false)}
|
||||
assets={assets}
|
||||
groups={groups}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -31,7 +31,7 @@ export const InvitePage: React.FC = () => {
|
||||
// verify token on mount
|
||||
const verifyToken = async () => {
|
||||
try {
|
||||
const response = await axiosInstance.get(`/auth/verify-invite?token=${token}`);
|
||||
const response = await axiosInstance.get(`/auth/invite/${token}`);
|
||||
setEmail(response.data.email);
|
||||
setStatus('valid');
|
||||
} catch (err) {
|
||||
@ -58,7 +58,7 @@ export const InvitePage: React.FC = () => {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await axiosInstance.post('/auth/activate-partner', {
|
||||
const response = await axiosInstance.post('/auth/invite/accept', {
|
||||
token,
|
||||
password
|
||||
});
|
||||
@ -80,7 +80,7 @@ export const InvitePage: React.FC = () => {
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Activation failed');
|
||||
setError(err.response?.data?.detail || err.response?.data?.error || 'Activation failed');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -38,6 +38,8 @@ export const OnboardingPage: React.FC = () => {
|
||||
const [msaContent, setMsaContent] = useState<string>('');
|
||||
const [msaPdfUrl, setMsaPdfUrl] = useState<string | null>(null);
|
||||
const [loadingDocs, setLoadingDocs] = useState(true);
|
||||
const [ndaSkipped, setNdaSkipped] = useState(false);
|
||||
const [msaSkipped, setMsaSkipped] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocs = async () => {
|
||||
@ -46,18 +48,73 @@ export const OnboardingPage: React.FC = () => {
|
||||
getActiveLegalDocument('NDA'),
|
||||
getActiveLegalDocument('MSA')
|
||||
]);
|
||||
setNdaContent(ndaData.content);
|
||||
setNdaPdfUrl(ndaData.pdfUrl || null);
|
||||
setMsaContent(msaData.content);
|
||||
setMsaPdfUrl(msaData.pdfUrl || null);
|
||||
|
||||
const ndaIsSkipped = !!ndaData.skipped;
|
||||
const msaIsSkipped = !!msaData.skipped;
|
||||
|
||||
setNdaSkipped(ndaIsSkipped);
|
||||
setMsaSkipped(msaIsSkipped);
|
||||
|
||||
if (!ndaIsSkipped) {
|
||||
setNdaContent(ndaData.content);
|
||||
setNdaPdfUrl(ndaData.pdfUrl || null);
|
||||
}
|
||||
if (!msaIsSkipped) {
|
||||
setMsaContent(msaData.content);
|
||||
setMsaPdfUrl(msaData.pdfUrl || null);
|
||||
}
|
||||
|
||||
// Fetch acceptances to determine active step
|
||||
const acceptances = await getMyAcceptances();
|
||||
const ndaAcceptance = acceptances.find((a: any) => a.document.type === 'NDA');
|
||||
const msaAcceptance = acceptances.find((a: any) => a.document.type === 'MSA');
|
||||
|
||||
if (ndaAcceptance) {
|
||||
if (ndaAcceptance.signatureBase64) {
|
||||
setNdaSignature(ndaAcceptance.signatureBase64);
|
||||
setNdaMode('draw');
|
||||
} else if (ndaAcceptance.documentUrl) {
|
||||
setNdaUploadUrl(ndaAcceptance.documentUrl);
|
||||
setNdaMode('upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (msaAcceptance) {
|
||||
if (msaAcceptance.signatureBase64) {
|
||||
setMsaSignature(msaAcceptance.signatureBase64);
|
||||
setMsaMode('draw');
|
||||
} else if (msaAcceptance.documentUrl) {
|
||||
setMsaUploadUrl(msaAcceptance.documentUrl);
|
||||
setMsaMode('upload');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine step based on skipped/accepted statuses
|
||||
if (ndaIsSkipped || ndaAcceptance) {
|
||||
if (msaIsSkipped || msaAcceptance) {
|
||||
setStep(3); // both done/skipped
|
||||
} else {
|
||||
setStep(2); // nda done/skipped, msa needed
|
||||
}
|
||||
} else {
|
||||
setStep(1); // nda needed
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch legal documents:', err);
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
};
|
||||
fetchDocs();
|
||||
}, []);
|
||||
|
||||
if (user?.onboardingStatus === 'APPROVED') {
|
||||
navigate('/client');
|
||||
} else if (user?.onboardingStatus === 'PENDING_APPROVAL') {
|
||||
setStep(3);
|
||||
setLoadingDocs(false);
|
||||
} else {
|
||||
fetchDocs();
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
const handlePrint = (type: DocumentType) => {
|
||||
const content = type === 'NDA' ? ndaContent : msaContent;
|
||||
@ -145,43 +202,6 @@ export const OnboardingPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.onboardingStatus === 'APPROVED') {
|
||||
navigate('/client');
|
||||
} else if (user?.onboardingStatus === 'PENDING_APPROVAL') {
|
||||
setStep(3); // Go straight to pending screen
|
||||
} else {
|
||||
// Check existing acceptances to skip steps
|
||||
getMyAcceptances().then(data => {
|
||||
const ndaAcceptance = data.find((a: any) => a.document.type === 'NDA');
|
||||
const msaAcceptance = data.find((a: any) => a.document.type === 'MSA');
|
||||
|
||||
if (ndaAcceptance) {
|
||||
if (ndaAcceptance.signatureBase64) {
|
||||
setNdaSignature(ndaAcceptance.signatureBase64);
|
||||
setNdaMode('draw');
|
||||
} else if (ndaAcceptance.documentUrl) {
|
||||
setNdaUploadUrl(ndaAcceptance.documentUrl);
|
||||
setNdaMode('upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (msaAcceptance) {
|
||||
if (msaAcceptance.signatureBase64) {
|
||||
setMsaSignature(msaAcceptance.signatureBase64);
|
||||
setMsaMode('draw');
|
||||
} else if (msaAcceptance.documentUrl) {
|
||||
setMsaUploadUrl(msaAcceptance.documentUrl);
|
||||
setMsaMode('upload');
|
||||
}
|
||||
}
|
||||
|
||||
if (ndaAcceptance && !msaAcceptance) setStep(2);
|
||||
if (ndaAcceptance && msaAcceptance) setStep(3);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: any;
|
||||
|
||||
@ -238,7 +258,12 @@ export const OnboardingPage: React.FC = () => {
|
||||
success(`${type} Agreement signed`, `Your signature on the ${type} has been registered.`);
|
||||
|
||||
if (type === 'NDA') {
|
||||
setStep(2);
|
||||
if (msaSkipped) {
|
||||
await checkAuth();
|
||||
setStep(3);
|
||||
} else {
|
||||
setStep(2);
|
||||
}
|
||||
} else {
|
||||
await checkAuth();
|
||||
setStep(3); // PENDING_APPROVAL
|
||||
@ -348,24 +373,28 @@ export const OnboardingPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="relative pl-8">
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
|
||||
{step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />}
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${ndaSkipped || step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
|
||||
{(ndaSkipped || step > 1) ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />}
|
||||
</div>
|
||||
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
|
||||
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
|
||||
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (ndaSkipped || step > 1) ? '100%' : '0%' }}></div>
|
||||
</div>
|
||||
<h3 className={`font-bold transition-colors ${step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>NDA Agreement</h3>
|
||||
<h3 className={`font-bold transition-colors ${ndaSkipped ? 'text-ink-400 line-through' : step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
NDA Agreement {ndaSkipped && <span className="text-[10px] text-ink-400 ml-1 font-normal">(Not Required)</span>}
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-ink-500 mt-1">Non-disclosure signature.</p>
|
||||
</div>
|
||||
|
||||
<div className="relative pl-8">
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
|
||||
{step > 2 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${msaSkipped || step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
|
||||
{(msaSkipped || step > 2) ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
||||
</div>
|
||||
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
|
||||
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
|
||||
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (msaSkipped || step > 2) ? '100%' : '0%' }}></div>
|
||||
</div>
|
||||
<h3 className={`font-bold transition-colors ${step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>MSA Agreement</h3>
|
||||
<h3 className={`font-bold transition-colors ${msaSkipped ? 'text-ink-400 line-through' : step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>
|
||||
MSA Agreement {msaSkipped && <span className="text-[10px] text-ink-400 ml-1 font-normal">(Not Required)</span>}
|
||||
</h3>
|
||||
<p className="text-xs font-medium text-ink-500 mt-1">Master Services Agreement.</p>
|
||||
</div>
|
||||
|
||||
@ -417,7 +446,7 @@ export const OnboardingPage: React.FC = () => {
|
||||
disabled={(!ndaSignature && !ndaUploadUrl) || isSubmitting}
|
||||
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Processing...' : 'Continue to MSA'}
|
||||
{isSubmitting ? 'Processing...' : msaSkipped ? 'Submit' : 'Continue to MSA'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -442,9 +471,13 @@ export const OnboardingPage: React.FC = () => {
|
||||
{renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)}
|
||||
|
||||
<div className="mt-auto pt-6 border-t border-ink-200 flex justify-between items-center">
|
||||
<button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 transition-colors">
|
||||
Back to NDA
|
||||
</button>
|
||||
{!ndaSkipped ? (
|
||||
<button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 transition-colors">
|
||||
Back to NDA
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={() => submitDocument('MSA')}
|
||||
disabled={(!msaSignature && !msaUploadUrl) || isSubmitting}
|
||||
@ -472,11 +505,15 @@ export const OnboardingPage: React.FC = () => {
|
||||
<div className="p-6 bg-ink-50 rounded-2xl border border-ink-200 max-w-sm w-full">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-ink-500">NDA Status</span>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">
|
||||
{ndaSkipped ? 'Not Required' : 'Signed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-ink-500">MSA Status</span>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
|
||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">
|
||||
{msaSkipped ? 'Not Required' : 'Signed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-3 border-t border-ink-200">
|
||||
<span className="text-sm font-medium text-ink-500">Account Access</span>
|
||||
|
||||
@ -143,7 +143,6 @@ export const ApprovalsPage: React.FC = () => {
|
||||
nda: false,
|
||||
msa: false,
|
||||
};
|
||||
const isEligible = partnerVerified.nda && partnerVerified.msa;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
@ -181,6 +180,16 @@ export const ApprovalsPage: React.FC = () => {
|
||||
? "Uploaded PDF"
|
||||
: "Digital Sign"}
|
||||
</span>
|
||||
{nda.signatureBase64 && (
|
||||
<div className="w-12 h-6 border border-ink-200 rounded bg-white flex items-center justify-center p-0.5 shadow-sm overflow-hidden shrink-0 ml-1" title="Drawn Signature">
|
||||
<img
|
||||
src={nda.signatureBase64}
|
||||
alt="NDA Signature"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ filter: 'brightness(0)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenPreview(partner, "NDA")}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
||||
@ -194,6 +203,13 @@ export const ApprovalsPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : !partner.assignedNdaId ? (
|
||||
<div className="flex items-center gap-2 text-ink-500">
|
||||
<CheckCircle className="w-4 h-4 text-ink-400" />
|
||||
<span className="text-xs font-semibold bg-ink-50 border border-ink-200 text-ink-500 px-2 py-0.5 rounded-md">
|
||||
Not Required
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-ink-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
@ -212,6 +228,16 @@ export const ApprovalsPage: React.FC = () => {
|
||||
? "Uploaded PDF"
|
||||
: "Digital Sign"}
|
||||
</span>
|
||||
{msa.signatureBase64 && (
|
||||
<div className="w-12 h-6 border border-ink-200 rounded bg-white flex items-center justify-center p-0.5 shadow-sm overflow-hidden shrink-0 ml-1" title="Drawn Signature">
|
||||
<img
|
||||
src={msa.signatureBase64}
|
||||
alt="MSA Signature"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ filter: 'brightness(0)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenPreview(partner, "MSA")}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
||||
@ -225,6 +251,13 @@ export const ApprovalsPage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : !partner.assignedMsaId ? (
|
||||
<div className="flex items-center gap-2 text-ink-500">
|
||||
<CheckCircle className="w-4 h-4 text-ink-400" />
|
||||
<span className="text-xs font-semibold bg-ink-50 border border-ink-200 text-ink-500 px-2 py-0.5 rounded-md">
|
||||
Not Required
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-ink-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
@ -240,9 +273,8 @@ export const ApprovalsPage: React.FC = () => {
|
||||
approvePartner(partner.id, partner.email)
|
||||
}
|
||||
disabled={
|
||||
!isEligible ||
|
||||
(approveMutation.isPending &&
|
||||
approveMutation.variables === partner.id)
|
||||
approveMutation.isPending &&
|
||||
approveMutation.variables === partner.id
|
||||
}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@ -285,6 +317,8 @@ export const ApprovalsPage: React.FC = () => {
|
||||
approveMutation.isPending &&
|
||||
approveMutation.variables === selectedPartner.id
|
||||
}
|
||||
assignedNdaId={selectedPartner.assignedNdaId}
|
||||
assignedMsaId={selectedPartner.assignedMsaId}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
351
Channel-Frontend/src/pages/admin/GroupDetailsPage.tsx
Normal file
351
Channel-Frontend/src/pages/admin/GroupDetailsPage.tsx
Normal file
@ -0,0 +1,351 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Folder, File, Tag, Trash2, Plus, Search, CheckCircle } from 'lucide-react';
|
||||
import { getAssetGroups, getAssets, addAssetsToGroup, removeAssetsFromGroup } from '../../services/assets-api';
|
||||
import type { AssetGroup } from '../../services/assets-api';
|
||||
import type { Asset } from '../../types/assets';
|
||||
import PageHeader from '../../components/ui/PageHeader';
|
||||
import { PageLayout } from '../../components/layout/PageLayout';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Modal from '../../components/ui/Modal';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export const GroupDetailsPage: React.FC = () => {
|
||||
const { groupId } = useParams<{ groupId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { success, error } = useToast();
|
||||
|
||||
const [group, setGroup] = useState<AssetGroup | null>(null);
|
||||
const [allAssets, setAllAssets] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [assetToRemove, setAssetToRemove] = useState<{ id: string, title: string } | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
const [groups, assets] = await Promise.all([
|
||||
getAssetGroups(),
|
||||
getAssets()
|
||||
]);
|
||||
const found = groups.find(g => g.id === groupId);
|
||||
setGroup(found || null);
|
||||
setAllAssets(assets);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadData();
|
||||
}, [groupId]);
|
||||
|
||||
const handleRemoveAsset = (assetId: string, assetTitle: string) => {
|
||||
setAssetToRemove({ id: assetId, title: assetTitle });
|
||||
};
|
||||
|
||||
const handleAddAssets = async () => {
|
||||
if (!groupId || selectedAssetIds.length === 0) return;
|
||||
try {
|
||||
await addAssetsToGroup(groupId, selectedAssetIds);
|
||||
success('Assets added', `Successfully added ${selectedAssetIds.length} assets to the bundle.`);
|
||||
setSelectedAssetIds([]);
|
||||
setIsAddModalOpen(false);
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
error('Failed to add assets', err.response?.data?.error || 'Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const existingAssetIds = new Set(group?.assets.map(a => a.id) || []);
|
||||
const addableAssets = allAssets.filter(a => !existingAssetIds.has(a.id));
|
||||
const filteredAddableAssets = addableAssets.filter(a =>
|
||||
a.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getFileIcon = (type: string, title: string) => {
|
||||
const lowerType = type.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-650 shrink-0">
|
||||
<span className="text-[10px] font-bold">PDF</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-650 shrink-0">
|
||||
<span className="text-[10px] font-bold">DOC</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-650 shrink-0">
|
||||
<span className="text-[10px] font-bold">PPT</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-xl bg-ink-500/10 border border-ink-500/20 flex items-center justify-center text-ink-600 shrink-0">
|
||||
<span className="text-[10px] font-bold">URL</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const headerNode = (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/admin/assets')}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors uppercase tracking-wider mb-2 cursor-pointer w-fit"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to Asset Library</span>
|
||||
</button>
|
||||
<PageHeader
|
||||
title={group ? `Bundle: ${group.name}` : 'Asset Bundle'}
|
||||
subtitle={group?.description || 'View and inspect the assets stored in this bundle.'}
|
||||
badge={
|
||||
<div className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase">
|
||||
<Folder className="w-3.5 h-3.5 text-ink-800" />
|
||||
<span>{group?.assets?.length || 0} Assets</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout header={headerNode}>
|
||||
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="py-20 flex justify-center items-center">
|
||||
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : !group ? (
|
||||
<div className="py-16 text-center bg-ink-0 border border-ink-200 rounded-xl">
|
||||
<Folder className="w-12 h-12 text-ink-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-ink-900">Group not found</h3>
|
||||
<p className="text-ink-500 text-sm mt-1">
|
||||
The asset group you are looking for does not exist or has been deleted.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate('/admin/assets')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
>
|
||||
Return to Catalog
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-ink-0 border border-ink-200 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xs font-bold text-ink-500 uppercase tracking-wider">
|
||||
Assets in this Bundle
|
||||
</h3>
|
||||
<Button
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Add Assets</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{group.assets.length === 0 ? (
|
||||
<div className="py-12 text-center border border-dashed border-ink-200 rounded-xl bg-ink-50/50">
|
||||
<File className="w-10 h-10 text-ink-300 mx-auto mb-2" />
|
||||
<p className="text-xs font-bold text-ink-900">This bundle is currently empty</p>
|
||||
<p className="text-[10px] text-ink-450 mt-1">Add assets to this group using the "Add Assets" button above.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{group.assets.map(asset => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="p-4 bg-ink-50/40 border border-ink-200 rounded-xl shadow-sm hover:border-ink-350 transition-all flex items-start gap-4"
|
||||
>
|
||||
{getFileIcon(asset.type, asset.title)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
|
||||
{asset.isDownloadable && (
|
||||
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.2 rounded bg-green-500/10 text-green-600 border border-green-500/20">
|
||||
Downloadable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{asset.description && (
|
||||
<p className="text-[10px] text-ink-550 mt-1 line-clamp-2 leading-relaxed">
|
||||
{asset.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-3 items-center text-[9px] text-ink-450 font-bold">
|
||||
<div className="flex items-center gap-1 bg-ink-100 px-1.5 py-0.5 rounded border border-ink-200 text-ink-700">
|
||||
<Tag className="w-3 h-3" />
|
||||
<span className="uppercase">{asset.categoryId || 'General'}</span>
|
||||
</div>
|
||||
{asset.subcategory && (
|
||||
<div className="bg-ink-50 px-1.5 py-0.5 rounded border border-ink-150">
|
||||
{asset.subcategory}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveAsset(asset.id, asset.title)}
|
||||
className="p-1.5 text-red-500 hover:text-red-750 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0"
|
||||
title="Remove from Bundle"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Assets Modal */}
|
||||
<Modal
|
||||
isOpen={isAddModalOpen}
|
||||
onClose={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setSelectedAssetIds([]);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
title="Add Assets to Bundle"
|
||||
subtitle={`Select assets to add to "${group?.name}"`}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-ink-450 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search catalog assets..."
|
||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg pl-9 pr-4 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-ink-200 rounded-xl overflow-hidden max-h-[300px] overflow-y-auto divide-y divide-ink-200 bg-ink-0">
|
||||
{filteredAddableAssets.length === 0 ? (
|
||||
<div className="p-8 text-center text-xs text-ink-400 font-medium">
|
||||
No new assets available to add
|
||||
</div>
|
||||
) : (
|
||||
filteredAddableAssets.map(asset => {
|
||||
const isSelected = selectedAssetIds.includes(asset.id);
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onClick={() => {
|
||||
setSelectedAssetIds(prev =>
|
||||
prev.includes(asset.id) ? prev.filter(x => x !== asset.id) : [...prev, asset.id]
|
||||
);
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${
|
||||
isSelected ? 'bg-ink-50/70' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'border-ink-900 bg-ink-900 text-ink-0'
|
||||
: 'border-ink-200 bg-ink-50'
|
||||
}`}>
|
||||
{isSelected && <CheckCircle className="w-3 h-3 stroke-[3]" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-ink-900">{asset.title}</p>
|
||||
<span className="text-[9px] text-ink-450 uppercase tracking-wider">
|
||||
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : asset.categoryId || 'General'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsAddModalOpen(false);
|
||||
setSelectedAssetIds([]);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddAssets}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={selectedAssetIds.length === 0}
|
||||
>
|
||||
Add {selectedAssetIds.length} Asset{selectedAssetIds.length !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Remove Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={assetToRemove !== null}
|
||||
onClose={() => setAssetToRemove(null)}
|
||||
title="Remove Asset from Bundle"
|
||||
subtitle="Confirm you want to remove this resource."
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4 font-sans">
|
||||
<p className="text-xs text-ink-600 leading-relaxed">
|
||||
Are you sure you want to remove <span className="font-bold text-ink-900">"{assetToRemove?.title}"</span> from this asset bundle?
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<Button
|
||||
onClick={() => setAssetToRemove(null)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!assetToRemove || !groupId) return;
|
||||
try {
|
||||
await removeAssetsFromGroup(groupId, [assetToRemove.id]);
|
||||
success('Asset removed', `"${assetToRemove.title}" was removed from the bundle.`);
|
||||
setAssetToRemove(null);
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
error('Failed to remove asset', err.response?.data?.error || 'Something went wrong.');
|
||||
}
|
||||
}}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Remove Asset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
@ -59,3 +59,48 @@ export const bulkShareAssets = async (
|
||||
): Promise<void> => {
|
||||
await axiosInstance.patch('/assets/bulk-share', { assetIds, shares });
|
||||
};
|
||||
|
||||
export interface AssetGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
assets: Asset[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const getAssetGroups = async (): Promise<AssetGroup[]> => {
|
||||
const response = await axiosInstance.get<AssetGroup[]>('/assets/groups/list');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createAssetGroup = async (payload: { name: string; description?: string; assetIds?: string[] }): Promise<AssetGroup> => {
|
||||
const response = await axiosInstance.post<AssetGroup>('/assets/groups/create', payload);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteAssetGroup = async (id: string): Promise<void> => {
|
||||
await axiosInstance.delete(`/assets/groups/${id}`);
|
||||
};
|
||||
|
||||
export const addAssetsToGroup = async (groupId: string, assetIds: string[]): Promise<void> => {
|
||||
await axiosInstance.patch(`/assets/groups/${groupId}/add-assets`, { assetIds });
|
||||
};
|
||||
|
||||
export const removeAssetsFromGroup = async (groupId: string, assetIds: string[]): Promise<void> => {
|
||||
await axiosInstance.patch(`/assets/groups/${groupId}/remove-assets`, { assetIds });
|
||||
};
|
||||
|
||||
export interface ScrapedCaseStudy {
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
problemStatement: string;
|
||||
solution: string;
|
||||
}
|
||||
|
||||
export const scrapeCaseStudy = async (url: string): Promise<ScrapedCaseStudy> => {
|
||||
const response = await axiosInstance.get<ScrapedCaseStudy>('/assets/scrape-case-study', {
|
||||
params: { url }
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -15,10 +15,36 @@ export interface Partner {
|
||||
onboardingStatus: string;
|
||||
mfaEnabled: boolean;
|
||||
createdAt: string;
|
||||
partnerGroup?: string | null;
|
||||
assignedNdaId?: string | null;
|
||||
assignedMsaId?: string | null;
|
||||
assignedNda?: { id: string; version: string } | null;
|
||||
assignedMsa?: { id: string; version: string } | null;
|
||||
sharedAssets?: Array<{ assetId: string }>;
|
||||
inviteToken?: string | null;
|
||||
}
|
||||
|
||||
export interface InviteParams {
|
||||
email: string;
|
||||
partnerGroup?: string | null;
|
||||
assignedNdaId?: string | null;
|
||||
assignedMsaId?: string | null;
|
||||
sharedAssetIds?: string[];
|
||||
mfaEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePartnerParams {
|
||||
partnerGroup?: string | null;
|
||||
assignedNdaId?: string | null;
|
||||
assignedMsaId?: string | null;
|
||||
sharedAssetIds?: string[];
|
||||
mfaEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface InviteResponse {
|
||||
token: string;
|
||||
emailSent?: boolean;
|
||||
emailError?: string;
|
||||
}
|
||||
|
||||
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
|
||||
@ -43,10 +69,25 @@ export const getPartners = async (): Promise<Partner[]> => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const invitePartner = async (email: string): Promise<InviteResponse> => {
|
||||
export const invitePartner = async (params: InviteParams): Promise<InviteResponse> => {
|
||||
const response = await axiosInstance.post<InviteResponse>(
|
||||
AUTH_API_ROUTES.invite,
|
||||
{ email }
|
||||
params
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updatePartner = async (partnerId: string, params: UpdatePartnerParams): Promise<Partner> => {
|
||||
const response = await axiosInstance.put<Partner>(
|
||||
`/auth/partners/${partnerId}`,
|
||||
params
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resendInvitePartner = async (partnerId: string): Promise<{ success: boolean }> => {
|
||||
const response = await axiosInstance.post<{ success: boolean }>(
|
||||
`/auth/partners/${partnerId}/resend-invite`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@ -4,6 +4,8 @@ export interface PendingPartner {
|
||||
id: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
assignedNdaId?: string | null;
|
||||
assignedMsaId?: string | null;
|
||||
acceptances: Array<{
|
||||
id: string;
|
||||
signatureHash: string | null;
|
||||
@ -26,6 +28,7 @@ export interface LegalDocument {
|
||||
content: string;
|
||||
pdfUrl?: string | null;
|
||||
version: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export interface LegalAcceptance {
|
||||
@ -62,6 +65,11 @@ export const approvePartnerAccess = async (partnerId: string): Promise<void> =>
|
||||
await axiosInstance.post<void>(LEGAL_API_ROUTES.approve(partnerId));
|
||||
};
|
||||
|
||||
export const getLegalDocuments = async (): Promise<LegalDocument[]> => {
|
||||
const response = await axiosInstance.get<LegalDocument[]>('/legal/documents');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getActiveLegalDocument = async (type: 'NDA' | 'MSA'): Promise<LegalDocument> => {
|
||||
const response = await axiosInstance.get<LegalDocument>(LEGAL_API_ROUTES.activeDoc(type));
|
||||
return response.data;
|
||||
|
||||
@ -42,6 +42,9 @@ export interface Asset {
|
||||
githubUrl: string | null;
|
||||
status: string;
|
||||
isDownloadable: boolean;
|
||||
thumbnailUrl?: string | null;
|
||||
problemStatement?: string | null;
|
||||
solution?: string | null;
|
||||
createdAt: string;
|
||||
sharedWith?: SharedWithOrg[];
|
||||
downloadRequests?: DownloadRequest[];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user