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) => (
+
+
+
+ | Enseignant |
+ Email |
+ Actions |
+
+
+
+ {data.map((user: any) => (
+
+ | {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)}>
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+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;