This commit is contained in:
Edwin S Lopez 2025-04-12 02:21:25 +00:00 committed by GitHub
commit 204419653e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1876 additions and 257 deletions

545
client/package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
export interface AdminTableType {
_id: string;
email: string;
created_at: Date;
updated_at?: Date;
title?: string;
name?: string;
roles?: string[];
}

View file

@ -0,0 +1,2 @@
export type LabelMap = { [key: string]: string };

View file

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

View file

@ -0,0 +1,11 @@
export interface UserType {
id: string;
name: string;
email: string;
created_at: string;
roles: string[];
}
export interface UsersResponse {
users: UserType[];
}

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

@ -12,3 +12,9 @@
.header img { .header img {
cursor: pointer; cursor: pointer;
} }
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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