diff --git a/client/package-lock.json b/client/package-lock.json index ee86258..68a7748 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,9 +14,10 @@ "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^7.0.2", + "@mui/icons-material": "6.4.7", "@mui/lab": "^5.0.0-alpha.153", - "@mui/material": "^7.0.2", + "@mui/material": "6.4.7", + "@mui/system": "6.4.7", "@types/uuid": "^9.0.7", "axios": "^1.8.1", "dompurify": "^3.2.5", @@ -3365,9 +3366,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.0.2.tgz", - "integrity": "sha512-TfeFU9TgN1N06hyb/pV/63FfO34nijZRMqgHk0TJ3gkl4Fbd+wZ73+ZtOd7jag6hMmzO9HSrBc6Vdn591nhkAg==", + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.11.tgz", + "integrity": "sha512-CzAQs9CTzlwbsF9ZYB4o4lLwBv1/qNE264NjuYao+ctAXsmlPtYa8RtER4UsUXSMxNN9Qi+aQdYcKl2sUpnmAw==", "license": "MIT", "funding": { "type": "opencollective", @@ -3375,12 +3376,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.0.2.tgz", - "integrity": "sha512-Bo57PFLOqXOqPNrXjd8AhzH5s6TCsNUQbvnQ0VKZ8D+lIlteqKnrk/O1luMJUc/BXONK7BfIdTdc7qOnXYbMdw==", + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz", + "integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.0" + "@babel/runtime": "^7.26.0" }, "engines": { "node": ">=14.0.0" @@ -3390,7 +3391,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.0.2", + "@mui/material": "^6.4.7", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -3441,194 +3442,14 @@ } } }, - "node_modules/@mui/material": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.0.2.tgz", - "integrity": "sha512-rjJlJ13+3LdLfobRplkXbjIFEIkn6LgpetgU/Cs3Xd8qINCCQK9qXQIjjQ6P0FXFTPFzEVMj0VgBR1mN+FhOcA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/core-downloads-tracker": "^7.0.2", - "@mui/system": "^7.0.2", - "@mui/types": "^7.4.1", - "@mui/utils": "^7.0.2", - "@popperjs/core": "^2.11.8", - "@types/react-transition-group": "^4.4.12", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1", - "react-is": "^19.1.0", - "react-transition-group": "^4.4.5" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.0.2", - "@types/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" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@mui/material-pigment-css": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.0.2.tgz", - "integrity": "sha512-6lt8heDC9wN8YaRqEdhqnm0cFCv08AMf4IlttFvOVn7ZdKd81PNpD/rEtPGLLwQAFyyKSxBG4/2XCgpbcdNKiA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/utils": "^7.0.2", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.0.2.tgz", - "integrity": "sha512-11Bt4YdHGlh7sB8P75S9mRCUxTlgv7HGbr0UKz6m6Z9KLeiw1Bm9y/t3iqLLVMvSHYB6zL8X8X+LmfTE++gyBw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.0.2.tgz", - "integrity": "sha512-yFUraAWYWuKIISPPEVPSQ1NLeqmTT4qiQ+ktmyS8LO/KwHxB+NNVOacEZaIofh5x1NxY8rzphvU5X2heRZ/RDA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/private-theming": "^7.0.2", - "@mui/styled-engine": "^7.0.2", - "@mui/types": "^7.4.1", - "@mui/utils": "^7.0.2", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/utils": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.0.2.tgz", - "integrity": "sha512-72gcuQjPzhj/MLmPHLCgZjy2VjOH4KniR/4qRtXTTXIEwbkgcN+Y5W/rC90rWtMmZbjt9svZev/z+QHUI4j74w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/types": "^7.4.1", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.14.tgz", - "integrity": "sha512-12t7NKzvYi819IO5IapW2BcR33wP/KAVrU8d7gLhGHoAmhDxyXlRoKiRij3TOD8+uzk0B6R9wHUNKi4baJcRNg==", + "node_modules/@mui/lab/node_modules/@mui/private-theming": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.14", + "@mui/utils": "^5.17.1", "prop-types": "^15.8.1" }, "engines": { @@ -3648,7 +3469,7 @@ } } }, - "node_modules/@mui/styled-engine": { + "node_modules/@mui/lab/node_modules/@mui/styled-engine": { "version": "5.16.14", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz", "integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==", @@ -3680,17 +3501,17 @@ } } }, - "node_modules/@mui/system": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.14.tgz", - "integrity": "sha512-KBxMwCb8mSIABnKvoGbvM33XHyT+sN0BzEBG+rsSc0lLQGzs7127KWkCA6/H8h6LZ00XpBEME5MAj8mZLiQ1tw==", + "node_modules/@mui/lab/node_modules/@mui/system": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.17.1.tgz", + "integrity": "sha512-aJrmGfQpyF0U4D4xYwA6ueVtQcEMebET43CUmKMP7e7iFh3sMIF3sBR0l8Urb4pqx1CBjHAaWgB0ojpND4Q3Jg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.14", + "@mui/private-theming": "^5.17.1", "@mui/styled-engine": "^5.16.14", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.14", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -3720,6 +3541,302 @@ } } }, + "node_modules/@mui/lab/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.4.7.tgz", + "integrity": "sha512-K65StXUeGAtFJ4ikvHKtmDCO5Ab7g0FZUu2J5VpoKD+O6Y3CjLYzRi+TMlI3kaL4CL158+FccMoOd/eaddmeRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.4.7", + "@mui/system": "^6.4.7", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.4.7", + "@types/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" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.4.11.tgz", + "integrity": "sha512-74AUmlHXaGNbyUqdK/+NwDJOZqgRQw6BcNvhoWYLq3LGbLTkE+khaJ7soz6cIabE4CPYqO2/QAIU1Z/HEjjpcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.4.7.tgz", + "integrity": "sha512-7wwc4++Ak6tGIooEVA9AY7FhH2p9fvBMORT4vNLMAysH3Yus/9B9RYMbrn3ANgsOyvT3Z7nE+SP8/+3FimQmcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.6", + "@mui/styled-engine": "^6.4.6", + "@mui/types": "^7.2.21", + "@mui/utils": "^6.4.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system/node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/types": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz", @@ -3738,13 +3855,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.14.tgz", - "integrity": "sha512-wn1QZkRzSmeXD1IguBVvJJHV3s6rxJrfb6YuC9Kk6Noh9f8Fb54nUs5JRkKm+BOerRhj5fLg05Dhx/H3Ofb8Mg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/types": "^7.2.15", + "@mui/types": "~7.2.15", "@types/prop-types": "^15.7.12", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -3767,6 +3884,20 @@ } } }, + "node_modules/@mui/utils/node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/client/package.json b/client/package.json index ac38087..7aff0e1 100644 --- a/client/package.json +++ b/client/package.json @@ -18,9 +18,10 @@ "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.0", - "@mui/icons-material": "^7.0.2", + "@mui/icons-material": "6.4.7", "@mui/lab": "^5.0.0-alpha.153", - "@mui/material": "^7.0.2", + "@mui/material": "6.4.7", + "@mui/system": "6.4.7", "@types/uuid": "^9.0.7", "axios": "^1.8.1", "dompurify": "^3.2.5", diff --git a/client/src/App.tsx b/client/src/App.tsx index a3e33fa..6d0fae2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -26,9 +26,15 @@ import Footer from './components/Footer/Footer'; import ApiService from './services/ApiService'; import OAuthCallback from './pages/AuthManager/callback/AuthCallback'; +import Users from './pages/Admin/Users'; +import Images from './pages/Admin/Images'; +import Stats from './pages/Admin/Stats'; + + const App: React.FC = () => { const [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn()); const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher()); + const [isAdmin, setIsAdmin] = useState(false); const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null); const location = useLocation(); @@ -37,6 +43,7 @@ const App: React.FC = () => { const checkLoginStatus = () => { setIsAuthenticated(ApiService.isLoggedIn()); setIsTeacherAuthenticated(ApiService.isLoggedInTeacher()); + setIsAdmin(ApiService.isAdmin()); }; const fetchAuthenticatedRooms = async () => { @@ -56,7 +63,7 @@ const App: React.FC = () => { return (
-
+
@@ -98,6 +105,11 @@ const App: React.FC = () => { {/* Pages authentification sélection */} } /> + + } /> + } /> + } /> +
diff --git a/client/src/Types/AdminTableType.tsx b/client/src/Types/AdminTableType.tsx new file mode 100644 index 0000000..44ff662 --- /dev/null +++ b/client/src/Types/AdminTableType.tsx @@ -0,0 +1,9 @@ +export interface AdminTableType { + _id: string; + email: string; + created_at: Date; + updated_at?: Date; + title?: string; + name?: string; + roles?: string[]; +} \ No newline at end of file diff --git a/client/src/Types/LabelMap.tsx b/client/src/Types/LabelMap.tsx new file mode 100644 index 0000000..af2d26e --- /dev/null +++ b/client/src/Types/LabelMap.tsx @@ -0,0 +1,2 @@ + +export type LabelMap = { [key: string]: string }; diff --git a/client/src/Types/QuizType.tsx b/client/src/Types/QuizType.tsx index b5e2b08..5078eda 100644 --- a/client/src/Types/QuizType.tsx +++ b/client/src/Types/QuizType.tsx @@ -9,3 +9,16 @@ export interface QuizType { created_at: Date; updated_at: Date; } + +export interface QuizTypeShort { + _id: string; + email: string; + title: string; + created_at: Date; + updated_at: Date; +} + +export interface QuizResponse { + quizzes: QuizTypeShort[]; + total: number; +} \ No newline at end of file diff --git a/client/src/Types/UserType.tsx b/client/src/Types/UserType.tsx new file mode 100644 index 0000000..db6336d --- /dev/null +++ b/client/src/Types/UserType.tsx @@ -0,0 +1,11 @@ +export interface UserType { + id: string; + name: string; + email: string; + created_at: string; + roles: string[]; +} + +export interface UsersResponse { + users: UserType[]; +} \ No newline at end of file diff --git a/client/src/__tests__/Types/AdminTableType.test.tsx b/client/src/__tests__/Types/AdminTableType.test.tsx new file mode 100644 index 0000000..2534f1b --- /dev/null +++ b/client/src/__tests__/Types/AdminTableType.test.tsx @@ -0,0 +1,17 @@ +import { AdminTableType } from "../../Types/AdminTableType"; + +it("AdminTableType allows valid data", () => { + const validData: AdminTableType = { + _id: "123", + email: "user@example.com", + created_at: new Date(), + updated_at: new Date(), + title: "Manager", + name: "John Doe", + roles: ["admin", "editor"], + }; + + expect(validData).toBeDefined(); + expect(validData._id).toBe("123"); + expect(validData.roles).toContain("admin"); +}); \ No newline at end of file diff --git a/client/src/__tests__/Types/FolderType.test.tsx b/client/src/__tests__/Types/FolderType.test.tsx new file mode 100644 index 0000000..b19a2d8 --- /dev/null +++ b/client/src/__tests__/Types/FolderType.test.tsx @@ -0,0 +1,62 @@ +import { FolderType } from "../../Types/FolderType"; + + +it('FolderType should allow correct structure with valid types', () => { + const validFolder: FolderType = { + _id: "1", + userId: "user123", + title: "My Folder", + created_at: "2025-03-30T22:08:47.839Z", + }; + expect(validFolder._id).toBe("1"); + expect(validFolder.userId).toBe("user123"); + expect(validFolder.title).toBe("My Folder"); + expect(validFolder.created_at).toBe("2025-03-30T22:08:47.839Z"); +}); + +it('FolderType should throw error if required fields are missing', () => { + const missingRequiredFields = (folder: any) => { + const requiredFields = ['_id', 'userId', 'title', 'created_at']; + for (const field of requiredFields) { + if (!folder[field]) { + throw new Error(`Missing required field: ${field}`); + } + } + }; + + // Test: Missing required field _id + expect(() => { + missingRequiredFields({ + userId: "user123", + title: "My Folder", + created_at: "2025-03-30T22:08:47.839Z", + }); + }).toThrow('Missing required field: _id'); + + // Test: Missing required field userId + expect(() => { + missingRequiredFields({ + _id: "1", + title: "My Folder", + created_at: "2025-03-30T22:08:47.839Z", + }); + }).toThrow('Missing required field: userId'); + + // Test: Missing required field title + expect(() => { + missingRequiredFields({ + _id: "1", + userId: "user123", + created_at: "2025-03-30T22:08:47.839Z", + }); + }).toThrow('Missing required field: title'); + + // Test: Missing required field created_at + expect(() => { + missingRequiredFields({ + _id: "1", + userId: "user123", + title: "My Folder", + }); + }).toThrow('Missing required field: created_at'); +}); \ No newline at end of file diff --git a/client/src/__tests__/Types/ImageType.test.tsx b/client/src/__tests__/Types/ImageType.test.tsx new file mode 100644 index 0000000..2d571c5 --- /dev/null +++ b/client/src/__tests__/Types/ImageType.test.tsx @@ -0,0 +1,79 @@ +import { ImageType, ImagesResponse, ImagesParams } from "../../Types/ImageType"; + +it("valid ImageType structure", () => { + const validImage: ImageType = { + id: "1", + file_content: "mockBase64Content", + file_name: "image.jpg", + mime_type: "image/jpeg", + }; + + expect(validImage).toHaveProperty("id", "1"); + expect(validImage).toHaveProperty("file_content", "mockBase64Content"); + expect(validImage).toHaveProperty("file_name", "image.jpg"); + expect(validImage).toHaveProperty("mime_type", "image/jpeg"); +}); + +it("invalid ImageType throws an error", () => { + const invalidImage: any = { + id: "1", + file_content: "mockBase64Content", + mime_type: "image/jpeg", + }; + + expect(() => { + expect(invalidImage).toHaveProperty("file_name"); + }).toThrow(); +}); + +it("valid ImagesResponse structure", () => { + const validResponse: ImagesResponse = { + images: [ + { + id: "1", + file_content: "mockBase64Content1", + file_name: "image1.jpg", + mime_type: "image/jpeg", + }, + { + id: "2", + file_content: "mockBase64Content2", + file_name: "image2.jpg", + mime_type: "image/jpeg", + }, + ], + total: 2, + }; + + expect(validResponse).toHaveProperty("images"); + expect(validResponse.images).toBeInstanceOf(Array); + expect(validResponse.images[0]).toHaveProperty("id"); + expect(validResponse.images[0]).toHaveProperty("file_content"); + expect(validResponse.images[0]).toHaveProperty("file_name"); + expect(validResponse.images[0]).toHaveProperty("mime_type"); + expect(validResponse).toHaveProperty("total", 2); +}); + +it("invalid ImagesResponse structure", () => { + const invalidResponse: any = { total: 2}; + expect(invalidResponse.images).toBeUndefined(); +}); + +it("valid ImagesParams structure", () => { + const validParams: ImagesParams = { + page: 1, + limit: 10, + uid: "user123", + }; + expect(validParams).toHaveProperty("page", 1); + expect(validParams).toHaveProperty("limit", 10); + expect(validParams).toHaveProperty("uid", "user123"); +}); + +it("invalid ImagesParams structure", () => { + const invalidParams: any = { page: 1}; + + expect(() => { + expect(invalidParams).toHaveProperty("limit"); + }).toThrow(); +}); \ No newline at end of file diff --git a/client/src/__tests__/Types/LabelMap.test.tsx b/client/src/__tests__/Types/LabelMap.test.tsx new file mode 100644 index 0000000..2dec463 --- /dev/null +++ b/client/src/__tests__/Types/LabelMap.test.tsx @@ -0,0 +1,35 @@ +import { LabelMap } from "../../Types/LabelMap"; + +it("LabelMap should only allow string keys and string values", () => { + // Valid LabelMap example with different keys + const validLabelMap: LabelMap = { + name: "Name", + email: "Email", + created_at: "Created At", + }; + + expect(validLabelMap).toBeDefined(); + expect(Object.keys(validLabelMap)).toEqual(["name", "email", "created_at"]); + expect(validLabelMap.name).toBe("Name"); + expect(validLabelMap.email).toBe("Email"); + expect(validLabelMap.created_at).toBe("Created At"); +}); + +it("LabelMap should allow only specified keys", () => { + const validLabelMap: LabelMap = { + name: "Name", + email: "Email", + created_at: "Created At", + }; + + const knownKeys = ["name", "email", "created_at"]; + const keys = Object.keys(validLabelMap); + knownKeys.forEach((key) => { + expect(keys).toContain(key); + expect(typeof validLabelMap[key]).toBe("string"); + }); + + Object.values(validLabelMap).forEach((value) => { + expect(typeof value).toBe("string"); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/Types/UserType.test.tsx b/client/src/__tests__/Types/UserType.test.tsx new file mode 100644 index 0000000..9fdaeec --- /dev/null +++ b/client/src/__tests__/Types/UserType.test.tsx @@ -0,0 +1,51 @@ +import { UserType, UsersResponse } from "../../Types/UserType"; + +it("valid UserType structure", () => { + const validUser: UserType = { + id: "1", + name: "John Doe", + email: "john.doe@example.com", + created_at: new Date().toISOString(), + roles: ["admin", "user"], + }; + expect(validUser).toHaveProperty("id", "1"); + expect(validUser).toHaveProperty("name", "John Doe"); + expect(validUser).toHaveProperty("email", "john.doe@example.com"); + expect(validUser).toHaveProperty("created_at"); + expect(validUser).toHaveProperty("roles"); + expect(validUser.roles).toBeInstanceOf(Array); + expect(validUser.roles).toContain("admin"); + expect(validUser.roles).toContain("user"); +}); + +it("valid UsersResponse structure", () => { + const validResponse: UsersResponse = { + users: [ + { + id: "1", + name: "John Doe", + email: "john.doe@example.com", + created_at: new Date().toISOString(), + roles: ["admin"], + }, + { + id: "2", + name: "Jane Smith", + email: "jane.smith@example.com", + created_at: new Date().toISOString(), + roles: ["user"], + }, + ], + }; + expect(validResponse).toHaveProperty("users"); + expect(validResponse.users).toBeInstanceOf(Array); + expect(validResponse.users[0]).toHaveProperty("id"); + expect(validResponse.users[0]).toHaveProperty("name"); + expect(validResponse.users[0]).toHaveProperty("email"); + expect(validResponse.users[0]).toHaveProperty("roles"); +}); + +it("invalid UsersResponse structure", () => { + const invalidResponse: any = { }; + expect(invalidResponse.users).toBeUndefined(); +}); diff --git a/client/src/__tests__/components/AdminDrawer/AdminDrawer.test.tsx b/client/src/__tests__/components/AdminDrawer/AdminDrawer.test.tsx new file mode 100644 index 0000000..fd34b2b --- /dev/null +++ b/client/src/__tests__/components/AdminDrawer/AdminDrawer.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AdminDrawer from '../../../components/AdminDrawer/AdminDrawer'; +import { BrowserRouter as Router } from 'react-router-dom'; // Import Router +import '@testing-library/jest-dom'; + +describe('AdminDrawer Component', () => { + test('renders the Admin button', () => { + render( + + + + ); + + // Check if the "Admin" button is in the document + const button = screen.getByRole('button', { name: /admin/i }); + expect(button).toBeInTheDocument(); + }); + + test('opens the drawer when the button is clicked', () => { + render( + + + + ); + + // Click the "Admin" button + const button = screen.getByRole('button', { name: /admin/i }); + fireEvent.click(button); + + // Check if the drawer is open (it should be a right-side drawer, so check for list items) + const statsItem = screen.getByText(/Stats/i); + expect(statsItem).toBeInTheDocument(); + }); + //TODO modify this test as no redirect as of yet +/* + test('closes the drawer when an item is clicked', () => { + render(); + + // Open the drawer by clicking the "Admin" button + const button = screen.getByRole('button', { name: /admin/i }); + fireEvent.click(button); + + // Click on a menu item (Stats, Images, or Users) + const statsItem = screen.getByText(/Stats/i); + expect(statsItem).toBeInTheDocument(); + fireEvent.click(statsItem); + + // Ensure that the drawer is closed after clicking an item + const statsItemAgain = screen.queryByText(/Stats/i); + expect(statsItemAgain).not.toBeInTheDocument(); + }); +*/ + test('menu items render correctly', () => { + render( + + + + ); + + // Open the drawer + const button = screen.getByRole('button', { name: /admin/i }); + fireEvent.click(button); + + // Check if all the menu items are rendered + expect(screen.getByText(/Stats/i)).toBeInTheDocument(); + expect(screen.getByText(/Images/i)).toBeInTheDocument(); + expect(screen.getByText(/Users/i)).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/components/AdminTable/AdminTable.test.tsx b/client/src/__tests__/components/AdminTable/AdminTable.test.tsx new file mode 100644 index 0000000..c937071 --- /dev/null +++ b/client/src/__tests__/components/AdminTable/AdminTable.test.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import AdminTable from "../../../components/AdminTable/AdminTable"; +import { AdminTableType } from "../../../Types/AdminTableType"; +import "@testing-library/jest-dom"; + +const mockData: AdminTableType[] = [ + { _id: "1", name: "John Doe", email: "john@example.com", created_at: new Date("2024-01-01"), roles: ["Admin"] }, + { _id: "2", name: "Jane Doe", email: "jane@example.com", created_at: new Date("2024-02-01"), roles: ["User"] }, + { _id: "3", name: "Alice Smith", email: "alice@example.com", created_at: new Date("2024-03-01"), roles: ["Editor"] }, +]; + +const labelMap = { + name: "Name", + email: "Email", + created_at: "Created At", + roles: "Roles", +}; + +describe("AdminTable Component", () => { + let mockOnDelete: jest.Mock; + + beforeEach(() => { + mockOnDelete = jest.fn(); + }); + + test("render AdminTable", () => { + render(); + + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Email")).toBeInTheDocument(); + expect(screen.getByText("Created At")).toBeInTheDocument(); + expect(screen.getByText("Roles")).toBeInTheDocument(); + expect(screen.getByText("John Doe")).toBeInTheDocument(); + expect(screen.getByText("jane@example.com")).toBeInTheDocument(); + }); + + test("filters data based on search input", () => { + render(); + const searchInput = screen.getByPlaceholderText("Recherche: Enseignant, Courriel..."); + + fireEvent.change(searchInput, { target: { value: "Alice" } }); + + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + expect(screen.queryByText("John Doe")).not.toBeInTheDocument(); + }); + + test("opens and closes confirmation dialog", async () => { + render(); + const deleteButton = screen.getAllByRole("button")[0]; + + fireEvent.click(deleteButton); + expect(screen.getByText("Confirmation")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + await waitFor(() => { + expect(screen.queryByText("Confirmation")).not.toBeInTheDocument(); + }); + }); + + test("onDelete when confirming delete", () => { + render(); + const deleteButton = screen.getAllByRole("button")[0]; + fireEvent.click(deleteButton); + + fireEvent.click(screen.getByText("Delete")); + expect(mockOnDelete).toHaveBeenCalledWith(mockData[0]); + }); + + test("pagination buttons test click", () => { + render(); + + const nextButton = screen.getByLabelText("Go to next page"); + fireEvent.click(nextButton); + + expect(screen.getByText("Alice Smith")).toBeInTheDocument(); + }); +}); diff --git a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx index 5586a6a..52eb3a3 100644 --- a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx +++ b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx @@ -27,7 +27,7 @@ describe("ImageGallery", () => { let mockHandleDelete: jest.Mock; beforeEach(async () => { - (ApiService.getUserImages as jest.Mock).mockResolvedValue({ images: mockImages, total: 3 }); + (ApiService.getImages as jest.Mock).mockResolvedValue({ images: mockImages, total: 3 }); (ApiService.deleteImage as jest.Mock).mockResolvedValue(true); (ApiService.uploadImage as jest.Mock).mockResolvedValue('mockImageUrl'); await act(async () => { @@ -62,7 +62,7 @@ describe("ImageGallery", () => { it("should delete an image and update the gallery", async () => { const fetchImagesMock = jest.fn().mockResolvedValue({ images: mockImages.filter((image) => image.id !== "1"), total: 2 }); - (ApiService.getUserImages as jest.Mock).mockImplementation(fetchImagesMock); + (ApiService.getImages as jest.Mock).mockImplementation(fetchImagesMock); await act(async () => { render(); diff --git a/client/src/__tests__/pages/Admin/Stats.test.tsx b/client/src/__tests__/pages/Admin/Stats.test.tsx new file mode 100644 index 0000000..62e7084 --- /dev/null +++ b/client/src/__tests__/pages/Admin/Stats.test.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import Stats from "../../../pages/Admin/Stats"; +import ApiService from '../../../services/ApiService'; +import '@testing-library/jest-dom'; + +jest.mock('../../../services/ApiService', () => ({ + getStats: jest.fn(), +})); + +describe("Stats Component", () => { + beforeEach(() => { + jest.clearAllMocks(); + (ApiService.getStats as jest.Mock).mockReset(); + }); + + test("renders loading state initially", async () => { + (ApiService.getStats as jest.Mock).mockImplementationOnce(() => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + quizzes: [], + total: 0, + }); + }, 100); + }) + ); + + await act(async () => { + render(); + }); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + test("fetches and displays data", async () => { + const mockStats = { + quizzes: [{ _id: "1", title: "Mock Quiz", created_at: "2025-03-01", updated_at: "2025-03-05", email: "teacher@example.com" }], + total: 5, + }; + + (ApiService.getStats as jest.Mock).mockResolvedValueOnce(mockStats); + + + await act(async () => { + render(); + }); + + await waitFor(() => screen.queryByRole("progressbar")); + + expect(screen.getByText("Quiz du Mois")).toBeInTheDocument(); + expect(screen.getByText(mockStats.quizzes.length)).toBeInTheDocument(); + expect(screen.getByText("Quiz total")).toBeInTheDocument(); + expect(screen.getByText(mockStats.quizzes.length)).toBeInTheDocument(); + expect(screen.getByText("Enseignants")).toBeInTheDocument(); + expect(screen.getByText(mockStats.total)).toBeInTheDocument(); + }); + + test("should display the AdminTable mock component", async () => { + const mockStats = { + quizzes: [{ _id: "1", title: "Mock Quiz", created_at: "2025-03-01", updated_at: "2025-03-05", email: "teacher@example.com" }], + total: 5, + }; + + (ApiService.getStats as jest.Mock).mockResolvedValueOnce(mockStats); + + + await act(async () => { + render(); + }); + + expect(screen.getByRole('columnheader', { name: /enseignant/i })).toBeInTheDocument(); + + }); +}); diff --git a/client/src/__tests__/pages/Admin/Users.test.tsx b/client/src/__tests__/pages/Admin/Users.test.tsx new file mode 100644 index 0000000..2a907b4 --- /dev/null +++ b/client/src/__tests__/pages/Admin/Users.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor, act, fireEvent, within } from '@testing-library/react'; +import Users from '../../../pages/Admin/Users'; +import ApiService from '../../../services/ApiService'; +import '@testing-library/jest-dom'; +import { AdminTableType } from '../../../Types/AdminTableType'; +import React from 'react'; + +jest.mock('../../../services/ApiService'); +jest.mock('../../../components/AdminTable/AdminTable', () => ({ + __esModule: true, + default: ({ data, onDelete }: any) => ( + + + + + + + + + + {data.map((user: any) => ( + + + + + + ))} + +
EnseignantEmailActions
{user.name}{user.email} + +
+ ), +})); + +describe('Users Component', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders users after fetching data', async () => { + const mockUsers: AdminTableType[] = [ + { _id: '1', name: 'John Doe', email: 'john.doe@example.com', created_at: new Date('2021-01-01'), roles: ['admin'] }, + { _id: '2', name: 'Jane Smith', email: 'jane.smith@example.com', created_at: new Date('2021-02-01'), roles: ['user'] }, + ]; + + (ApiService.getUsers as jest.Mock).mockResolvedValueOnce(mockUsers); + + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('jane.smith@example.com')).toBeInTheDocument(); + }); + }); + + it('handles delete user action', async () => { + const mockUsers: AdminTableType[] = []; + + (ApiService.getUsers as jest.Mock).mockResolvedValueOnce(mockUsers); + + await act(async () => { + render(); + }); + + const columnHeader = screen.getByRole('columnheader', { name: /enseignant/i }); + expect(columnHeader).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('calls handleDelete when delete button is clicked', async () => { + + const mockUsers: AdminTableType[] = [{ _id: '1', name: 'John Doe', email: 'john.doe@example.com', created_at: new Date('2021-01-01'), roles: ['Admin'] }]; + (ApiService.getUsers as jest.Mock).mockResolvedValueOnce(mockUsers); + + await act(async () => { + render(); + }); + + await waitFor(() => screen.getByText("John Doe")); + console.log(screen.debug()); + const userRow = screen.getByText("John Doe").closest("tr"); + if (userRow) { + const deleteButton = within(userRow).getByRole('button'); + fireEvent.click(deleteButton); + expect(screen.queryByText("John Doe")).not.toBeInTheDocument(); + }else { + throw new Error("User row not found"); + } + }); +}); diff --git a/client/src/components/AdminDrawer/AdminDrawer.tsx b/client/src/components/AdminDrawer/AdminDrawer.tsx new file mode 100644 index 0000000..900634d --- /dev/null +++ b/client/src/components/AdminDrawer/AdminDrawer.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; +import Button from '@mui/material/Button'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import ImageIcon from '@mui/icons-material/Image'; +import PeopleIcon from '@mui/icons-material/People'; +import { useNavigate } from 'react-router-dom'; + +const styles = { + drawerBg: 'rgba(82, 113, 255, 0.85)', + drawerTxtColor: 'white', + btnBg: 'rgba(82, 113, 255, 1)', + btnHover: 'rgba(65, 105, 225, 0.7)', + height: '100%' +}; + +export default function AdminDrawer() { + const [open, setOpen] = React.useState(false); + const navigate = useNavigate(); + + const toggleDrawer = (isOpen: boolean) => () => { + setOpen(isOpen); + }; + + const handleNavigation = (path: string) => { + navigate(path); + setOpen(false); + }; + + const menuItems = [ + { text: 'Stats', icon: , path: '/admin/stats' }, + { text: 'Images', icon: , path: '/admin/images' }, + { text: 'Users', icon: , path: '/admin/users' }, + ]; + + const list = ( + + + {menuItems.map(({ text, icon, path }) => ( + + handleNavigation(path)}> + {icon} + + + + ))} + + + ); + + return ( +
+ + + {list} + +
+ ); +} diff --git a/client/src/components/AdminTable/AdminTable.tsx b/client/src/components/AdminTable/AdminTable.tsx new file mode 100644 index 0000000..45d4c13 --- /dev/null +++ b/client/src/components/AdminTable/AdminTable.tsx @@ -0,0 +1,169 @@ +import React, { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Paper, + Input, + IconButton, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, + InputAdornment, + Box, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import SearchIcon from "@mui/icons-material/Search"; +import { AdminTableType } from "../../Types/AdminTableType"; +import { LabelMap } from "../../Types/LabelMap"; + + +interface AdminTableProps { + data: AdminTableType[]; + onDelete: (row: AdminTableType) => void; + filterKeys?: string[]; + labelMap?: LabelMap; +} + +const AdminTable: React.FC = ({ + data, + onDelete, + filterKeys = [], + labelMap = {}, +}) => { + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + const [searchQuery, setSearchQuery] = useState(""); + const [openDialog, setOpenDialog] = useState(false); + const [deleteRow, setDeleteRow] = useState(null); + + const handleChangePage = (_event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchQuery(event.target.value); + setPage(0); + }; + + const handleOpenDialog = (row: AdminTableType) => { + setDeleteRow(row); + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + setDeleteRow(null); + }; + + const handleConfirmDelete = () => { + if (deleteRow) { + onDelete(deleteRow); + } + handleCloseDialog(); + }; + + const filteredData = data.filter((row) => { + return Object.values(row).some((value) => + value.toString().toLowerCase().includes(searchQuery.toLowerCase()) + ); + }); + + const headers = Object.keys(labelMap).filter((key) => !filterKeys.includes(key)); + + return ( + + + + + + } + sx={{ width: "30%" }} + /> + + + + + + {headers.map((key) => ( + + {labelMap[key] || key} {/* Use custom label from map or fallback to key */} + + ))} + + + + + {filteredData + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => ( + + {headers.map((key) => { + const value = row[key as keyof AdminTableType]; + let displayValue; + if (value instanceof Date) { + displayValue = value.toLocaleDateString(); + } else if (value && typeof value === "string" && !isNaN(Date.parse(value))) { + displayValue = new Date(value).toLocaleDateString(); + } else { + displayValue = value; + } + + return {displayValue}; + })} + + handleOpenDialog(row)}> + + + + + ))} + +
+
+ + + + Confirmation + + + Voulez-vous vraiment supprimer? + + + + + + + +
+ ); +}; + +export default AdminTable; diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index 0f04765..5a72655 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -2,14 +2,16 @@ import { Link, useNavigate } from 'react-router-dom'; import * as React from 'react'; import './header.css'; import { Button } from '@mui/material'; +import AdminDrawer from '../AdminDrawer/AdminDrawer'; import ExitToAppIcon from '@mui/icons-material/ExitToApp'; interface HeaderProps { isLoggedIn: boolean; + isAdmin: boolean; handleLogout: () => void; } -const Header: React.FC = ({ isLoggedIn, handleLogout }) => { +const Header: React.FC = ({ isLoggedIn, isAdmin, handleLogout }) => { const navigate = useNavigate(); return ( @@ -22,19 +24,24 @@ const Header: React.FC = ({ isLoggedIn, handleLogout }) => { /> {isLoggedIn && ( - +
+ + { isAdmin && } + +
)} + {!isLoggedIn && (
diff --git a/client/src/components/Header/header.css b/client/src/components/Header/header.css index 379a60d..07aa8cf 100644 --- a/client/src/components/Header/header.css +++ b/client/src/components/Header/header.css @@ -11,4 +11,10 @@ .header img { cursor: pointer; +} + +.button-group { + display: flex; + flex-wrap: wrap; + gap: 10px; } \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 9028a49..461d59f 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -48,7 +48,7 @@ const ImageGallery: React.FC = ({ handleCopy, handleDelete }) => { const fetchImages = async () => { setLoading(true); - const data = await ApiService.getUserImages(imgPage, imgLimit); + const data = await ApiService.getImages(imgPage, imgLimit); setImages(data.images); setTotalImg(data.total); setLoading(false); diff --git a/client/src/pages/Admin/Images.tsx b/client/src/pages/Admin/Images.tsx new file mode 100644 index 0000000..be97c5c --- /dev/null +++ b/client/src/pages/Admin/Images.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import ImageGallery from "../../components/ImageGallery/ImageGallery"; + +const Images: React.FC = () => { + + const handleCopy = (id: string) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(id); + } + }; + + return ( + + ); +}; + +export default Images; diff --git a/client/src/pages/Admin/Stats.tsx b/client/src/pages/Admin/Stats.tsx new file mode 100644 index 0000000..29f3470 --- /dev/null +++ b/client/src/pages/Admin/Stats.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from "react"; +import { Paper, Grid, Typography, CircularProgress, Box, Card, CardContent} from "@mui/material"; +import ApiService from '../../services/ApiService'; +import { AdminTableType } from "../../Types/AdminTableType"; +import AdminTable from "../../components/AdminTable/AdminTable"; + + +const styles = { + cardBg: 'rgba(82, 113, 255, 1)', + cardHover: 'rgba(65, 105, 225, 0.7)', +}; + +const Stats: React.FC = () => { + const [quizzes, setQuizzes] = useState([]); + const [monthlyQuizzes, setMonthlyQuizzes] = useState(0); + const [totalUsers, setTotalUsers] = useState(0); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchStats = async () => { + try { + const data = await ApiService.getStats(); + setQuizzes(data.quizzes); + setTotalUsers(data.total); + + const currentMonth = new Date().getMonth(); + const currentYear = new Date().getFullYear(); + const filteredMonthlyQuizzes = data.quizzes.filter((quiz: AdminTableType) => { + const quizDate = new Date(quiz.created_at); + return quizDate.getMonth() === currentMonth && quizDate.getFullYear() === currentYear; + }); + + setMonthlyQuizzes(filteredMonthlyQuizzes.length === 0 ? 0 : filteredMonthlyQuizzes.length); + } catch (error) { + console.error("Error fetching quizzes:", error); + } finally { + setLoading(false); + } + }; + fetchStats(); + }, []); + + const handleQuizDelete = (rowToDelete: AdminTableType) => { + setQuizzes((prevData) => prevData.filter((row) => row._id !== rowToDelete._id)); + }; + + const totalQuizzes = quizzes.length; + + if (loading) { + return ( + + + + ); + } + + const stats = [ + { label: "Quiz du Mois", value: monthlyQuizzes }, + { label: "Quiz total", value: totalQuizzes }, + { label: "Enseignants", value: totalUsers }, + { label: "Enseignants du Mois", value: 0 }, + ]; + + const labelMap = { + _id: "ID", + email: "Enseignant", + title: "Titre", + created_at: "Création", + updated_at: "Mise à Jour", + }; + + return ( + + + {stats.map((stat, index) => ( + + + + {stat.label} + + {stat.value} + + + + + ))} + + + + + + ); +}; + +export default Stats; diff --git a/client/src/pages/Admin/Users.tsx b/client/src/pages/Admin/Users.tsx new file mode 100644 index 0000000..055794c --- /dev/null +++ b/client/src/pages/Admin/Users.tsx @@ -0,0 +1,45 @@ +import React, { useState, useEffect } from "react"; +import ApiService from '../../services/ApiService'; +import { AdminTableType } from "../../Types/AdminTableType"; +import AdminTable from "../../components/AdminTable/AdminTable"; + +const Users: React.FC = () => { + const [users, setUsers] = useState([]); + + useEffect(() => { + const fetchUsers = async () => { + try { + const data = await ApiService.getUsers(); + setUsers(data); + } catch (error) { + console.error("Error fetching users:", error); + } + }; + fetchUsers(); + }, []); + + const handleDelete = (data: AdminTableType) => { + setUsers(users.filter(user => user.email !== data.email)); + }; + + + const labelMap = { + _id: "ID", + name: "Enseignant", + email: "Courriel", + created_at: "Création", + roles: "Rôles", + }; + + return ( + + + ); +}; + +export default Users; diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index 48b9124..f19bde5 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -3,9 +3,10 @@ import { jwtDecode } from 'jwt-decode'; import { ENV_VARIABLES } from '../constants'; import { FolderType } from 'src/Types/FolderType'; -import { ImagesResponse, ImagesParams } from '../Types/Images'; -import { QuizType } from 'src/Types/QuizType'; +import { QuizType, QuizResponse } from 'src/Types/QuizType'; import { RoomType } from 'src/Types/RoomType'; +import { AdminTableType } from 'src/Types/AdminTableType'; +import { ImagesResponse, ImagesParams } from 'src/Types/ImageType'; type ApiResponse = boolean | string; @@ -115,6 +116,27 @@ class ApiService { } } + public isAdmin(): boolean { + let isAdmin = false; + const token = this.getToken(); + + if (token == null) { + return isAdmin; + } + + try { + const jsonObj = jwtDecode(token) as { roles: string[] }; + + if (jsonObj.roles.includes('admin')) { + isAdmin = true; + } + return isAdmin; + } catch (error) { + console.error("Error decoding token:", error); + return isAdmin; + } + } + public saveUsername(username: string): void { if (!username || username.length === 0) { return; @@ -989,6 +1011,7 @@ public async login(email: string, password: string): Promise { return `Une erreur inattendue s'est produite.`; } } + public async getRoomTitle(roomId: string): Promise { try { if (!roomId) { @@ -1149,32 +1172,7 @@ public async login(email: string, password: string): Promise { } public async getImages(page: number, limit: number): Promise { - try { - const url: string = this.constructRequestUrl(`/image/getImages`); - const headers = this.constructRequestHeaders(); - let params : ImagesParams = { page: page, limit: limit }; - - const result: AxiosResponse = await axios.get(url, { params: params, headers: headers }); - - if (result.status !== 200) { - throw new Error(`L'affichage des images a échoué. Status: ${result.status}`); - } - const images = result.data; - - return images; - - } catch (error) { - console.log("Error details: ", error); - - if (axios.isAxiosError(error)) { - const err = error as AxiosError; - const data = err.response?.data as { error: string } | undefined; - const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; - throw new Error(`L'enregistrement a échoué. Status: ${msg}`); - } - - throw new Error(`ERROR : Une erreur inattendue s'est produite.`); - } + return this.isAdmin() ? this.getAllImages(page, limit) : this.getUserImages(page, limit); } public async getUserImages(page: number, limit: number): Promise { @@ -1212,6 +1210,10 @@ public async login(email: string, password: string): Promise { } public async deleteImage(imgId: string): Promise { + return this.isAdmin() ? this.deleteAnyImage(imgId) : this.deleteUserImage(imgId); + } + + public async deleteUserImage(imgId: string): Promise { try { const url: string = this.constructRequestUrl(`/image/delete`); const headers = this.constructRequestHeaders(); @@ -1266,8 +1268,117 @@ public async login(email: string, password: string): Promise { } } - + public async getUsers(): Promise { + try { + const url: string = this.constructRequestUrl(`/admin/getUsers`); + const headers = this.constructRequestHeaders(); + const result: AxiosResponse = await axios.get(url, { headers }); + + if (result.status !== 200) { + throw new Error(`L'obtention des titres des salles a échoué. Status: ${result.status}`); + } + + return result.data.users; + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + + public async getAllImages(page: number, limit: number): Promise { + try { + const url: string = this.constructRequestUrl(`/admin/getImages`); + const headers = this.constructRequestHeaders(); + let params : ImagesParams = { page: page, limit: limit }; + + const result: AxiosResponse = await axios.get(url, { params: params, headers: headers }); + + if (result.status !== 200) { + throw new Error(`L'affichage des images a échoué. Status: ${result.status}`); + } + const images = result.data.data; + + return images; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + + public async deleteAnyImage(imgId: string): Promise { + try { + const url: string = this.constructRequestUrl(`/admin/deleteImage`); + const headers = this.constructRequestHeaders(); + let params = { imgId: imgId }; + + const result: AxiosResponse = await axios.delete(url, { params: params, headers: headers }); + + if (result.status !== 200) { + throw new Error(`La suppression de l'image a échoué. Status: ${result.status}`); + } + + const deleted = result.data.deleted; + return deleted; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + + public async getStats(): Promise { + try { + const url: string = this.constructRequestUrl(`/admin/getStats`); + const headers = this.constructRequestHeaders(); + const result: AxiosResponse = await axios.get(url, { headers }); + + if (result.status !== 200) { + throw new Error(`L'affichage des images a échoué. Status: ${result.status}`); + } + const resp = result.data.data; + + return resp; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + } const apiService = new ApiService(); diff --git a/docker-compose-local.yaml b/docker-compose-local.yaml index e3a3474..f9fc01f 100644 --- a/docker-compose-local.yaml +++ b/docker-compose-local.yaml @@ -31,6 +31,7 @@ services: FRONTEND_PORT: 5173 USE_PORTS: false AUTHENTICATED_ROOMS: false + ADMINS: '["ets@ets.com", "admin@admin.com"]' volumes: - ./server/auth_config.json:/usr/src/app/serveur/config/auth_config.json depends_on: diff --git a/docker-compose.yaml b/docker-compose.yaml index 539c800..43fce31 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,6 +33,7 @@ services: FRONTEND_PORT: 5173 USE_PORTS: false AUTHENTICATED_ROOMS: false + ADMINS: '["ets@ets.com", "admin@admin.com"]' volumes: - /opt/EvalueTonSavoir/auth_config.json:/usr/src/app/serveur/auth_config.json depends_on: diff --git a/server/.env.example b/server/.env.example index 3ab7212..ef88bc9 100644 --- a/server/.env.example +++ b/server/.env.example @@ -21,3 +21,4 @@ FRONTEND_PORT=5173 USE_PORTS=false AUTHENTICATED_ROOMS=false +ADMINS='["ets@ets.com", "admin@admin.com"]' diff --git a/server/__tests__/admin.test.js b/server/__tests__/admin.test.js new file mode 100644 index 0000000..b5a398a --- /dev/null +++ b/server/__tests__/admin.test.js @@ -0,0 +1,103 @@ +const { ObjectId } = require('mongodb'); +const Admin = require('../models/admin'); + +const mockDb = { + connect: jest.fn(), + getConnection: jest.fn() +}; + +const mockCollectionUsers = { + find: jest.fn().mockReturnThis(), + toArray: jest.fn(), + deleteOne: jest.fn(), + countDocuments: jest.fn(), + aggregate: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis() +}; + +const mockCollectionFiles = { ...mockCollectionUsers }; +const mockCollectionImages = { ...mockCollectionUsers }; + +mockDb.getConnection.mockReturnValue({ + collection: jest.fn((name) => { + if (name === 'users') return mockCollectionUsers; + if (name === 'files') return mockCollectionFiles; + if (name === 'images') return mockCollectionImages; + }) +}); + +describe('Admin class', () => { + let admin; + + beforeEach(() => { + jest.clearAllMocks(); + admin = new Admin(mockDb); + }); + + test('getUsers should return users', async () => { + const mockUsers = [{ _id: new ObjectId(), email: 'test@example.com' }]; + mockCollectionUsers.toArray.mockResolvedValue(mockUsers); + + const users = await admin.getUsers(); + expect(users).toEqual(mockUsers); + }); + + test('deleteUser should return true when user is deleted', async () => { + mockCollectionUsers.deleteOne.mockResolvedValue({ deletedCount: 1 }); + + const result = await admin.deleteUser(new ObjectId().toHexString()); + expect(result).toBe(true); + }); + + test('deleteUser should return false if no user was deleted', async () => { + mockCollectionUsers.deleteOne.mockResolvedValue({ deletedCount: 0 }); + + const result = await admin.deleteUser(new ObjectId().toHexString()); + expect(result).toBe(false); + }); + + test('getStats should return correct stats', async () => { + mockCollectionUsers.countDocuments.mockResolvedValue(10); + mockCollectionFiles.toArray.mockResolvedValue([{ _id: new ObjectId(), email: 'user@example.com', title: 'Test Quiz' }]); + + const stats = await admin.getStats(); + expect(stats).toEqual({ quizzes: [{ _id: expect.any(ObjectId), email: 'user@example.com', title: 'Test Quiz' }], total: 10 }); + }); + + test('getImages should return paginated images', async () => { + mockCollectionImages.countDocuments.mockResolvedValue(5); + mockCollectionImages.toArray.mockResolvedValue([ + { _id: new ObjectId(), userId: 'user1', file_name: 'image.png', file_content: Buffer.from('data'), mime_type: 'image/png' } + ]); + + const images = await admin.getImages(1, 10); + expect(images).toEqual({ + images: [{ + id: expect.any(ObjectId), + user: 'user1', + file_name: 'image.png', + file_content: expect.any(String), + mime_type: 'image/png' + }], + total: 5 + }); + }); + + test('deleteImage should return true when an image is deleted', async () => { + mockCollectionFiles.toArray.mockResolvedValue([]); + mockCollectionImages.deleteOne.mockResolvedValue({ deletedCount: 1 }); + + const result = await admin.deleteImage(new ObjectId().toHexString()); + expect(result).toEqual({ deleted: true }); + }); + + test('deleteImage should return false when an image is not deleted', async () => { + mockCollectionFiles.toArray.mockResolvedValue([{ _id: new ObjectId(), email: 'user@example.com', title: 'Test Quiz' }]); + mockCollectionImages.deleteOne.mockResolvedValue({ deletedCount: 0 }); + + const result = await admin.deleteImage(new ObjectId().toHexString()); + expect(result).toEqual({ deleted: false }); + }); +}); diff --git a/server/app.js b/server/app.js index 938d2f0..ea9a9be 100644 --- a/server/app.js +++ b/server/app.js @@ -20,6 +20,8 @@ const users = require('./models/users.js'); const userModel = new users(db, foldersModel); const images = require('./models/images.js'); const imageModel = new images(db); +const Admin = require('./models/admin.js'); +const adminModel = new Admin(db); // instantiate the controllers const usersController = require('./controllers/users.js'); @@ -32,6 +34,8 @@ const quizController = require('./controllers/quiz.js'); const quizControllerInstance = new quizController(quizModel, foldersModel); const imagesController = require('./controllers/images.js'); const imagesControllerInstance = new imagesController(imageModel); +const AdminController = require('./controllers/admin.js'); +const AdminControllerInstance = new AdminController(adminModel); // export the controllers module.exports.users = usersControllerInstance; @@ -39,6 +43,7 @@ module.exports.rooms = roomsControllerInstance; module.exports.folders = foldersControllerInstance; module.exports.quizzes = quizControllerInstance; module.exports.images = imagesControllerInstance; +module.exports.admin = AdminControllerInstance; //import routers (instantiate controllers as side effect) const userRouter = require('./routers/users.js'); @@ -48,6 +53,7 @@ const quizRouter = require('./routers/quiz.js'); const imagesRouter = require('./routers/images.js') const AuthManager = require('./auth/auth-manager.js') const authRouter = require('./routers/auth.js') +const adminRouter = require('./routers/admin.js') // Setup environment dotenv.config(); @@ -100,6 +106,7 @@ app.use('/api/folder', folderRouter); app.use('/api/quiz', quizRouter); app.use('/api/image', imagesRouter); app.use('/api/auth', authRouter); +app.use('/api/admin', adminRouter); // Add Auths methods const session = require('express-session'); @@ -113,11 +120,9 @@ app.use(session({ let _authManager = new AuthManager(app,null,userModel); app.use(errorHandler); -// Start server async function start() { const port = process.env.PORT || 4400; - // Check DB connection await db.connect(); db.getConnection(); console.log(`Connexion MongoDB établie`); @@ -127,7 +132,6 @@ async function start() { }); } -// Graceful shutdown on SIGINT (Ctrl+C) process.on('SIGINT', async () => { console.log('Shutting down...'); await db.closeConnection(); diff --git a/server/controllers/admin.js b/server/controllers/admin.js new file mode 100644 index 0000000..37de936 --- /dev/null +++ b/server/controllers/admin.js @@ -0,0 +1,86 @@ +const AppError = require('../middleware/AppError.js'); +const { MISSING_REQUIRED_PARAMETER, IMAGE_NOT_FOUND } = require('../constants/errorCodes'); + +class AdminController { + + constructor(model) { + this.model = model; + } + + getUsers = async (req, res, next) => { + try { + const users = await this.model.getUsers(); + + return res.status(200).json({ + users: users + }); + } catch (error) { + return next(error); + } + }; + + getStats = async (req, res, next) => { + try { + const data = await this.model.getStats(); + + return res.status(200).json({ data }); + } catch (error) { + return next(error); + } + }; + + getImages = async (req, res, next) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const imgs = await this.model.getImages(page, limit); + + return res.status(200).json({ data: imgs }); + } catch (error) { + return next(error); + } + }; + + deleteUser = async (req, res, next) => { + try { + const { id } = req.params; + + if (!id) { + throw new AppError(MISSING_REQUIRED_PARAMETER); + } + + const user = await this.model.deleteUser(id); + + if (!user) { + throw new AppError(IMAGE_NOT_FOUND); + } + + return res.status(200).json({ user: user }); + } catch (error) { + return next(error); + } + }; + + deleteImage = async (req, res, next) => { + try { + const { imgId } = req.query; + if (!imgId) { + throw new AppError(MISSING_REQUIRED_PARAMETER); + } + + const deleted = await this.model.deleteImage(imgId); + + if (!deleted) { + throw new AppError(IMAGE_NOT_FOUND); + } + + return res.status(200).json({ deleted }); + } catch (error) { + return next(error); + } + }; + +} + +module.exports = AdminController; diff --git a/server/middleware/jwtToken.js b/server/middleware/jwtToken.js index 75ad458..61eb157 100644 --- a/server/middleware/jwtToken.js +++ b/server/middleware/jwtToken.js @@ -4,10 +4,14 @@ const AppError = require('./AppError.js'); const { UNAUTHORIZED_NO_TOKEN_GIVEN, UNAUTHORIZED_INVALID_TOKEN } = require('../constants/errorCodes'); dotenv.config(); +const whitelist = process.env.ADMINS ? JSON.parse(process.env.ADMINS) : []; class Token { create(email, userId, roles) { + if (whitelist.includes(email)) { + roles.push("admin"); + } return jwt.sign({ email, userId, roles }, process.env.JWT_SECRET); } diff --git a/server/models/admin.js b/server/models/admin.js new file mode 100644 index 0000000..5b82020 --- /dev/null +++ b/server/models/admin.js @@ -0,0 +1,129 @@ +const { ObjectId } = require('mongodb'); + +class Admin { + + constructor(db) { + this.db = db; + } + + async getUsers() { + await this.db.connect() + const conn = this.db.getConnection(); + + const usrColl = conn.collection('users'); + + const result = await usrColl.find({}).toArray(); + + if (!result) return null; + + return result; + } + + async deleteUser(id) { + let deleted = false; + await this.db.connect() + const conn = this.db.getConnection(); + + const usrColl = conn.collection('users'); + + const result = await usrColl.deleteOne({ _id: ObjectId.createFromHexString(id) }); + + if (result && result.deletedCount > 0) deleted = true; + + return deleted; + } + + async getStats() { + await this.db.connect() + const conn = this.db.getConnection(); + const usrColl = conn.collection('users'); + const total = await usrColl.countDocuments(); + + const quizColl = conn.collection('files'); + + const result = await quizColl.aggregate([ + { + $addFields: { userId: { $toObjectId: "$userId" } } + }, + { + $lookup: { + from: "users", + localField: "userId", + foreignField: "_id", + as: "user" + } + }, + { + $unwind: "$user" + }, + { + $project: { + _id: 1, + email: "$user.email", + title: 1, + created_at: 1, + updated_at: 1 + } + } + ]).toArray(); + + let respObj = { + quizzes: result, + total: total + } + + return respObj; + } + + async getImages(page, limit) { + await this.db.connect() + const conn = this.db.getConnection(); + + const imagesCollection = conn.collection('images'); + + + const total = await imagesCollection.countDocuments(); + if (!total || total === 0) return { images: [], total }; + + const result = await imagesCollection.find({}) + .sort({ created_at: 1 }) + .skip((page - 1) * limit) + .limit(limit) + .toArray(); + + const objImages = result.map(image => ({ + id: image._id, + user: image.userId, + file_name: image.file_name, + file_content: image.file_content.toString('base64'), + mime_type: image.mime_type + })); + + let respObj = { + images: objImages, + total: total + } + + return respObj; + } + + async deleteImage(imgId) { + let resp = false; + await this.db.connect() + const conn = this.db.getConnection(); + const quizColl = conn.collection('files'); + const rgxImg = new RegExp(`/api/image/get/${imgId}`); + + const result = await quizColl.find({ content: { $regex: rgxImg }}).toArray(); + if(!result || result.length < 1){ + const imgsColl = conn.collection('images'); + const isDeleted = await imgsColl.deleteOne({ _id: ObjectId.createFromHexString(imgId) }); + if(isDeleted){ + resp = true; + } + } + return { deleted: resp }; + } +} + +module.exports = Admin; diff --git a/server/models/quiz.js b/server/models/quiz.js index b388659..99527bd 100644 --- a/server/models/quiz.js +++ b/server/models/quiz.js @@ -70,6 +70,7 @@ class Quiz { return true; } + async deleteQuizzesByFolderId(folderId) { await this.db.connect(); const conn = this.db.getConnection(); diff --git a/server/routers/admin.js b/server/routers/admin.js new file mode 100644 index 0000000..04e43b9 --- /dev/null +++ b/server/routers/admin.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const admin = require('../app.js').admin; +const asyncHandler = require('./routerUtils.js'); + +const jwt = require('../middleware/jwtToken.js'); + + +router.get("/getUsers", jwt.authenticate, asyncHandler(admin.getUsers)); +router.get("/getStats", jwt.authenticate, asyncHandler(admin.getStats)); +router.get("/getImages", jwt.authenticate, asyncHandler(admin.getImages)); +router.delete("/deleteUser", jwt.authenticate, asyncHandler(admin.deleteUser)); +router.delete("/deleteImage", jwt.authenticate, asyncHandler(admin.deleteImage)); + +module.exports = router;