Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f712dba23 | |||
| 1abeab6e46 | |||
| fe9eb915e1 | |||
| 86cdffdd1c | |||
| 3a909e6265 | |||
| c71e0bf602 |
28 changed files with 3736 additions and 379 deletions
5
.env
5
.env
|
|
@ -14,4 +14,7 @@ AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
||||||
|
|
||||||
# Required to tell Auth.js to trust your localhost/proxy URL
|
# Required to tell Auth.js to trust your localhost/proxy URL
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
# added initial admin user
|
||||||
|
INITIAL_ADMIN_EMAIL="slohning@live.com.au"
|
||||||
|
|
@ -14,4 +14,7 @@ AUTH_MICROSOFT_ENTRA_ID_ID="b549931a-f491-436b-b7bc-d37d8ca3c17e"
|
||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
AUTH_MICROSOFT_ENTRA_ID_SECRET="O3p8Q~oMph-0kSLwkvzEJzJdx_iHGOjJjrr5Pa1A"
|
||||||
|
|
||||||
# Required to tell Auth.js to trust your localhost/proxy URL
|
# Required to tell Auth.js to trust your localhost/proxy URL
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
# added initial admin user
|
||||||
|
INITIAL_ADMIN_EMAIL="slohning@live.com.au"
|
||||||
2180
docs/Ai-converstion.md
Normal file
2180
docs/Ai-converstion.md
Normal file
File diff suppressed because it is too large
Load diff
BIN
docs/Ai-converstion.pdf
Normal file
BIN
docs/Ai-converstion.pdf
Normal file
Binary file not shown.
|
|
@ -24,6 +24,7 @@
|
||||||
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file)
|
- [7. Run this whenever you change your schema.prisma file](#7-run-this-whenever-you-change-your-schemaprisma-file)
|
||||||
- [7.1. Pro-Tip: Update your package.json](#71-pro-tip-update-your-packagejson)
|
- [7.1. Pro-Tip: Update your package.json](#71-pro-tip-update-your-packagejson)
|
||||||
- [8. Where we are up to 8/1/2026](#8-where-we-are-up-to-812026)
|
- [8. Where we are up to 8/1/2026](#8-where-we-are-up-to-812026)
|
||||||
|
- [9. Testing Creating And Uploading Folders and Files](#9-testing-creating-and-uploading-folders-and-files)
|
||||||
|
|
||||||
# 1. Reference
|
# 1. Reference
|
||||||
|
|
||||||
|
|
@ -340,4 +341,32 @@ I can see a few problems,
|
||||||
|
|
||||||
3. We would have to create the UUID ourselves, so that we can create a folder with that specific UUID and store the file in the folder.
|
3. We would have to create the UUID ourselves, so that we can create a folder with that specific UUID and store the file in the folder.
|
||||||
|
|
||||||
4. The Upload file Page needs to be able to create folder as well select a parent , add a description of either the file
|
4. The Upload file Page needs to be able to create folder as well select a parent , add a description of either the file
|
||||||
|
|
||||||
|
# 9. Testing Creating And Uploading Folders and Files
|
||||||
|
|
||||||
|
The functionality works may not the best user interface
|
||||||
|
|
||||||
|
Projects ID 0371fdfe-a2d1-4f25-b229-804f299bc064
|
||||||
|
Project-1 ID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1 parentID 0371fdfe-a2d1-4f25-b229-804f299bc064
|
||||||
|
AS-NZS3000-2018.pdf parentID c8eebe0e-6e90-4cfb-8e44-c05f7064f3b1
|
||||||
|
|
||||||
|
Now we will go now and add metadata
|
||||||
|
|
||||||
|
Now we have the metadata along with some great filters on the data board page
|
||||||
|
|
||||||
|
Now we are going to implement an an update of a file or folder stored. 12/1/2026
|
||||||
|
|
||||||
|
For download there 2 function we need to implement
|
||||||
|
|
||||||
|
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
|
||||||
|
|
||||||
|
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
|
||||||
|
|
||||||
|
For download there 2 function we need to implement
|
||||||
|
|
||||||
|
1) down load the file to ~/user/Downloads, this should work for any file type including pdf
|
||||||
|
|
||||||
|
2) if the file is a pdf open the file in another tab in the browser as most browser support reading a pdf
|
||||||
|
|
||||||
|
So we need to implement 2 new actions, downLoad file and open pdf to read. Does this make sense ?
|
||||||
BIN
docs/notes.pdf
BIN
docs/notes.pdf
Binary file not shown.
102
package-lock.json
generated
102
package-lock.json
generated
|
|
@ -13,9 +13,9 @@
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.6",
|
"@mui/icons-material": "^7.3.6",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.7",
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.23.0",
|
"@mui/x-data-grid": "^8.24.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
|
@ -1333,9 +1333,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz",
|
||||||
"integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==",
|
"integrity": "sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -1369,22 +1369,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/material": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.7.tgz",
|
||||||
"integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==",
|
"integrity": "sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/core-downloads-tracker": "^7.3.6",
|
"@mui/core-downloads-tracker": "^7.3.7",
|
||||||
"@mui/system": "^7.3.6",
|
"@mui/system": "^7.3.7",
|
||||||
"@mui/types": "^7.4.9",
|
"@mui/types": "^7.4.10",
|
||||||
"@mui/utils": "^7.3.6",
|
"@mui/utils": "^7.3.7",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@types/react-transition-group": "^4.4.12",
|
"@types/react-transition-group": "^4.4.12",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.2.3",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react-is": "^19.2.0",
|
"react-is": "^19.2.3",
|
||||||
"react-transition-group": "^4.4.5"
|
"react-transition-group": "^4.4.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1397,7 +1397,7 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emotion/react": "^11.5.0",
|
"@emotion/react": "^11.5.0",
|
||||||
"@emotion/styled": "^11.3.0",
|
"@emotion/styled": "^11.3.0",
|
||||||
"@mui/material-pigment-css": "^7.3.6",
|
"@mui/material-pigment-css": "^7.3.7",
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
|
@ -1459,13 +1459,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@mui/private-theming": {
|
"node_modules/@mui/private-theming": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.7.tgz",
|
||||||
"integrity": "sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==",
|
"integrity": "sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/utils": "^7.3.6",
|
"@mui/utils": "^7.3.7",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1486,16 +1486,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/styled-engine": {
|
"node_modules/@mui/styled-engine": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.7.tgz",
|
||||||
"integrity": "sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==",
|
"integrity": "sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@emotion/cache": "^11.14.0",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@emotion/serialize": "^1.3.3",
|
"@emotion/serialize": "^1.3.3",
|
||||||
"@emotion/sheet": "^1.4.0",
|
"@emotion/sheet": "^1.4.0",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.2.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1520,18 +1520,18 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/system": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.7.tgz",
|
||||||
"integrity": "sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==",
|
"integrity": "sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/private-theming": "^7.3.6",
|
"@mui/private-theming": "^7.3.7",
|
||||||
"@mui/styled-engine": "^7.3.6",
|
"@mui/styled-engine": "^7.3.7",
|
||||||
"@mui/types": "^7.4.9",
|
"@mui/types": "^7.4.10",
|
||||||
"@mui/utils": "^7.3.6",
|
"@mui/utils": "^7.3.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.2.3",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1560,9 +1560,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/types": {
|
"node_modules/@mui/types": {
|
||||||
"version": "7.4.9",
|
"version": "7.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.10.tgz",
|
||||||
"integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==",
|
"integrity": "sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
|
|
@ -1577,17 +1577,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "7.3.6",
|
"version": "7.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.7.tgz",
|
||||||
"integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==",
|
"integrity": "sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/types": "^7.4.9",
|
"@mui/types": "^7.4.10",
|
||||||
"@types/prop-types": "^15.7.15",
|
"@types/prop-types": "^15.7.15",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react-is": "^19.2.0"
|
"react-is": "^19.2.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|
@ -1613,15 +1613,15 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@mui/x-data-grid": {
|
"node_modules/@mui/x-data-grid": {
|
||||||
"version": "8.23.0",
|
"version": "8.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.24.0.tgz",
|
||||||
"integrity": "sha512-fv8AiibTgwPKJsXtYdc5bYDDW3EQp/ieQ0iUejvZ2JbYikMIPTYjI4pHy7zW3F3RDsi6DGQnw5AW6LeoDcT0fA==",
|
"integrity": "sha512-OCTrskRlxwJZ+vYWbgQnQPXWNMA/nY/RIkoESjbCG38N9fJicVCdyjszjcQn9V1NQH4fwkMlIN+DVU6zzbqJxQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/utils": "^7.3.5",
|
"@mui/utils": "^7.3.5",
|
||||||
"@mui/x-internals": "8.23.0",
|
"@mui/x-internals": "8.24.0",
|
||||||
"@mui/x-virtualizer": "0.3.0",
|
"@mui/x-virtualizer": "0.3.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
|
@ -1651,9 +1651,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/x-internals": {
|
"node_modules/@mui/x-internals": {
|
||||||
"version": "8.23.0",
|
"version": "8.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.24.0.tgz",
|
||||||
"integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==",
|
"integrity": "sha512-tHPk8eUcuwvhNsAc4zcia8y+issjC+bLGssrQQSdLLeLIf9ewFbcwAURNTSnNhreyj/4RJOemVrSxKXpjkdVvQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
|
|
@ -1673,14 +1673,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/x-virtualizer": {
|
"node_modules/@mui/x-virtualizer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.1.tgz",
|
||||||
"integrity": "sha512-ScQ3xullKQAtbIT9N7PVhgW9BDTo2Hu8fr/+N08TdRMJcq8bsVJB25mlcHk6zxjlgCuojqaeGfw71S1FOvyCXQ==",
|
"integrity": "sha512-MhQ7U/zmSofBxeCRMiolhZWJ80RUJMFFMRqeehJu5GqscnudiVcplZb0eNsAkz9IjnJTyrjyLS0PY+kn78enOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4",
|
"@babel/runtime": "^7.28.4",
|
||||||
"@mui/utils": "^7.3.5",
|
"@mui/utils": "^7.3.5",
|
||||||
"@mui/x-internals": "8.23.0"
|
"@mui/x-internals": "8.24.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.6",
|
"@mui/icons-material": "^7.3.6",
|
||||||
"@mui/material": "^7.3.6",
|
"@mui/material": "^7.3.7",
|
||||||
"@mui/material-nextjs": "^7.3.6",
|
"@mui/material-nextjs": "^7.3.6",
|
||||||
"@mui/x-data-grid": "^8.23.0",
|
"@mui/x-data-grid": "^8.24.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,17 @@ generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Define the possible roles
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String? // Renamed from displayName for NextAuth compatibility
|
name String?
|
||||||
email String @unique
|
email String @unique
|
||||||
|
role Role @default(USER) // 2. Add this line (Defaults to USER)
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
azureAdUserId String? @unique
|
azureAdUserId String? @unique
|
||||||
|
|
@ -50,9 +57,9 @@ model Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
model FileNode {
|
model FileNode {
|
||||||
id String @id // Removed @default(uuid()) to allow manual assignment
|
id String @id
|
||||||
name String
|
name String
|
||||||
size BigInt?
|
size BigInt? // Preserved your BigInt size column
|
||||||
isFolder Boolean @default(false)
|
isFolder Boolean @default(false)
|
||||||
oneDriveId String? @unique
|
oneDriveId String? @unique
|
||||||
path String
|
path String
|
||||||
|
|
@ -64,7 +71,8 @@ model FileNode {
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
|
||||||
parentId String?
|
parentId String?
|
||||||
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id])
|
// Added onDelete: Cascade here to allow deleting folders and their children automatically
|
||||||
|
parent FileNode? @relation("TreeHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
children FileNode[] @relation("TreeHierarchy")
|
children FileNode[] @relation("TreeHierarchy")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
|
||||||
66
src/app/api/download/route.ts
Normal file
66
src/app/api/download/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// src/app/api/download/route.ts
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { auth } from "@/auth"; // Import the auth function from your central config
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 1. Check Authentication using the V5 auth() helper
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
// In V5, tokens are usually handled in the session callback
|
||||||
|
if (!session || !session.accessToken) {
|
||||||
|
return new NextResponse("Unauthorized - No Access Token found", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get parameters from URL
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const fileNodeId = searchParams.get('id');
|
||||||
|
const mode = searchParams.get('mode') === 'inline' ? 'inline' : 'attachment';
|
||||||
|
|
||||||
|
if (!fileNodeId) {
|
||||||
|
return new NextResponse("File ID is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find the file in your Postgres FileNode table
|
||||||
|
const fileNode = await prisma.fileNode.findUnique({
|
||||||
|
where: { id: fileNodeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileNode || !fileNode.oneDriveId) {
|
||||||
|
return new NextResponse("File not found in database", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch the file stream from Microsoft Graph
|
||||||
|
const graphResponse = await fetch(
|
||||||
|
`https://graph.microsoft.com/v1.0/me/drive/items/${fileNode.oneDriveId}/content`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!graphResponse.ok) {
|
||||||
|
const errorText = await graphResponse.text();
|
||||||
|
console.error('MS Graph Error:', errorText);
|
||||||
|
return new NextResponse(`OneDrive Error: ${graphResponse.statusText}`, { status: graphResponse.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Stream the response directly to the client
|
||||||
|
return new NextResponse(graphResponse.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': fileNode.mimeType || 'application/octet-stream',
|
||||||
|
// Note: Using encodeURIComponent for filename to handle special characters
|
||||||
|
'Content-Disposition': `${mode}; filename="${encodeURIComponent(fileNode.name)}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
return new NextResponse("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,186 @@
|
||||||
'use server';
|
'use server';
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. FETCH: Get all file nodes for the Dashboard
|
||||||
|
*/
|
||||||
export async function getFileNodes() {
|
export async function getFileNodes() {
|
||||||
const session = await auth();
|
try {
|
||||||
|
const nodes = await prisma.fileNode.findMany({
|
||||||
if (!session?.user?.id) {
|
orderBy: {
|
||||||
return []; // Return empty if not logged in
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching file nodes:", error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = await prisma.fileNode.findMany({
|
/**
|
||||||
where: {
|
* 2. DOWNLOAD: Generates the authenticated OneDrive URL
|
||||||
ownerId: session.user.id, // Only get THIS user's files
|
*/
|
||||||
},
|
export async function getDownloadUrlAction(id: string) {
|
||||||
orderBy: {
|
const session = await auth();
|
||||||
orderIndex: 'asc',
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
},
|
|
||||||
|
const file = await prisma.fileNode.findUnique({ where: { id } });
|
||||||
|
if (!file || !file.oneDriveId) throw new Error("File not found or missing cloud ID");
|
||||||
|
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
const res = await fetch(
|
||||||
|
`https://graph.microsoft.com/v1.0/me/drive/items/${file.oneDriveId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to contact OneDrive");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const downloadUrl = data["@microsoft.graph.downloadUrl"];
|
||||||
|
|
||||||
|
if (!downloadUrl) throw new Error("OneDrive did not provide a download link");
|
||||||
|
|
||||||
|
return { downloadUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. DELETE: Remove from OneDrive (via ID) and Database
|
||||||
|
* Folders are virtual (DB only), so cloud deletion is skipped if oneDriveId is null.
|
||||||
|
*/
|
||||||
|
export async function deleteFileAction(fileId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const node = await prisma.fileNode.findUnique({
|
||||||
|
where: { id: fileId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return nodes.map(node => ({
|
if (!node) {
|
||||||
...node,
|
revalidatePath("/dashboard");
|
||||||
size: node.size ? Number(node.size) : null,
|
return { success: true };
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const isAdmin = session.user.role === "ADMIN";
|
||||||
|
const isOwner = node.ownerId === session.user.id;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner) {
|
||||||
|
throw new Error("Permission Denied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
|
||||||
|
// Only attempt cloud deletion if it's a file/storage with a oneDriveId.
|
||||||
|
// Virtual folders created in the DB have no oneDriveId and are skipped.
|
||||||
|
if (accessToken && node.oneDriveId) {
|
||||||
|
const onedriveRes = await fetch(
|
||||||
|
`https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onedriveRes.ok && onedriveRes.status !== 404) {
|
||||||
|
console.warn("OneDrive Deletion Warning: Cloud record might still exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cloudError) {
|
||||||
|
console.error("Cloud cleanup failed:", cloudError);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.fileNode.delete({ where: { id: fileId } });
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/upload");
|
||||||
|
return { success: true };
|
||||||
|
} catch (dbError) {
|
||||||
|
throw new Error("Failed to remove the record from the database.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. MOVE: Assign file to folder or folder to another folder (Virtual Move)
|
||||||
|
*/
|
||||||
|
export async function moveNodeAction(nodeId: string, newParentId: string | null) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
if (nodeId === newParentId) throw new Error("Cannot move to self.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.fileNode.update({
|
||||||
|
where: { id: nodeId },
|
||||||
|
data: { parentId: newParentId }
|
||||||
|
});
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Move failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. UPDATE & REPLACE: Full update of metadata and OneDrive content
|
||||||
|
*/
|
||||||
|
export async function updateFileFullAction(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
const parentIdRaw = formData.get("parentId") as string;
|
||||||
|
const metadataStr = formData.get("metadata") as string;
|
||||||
|
const newFile = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
const parentId = parentIdRaw === "root" ? null : parentIdRaw;
|
||||||
|
let metadata = JSON.parse(metadataStr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
|
const node = await prisma.fileNode.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
// Update physical file content only if a new file is uploaded and we have a target oneDriveId
|
||||||
|
if (newFile && newFile.size > 0 && node?.oneDriveId) {
|
||||||
|
const onedrivePath = `https://graph.microsoft.com/v1.0/me/drive/items/${node.oneDriveId}/content`;
|
||||||
|
|
||||||
|
const uploadRes = await fetch(onedrivePath, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": newFile.type
|
||||||
|
},
|
||||||
|
body: Buffer.from(await newFile.arrayBuffer()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) throw new Error("OneDrive content update failed");
|
||||||
|
|
||||||
|
metadata.type = newFile.name.split('.').pop()?.toUpperCase() || 'UNKNOWN';
|
||||||
|
metadata.mimeType = newFile.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.fileNode.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parentId,
|
||||||
|
metadata,
|
||||||
|
size: newFile ? BigInt(newFile.size) : undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Full Update Failure:", error);
|
||||||
|
throw new Error(error.message || "Failed to update record.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,75 +1,279 @@
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
|
// src/app/dashboard/dashboard-view.tsx
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, CircularProgress } from "@mui/material";
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
InputAdornment,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridColDef,
|
||||||
|
Toolbar,
|
||||||
|
QuickFilter,
|
||||||
|
QuickFilterControl,
|
||||||
|
QuickFilterClear,
|
||||||
|
} from "@mui/x-data-grid";
|
||||||
import SyncIcon from "@mui/icons-material/Sync";
|
import SyncIcon from "@mui/icons-material/Sync";
|
||||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
|
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
|
||||||
import { syncOneDrive } from "./sync-actions";
|
import { syncOneDrive } from "./sync-actions";
|
||||||
|
import { deleteFileAction } from "./actions";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
function CustomToolbar() {
|
||||||
|
return (
|
||||||
|
<Toolbar sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h6" fontWeight="bold" color="primary">
|
||||||
|
Library
|
||||||
|
</Typography>
|
||||||
|
<QuickFilter sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<QuickFilterControl
|
||||||
|
render={({ ref, ...controlProps }, state) => (
|
||||||
|
<TextField
|
||||||
|
{...controlProps}
|
||||||
|
inputRef={ref}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
placeholder="Search files and metadata..."
|
||||||
|
sx={{ width: 350 }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: state.value ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<QuickFilterClear size="small">
|
||||||
|
<CancelIcon fontSize="small" />
|
||||||
|
</QuickFilterClear>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</QuickFilter>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DashboardViewProps {
|
interface DashboardViewProps {
|
||||||
initialFiles: any[];
|
initialFiles: any[];
|
||||||
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardView({ initialFiles }: DashboardViewProps) {
|
export default function DashboardView({ initialFiles, user }: DashboardViewProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isAdmin = user?.role === "ADMIN";
|
||||||
|
|
||||||
|
const getVirtualPath = (parentId: string | null): string => {
|
||||||
|
if (!parentId) return "WebCalibre";
|
||||||
|
const parent = initialFiles.find((f) => f.id === parentId);
|
||||||
|
if (!parent) return "WebCalibre";
|
||||||
|
const prefix = parent.parentId ? `${getVirtualPath(parent.parentId)} / ` : "";
|
||||||
|
return `${prefix}${parent.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await syncOneDrive();
|
await syncOneDrive();
|
||||||
// This tells Next.js to re-run the Server Component (page.tsx)
|
|
||||||
// and fetch the fresh data from Postgres
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Sync failed:", error);
|
console.error("Sync failed:", error);
|
||||||
alert("Failed to sync OneDrive");
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setIsRefreshing(false), 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, name: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteFileAction(id);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error.message || "Failed to delete file");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- NEW DOWNLOAD FUNCTIONS ---
|
||||||
|
const handleDownload = (id: string) => {
|
||||||
|
// Triggers local folder download via Content-Disposition: attachment
|
||||||
|
window.location.href = `/api/download?id=${id}&mode=attachment`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewInTab = (id: string) => {
|
||||||
|
// Opens in a new tab via Content-Disposition: inline
|
||||||
|
window.open(`/api/download?id=${id}&mode=inline`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
const columns: GridColDef[] = [
|
const columns: GridColDef[] = [
|
||||||
{ field: "name", headerName: "File Name", width: 300 },
|
|
||||||
{
|
{
|
||||||
field: "metadata",
|
field: "name",
|
||||||
|
headerName: "Name",
|
||||||
|
flex: 1.5,
|
||||||
|
minWidth: 250,
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
{params.row.isFolder ? <FolderIcon sx={{ color: '#FFB020' }} /> : <InsertDriveFileIcon color="action" />}
|
||||||
|
<Typography variant="body2">{params.value}</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "parentId",
|
||||||
|
headerName: "Location",
|
||||||
|
flex: 1,
|
||||||
|
renderCell: (params) => <Chip label={getVirtualPath(params.value)} size="small" variant="outlined" />
|
||||||
|
},
|
||||||
|
{ field: "description", headerName: "Description", flex: 1 },
|
||||||
|
{
|
||||||
|
field: "type",
|
||||||
headerName: "Type",
|
headerName: "Type",
|
||||||
width: 120,
|
width: 120,
|
||||||
valueGetter: (params) => params?.type || "Unknown"
|
valueGetter: (value, row) => row.metadata?.type || (row.isFolder ? "Folder" : "File"),
|
||||||
|
renderCell: (params) => (
|
||||||
|
<Typography variant="caption" sx={{ textTransform: 'uppercase', fontWeight: 'bold', color: 'text.secondary' }}>
|
||||||
|
{params.value}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "size",
|
field: "size",
|
||||||
headerName: "Size (MB)",
|
headerName: "Size",
|
||||||
width: 120,
|
width: 100,
|
||||||
valueGetter: (value) => value ? (Number(value) / 1024 / 1024).toFixed(2) : "0"
|
renderCell: (params) => params.row.isFolder ? "--" : `${(Number(params.value) / 1024 / 1024).toFixed(2)} MB`
|
||||||
},
|
},
|
||||||
{ field: "updatedAt", headerName: "Last Synced", width: 200 },
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "Actions",
|
||||||
|
width: 180, // Increased width to accommodate new buttons
|
||||||
|
align: 'right',
|
||||||
|
renderCell: (params) => {
|
||||||
|
const isOwner = params.row.ownerId === user?.id;
|
||||||
|
const isFolder = params.row.isFolder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||||
|
{!isFolder && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="View in Tab">
|
||||||
|
<IconButton size="small" color="info" onClick={() => handleViewInTab(params.row.id)}>
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Download to Folder">
|
||||||
|
<IconButton size="small" color="success" onClick={() => handleDownload(params.row.id)}>
|
||||||
|
<DownloadIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isAdmin || isOwner) && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Edit Details">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => router.push(`/update/${params.row.id}`)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleDelete(params.row.id, params.row.name)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "metadata_search",
|
||||||
|
headerName: "Metadata Search",
|
||||||
|
width: 0,
|
||||||
|
valueGetter: (value, row) => {
|
||||||
|
if (!row.metadata) return "";
|
||||||
|
return Object.entries(row.metadata)
|
||||||
|
.filter(([k]) => k !== 'type' && k !== 'mimeType')
|
||||||
|
.map(([k, v]) => `${k}:${v}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Box className="space-y-4">
|
||||||
<div className="flex justify-end">
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mb: 2 }}>
|
||||||
<Button
|
<Button variant="outlined" startIcon={isRefreshing ? <CircularProgress size={16} /> : <RefreshIcon />} onClick={handleRefresh}>
|
||||||
variant="contained"
|
Refresh List
|
||||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />}
|
|
||||||
onClick={handleSync}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? "Syncing..." : "Sync OneDrive"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button variant="contained" startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <SyncIcon />} onClick={handleSync} disabled={loading}>
|
||||||
|
Sync OneDrive
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<div style={{ height: 600, width: "100%" }}>
|
<Box sx={{ height: 700, width: "100%", bgcolor: 'background.paper', borderRadius: 3, boxShadow: 1, overflow: 'hidden' }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={initialFiles}
|
rows={initialFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSizeOptions={[10, 25, 50]}
|
slots={{ toolbar: CustomToolbar }}
|
||||||
|
showToolbar
|
||||||
|
disableRowSelectionOnClick
|
||||||
initialState={{
|
initialState={{
|
||||||
pagination: { paginationModel: { pageSize: 10 } },
|
columns: {
|
||||||
|
columnVisibilityModel: {
|
||||||
|
metadata_search: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
border: 'none',
|
||||||
|
'& .MuiDataGrid-columnHeaders': { bgcolor: '#f8f9fa' },
|
||||||
|
'& .MuiDataGrid-toolbarContainer': { borderBottom: '1px solid #eee' }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getFileNodes } from "./actions";
|
import { getFileNodes } from "./actions";
|
||||||
import DashboardView from "./dashboard-view"
|
import DashboardView from "./dashboard-view";
|
||||||
|
import { Box, Typography, Chip } from "@mui/material";
|
||||||
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -14,12 +16,41 @@ export default async function DashboardPage() {
|
||||||
// Fetch initial files from PostgreSQL
|
// Fetch initial files from PostgreSQL
|
||||||
const initialFiles = await getFileNodes();
|
const initialFiles = await getFileNodes();
|
||||||
|
|
||||||
|
// Determine admin status for the header display
|
||||||
|
// @ts-ignore
|
||||||
|
const isAdmin = session.user.role === "ADMIN";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="p-8">
|
<main className="p-8">
|
||||||
<h1 className="text-2xl font-bold mb-6">My OneDrive Library</h1>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 6 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight={800} sx={{ color: 'text.primary' }}>
|
||||||
|
My OneDrive Library
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Manage your synchronized files and project folders.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Chip
|
||||||
|
icon={<AdminPanelSettingsIcon />}
|
||||||
|
label="Admin Access"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Pass the data to the interactive Client Component */}
|
{/* Pass both initialFiles AND the user object.
|
||||||
<DashboardView initialFiles={initialFiles} />
|
The DashboardView will use user.role and user.id to
|
||||||
|
decide who can see the 'Delete' button.
|
||||||
|
*/}
|
||||||
|
<DashboardView
|
||||||
|
initialFiles={initialFiles}
|
||||||
|
user={session.user}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,52 +1,62 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { getFreshAccessToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
export async function syncOneDrive() {
|
export async function syncOneDrive() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
// 1. Get the OAuth token from your PostgreSQL 'Account' table
|
try {
|
||||||
const account = await prisma.account.findFirst({
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
where: { userId: session.user.id },
|
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root:/WebCalibre:/children", {
|
||||||
});
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
|
||||||
if (!account?.access_token) throw new Error("Microsoft account not linked properly");
|
|
||||||
|
|
||||||
// 2. Fetch the files from Microsoft Graph
|
|
||||||
const response = await fetch("https://graph.microsoft.com/v1.0/me/drive/root/children", {
|
|
||||||
headers: { Authorization: `Bearer ${account.access_token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// 3. The "Sync Loop": Map OneDrive items to your Postgres FileNode table
|
|
||||||
for (const item of data.value) {
|
|
||||||
const extension = item.name.split('.').pop()?.toUpperCase() || (item.folder ? 'FOLDER' : 'UNKNOWN');
|
|
||||||
|
|
||||||
await prisma.fileNode.upsert({
|
|
||||||
where: { oneDriveId: item.id },
|
|
||||||
update: {
|
|
||||||
name: item.name,
|
|
||||||
size: item.size,
|
|
||||||
metadata: { type: extension }, // Store the "Type" in your JSONB field
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
oneDriveId: item.id,
|
|
||||||
name: item.name,
|
|
||||||
size: item.size,
|
|
||||||
isFolder: !!item.folder,
|
|
||||||
path: item.parentReference.path + '/' + item.name,
|
|
||||||
ownerId: session.user.id,
|
|
||||||
metadata: { type: extension },
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return { success: true, count: 0 };
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let syncedCount = 0;
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
for (const item of data.value) {
|
||||||
|
const isFolder = !!item.folder;
|
||||||
|
|
||||||
|
// Only skip if it's a folder AND it's a UUID (storage container)
|
||||||
|
// If a user named a file with a UUID, we still want it.
|
||||||
|
if (isFolder && uuidRegex.test(item.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = isFolder ? 'FOLDER' : (item.name.split('.').pop()?.toUpperCase() || 'UNKNOWN');
|
||||||
|
|
||||||
|
await prisma.fileNode.upsert({
|
||||||
|
where: { oneDriveId: item.id }, // Primary match
|
||||||
|
update: {
|
||||||
|
name: item.name,
|
||||||
|
size: BigInt(item.size || 0),
|
||||||
|
isFolder: isFolder,
|
||||||
|
path: item.parentReference?.path + '/' + item.name,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
oneDriveId: item.id,
|
||||||
|
name: item.name,
|
||||||
|
size: BigInt(item.size || 0),
|
||||||
|
isFolder: isFolder,
|
||||||
|
path: item.parentReference?.path + '/' + item.name,
|
||||||
|
ownerId: session.user.id,
|
||||||
|
metadata: { type: extension, mimeType: item.file?.mimeType || null },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
syncedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard');
|
||||||
|
return { success: true, count: syncedCount };
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the dashboard UI to show new data
|
|
||||||
revalidatePath('/dashboard');
|
|
||||||
return { success: true, count: data.value.length };
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Box, Button, Typography, Paper, LinearProgress, Container } from "@mui/material";
|
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
|
||||||
import { uploadFileAction } from "../upload-actions";
|
|
||||||
|
|
||||||
export default function UploadPage() {
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await uploadFileAction(formData);
|
|
||||||
alert("Book added to WebCalibre!");
|
|
||||||
setFile(null);
|
|
||||||
} catch (err) {
|
|
||||||
alert("Upload failed. Check console.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container maxWidth="sm" sx={{ mt: 8 }}>
|
|
||||||
<Paper elevation={3} sx={{ p: 4, textAlign: 'center', borderRadius: 4 }}>
|
|
||||||
<Typography variant="h5" gutterBottom fontWeight="bold">
|
|
||||||
Upload to Library
|
|
||||||
</Typography>
|
|
||||||
<Typography color="textSecondary" mb={4}>
|
|
||||||
Files will be saved in your OneDrive "WebCalibre" folder.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ border: '2px dashed #ccc', p: 4, mb: 3, borderRadius: 2 }}>
|
|
||||||
<input
|
|
||||||
accept=".pdf,.epub,.mobi,.txt"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
id="file-input"
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
|
||||||
/>
|
|
||||||
<label htmlFor="file-input">
|
|
||||||
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
|
|
||||||
{file ? file.name : "Choose Book File"}
|
|
||||||
</Button>
|
|
||||||
</label>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
size="large"
|
|
||||||
disabled={!file || loading}
|
|
||||||
onClick={handleUpload}
|
|
||||||
>
|
|
||||||
{loading ? "Uploading..." : "Start Upload"}
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -12,6 +12,17 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
// Fetch the session server-side to prevent UI flickering
|
// Fetch the session server-side to prevent UI flickering
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We enhance the user object before passing it to the Navbar.
|
||||||
|
* This ensures the Navbar knows if the current user is the "Bootstrap Admin"
|
||||||
|
* defined in our environment variables.
|
||||||
|
*/
|
||||||
|
const navbarUser = session?.user ? {
|
||||||
|
...session.user,
|
||||||
|
// Add the bootstrap flag for administrative UI access
|
||||||
|
isBootstrap: session.user.email === process.env.INITIAL_ADMIN_EMAIL
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -19,8 +30,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||||
|
|
||||||
{/* ThemeRegistry handles the MUI Theme and Cache Provider */}
|
{/* ThemeRegistry handles the MUI Theme and Cache Provider */}
|
||||||
<ThemeRegistry>
|
<ThemeRegistry>
|
||||||
{/* We pass the user object to the Navbar so it can show the profile pic */}
|
{/* We pass navbarUser (which includes role and isBootstrap)
|
||||||
<Navbar user={session?.user} />
|
so the Drawer knows whether to show the Settings link.
|
||||||
|
*/}
|
||||||
|
<Navbar user={navbarUser} />
|
||||||
|
|
||||||
<main style={{ minHeight: '100vh' }}>
|
<main style={{ minHeight: '100vh' }}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
68
src/app/settings/actions.ts
Normal file
68
src/app/settings/actions.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles a user's role between 'ADMIN' and 'USER'.
|
||||||
|
* * Security Logic:
|
||||||
|
* 1. Checks if the caller is the Bootstrap Admin (via .env).
|
||||||
|
* 2. Checks if the caller has the 'ADMIN' role in the database.
|
||||||
|
* 3. Prevents the Bootstrap Admin from being demoted to 'USER'.
|
||||||
|
*/
|
||||||
|
export async function toggleUserRoleAction(targetUserId: string) {
|
||||||
|
const session = await auth();
|
||||||
|
const callerEmail = session?.user?.email;
|
||||||
|
|
||||||
|
if (!callerEmail) {
|
||||||
|
throw new Error("Unauthorized: No session found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authorization: Who is trying to change the role?
|
||||||
|
const isBootstrap = callerEmail === process.env.INITIAL_ADMIN_EMAIL;
|
||||||
|
|
||||||
|
const callerDbRecord = await prisma.user.findUnique({
|
||||||
|
where: { email: callerEmail },
|
||||||
|
select: { role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = isBootstrap || callerDbRecord?.role === "ADMIN";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new Error("Forbidden: You do not have permission to manage roles.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch the target user to be modified
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
select: { id: true, email: true, role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new Error("User not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Protection: Prevent demoting the primary bootstrap admin
|
||||||
|
// This ensures you don't accidentally lock yourself out of the settings page.
|
||||||
|
if (targetUser.email === process.env.INITIAL_ADMIN_EMAIL && targetUser.role === "ADMIN") {
|
||||||
|
throw new Error("Security Restriction: The primary Bootstrap Admin role cannot be removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Determine new role
|
||||||
|
const newRole = targetUser.role === "ADMIN" ? "USER" : "ADMIN";
|
||||||
|
|
||||||
|
// 5. Execute Update
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: targetUserId },
|
||||||
|
data: { role: newRole }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Refresh the data on the Settings page
|
||||||
|
revalidatePath("/settings");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `User ${targetUser.email} is now a ${newRole}`
|
||||||
|
};
|
||||||
|
}
|
||||||
123
src/app/settings/page.tsx
Normal file
123
src/app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
Breadcrumbs
|
||||||
|
} from "@mui/material";
|
||||||
|
import Link from "next/link";
|
||||||
|
import UserRow from "./user-row";
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const userEmail = session?.user?.email || "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Access Control
|
||||||
|
* Check if the logged-in user is the Bootstrap Admin from .env
|
||||||
|
* or has the ADMIN role assigned in the database.
|
||||||
|
*/
|
||||||
|
const isBootstrap = userEmail === process.env.INITIAL_ADMIN_EMAIL;
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { email: userEmail },
|
||||||
|
select: { id: true, role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = isBootstrap || dbUser?.role === "ADMIN";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
redirect("/dashboard"); // Unauthorized users are sent back to Dashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. Data Fetching
|
||||||
|
* Get all users to display in the management table.
|
||||||
|
*/
|
||||||
|
const allUsers = await prisma.user.findMany({
|
||||||
|
orderBy: { email: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 6 }}>
|
||||||
|
{/* Breadcrumbs for easier navigation */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link href="/dashboard" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Typography color="text.primary">Settings</Typography>
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<Box mb={4} sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<SettingsIcon color="primary" sx={{ fontSize: 40 }} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" fontWeight={800} color="text.primary">
|
||||||
|
User Management
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Assign Administrative privileges and manage user access.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 3. Bootstrap Warning Box */}
|
||||||
|
{isBootstrap && (
|
||||||
|
<Alert severity="info" variant="outlined" sx={{ mb: 4, borderRadius: 2 }}>
|
||||||
|
You are authenticated via <strong>INITIAL_ADMIN_EMAIL</strong>.
|
||||||
|
This provides permanent access to this page regardless of database settings.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. User Management Table */}
|
||||||
|
<Paper elevation={0} sx={{ border: '1px solid #e0e0e0', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<Table>
|
||||||
|
<TableHead sx={{ bgcolor: '#f8f9fa' }}>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>User Identity</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Current Role</TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{allUsers.length > 0 ? (
|
||||||
|
allUsers.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
currentUserId={dbUser?.id || ""}
|
||||||
|
isBootstrapAdmin={isBootstrap}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography color="text.secondary">No users found in database.</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Note: Users must log out and log back in for role changes to take effect in their active session.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/settings/user-row.tsx
Normal file
113
src/app/settings/user-row.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
Chip,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Typography,
|
||||||
|
Tooltip
|
||||||
|
} from "@mui/material";
|
||||||
|
import { toggleUserRoleAction } from "./actions";
|
||||||
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
|
|
||||||
|
interface UserRowProps {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
currentUserId: string;
|
||||||
|
isBootstrapAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserRow({ user, currentUserId, isBootstrapAdmin }: UserRowProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
const confirmMsg = `Are you sure you want to change ${user.email} to a ${user.role === 'ADMIN' ? 'USER' : 'ADMIN'}?`;
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await toggleUserRoleAction(user.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || "An error occurred while updating the role.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelf = user.id === currentUserId;
|
||||||
|
const isAdmin = user.role === "ADMIN";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REVISED LOGIC:
|
||||||
|
* We only "Lock" the button if:
|
||||||
|
* 1. You are the Bootstrap Admin AND
|
||||||
|
* 2. You are already an ADMIN in the database.
|
||||||
|
* This allows you to promote yourself from USER to ADMIN, but prevents demotion.
|
||||||
|
*/
|
||||||
|
const cannotDemote = isBootstrapAdmin && isSelf && isAdmin;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow hover sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight={isSelf ? 700 : 400}>
|
||||||
|
{user.email}
|
||||||
|
</Typography>
|
||||||
|
{isSelf && (
|
||||||
|
<Typography variant="caption" color="primary" sx={{ display: 'block' }}>
|
||||||
|
Current Session
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
icon={isAdmin ? <AdminPanelSettingsIcon /> : <PersonIcon />}
|
||||||
|
label={isAdmin ? "ADMIN" : "USER"}
|
||||||
|
color={isAdmin ? "primary" : "default"}
|
||||||
|
variant={isAdmin ? "filled" : "outlined"}
|
||||||
|
size="small"
|
||||||
|
sx={{ fontWeight: 600, px: 1 }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell align="right">
|
||||||
|
{cannotDemote ? (
|
||||||
|
<Tooltip title="The Primary Admin cannot be demoted to ensure system access.">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled
|
||||||
|
startIcon={<SecurityIcon />}
|
||||||
|
sx={{ minWidth: 120, textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Primary Admin
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
color={isAdmin ? "inherit" : "primary"}
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ minWidth: 120, textTransform: 'none' }}
|
||||||
|
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : null}
|
||||||
|
>
|
||||||
|
{isAdmin ? "Demote to User" : "Promote to Admin"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/update/[id]/_actions.ts
Normal file
49
src/app/update/[id]/_actions.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
//src/app/update/[id]/_actions.ts
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function updateFileAction(formData: FormData) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
const parentIdRaw = formData.get("parentId") as string;
|
||||||
|
const customMetadataRaw = formData.get("customMetadata") as string;
|
||||||
|
|
||||||
|
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
||||||
|
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Get existing record to preserve system metadata (like mimeType)
|
||||||
|
const existing = await prisma.fileNode.findUnique({ where: { id } });
|
||||||
|
const existingMetadata = (existing?.metadata as Record<string, any>) || {};
|
||||||
|
|
||||||
|
// 2. Update the record
|
||||||
|
await prisma.fileNode.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parentId,
|
||||||
|
metadata: {
|
||||||
|
...customMetadata, // User's new keys
|
||||||
|
type: name.split('.').pop()?.toUpperCase() || existingMetadata.type || "FILE",
|
||||||
|
mimeType: existingMetadata.mimeType // Preserve the original mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath(`/update/${id}`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/update/[id]/page.tsx
Normal file
34
src/app/update/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { Container } from "@mui/material";
|
||||||
|
import UpdateView from "./update-view";
|
||||||
|
|
||||||
|
// Note: params is now handled as a Promise
|
||||||
|
export default async function UpdatePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) redirect("/");
|
||||||
|
|
||||||
|
// 1. Await the params to get the actual ID
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 2. Fetch the specific file using the awaited ID
|
||||||
|
const fileNode = await prisma.fileNode.findUnique({
|
||||||
|
where: { id: id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileNode) notFound();
|
||||||
|
|
||||||
|
// Fetch folders for the destination dropdown
|
||||||
|
const folders = await prisma.fileNode.findMany({
|
||||||
|
where: { isFolder: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
|
<UpdateView fileNode={fileNode} folders={folders} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/app/update/[id]/update-view.tsx
Normal file
171
src/app/update/[id]/update-view.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Box, Button, Typography, Paper, Stack,
|
||||||
|
TextField, MenuItem, IconButton, Grid, Divider
|
||||||
|
} from "@mui/material";
|
||||||
|
import SaveIcon from "@mui/icons-material/Save";
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { updateFileAction } from "./_actions";
|
||||||
|
|
||||||
|
interface MetadataPair {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateView({ fileNode, folders }: any) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 1. Initialize Basic Info
|
||||||
|
const [name, setName] = useState(fileNode.name);
|
||||||
|
const [description, setDescription] = useState(fileNode.description || "");
|
||||||
|
const [parentId, setParentId] = useState(fileNode.parentId || "");
|
||||||
|
|
||||||
|
// 2. Parse existing JSON metadata into Key/Value array for the UI
|
||||||
|
// We filter out 'type' and 'mimeType' as they are system-managed
|
||||||
|
const initialMetadata = Object.entries(fileNode.metadata || {})
|
||||||
|
.filter(([key]) => !['type', 'mimeType'].includes(key))
|
||||||
|
.map(([key, value]) => ({ key, value: String(value) }));
|
||||||
|
|
||||||
|
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>(initialMetadata);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("id", fileNode.id);
|
||||||
|
formData.append("name", name);
|
||||||
|
formData.append("description", description);
|
||||||
|
formData.append("parentId", parentId);
|
||||||
|
|
||||||
|
// Convert array back to object for storage
|
||||||
|
const metadataObj = customMetadata.reduce((acc, curr) => {
|
||||||
|
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
formData.append("customMetadata", JSON.stringify(metadataObj));
|
||||||
|
|
||||||
|
const res = await updateFileAction(formData);
|
||||||
|
if (res.success) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
alert("Update failed");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => router.back()} sx={{ mb: 2 }}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom>
|
||||||
|
Edit File Details
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack spacing={4} sx={{ mt: 2 }}>
|
||||||
|
{/* Name Field */}
|
||||||
|
<TextField
|
||||||
|
label="File Name"
|
||||||
|
fullWidth value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Folder Select */}
|
||||||
|
<TextField
|
||||||
|
id="update-dest-select"
|
||||||
|
select fullWidth label="Destination Folder"
|
||||||
|
value={parentId}
|
||||||
|
onChange={(e) => setParentId(e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
select: { displayEmpty: true },
|
||||||
|
inputLabel: { shrink: true }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value=""><em>-- Root --</em></MenuItem>
|
||||||
|
{folders.map((f: any) => (
|
||||||
|
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{/* Custom Metadata Section */}
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<AssignmentIcon color="primary" /> Custom Attributes
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
startIcon={<AddCircleOutlineIcon />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => setCustomMetadata([...customMetadata, { key: "", value: "" }])}
|
||||||
|
>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{customMetadata.map((row, index) => (
|
||||||
|
<Grid container spacing={1} key={index} alignItems="center">
|
||||||
|
<Grid item xs={5}>
|
||||||
|
<TextField
|
||||||
|
fullWidth size="small" placeholder="Key"
|
||||||
|
value={row.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...customMetadata];
|
||||||
|
updated[index].key = e.target.value;
|
||||||
|
setCustomMetadata(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth size="small" placeholder="Value"
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...customMetadata];
|
||||||
|
updated[index].value = e.target.value;
|
||||||
|
setCustomMetadata(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={1}>
|
||||||
|
<IconButton color="error" onClick={() => setCustomMetadata(customMetadata.filter((_, i) => i !== index))}>
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
multiline rows={4} fullWidth
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained" size="large" fullWidth
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ py: 1.5, fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,34 +6,44 @@ import { prisma } from "@/lib/prisma";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a virtual folder in the database.
|
* 1. CREATE FOLDER: Virtual Only
|
||||||
* No physical folder is created on OneDrive to keep storage flat and fast.
|
* Logic: User-created organizational folders exist ONLY in the database.
|
||||||
|
* No call to OneDrive is made here.
|
||||||
*/
|
*/
|
||||||
export async function createFolderAction(name: string, parentId: string | null = null) {
|
export async function createFolderAction(name: string, parentId?: string | null) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) throw new Error("Unauthorized");
|
if (!session?.user?.id) throw new Error("Unauthorized");
|
||||||
|
|
||||||
const internalId = crypto.randomUUID();
|
try {
|
||||||
|
const internalId = crypto.randomUUID();
|
||||||
|
|
||||||
await prisma.fileNode.create({
|
const newNode = await prisma.fileNode.create({
|
||||||
data: {
|
data: {
|
||||||
id: internalId,
|
id: internalId,
|
||||||
name: name,
|
oneDriveId: null, // Virtual folders do not have a cloud ID
|
||||||
isFolder: true,
|
name: name,
|
||||||
path: `/virtual/${name}`,
|
isFolder: true,
|
||||||
ownerId: session.user.id,
|
path: `virtual:/${name}`,
|
||||||
parentId: parentId || null,
|
ownerId: session.user.id,
|
||||||
}
|
parentId: parentId || null,
|
||||||
});
|
metadata: { type: "FOLDER" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath("/upload");
|
revalidatePath("/upload");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
|
||||||
|
return { success: true, node: newNode };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Folder creation error:", error);
|
||||||
|
throw new Error(error.message || "Failed to create virtual folder");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a file to a unique UUID folder on OneDrive
|
* 2. UPLOAD FILE: Physical Container
|
||||||
* and links it to a virtual parent in the DB.
|
* Logic: Creates a physical folder (UUID) on OneDrive to hold the file.
|
||||||
|
* This ensures every file has a unique storage space in the cloud.
|
||||||
*/
|
*/
|
||||||
export async function uploadFileAction(formData: FormData) {
|
export async function uploadFileAction(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -41,65 +51,85 @@ export async function uploadFileAction(formData: FormData) {
|
||||||
|
|
||||||
const file = formData.get("file") as File;
|
const file = formData.get("file") as File;
|
||||||
const description = formData.get("description") as string || "";
|
const description = formData.get("description") as string || "";
|
||||||
const parentId = formData.get("parentId") as string | null;
|
const parentIdRaw = formData.get("parentId") as string | null;
|
||||||
|
const parentId = (parentIdRaw === "" || parentIdRaw === "root") ? null : parentIdRaw;
|
||||||
|
|
||||||
|
const customMetadataRaw = formData.get("customMetadata") as string;
|
||||||
|
const customMetadata = customMetadataRaw ? JSON.parse(customMetadataRaw) : {};
|
||||||
|
|
||||||
if (!file) throw new Error("No file selected");
|
if (!file) throw new Error("No file selected");
|
||||||
|
|
||||||
const accessToken = await getFreshAccessToken(session.user.id);
|
const accessToken = await getFreshAccessToken(session.user.id);
|
||||||
const rootFolder = "WebCalibre";
|
const rootFolder = "WebCalibre";
|
||||||
const internalId = crypto.randomUUID();
|
const internalId = crypto.randomUUID(); // This UUID will be the OneDrive folder name
|
||||||
|
|
||||||
// 1. Ensure WebCalibre exists (Simplified for brevity)
|
// 1. Create the Physical Storage Folder on OneDrive
|
||||||
// ... (Keep the root folder check logic from your previous version)
|
|
||||||
|
|
||||||
// 2. Create the unique UUID folder on OneDrive
|
|
||||||
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
|
const createSubFolderRes = await fetch(`https://graph.microsoft.com/v1.0/me/drive/root:/${rootFolder}:/children`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
headers: {
|
||||||
body: JSON.stringify({ name: internalId, folder: {}, "@microsoft.graph.conflictBehavior": "fail" })
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: internalId,
|
||||||
|
folder: {},
|
||||||
|
"@microsoft.graph.conflictBehavior": "fail"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!createSubFolderRes.ok) throw new Error("Storage directory creation failed");
|
if (!createSubFolderRes.ok) {
|
||||||
|
const errorData = await createSubFolderRes.json();
|
||||||
|
throw new Error(errorData.error?.message || "Storage directory creation failed");
|
||||||
|
}
|
||||||
const subFolderData = await createSubFolderRes.json();
|
const subFolderData = await createSubFolderRes.json();
|
||||||
|
|
||||||
// 3. Create Upload Session & Upload
|
// 2. Create Upload Session inside the new Physical Folder
|
||||||
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
|
const sessionUrl = `https://graph.microsoft.com/v1.0/me/drive/items/${subFolderData.id}:/${encodeURIComponent(file.name)}:/createUploadSession`;
|
||||||
const sessionRes = await fetch(sessionUrl, {
|
const sessionRes = await fetch(sessionUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
|
body: JSON.stringify({ item: { "@microsoft.graph.conflictBehavior": "replace" } })
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uploadUrl } = await sessionRes.json();
|
const { uploadUrl } = await sessionRes.json();
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// 3. PUT the file binary
|
||||||
const uploadRes = await fetch(uploadUrl, {
|
const uploadRes = await fetch(uploadUrl, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Length": `${file.size}`, "Content-Range": `bytes 0-${file.size - 1}/${file.size}` },
|
headers: {
|
||||||
|
"Content-Length": `${file.size}`,
|
||||||
|
"Content-Range": `bytes 0-${file.size - 1}/${file.size}`
|
||||||
|
},
|
||||||
body: buffer
|
body: buffer
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!uploadRes.ok) throw new Error("OneDrive stream failed");
|
const uploadedFileData = await uploadRes.json();
|
||||||
const driveItem = await uploadRes.json();
|
const oneDriveId = uploadedFileData.id;
|
||||||
|
const extension = file.name.split('.').pop()?.toUpperCase() || "UNKNOWN";
|
||||||
|
|
||||||
// 4. Record in DB
|
// 4. Create record in Database
|
||||||
|
// Link it to the VIRTUAL folder via parentId
|
||||||
await prisma.fileNode.create({
|
await prisma.fileNode.create({
|
||||||
data: {
|
data: {
|
||||||
id: internalId,
|
id: internalId,
|
||||||
oneDriveId: driveItem.id,
|
oneDriveId: oneDriveId,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
description: description,
|
description: description,
|
||||||
size: BigInt(file.size),
|
size: BigInt(file.size),
|
||||||
isFolder: false,
|
isFolder: false,
|
||||||
path: `/${rootFolder}/${internalId}/${file.name}`,
|
path: `/${rootFolder}/${internalId}/${file.name}`,
|
||||||
ownerId: session.user.id,
|
ownerId: session.user.id,
|
||||||
parentId: parentId || null, // VIRTUAL HIERARCHY
|
parentId: parentId,
|
||||||
metadata: {
|
metadata: {
|
||||||
type: file.name.split('.').pop()?.toUpperCase() || "UNKNOWN",
|
...customMetadata,
|
||||||
|
type: extension,
|
||||||
mimeType: file.type
|
mimeType: file.type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/upload");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -2,16 +2,24 @@ import { auth } from "@/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import UploadView from "./upload-view";
|
import UploadView from "./upload-view";
|
||||||
import { Container } from "@mui/material";
|
import { Container } from "@mui/material";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function UploadPage() {
|
export default async function UploadPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
// Guard: Must be logged in to upload
|
|
||||||
if (!session) redirect("/");
|
if (!session) redirect("/");
|
||||||
|
|
||||||
|
// Fetch only folders so the user can select a destination
|
||||||
|
const folders = await prisma.fileNode.findMany({
|
||||||
|
where: { isFolder: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true, parentId: true }
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{ py: 8 }}>
|
<Container maxWidth="md" sx={{ py: 8 }}>
|
||||||
<UploadView user={session.user} />
|
{/* Pass folders to the view */}
|
||||||
|
<UploadView user={session.user} folders={folders} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,197 +1,250 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Box, Button, Typography, Paper, LinearProgress, Stack,
|
Box, Button, Typography, Paper, Stack,
|
||||||
TextField, MenuItem, IconButton, Tooltip, Divider
|
TextField, MenuItem, IconButton, Divider,
|
||||||
|
Grid, CircularProgress
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||||
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { uploadFileAction, createFolderAction } from "./_actions";
|
import { uploadFileAction, createFolderAction } from "./_actions";
|
||||||
|
|
||||||
/**
|
interface MetadataPair {
|
||||||
* Format bytes to human readable string (MiB/KiB)
|
key: string;
|
||||||
*/
|
value: string;
|
||||||
const formatFileSize = (bytes: number) => {
|
}
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UploadView({ user, folders = [] }: { user: any, folders?: any[] }) {
|
export default function UploadView({ user, folders = [] }: any) {
|
||||||
|
const router = useRouter();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [parentId, setParentId] = useState("");
|
const [parentId, setParentId] = useState("");
|
||||||
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
const [status, setStatus] = useState<'idle' | 'uploading' | 'success'>('idle');
|
||||||
|
const [customMetadata, setCustomMetadata] = useState<MetadataPair[]>([]);
|
||||||
|
|
||||||
// Folder Creation State
|
// Folder Creation State
|
||||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||||
// --- RESTORED FILE SIZE LIMITS ---
|
|
||||||
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MiB Limit
|
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderName.trim()) return;
|
if (!newFolderName.trim()) return;
|
||||||
|
setIsCreatingFolder(true);
|
||||||
try {
|
try {
|
||||||
await createFolderAction(newFolderName);
|
// FIX: Pass the current parentId to the action so it nests correctly
|
||||||
setNewFolderName("");
|
const result = await createFolderAction(newFolderName, parentId);
|
||||||
setShowFolderInput(false);
|
if (result.success) {
|
||||||
} catch (err) {
|
setNewFolderName("");
|
||||||
alert("Error creating folder");
|
setShowFolderInput(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || "Failed to create folder");
|
||||||
|
} finally {
|
||||||
|
setIsCreatingFolder(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addMetadataRow = () => setCustomMetadata([...customMetadata, { key: "", value: "" }]);
|
||||||
|
|
||||||
|
const removeMetadataRow = (index: number) => {
|
||||||
|
setCustomMetadata(customMetadata.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMetadataRow = (index: number, field: 'key' | 'value', val: string) => {
|
||||||
|
const updated = [...customMetadata];
|
||||||
|
updated[index][field] = val;
|
||||||
|
setCustomMetadata(updated);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
// RESTORED: Client-side size validation
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
alert(`File is too large! Maximum size allowed is ${formatFileSize(MAX_FILE_SIZE)}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('uploading');
|
setStatus('uploading');
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("description", description);
|
formData.append("description", description);
|
||||||
formData.append("parentId", parentId);
|
formData.append("parentId", parentId);
|
||||||
|
|
||||||
|
const metadataObj = customMetadata.reduce((acc, curr) => {
|
||||||
|
if (curr.key.trim()) acc[curr.key.trim()] = curr.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
formData.append("customMetadata", JSON.stringify(metadataObj));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFileAction(formData);
|
const result = await uploadFileAction(formData);
|
||||||
setStatus('success');
|
if (result.success) {
|
||||||
setFile(null);
|
setStatus('success');
|
||||||
setDescription("");
|
setFile(null);
|
||||||
|
setDescription("");
|
||||||
|
setCustomMetadata([]);
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
alert("Upload failed.");
|
||||||
alert("Upload failed. Ensure file size is within limits and check server logs.");
|
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4 }} elevation={3}>
|
<Paper sx={{ p: { xs: 3, md: 6 }, borderRadius: 4, maxWidth: 800, mx: 'auto' }} elevation={3}>
|
||||||
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
<Typography variant="h4" fontWeight={900} color="primary" gutterBottom align="center">
|
||||||
Add to Library
|
Add to Library
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack spacing={4} sx={{ mt: 4 }}>
|
<Stack spacing={4} sx={{ mt: 4 }}>
|
||||||
|
{/* 1. Destination */}
|
||||||
{/* Section 1: Folder Selection */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
|
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
1. Select Target Project / Folder
|
<FolderIcon color="primary" /> 1. Destination
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
|
id="project-destination-select"
|
||||||
select
|
select
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Choose Folder"
|
label="Target Project / Folder"
|
||||||
value={parentId}
|
value={parentId}
|
||||||
onChange={(e) => setParentId(e.target.value)}
|
onChange={(e) => setParentId(e.target.value)}
|
||||||
disabled={status === 'uploading'}
|
size="small"
|
||||||
|
slotProps={{
|
||||||
|
select: { displayEmpty: true },
|
||||||
|
inputLabel: { shrink: true },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value=""><em>None (Root)</em></MenuItem>
|
<MenuItem value=""><em>-- Root (Main Folder) --</em></MenuItem>
|
||||||
{folders.map((f) => (
|
{folders.map((f: any) => (
|
||||||
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
<MenuItem key={f.id} value={f.id}>{f.name}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
<Tooltip title="Create New Folder">
|
<IconButton
|
||||||
<IconButton
|
color="primary"
|
||||||
color="primary"
|
onClick={() => setShowFolderInput(!showFolderInput)}
|
||||||
onClick={() => setShowFolderInput(!showFolderInput)}
|
sx={{ border: '1px solid #ccc', borderRadius: 1 }}
|
||||||
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}
|
>
|
||||||
>
|
<CreateNewFolderIcon />
|
||||||
<CreateNewFolderIcon />
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{showFolderInput && (
|
{showFolderInput && (
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 2 }}>
|
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8f9fa', borderRadius: 2 }}>
|
||||||
<TextField
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
size="small"
|
{parentId ? `Create inside current selection` : `Create at Root`}
|
||||||
fullWidth
|
</Typography>
|
||||||
placeholder="New folder name..."
|
<Stack direction="row" spacing={1}>
|
||||||
value={newFolderName}
|
<TextField
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
fullWidth size="small" placeholder="Folder Name (e.g. Project-2)"
|
||||||
autoFocus
|
value={newFolderName}
|
||||||
/>
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
<Button variant="contained" onClick={handleCreateFolder}>Create</Button>
|
onKeyPress={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||||
</Stack>
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleCreateFolder}
|
||||||
|
disabled={isCreatingFolder || !newFolderName}
|
||||||
|
>
|
||||||
|
{isCreatingFolder ? <CircularProgress size={24} /> : "Create"}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
{/* 2. Upload Area */}
|
||||||
|
|
||||||
{/* Section 2: File Selection */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" gutterBottom fontWeight="bold">
|
<Typography variant="h6" gutterBottom fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
2. Upload File
|
<CloudUploadIcon color="primary" /> 2. Upload File
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ p: 4, border: '2px dashed #ccc', borderRadius: 2, textAlign: 'center', bgcolor: '#fcfcfc' }}>
|
|
||||||
<input
|
<input
|
||||||
type="file" id="file-input" hidden
|
type="file"
|
||||||
accept=".pdf,.epub,.mobi,.azw3,.txt,.png,.jpeg"
|
ref={fileInputRef}
|
||||||
onChange={(e) => {
|
style={{ display: 'none' }}
|
||||||
setFile(e.target.files?.[0] || null);
|
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||||
setStatus('idle');
|
/>
|
||||||
}}
|
|
||||||
/>
|
<Button
|
||||||
<label htmlFor="file-input">
|
variant="outlined"
|
||||||
<Button variant="outlined" component="span" startIcon={<CloudUploadIcon />}>
|
fullWidth
|
||||||
{file ? "Change File" : "Choose Book/Image"}
|
sx={{ p: 4, borderStyle: 'dashed', textTransform: 'none' }}
|
||||||
</Button>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
</label>
|
>
|
||||||
{file && (
|
{file ? (
|
||||||
<Box mt={2}>
|
<Box>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography color="success.main" fontWeight="bold">✓ {file.name}</Typography>
|
||||||
Selected: <strong>{file.name}</strong>
|
<Typography variant="caption" color="text.secondary">
|
||||||
</Typography>
|
Click to change file ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||||
<Typography variant="caption" sx={{ color: file.size > MAX_FILE_SIZE ? 'error.main' : 'text.disabled' }}>
|
|
||||||
Size: {formatFileSize(file.size)} {file.size > MAX_FILE_SIZE && "(Too Large)"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
"Click to Select File"
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Section 3: Description */}
|
{/* 3. Custom Attributes */}
|
||||||
<TextField
|
<Box>
|
||||||
label="Notes / Description"
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
multiline rows={2} fullWidth
|
<Typography variant="h6" fontWeight="700" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
value={description}
|
<AssignmentIcon color="primary" /> 3. Custom Attributes
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
disabled={status === 'uploading'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Status & Action */}
|
|
||||||
{status === 'uploading' && (
|
|
||||||
<Box>
|
|
||||||
<LinearProgress sx={{ borderRadius: 5, height: 10 }} />
|
|
||||||
<Typography variant="caption" color="primary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
|
|
||||||
Streaming to OneDrive storage...
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Button startIcon={<AddCircleOutlineIcon />} size="small" onClick={addMetadataRow}>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{customMetadata.map((row, index) => (
|
||||||
|
<Grid container spacing={1} key={index} alignItems="center">
|
||||||
|
<Grid item xs={5}>
|
||||||
|
<TextField
|
||||||
|
fullWidth size="small" placeholder="Key (e.g. Project-ID)"
|
||||||
|
value={row.key} onChange={(e) => updateMetadataRow(index, 'key', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth size="small" placeholder="Value"
|
||||||
|
value={row.value} onChange={(e) => updateMetadataRow(index, 'value', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={1}>
|
||||||
|
<IconButton color="error" onClick={() => removeMetadataRow(index)}>
|
||||||
|
<DeleteOutlineIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="General Description"
|
||||||
|
multiline rows={2} fullWidth
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained" size="large" fullWidth
|
variant="contained" size="large" fullWidth
|
||||||
disabled={!file || status === 'uploading' || file.size > MAX_FILE_SIZE}
|
disabled={!file || status === 'uploading'}
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
sx={{ py: 2, fontWeight: 'bold' }}
|
sx={{ py: 2, fontWeight: 'bold' }}
|
||||||
>
|
>
|
||||||
{status === 'uploading' ? 'Uploading...' : 'Confirm Upload'}
|
{status === 'uploading' ? 'Uploading to OneDrive...' : 'Start Upload'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{status === 'success' && (
|
|
||||||
<Typography align="center" color="success.main" fontWeight="bold">
|
|
||||||
✅ Successfully Added!
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
26
src/auth.ts
26
src/auth.ts
|
|
@ -1,3 +1,4 @@
|
||||||
|
// src/auth.ts
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
@ -9,25 +10,42 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
...authConfig,
|
...authConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account, user }) {
|
async jwt({ token, account, user }) {
|
||||||
// On the first sign in, 'account' contains the refresh_token
|
// 1. Handle OAuth tokens (from first sign-in)
|
||||||
|
// This captures the tokens directly from the Microsoft Azure response
|
||||||
if (account) {
|
if (account) {
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
token.refreshToken = account.refresh_token;
|
token.refreshToken = account.refresh_token;
|
||||||
token.expiresAt = account.expires_at;
|
token.expiresAt = account.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Attach User ID and Role to the token
|
||||||
|
// This runs when the user first logs in
|
||||||
if (user) {
|
if (user) {
|
||||||
token.sub = user.id;
|
token.sub = user.id;
|
||||||
|
// @ts-ignore - 'role' is a custom field in your Postgres User table
|
||||||
|
token.role = user.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (session?.user && token.sub) {
|
// 3. Pass values from the JWT Token into the Client-facing Session
|
||||||
session.user.id = token.sub;
|
// This makes the tokens and IDs available to your API routes and Components
|
||||||
|
if (session?.user) {
|
||||||
|
session.user.id = token.sub as string;
|
||||||
|
|
||||||
|
// @ts-ignore - Attaching the role for UI permissions
|
||||||
|
session.user.role = token.role as string;
|
||||||
|
|
||||||
|
// IMPORTANT: We must attach the accessToken here so the
|
||||||
|
// /api/download route can use it to fetch from MS Graph
|
||||||
|
session.accessToken = token.accessToken as string;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Adding events can help debug if the account is actually linking
|
|
||||||
events: {
|
events: {
|
||||||
async linkAccount({ account, user }) {
|
async linkAccount({ account, user }) {
|
||||||
console.log("🔗 Account linked successfully for user:", user.id);
|
console.log("🔗 Account linked successfully for user:", user.id);
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,18 @@ import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { signIn, signOut } from "next-auth/react";
|
import { signIn, signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
// Updated Interface to include Role and Bootstrap status
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
user?: {
|
user?: {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
role?: string; // Added
|
||||||
|
isBootstrap?: boolean; // Added
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,6 +29,9 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const isLoggedIn = !!user;
|
const isLoggedIn = !!user;
|
||||||
|
|
||||||
|
// Logic: Is this user an Admin?
|
||||||
|
const isAdmin = user?.role === 'ADMIN' || user?.isBootstrap === true;
|
||||||
|
|
||||||
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||||
if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
|
if (event.type === 'keydown' && ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift')) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -32,12 +39,18 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
setIsDrawerOpen(open);
|
setIsDrawerOpen(open);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build the list dynamically based on permissions
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
{ text: 'Dashboard', icon: <DashboardIcon />, href: '/dashboard' },
|
||||||
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
{ text: 'Library', icon: <LibraryBooksIcon />, href: '/library' },
|
||||||
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
{ text: 'Upload File', icon: <CloudUploadIcon />, href: '/upload' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Only push Settings if the user has Admin rights
|
||||||
|
if (isAdmin) {
|
||||||
|
navItems.push({ text: 'Settings', icon: <SettingsIcon />, href: '/settings' });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
<AppBar position="sticky" elevation={0} sx={{ backgroundColor: 'white', color: 'text.primary', borderBottom: '1px solid #e0e0e0' }}>
|
||||||
|
|
@ -67,7 +80,9 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
<>
|
<>
|
||||||
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
<Box sx={{ textAlign: 'right', display: { xs: 'none', sm: 'block' } }}>
|
||||||
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>{user.name}</Typography>
|
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>{user.name}</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>{user.email}</Typography>
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||||
|
{user.email} {isAdmin && "(Admin)"}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
|
<Avatar src={user.image || ""} sx={{ width: 38, height: 38, border: '1px solid #eee' }}>
|
||||||
{user.name?.charAt(0)}
|
{user.name?.charAt(0)}
|
||||||
|
|
@ -104,6 +119,18 @@ export default function Navbar({ user }: NavbarProps) {
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
{/* Visual indicator for non-admins if you want it greyed out instead of hidden */}
|
||||||
|
{!isAdmin && isLoggedIn && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
<ListItem sx={{ opacity: 0.5 }}>
|
||||||
|
<ListItemIcon><SettingsIcon /></ListItemIcon>
|
||||||
|
<ListItemText primary="Settings" secondary="Admin Only" />
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
24
src/types/next-auth.d.ts
vendored
Normal file
24
src/types/next-auth.d.ts
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// src/types/next-auth.d.ts
|
||||||
|
import NextAuth, { DefaultSession } from "next-auth"
|
||||||
|
import { JWT } from "next-auth/jwt"
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
/**
|
||||||
|
* Returned by `useSession`, `auth`, and received as a prop on the `SessionProvider` React Context
|
||||||
|
*/
|
||||||
|
interface Session {
|
||||||
|
accessToken?: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
role?: string;
|
||||||
|
} & DefaultSession["user"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
|
||||||
|
interface JWT {
|
||||||
|
accessToken?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue