Final_prod

This commit is contained in:
kenilkb 2026-07-15 14:28:24 +05:30
parent 765c469975
commit 9c19ca9dd7
42 changed files with 3614 additions and 257 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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); }
}
}

View File

@ -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;

View File

@ -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');

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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 }
});
}
}

View File

@ -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,

View File

@ -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 },

View 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;
}
}
}

View 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}`);
}
}
}

View File

@ -0,0 +1,3 @@
import { AsyncLocalStorage } from 'async_hooks';
export const originStorage = new AsyncLocalStorage<string>();

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>
),
},
],
},
]);

View File

@ -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"
>

View File

@ -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
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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(),

View File

@ -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');

View File

@ -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>
);
};

View File

@ -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);
}
};

View File

@ -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>

View File

@ -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

View 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>
);
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;

View File

@ -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[];