mirror of
https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir.git
synced 2025-08-11 21:23:54 -04:00
Merge e24df3d5bf into 069b62957f
This commit is contained in:
commit
204419653e
36 changed files with 1876 additions and 257 deletions
545
client/package-lock.json
generated
545
client/package-lock.json
generated
|
|
@ -14,9 +14,10 @@
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@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/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",
|
"@types/uuid": "^9.0.7",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.5",
|
||||||
|
|
@ -3365,9 +3366,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
"version": "7.0.2",
|
"version": "6.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.4.11.tgz",
|
||||||
"integrity": "sha512-TfeFU9TgN1N06hyb/pV/63FfO34nijZRMqgHk0TJ3gkl4Fbd+wZ73+ZtOd7jag6hMmzO9HSrBc6Vdn591nhkAg==",
|
"integrity": "sha512-CzAQs9CTzlwbsF9ZYB4o4lLwBv1/qNE264NjuYao+ctAXsmlPtYa8RtER4UsUXSMxNN9Qi+aQdYcKl2sUpnmAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -3375,12 +3376,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/icons-material": {
|
"node_modules/@mui/icons-material": {
|
||||||
"version": "7.0.2",
|
"version": "6.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.4.7.tgz",
|
||||||
"integrity": "sha512-Bo57PFLOqXOqPNrXjd8AhzH5s6TCsNUQbvnQ0VKZ8D+lIlteqKnrk/O1luMJUc/BXONK7BfIdTdc7qOnXYbMdw==",
|
"integrity": "sha512-Rk8cs9ufQoLBw582Rdqq7fnSXXZTqhYRbpe1Y5SAz9lJKZP3CIdrj0PfG8HJLGw1hrsHFN/rkkm70IDzhJsG1g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.0"
|
"@babel/runtime": "^7.26.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|
@ -3390,7 +3391,7 @@
|
||||||
"url": "https://opencollective.com/mui-org"
|
"url": "https://opencollective.com/mui-org"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mui/material": "^7.0.2",
|
"@mui/material": "^6.4.7",
|
||||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -3441,194 +3442,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/material": {
|
"node_modules/@mui/lab/node_modules/@mui/private-theming": {
|
||||||
"version": "7.0.2",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz",
|
||||||
"integrity": "sha512-rjJlJ13+3LdLfobRplkXbjIFEIkn6LgpetgU/Cs3Xd8qINCCQK9qXQIjjQ6P0FXFTPFzEVMj0VgBR1mN+FhOcA==",
|
"integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==",
|
||||||
"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==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.9",
|
"@babel/runtime": "^7.23.9",
|
||||||
"@mui/utils": "^5.16.14",
|
"@mui/utils": "^5.17.1",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3648,7 +3469,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/styled-engine": {
|
"node_modules/@mui/lab/node_modules/@mui/styled-engine": {
|
||||||
"version": "5.16.14",
|
"version": "5.16.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz",
|
||||||
"integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==",
|
"integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==",
|
||||||
|
|
@ -3680,17 +3501,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/system": {
|
"node_modules/@mui/lab/node_modules/@mui/system": {
|
||||||
"version": "5.16.14",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.17.1.tgz",
|
||||||
"integrity": "sha512-KBxMwCb8mSIABnKvoGbvM33XHyT+sN0BzEBG+rsSc0lLQGzs7127KWkCA6/H8h6LZ00XpBEME5MAj8mZLiQ1tw==",
|
"integrity": "sha512-aJrmGfQpyF0U4D4xYwA6ueVtQcEMebET43CUmKMP7e7iFh3sMIF3sBR0l8Urb4pqx1CBjHAaWgB0ojpND4Q3Jg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.9",
|
"@babel/runtime": "^7.23.9",
|
||||||
"@mui/private-theming": "^5.16.14",
|
"@mui/private-theming": "^5.17.1",
|
||||||
"@mui/styled-engine": "^5.16.14",
|
"@mui/styled-engine": "^5.16.14",
|
||||||
"@mui/types": "^7.2.15",
|
"@mui/types": "~7.2.15",
|
||||||
"@mui/utils": "^5.16.14",
|
"@mui/utils": "^5.17.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
"prop-types": "^15.8.1"
|
"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": {
|
"node_modules/@mui/types": {
|
||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.1.tgz",
|
||||||
|
|
@ -3738,13 +3855,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/utils": {
|
"node_modules/@mui/utils": {
|
||||||
"version": "5.16.14",
|
"version": "5.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz",
|
||||||
"integrity": "sha512-wn1QZkRzSmeXD1IguBVvJJHV3s6rxJrfb6YuC9Kk6Noh9f8Fb54nUs5JRkKm+BOerRhj5fLg05Dhx/H3Ofb8Mg==",
|
"integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.23.9",
|
"@babel/runtime": "^7.23.9",
|
||||||
"@mui/types": "^7.2.15",
|
"@mui/types": "~7.2.15",
|
||||||
"@types/prop-types": "^15.7.12",
|
"@types/prop-types": "^15.7.12",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"prop-types": "^15.8.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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,10 @@
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@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/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",
|
"@types/uuid": "^9.0.7",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.5",
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,15 @@ import Footer from './components/Footer/Footer';
|
||||||
import ApiService from './services/ApiService';
|
import ApiService from './services/ApiService';
|
||||||
import OAuthCallback from './pages/AuthManager/callback/AuthCallback';
|
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 App: React.FC = () => {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn());
|
const [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn());
|
||||||
const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher());
|
const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher());
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null);
|
const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
|
|
@ -37,6 +43,7 @@ const App: React.FC = () => {
|
||||||
const checkLoginStatus = () => {
|
const checkLoginStatus = () => {
|
||||||
setIsAuthenticated(ApiService.isLoggedIn());
|
setIsAuthenticated(ApiService.isLoggedIn());
|
||||||
setIsTeacherAuthenticated(ApiService.isLoggedInTeacher());
|
setIsTeacherAuthenticated(ApiService.isLoggedInTeacher());
|
||||||
|
setIsAdmin(ApiService.isAdmin());
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAuthenticatedRooms = async () => {
|
const fetchAuthenticatedRooms = async () => {
|
||||||
|
|
@ -56,7 +63,7 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<Header isLoggedIn={isAuthenticated} handleLogout={handleLogout} />
|
<Header isLoggedIn={isAuthenticated} isAdmin={isAdmin} handleLogout={handleLogout} />
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -98,6 +105,11 @@ const App: React.FC = () => {
|
||||||
|
|
||||||
{/* Pages authentification sélection */}
|
{/* Pages authentification sélection */}
|
||||||
<Route path="/auth/callback" element={<OAuthCallback />} />
|
<Route path="/auth/callback" element={<OAuthCallback />} />
|
||||||
|
|
||||||
|
<Route path="/admin/stats" element={<Stats />} />
|
||||||
|
<Route path="/admin/images" element={<Images />} />
|
||||||
|
<Route path="/admin/users" element={<Users />} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
9
client/src/Types/AdminTableType.tsx
Normal file
9
client/src/Types/AdminTableType.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface AdminTableType {
|
||||||
|
_id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at?: Date;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
2
client/src/Types/LabelMap.tsx
Normal file
2
client/src/Types/LabelMap.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
export type LabelMap = { [key: string]: string };
|
||||||
|
|
@ -9,3 +9,16 @@ export interface QuizType {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_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;
|
||||||
|
}
|
||||||
11
client/src/Types/UserType.tsx
Normal file
11
client/src/Types/UserType.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface UserType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsersResponse {
|
||||||
|
users: UserType[];
|
||||||
|
}
|
||||||
17
client/src/__tests__/Types/AdminTableType.test.tsx
Normal file
17
client/src/__tests__/Types/AdminTableType.test.tsx
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
62
client/src/__tests__/Types/FolderType.test.tsx
Normal file
62
client/src/__tests__/Types/FolderType.test.tsx
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
79
client/src/__tests__/Types/ImageType.test.tsx
Normal file
79
client/src/__tests__/Types/ImageType.test.tsx
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
35
client/src/__tests__/Types/LabelMap.test.tsx
Normal file
35
client/src/__tests__/Types/LabelMap.test.tsx
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
51
client/src/__tests__/Types/UserType.test.tsx
Normal file
51
client/src/__tests__/Types/UserType.test.tsx
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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(
|
||||||
|
<Router>
|
||||||
|
<AdminDrawer />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Router>
|
||||||
|
<AdminDrawer />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(<AdminDrawer />);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<Router>
|
||||||
|
<AdminDrawer />
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(<AdminTable data={mockData} onDelete={mockOnDelete} labelMap={labelMap} />);
|
||||||
|
|
||||||
|
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(<AdminTable data={mockData} onDelete={mockOnDelete} labelMap={labelMap} />);
|
||||||
|
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(<AdminTable data={mockData} onDelete={mockOnDelete} labelMap={labelMap} />);
|
||||||
|
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(<AdminTable data={mockData} onDelete={mockOnDelete} labelMap={labelMap} />);
|
||||||
|
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(<AdminTable data={mockData} onDelete={mockOnDelete} labelMap={labelMap} />);
|
||||||
|
|
||||||
|
const nextButton = screen.getByLabelText("Go to next page");
|
||||||
|
fireEvent.click(nextButton);
|
||||||
|
|
||||||
|
expect(screen.getByText("Alice Smith")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,7 +27,7 @@ describe("ImageGallery", () => {
|
||||||
let mockHandleDelete: jest.Mock;
|
let mockHandleDelete: jest.Mock;
|
||||||
|
|
||||||
beforeEach(async () => {
|
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.deleteImage as jest.Mock).mockResolvedValue(true);
|
||||||
(ApiService.uploadImage as jest.Mock).mockResolvedValue('mockImageUrl');
|
(ApiService.uploadImage as jest.Mock).mockResolvedValue('mockImageUrl');
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -62,7 +62,7 @@ describe("ImageGallery", () => {
|
||||||
it("should delete an image and update the gallery", async () => {
|
it("should delete an image and update the gallery", async () => {
|
||||||
const fetchImagesMock = jest.fn().mockResolvedValue({ images: mockImages.filter((image) => image.id !== "1"), total: 2 });
|
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 () => {
|
await act(async () => {
|
||||||
render(<ImageGallery handleDelete={mockHandleDelete} />);
|
render(<ImageGallery handleDelete={mockHandleDelete} />);
|
||||||
|
|
|
||||||
75
client/src/__tests__/pages/Admin/Stats.test.tsx
Normal file
75
client/src/__tests__/pages/Admin/Stats.test.tsx
Normal file
|
|
@ -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(<Stats />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<Stats />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<Stats />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('columnheader', { name: /enseignant/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
93
client/src/__tests__/pages/Admin/Users.test.tsx
Normal file
93
client/src/__tests__/pages/Admin/Users.test.tsx
Normal file
|
|
@ -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) => (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Enseignant</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((user: any) => (
|
||||||
|
<tr key={user.email}>
|
||||||
|
<td>{user.name}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => onDelete(user)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(<Users />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<Users />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(<Users />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
71
client/src/components/AdminDrawer/AdminDrawer.tsx
Normal file
71
client/src/components/AdminDrawer/AdminDrawer.tsx
Normal file
|
|
@ -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: <BarChartIcon />, path: '/admin/stats' },
|
||||||
|
{ text: 'Images', icon: <ImageIcon />, path: '/admin/images' },
|
||||||
|
{ text: 'Users', icon: <PeopleIcon />, path: '/admin/users' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const list = (
|
||||||
|
<Box sx={{ width: 250, backgroundColor: styles.drawerBg, height: styles.height, color: styles.drawerTxtColor }} role="presentation" onClick={toggleDrawer(false)}>
|
||||||
|
<List>
|
||||||
|
{menuItems.map(({ text, icon, path }) => (
|
||||||
|
<ListItem key={text} disablePadding>
|
||||||
|
<ListItemButton onClick={() => handleNavigation(path)}>
|
||||||
|
<ListItemIcon sx={{ color: styles.drawerTxtColor }}>{icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
sx={{ backgroundColor: styles.btnBg, color: 'white', '&:hover': { backgroundColor: styles.btnHover } }}
|
||||||
|
onClick={toggleDrawer(true)}
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Button>
|
||||||
|
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
|
||||||
|
{list}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
client/src/components/AdminTable/AdminTable.tsx
Normal file
169
client/src/components/AdminTable/AdminTable.tsx
Normal file
|
|
@ -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<AdminTableProps> = ({
|
||||||
|
data,
|
||||||
|
onDelete,
|
||||||
|
filterKeys = [],
|
||||||
|
labelMap = {},
|
||||||
|
}) => {
|
||||||
|
const [page, setPage] = useState<number>(0);
|
||||||
|
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [openDialog, setOpenDialog] = useState<boolean>(false);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<AdminTableType | null>(null);
|
||||||
|
|
||||||
|
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||||
|
setRowsPerPage(parseInt(event.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Paper sx={{ width: "100%", overflow: "hidden", padding: "16px" }}>
|
||||||
|
<Box display="flex" justifyContent="flex-start" marginBottom={2}>
|
||||||
|
<Input
|
||||||
|
placeholder="Recherche: Enseignant, Courriel..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
startAdornment={
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
|
sx={{ width: "30%" }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{headers.map((key) => (
|
||||||
|
<TableCell key={key} sx={{ fontWeight: "bold", fontSize: "1.1rem" }}>
|
||||||
|
{labelMap[key] || key} {/* Use custom label from map or fallback to key */}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{filteredData
|
||||||
|
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
|
||||||
|
.map((row, index) => (
|
||||||
|
<TableRow key={row._id} sx={{ backgroundColor: index % 2 === 0 ? "#f9f9f9" : "inherit" }}>
|
||||||
|
{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 <TableCell key={key}>{displayValue}</TableCell>;
|
||||||
|
})}
|
||||||
|
<TableCell>
|
||||||
|
<IconButton color="error" onClick={() => handleOpenDialog(row)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
rowsPerPageOptions={[5, 10, 25]}
|
||||||
|
component="div"
|
||||||
|
count={filteredData.length}
|
||||||
|
rowsPerPage={rowsPerPage}
|
||||||
|
page={page}
|
||||||
|
onPageChange={handleChangePage}
|
||||||
|
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog open={openDialog} onClose={handleCloseDialog}>
|
||||||
|
<DialogTitle>Confirmation</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Voulez-vous vraiment supprimer?
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||||
|
<Button onClick={handleConfirmDelete} color="error">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTable;
|
||||||
|
|
@ -2,14 +2,16 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './header.css';
|
import './header.css';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
|
import AdminDrawer from '../AdminDrawer/AdminDrawer';
|
||||||
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
handleLogout: () => void;
|
handleLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
|
const Header: React.FC<HeaderProps> = ({ isLoggedIn, isAdmin, handleLogout }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -22,6 +24,9 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
|
<div className="button-group">
|
||||||
|
|
||||||
|
{ isAdmin && <AdminDrawer /> }
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -33,8 +38,10 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
|
||||||
>
|
>
|
||||||
Déconnexion
|
Déconnexion
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
<div className="auth-selection-btn">
|
<div className="auth-selection-btn">
|
||||||
<Link to="/login">
|
<Link to="/login">
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,9 @@
|
||||||
.header img {
|
.header img {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ const ImageGallery: React.FC<ImagesProps> = ({ handleCopy, handleDelete }) => {
|
||||||
|
|
||||||
const fetchImages = async () => {
|
const fetchImages = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await ApiService.getUserImages(imgPage, imgLimit);
|
const data = await ApiService.getImages(imgPage, imgLimit);
|
||||||
setImages(data.images);
|
setImages(data.images);
|
||||||
setTotalImg(data.total);
|
setTotalImg(data.total);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
19
client/src/pages/Admin/Images.tsx
Normal file
19
client/src/pages/Admin/Images.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<ImageGallery
|
||||||
|
handleCopy={handleCopy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Images;
|
||||||
108
client/src/pages/Admin/Stats.tsx
Normal file
108
client/src/pages/Admin/Stats.tsx
Normal file
|
|
@ -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<AdminTableType[]>([]);
|
||||||
|
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 (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
||||||
|
<CircularProgress size={80} thickness={5} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper className="p-4" sx={{ boxShadow: 'none', padding: 3 }}>
|
||||||
|
<Grid container spacing={3} justifyContent="center">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<Grid item xs={12} sm={3} key={index}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 2,
|
||||||
|
backgroundColor: styles.cardBg,
|
||||||
|
color: "white",
|
||||||
|
transition: "background-color 0.3s ease",
|
||||||
|
"&:hover": { backgroundColor: styles.cardHover },
|
||||||
|
}}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" sx={{ color: "white" }}>{stat.label}</Typography>
|
||||||
|
<Typography variant="h4" sx={{ color: "white" }}>
|
||||||
|
{stat.value}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
|
<AdminTable
|
||||||
|
data={quizzes}
|
||||||
|
onDelete={handleQuizDelete}
|
||||||
|
filterKeys={["_id"]}
|
||||||
|
labelMap={labelMap}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stats;
|
||||||
45
client/src/pages/Admin/Users.tsx
Normal file
45
client/src/pages/Admin/Users.tsx
Normal file
|
|
@ -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<AdminTableType[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AdminTable
|
||||||
|
data={users}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
filterKeys={["_id", "password"]}
|
||||||
|
labelMap={labelMap}
|
||||||
|
/>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Users;
|
||||||
|
|
@ -3,9 +3,10 @@ import { jwtDecode } from 'jwt-decode';
|
||||||
import { ENV_VARIABLES } from '../constants';
|
import { ENV_VARIABLES } from '../constants';
|
||||||
|
|
||||||
import { FolderType } from 'src/Types/FolderType';
|
import { FolderType } from 'src/Types/FolderType';
|
||||||
import { ImagesResponse, ImagesParams } from '../Types/Images';
|
import { QuizType, QuizResponse } from 'src/Types/QuizType';
|
||||||
import { QuizType } from 'src/Types/QuizType';
|
|
||||||
import { RoomType } from 'src/Types/RoomType';
|
import { RoomType } from 'src/Types/RoomType';
|
||||||
|
import { AdminTableType } from 'src/Types/AdminTableType';
|
||||||
|
import { ImagesResponse, ImagesParams } from 'src/Types/ImageType';
|
||||||
|
|
||||||
type ApiResponse = boolean | string;
|
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 {
|
public saveUsername(username: string): void {
|
||||||
if (!username || username.length === 0) {
|
if (!username || username.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -989,6 +1011,7 @@ public async login(email: string, password: string): Promise<any> {
|
||||||
return `Une erreur inattendue s'est produite.`;
|
return `Une erreur inattendue s'est produite.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRoomTitle(roomId: string): Promise<string | string> {
|
public async getRoomTitle(roomId: string): Promise<string | string> {
|
||||||
try {
|
try {
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
|
|
@ -1149,32 +1172,7 @@ public async login(email: string, password: string): Promise<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getImages(page: number, limit: number): Promise<ImagesResponse> {
|
public async getImages(page: number, limit: number): Promise<ImagesResponse> {
|
||||||
try {
|
return this.isAdmin() ? this.getAllImages(page, limit) : this.getUserImages(page, limit);
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUserImages(page: number, limit: number): Promise<ImagesResponse> {
|
public async getUserImages(page: number, limit: number): Promise<ImagesResponse> {
|
||||||
|
|
@ -1212,6 +1210,10 @@ public async login(email: string, password: string): Promise<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteImage(imgId: string): Promise<ApiResponse> {
|
public async deleteImage(imgId: string): Promise<ApiResponse> {
|
||||||
|
return this.isAdmin() ? this.deleteAnyImage(imgId) : this.deleteUserImage(imgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteUserImage(imgId: string): Promise<ApiResponse> {
|
||||||
try {
|
try {
|
||||||
const url: string = this.constructRequestUrl(`/image/delete`);
|
const url: string = this.constructRequestUrl(`/image/delete`);
|
||||||
const headers = this.constructRequestHeaders();
|
const headers = this.constructRequestHeaders();
|
||||||
|
|
@ -1266,7 +1268,116 @@ public async login(email: string, password: string): Promise<any> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUsers(): Promise<AdminTableType[]> {
|
||||||
|
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<ImagesResponse> {
|
||||||
|
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<ApiResponse> {
|
||||||
|
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<QuizResponse> {
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ services:
|
||||||
FRONTEND_PORT: 5173
|
FRONTEND_PORT: 5173
|
||||||
USE_PORTS: false
|
USE_PORTS: false
|
||||||
AUTHENTICATED_ROOMS: false
|
AUTHENTICATED_ROOMS: false
|
||||||
|
ADMINS: '["ets@ets.com", "admin@admin.com"]'
|
||||||
volumes:
|
volumes:
|
||||||
- ./server/auth_config.json:/usr/src/app/serveur/config/auth_config.json
|
- ./server/auth_config.json:/usr/src/app/serveur/config/auth_config.json
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ services:
|
||||||
FRONTEND_PORT: 5173
|
FRONTEND_PORT: 5173
|
||||||
USE_PORTS: false
|
USE_PORTS: false
|
||||||
AUTHENTICATED_ROOMS: false
|
AUTHENTICATED_ROOMS: false
|
||||||
|
ADMINS: '["ets@ets.com", "admin@admin.com"]'
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/EvalueTonSavoir/auth_config.json:/usr/src/app/serveur/auth_config.json
|
- /opt/EvalueTonSavoir/auth_config.json:/usr/src/app/serveur/auth_config.json
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ FRONTEND_PORT=5173
|
||||||
USE_PORTS=false
|
USE_PORTS=false
|
||||||
|
|
||||||
AUTHENTICATED_ROOMS=false
|
AUTHENTICATED_ROOMS=false
|
||||||
|
ADMINS='["ets@ets.com", "admin@admin.com"]'
|
||||||
|
|
|
||||||
103
server/__tests__/admin.test.js
Normal file
103
server/__tests__/admin.test.js
Normal file
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -20,6 +20,8 @@ const users = require('./models/users.js');
|
||||||
const userModel = new users(db, foldersModel);
|
const userModel = new users(db, foldersModel);
|
||||||
const images = require('./models/images.js');
|
const images = require('./models/images.js');
|
||||||
const imageModel = new images(db);
|
const imageModel = new images(db);
|
||||||
|
const Admin = require('./models/admin.js');
|
||||||
|
const adminModel = new Admin(db);
|
||||||
|
|
||||||
// instantiate the controllers
|
// instantiate the controllers
|
||||||
const usersController = require('./controllers/users.js');
|
const usersController = require('./controllers/users.js');
|
||||||
|
|
@ -32,6 +34,8 @@ const quizController = require('./controllers/quiz.js');
|
||||||
const quizControllerInstance = new quizController(quizModel, foldersModel);
|
const quizControllerInstance = new quizController(quizModel, foldersModel);
|
||||||
const imagesController = require('./controllers/images.js');
|
const imagesController = require('./controllers/images.js');
|
||||||
const imagesControllerInstance = new imagesController(imageModel);
|
const imagesControllerInstance = new imagesController(imageModel);
|
||||||
|
const AdminController = require('./controllers/admin.js');
|
||||||
|
const AdminControllerInstance = new AdminController(adminModel);
|
||||||
|
|
||||||
// export the controllers
|
// export the controllers
|
||||||
module.exports.users = usersControllerInstance;
|
module.exports.users = usersControllerInstance;
|
||||||
|
|
@ -39,6 +43,7 @@ module.exports.rooms = roomsControllerInstance;
|
||||||
module.exports.folders = foldersControllerInstance;
|
module.exports.folders = foldersControllerInstance;
|
||||||
module.exports.quizzes = quizControllerInstance;
|
module.exports.quizzes = quizControllerInstance;
|
||||||
module.exports.images = imagesControllerInstance;
|
module.exports.images = imagesControllerInstance;
|
||||||
|
module.exports.admin = AdminControllerInstance;
|
||||||
|
|
||||||
//import routers (instantiate controllers as side effect)
|
//import routers (instantiate controllers as side effect)
|
||||||
const userRouter = require('./routers/users.js');
|
const userRouter = require('./routers/users.js');
|
||||||
|
|
@ -48,6 +53,7 @@ const quizRouter = require('./routers/quiz.js');
|
||||||
const imagesRouter = require('./routers/images.js')
|
const imagesRouter = require('./routers/images.js')
|
||||||
const AuthManager = require('./auth/auth-manager.js')
|
const AuthManager = require('./auth/auth-manager.js')
|
||||||
const authRouter = require('./routers/auth.js')
|
const authRouter = require('./routers/auth.js')
|
||||||
|
const adminRouter = require('./routers/admin.js')
|
||||||
|
|
||||||
// Setup environment
|
// Setup environment
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
@ -100,6 +106,7 @@ app.use('/api/folder', folderRouter);
|
||||||
app.use('/api/quiz', quizRouter);
|
app.use('/api/quiz', quizRouter);
|
||||||
app.use('/api/image', imagesRouter);
|
app.use('/api/image', imagesRouter);
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
// Add Auths methods
|
// Add Auths methods
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
|
@ -113,11 +120,9 @@ app.use(session({
|
||||||
let _authManager = new AuthManager(app,null,userModel);
|
let _authManager = new AuthManager(app,null,userModel);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// Start server
|
|
||||||
async function start() {
|
async function start() {
|
||||||
const port = process.env.PORT || 4400;
|
const port = process.env.PORT || 4400;
|
||||||
|
|
||||||
// Check DB connection
|
|
||||||
await db.connect();
|
await db.connect();
|
||||||
db.getConnection();
|
db.getConnection();
|
||||||
console.log(`Connexion MongoDB établie`);
|
console.log(`Connexion MongoDB établie`);
|
||||||
|
|
@ -127,7 +132,6 @@ async function start() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown on SIGINT (Ctrl+C)
|
|
||||||
process.on('SIGINT', async () => {
|
process.on('SIGINT', async () => {
|
||||||
console.log('Shutting down...');
|
console.log('Shutting down...');
|
||||||
await db.closeConnection();
|
await db.closeConnection();
|
||||||
|
|
|
||||||
86
server/controllers/admin.js
Normal file
86
server/controllers/admin.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -4,10 +4,14 @@ const AppError = require('./AppError.js');
|
||||||
const { UNAUTHORIZED_NO_TOKEN_GIVEN, UNAUTHORIZED_INVALID_TOKEN } = require('../constants/errorCodes');
|
const { UNAUTHORIZED_NO_TOKEN_GIVEN, UNAUTHORIZED_INVALID_TOKEN } = require('../constants/errorCodes');
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
const whitelist = process.env.ADMINS ? JSON.parse(process.env.ADMINS) : [];
|
||||||
|
|
||||||
class Token {
|
class Token {
|
||||||
|
|
||||||
create(email, userId, roles) {
|
create(email, userId, roles) {
|
||||||
|
if (whitelist.includes(email)) {
|
||||||
|
roles.push("admin");
|
||||||
|
}
|
||||||
return jwt.sign({ email, userId, roles }, process.env.JWT_SECRET);
|
return jwt.sign({ email, userId, roles }, process.env.JWT_SECRET);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
129
server/models/admin.js
Normal file
129
server/models/admin.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -70,6 +70,7 @@ class Quiz {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteQuizzesByFolderId(folderId) {
|
async deleteQuizzesByFolderId(folderId) {
|
||||||
await this.db.connect();
|
await this.db.connect();
|
||||||
const conn = this.db.getConnection();
|
const conn = this.db.getConnection();
|
||||||
|
|
|
||||||
15
server/routers/admin.js
Normal file
15
server/routers/admin.js
Normal file
|
|
@ -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;
|
||||||
Loading…
Reference in a new issue