Compare commits

...

106 commits

Author SHA1 Message Date
Philippe Côté
71f57fd2cf
Merge branch 'main' into philippe/partage_quiz 2025-03-10 14:32:37 -04:00
Christopher (Cris) Fuhrman
ba055070a3
Merge pull request #282 from ets-cfuhrman-pfe/bug/sso-email
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 59s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 59s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m26s
Tests / lint-and-tests (server) (push) Failing after 56s
FIX sso envoi email
2025-03-08 18:32:43 -05:00
Christopher (Cris) Fuhrman
cbfd37ae0e
Merge pull request #269 from ets-cfuhrman-pfe/JubaAzul/issue200
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 58s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 58s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m26s
Tests / lint-and-tests (server) (push) Failing after 58s
Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
2025-03-08 11:30:43 -05:00
C. Fuhrman
623b749e4f refactor AnswerType 2025-03-08 11:05:25 -05:00
C. Fuhrman
73e0326f44 tests améliorés (toujours pas idéal) 2025-03-08 02:31:55 -05:00
C. Fuhrman
ca1eb4737d Plus besoin de local storage si on passe toutes les questions à TeacherModeQuiz aussi
Nécessite un nouveau message launch-teacher-mode
2025-03-08 01:09:41 -05:00
JubaAzul
22482592cd Test 2025-03-07 16:33:57 -05:00
Eddi3_As
79066179ac FIX sso envoi email 2025-03-07 14:04:30 -05:00
JubaAzul
ec2de888ec Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
Fixes #200
2025-03-07 13:39:56 -05:00
JubaAzul
1c2d4134af Ajout de cross-env dans package.json 2025-03-07 13:12:32 -05:00
C. Fuhrman
2b7ea55f9c Merge branch 'JubaAzul/issue200' of https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir into JubaAzul/issue200 2025-03-07 12:50:19 -05:00
C. Fuhrman
75b91346e3 Merge branch 'main' into JubaAzul/issue200 2025-03-07 12:46:45 -05:00
JubaAzul
70b788fbd0 Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
Fixes #200
2025-03-07 12:42:56 -05:00
JubaAzul
6a340556e2 Amélioration de beaucoup de features 2025-03-07 12:20:39 -05:00
JubaAzul
f8dd95f651 Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
Fixes #200
2025-03-07 11:36:43 -05:00
C. Fuhrman
74fcc23a07 Revert "Merge pull request #280 from ets-cfuhrman-pfe/bug/sso-consent"
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 18s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 18s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m2s
Tests / lint-and-tests (server) (push) Failing after 1m0s
This reverts commit 7418f53e31, reversing
changes made to 9d24507f41.
2025-03-06 21:19:57 -05:00
C. Fuhrman
7afaa54758 Revert "Merge pull request #281 from ets-cfuhrman-pfe/bug/sso-consent"
This reverts commit 59dc478eb1, reversing
changes made to 7418f53e31.
2025-03-06 21:18:49 -05:00
Christopher (Cris) Fuhrman
59dc478eb1
Merge pull request #281 from ets-cfuhrman-pfe/bug/sso-consent
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 17s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 17s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m6s
Tests / lint-and-tests (server) (push) Failing after 57s
corrections to received user
2025-03-06 20:28:01 -05:00
C. Fuhrman
6b0cca9929 corrections to received user 2025-03-06 20:24:12 -05:00
Christopher (Cris) Fuhrman
7418f53e31
Merge pull request #280 from ets-cfuhrman-pfe/bug/sso-consent
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 57s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 56s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m33s
Tests / lint-and-tests (server) (push) Failing after 56s
FIX consent & ajout tests pour oidc
2025-03-06 19:49:26 -05:00
Eddi3_As
50924f8576 FIX consent & ajout tests pour oidc 2025-03-06 18:22:40 -05:00
NouhailaAater
9d24507f41
Merge pull request #259 from ets-cfuhrman-pfe/feature/add-room-collection
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 20s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 19s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 59s
Tests / lint-and-tests (server) (push) Failing after 50s
User Story 34 : Numéro de salle permanent par professeur
2025-03-06 15:04:28 -05:00
C. Fuhrman
55156de5f9 réintialiser n'est pas fonctionnel 2025-03-06 14:51:51 -05:00
C. Fuhrman
b6be822720 Créer compte fonctionne en dev (simpleauth) 2025-03-06 14:48:07 -05:00
C. Fuhrman
9f4414f68c Supporter simpleauth lorsque mode development 2025-03-06 12:03:18 -05:00
C. Fuhrman
5760469e60 documente un test qui laisse "open handles" dans jest (connexion BD) 2025-03-06 11:57:20 -05:00
C. Fuhrman
93c9f10197 hover gênant (affecte tous les boutons de l'App) 2025-03-06 09:48:06 -05:00
C. Fuhrman
65d2121f79 Erreur de merge 2025-03-06 09:47:27 -05:00
C. Fuhrman
9aab6fa610 simpleauth pour MODE=development 2025-03-06 00:37:22 -05:00
C. Fuhrman
3e1e3c7f0d Corrige problèmes identifiés par eslint 2025-03-06 00:30:27 -05:00
JubaAzul
60ad2df67d Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
Fixes #200
2025-03-04 16:49:12 -05:00
NouhailaAater
752e168a53 Remove unused import 2025-03-04 16:48:48 -05:00
NouhailaAater
4a971bba76 fix ESLint Jest plugin issue 2025-03-04 16:45:12 -05:00
NouhailaAater
8a740beab8 Correction tests 2025-03-04 16:43:11 -05:00
NouhailaAater
cd13c5f798 Merge branch 'main' of https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir into feature/add-room-collection 2025-03-04 16:17:17 -05:00
Christopher (Cris) Fuhrman
59ac5a88c2
Merge pull request #275 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 18s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 18s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m8s
Tests / lint-and-tests (server) (push) Failing after 1m3s
lowercase email to match the mongo user key
2025-03-04 16:04:56 -05:00
C. Fuhrman
e817746801 lowercase email to match the mongo user key 2025-03-04 15:54:14 -05:00
Christopher (Cris) Fuhrman
8412218bb2
Merge pull request #274 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 17s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 17s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m7s
Tests / lint-and-tests (server) (push) Failing after 1m0s
hard-code teacher role
2025-03-04 15:43:09 -05:00
C. Fuhrman
06797822b0 hard-code teacher role 2025-03-04 15:41:58 -05:00
Christopher (Cris) Fuhrman
7e9c78fd93
Merge pull request #273 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 18s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 18s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 59s
Tests / lint-and-tests (server) (push) Failing after 1m9s
order of init
2025-03-04 14:52:55 -05:00
C. Fuhrman
fd160aaba7 order of init 2025-03-04 14:47:44 -05:00
Christopher (Cris) Fuhrman
1b06af0b70
Merge pull request #272 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 18s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 17s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m5s
Tests / lint-and-tests (server) (push) Failing after 1m2s
userModel traces
2025-03-04 14:25:39 -05:00
C. Fuhrman
c098d002c8 userModel traces 2025-03-04 14:24:36 -05:00
Christopher (Cris) Fuhrman
b3c618a811
Merge pull request #271 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 18s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 17s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m0s
Tests / lint-and-tests (server) (push) Failing after 53s
fix profile displayName
2025-03-04 14:05:07 -05:00
C. Fuhrman
bb43eca723 fix profile displayName 2025-03-04 14:03:43 -05:00
Christopher (Cris) Fuhrman
e200973af7
Merge pull request #270 from ets-cfuhrman-pfe/debug-oidc-1
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 57s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 57s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m27s
Tests / lint-and-tests (server) (push) Failing after 58s
Console logs to see debugging on deployment
2025-03-04 13:53:34 -05:00
C. Fuhrman
c5d4368816 Console logs 2025-03-04 13:50:12 -05:00
JubaAzul
fe44409e16 Réponse à une question non enregistrée lorsque Étudiant reviens en arrière dans le quiz
Fixes #200
2025-03-03 17:00:42 -05:00
Christopher (Cris) Fuhrman
1317d3b1af
Merge pull request #267 from ets-cfuhrman-pfe/restore-reverted-sso-pr
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 1m48s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 49s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 17s
Tests / lint-and-tests (client) (push) Failing after 1m24s
Tests / lint-and-tests (server) (push) Failing after 55s
Ajustement callback URI
2025-03-03 11:29:24 -05:00
Eddi3_As
44c023e023 FIX error on user model not being init 2025-03-02 17:57:49 -05:00
NouhailaAater
5ebe86a471 Merge branch 'main' into feature/add-room-collection 2025-03-02 03:18:41 -05:00
NouhailaAater
bd1ea4c2f6 Correction room controller 2025-03-02 02:56:49 -05:00
NouhailaAater
545d6551f6 Correction 2025-03-02 01:56:37 -05:00
NouhailaAater
9ece28a86b mise à jour des errorCodes 2025-03-02 01:21:32 -05:00
Eddi3_As
a049ba5da1 FIX simpleauth for local dev 2025-03-01 23:01:31 -05:00
Eddi3_As
2499f78b6a oauth-tester path modified 2025-03-01 19:30:37 -05:00
Eddi3_As
2454cb3a95 ajout callback uri et debug consoles 2025-03-01 19:24:24 -05:00
Christopher (Cris) Fuhrman
20b6f29a2c
Merge pull request #256 from ets-cfuhrman-pfe/restore-reverted-sso-pr
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 19s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 18s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / lint-and-tests (client) (push) Failing after 1m10s
Tests / lint-and-tests (server) (push) Failing after 1m5s
Revert "Merge pull request #239 from ets-cfuhrman-pfe/revert-217-PFEH…
2025-02-28 13:20:42 -05:00
Eddi3_As
3a2baaaa1c Merge remote-tracking branch 'test-1/main' into restore-reverted-sso-pr 2025-02-28 13:13:03 -05:00
C. Fuhrman
bc01dd519f Utilise npm install plutôt que npm ci (workaround temporaire) 2025-02-28 11:13:18 -05:00
C. Fuhrman
31f94a93d9 sync package-lock.json (après maj de node à 20.18.3) 2025-02-28 10:55:46 -05:00
C. Fuhrman
4828c0b578 corriger package-lock.json 2025-02-28 10:43:59 -05:00
C. Fuhrman
833c953efb update vite (vulnérabilité) 2025-02-28 10:16:54 -05:00
C. Fuhrman
4b36b11957 npm-check -u (client) 2025-02-28 10:05:38 -05:00
Christopher (Cris) Fuhrman
2a8913fadc
Merge pull request #263 from ets-cfuhrman-pfe/fuhrmanator/issue262
Some checks failed
CI/CD Pipeline for Backend / build_and_push_backend (push) Failing after 53s
CI/CD Pipeline for Nginx Router / build_and_push_nginx (push) Failing after 51s
CI/CD Pipeline for Frontend / build_and_push_frontend (push) Failing after 18s
Tests / tests (client) (push) Failing after 1m26s
Tests / tests (server) (push) Failing after 1m1s
Touche retour équivaut à un clique sur le bouton
2025-02-28 08:53:34 -05:00
NouhailaAater
e4739468ba fix tests 2025-02-28 03:16:54 -05:00
NouhailaAater
323efca180 fix tests 2025-02-28 03:13:28 -05:00
NouhailaAater
eb3e06f5d3 Rooms and selection update automatically without a refresh 2025-02-28 03:07:29 -05:00
NouhailaAater
f68806cfd1 Automatically select the newly created room 2025-02-28 02:48:57 -05:00
NouhailaAater
2fc922d01f Fix existing room error handling with AppError 2025-02-28 02:28:47 -05:00
C. Fuhrman
b1fc8e843b ignorer touch si champs vide 2025-02-27 23:43:58 -05:00
C. Fuhrman
5b41f24404 Return key submits form
Fixes #262
2025-02-27 23:20:17 -05:00
C. Fuhrman
4cc6ee79e4 Permet d'ajouter une première salle
Nom de salle toujours en majuscules (bd)
2025-02-27 16:07:00 -05:00
C. Fuhrman
70d6d1bc56 Nom de la salle doiit être un majuscule
Supprimer des créations de socket/salle superflues
Suppression (nettoyage) des salles et socket plus robuste
diminue le bazaar de useEffect (!)
2025-02-27 15:49:09 -05:00
NouhailaAater
38e366a7de Remove automatic room creation 2025-02-27 13:34:56 -05:00
C. Fuhrman
0d56fa246d AppErreur lancée par les contrôleurs, Erreur lancée par les modèles. 2025-02-27 09:06:58 -05:00
NouhailaAater
3855eca6c6 Fix ManageRoom tests 2025-02-27 02:17:54 -05:00
NouhailaAater
fc69a37e53 Fix tests to correctly handle AppError mock and instance checks 2025-02-27 01:03:56 -05:00
NouhailaAater
d2bf18b88d import appError dans le model 2025-02-27 00:43:20 -05:00
NouhailaAater
068f97ac47 test a jour 2025-02-26 15:28:00 -05:00
C. Fuhrman
cf1a5ae4a0 ajouter nom de la salle à la navigation manage-room 2025-02-26 14:38:36 -05:00
NouhailaAater
2c3c6eed90 Merge branch 'feature/add-room-collection' of https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir into feature/add-room-collection 2025-02-26 14:07:30 -05:00
NouhailaAater
a99664d8ff traitement des erreurs et afficher un dialogue erreur si room existe 2025-02-26 14:07:18 -05:00
C. Fuhrman
784ac277d0 corriger tests brisés
supprimer tests de contenu de salle (copier-coller des tests de dossier je crois)
2025-02-26 13:06:03 -05:00
C. Fuhrman
d584374347 add-room est dans le menu déroulant plutôt que dans un bouton
renommer des variables (Dashboard fait beaucoup, ça prend des noms précis)
actualiser la liste des salles après add (bug)
2025-02-26 09:48:40 -05:00
C. Fuhrman
0bf2bf7747 select first room by default on dashboard 2025-02-26 09:31:20 -05:00
NouhailaAater
5436fc3a1f Correction if aucune salle est selectionner 2025-02-25 16:12:57 -05:00
NouhailaAater
9486eacc53 Correction test ManageRoom 2025-02-24 14:32:08 -05:00
NouhailaAater
162117a58a Correction test server 2025-02-24 05:10:57 -05:00
NouhailaAater
b5547cb100 Ensure room title uniqueness by normalizing case sensitivity 2025-02-24 04:09:55 -05:00
NouhailaAater
bf2e6502f3 Generate a random room name on the client side and set the first created room name as the default 2025-02-24 03:50:15 -05:00
NouhailaAater
94c728fa09 Debug join quiz 2025-02-24 03:29:36 -05:00
NouhailaAater
39ce176ae7 Ajout de RoomContext et deplacement de choix/creation de liste room dans le dashboard 2025-02-23 22:40:46 -05:00
NouhailaAater
81c530eac6 correction rooms.test.js 2025-02-22 02:19:06 -05:00
NouhailaAater
9286fe6b9c Correction socket 2025-02-22 02:04:49 -05:00
NouhailaAater
c9b76df2cd correction user-joined 2025-02-22 01:23:42 -05:00
NouhailaAater
5c736f4ca0 branch 'main' into feature/add-room-collection 2025-02-22 00:24:04 -05:00
NouhailaAater
562fdfb791 ajout tests 2025-02-22 00:20:37 -05:00
NouhailaAater
5f87aa1b7a Correction ajout salle 2025-02-21 22:51:42 -05:00
NouhailaAater
fd9f04d116 Correction 2025-02-21 20:15:32 -05:00
C. Fuhrman
9143b5f524 Revert "Merge pull request #239 from ets-cfuhrman-pfe/revert-217-PFEH25-merge-it2main"
This reverts commit 0febb5a394, reversing
changes made to 687f5be45a.
2025-02-21 14:48:21 -05:00
NouhailaAater
29924a6786 Ajout boite dialog 2025-02-20 02:17:24 -05:00
NouhailaAater
b42cbb3647 ajout d'une nouvelle salle 2025-02-20 01:37:25 -05:00
NouhailaAater
c3e56502d8 Utilisation liste room 2025-02-20 00:37:01 -05:00
NouhailaAater
a9743ad5d4 Affichage de la liste des salles 2025-02-20 00:19:32 -05:00
NouhailaAater
3c7e4c68e7 ajout room collection 2025-02-19 18:56:37 -05:00
97 changed files with 6724 additions and 2767 deletions

View file

@ -8,29 +8,50 @@ on:
branches:
- main
env:
MONGO_URI: mongodb://localhost:27017
MONGO_DATABASE: evaluetonsavoir
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies, lint and Run Tests
run: |
echo "Installing dependencies..."
npm ci
echo "Running ESLint..."
npx eslint .
echo "Running tests..."
npm test
working-directory: ${{ matrix.directory }}
lint-and-tests:
strategy:
matrix:
directory: [client, server]
fail-fast: false
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ matrix.directory }}/package-lock.json
- name: Process ${{ matrix.directory }}
working-directory: ${{ matrix.directory }}
timeout-minutes: 5
run: |
echo "Installing dependencies..."
npm install
echo "Running ESLint..."
npx eslint .
echo "Running tests..."
echo "::group::Installing dependencies for ${{ matrix.directory }}"
npm ci
echo "::endgroup::"
echo "::group::Running ESLint"
npx eslint . || {
echo "ESLint failed with exit code $?"
exit 1
}
echo "::endgroup::"
echo "::group::Running Tests"
npm test
echo "::endgroup::"

5
.gitignore vendored
View file

@ -122,10 +122,13 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
.env
launch.json
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
db-backup/
db-backup/

View file

@ -0,0 +1,33 @@
{
"folders": [
{
"path": "."
},
{
"name": "server",
"path": "server"
},
{
"name": "client",
"path": "client"
}
],
"settings": {
"jest.disabledWorkspaceFolders": [
"EvalueTonSavoir"
]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": [
"javascript",
"typescript",
"javascriptreact",
"typescriptreact"
],
// use the same eslint config as `npx eslint`
"eslint.experimental.useFlatConfig": true,
"eslint.nodePath": "./node_modules"
}

View file

@ -1,19 +0,0 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View file

@ -1,4 +1,4 @@
/* eslint-disable no-undef */
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript']
};

View file

@ -1,29 +1,77 @@
import react from "eslint-plugin-react";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import jest from "eslint-plugin-jest";
import reactRefresh from "eslint-plugin-react-refresh";
import unusedImports from "eslint-plugin-unused-imports";
import eslintComments from "eslint-plugin-eslint-comments";
/** @type {import('eslint').Linter.Config[]} */
export default [
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
languageOptions: {
globals: globals.browser,
{
ignores: ["node_modules", "dist/**/*"],
},
rules: {
"no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_" // Ignore catch clause parameters that start with _
}],
},
settings: {
react: {
version: "detect", // Automatically detect the React version
},
},
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.serviceworker,
...globals.browser,
...globals.jest,
...globals.node,
process: "readonly",
},
},
plugins: {
"@typescript-eslint": typescriptEslint,
react,
jest,
"react-refresh": reactRefresh,
"unused-imports": unusedImports,
"eslint-comments": eslintComments
},
rules: {
// Auto-fix unused variables
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}
],
// Handle directive comments
"eslint-comments/no-unused-disable": "warn",
"eslint-comments/no-unused-enable": "warn",
// Jest configurations
"jest/valid-expect": ["error", { "alwaysAwait": true }],
"jest/prefer-to-have-length": "warn",
"jest/no-disabled-tests": "off",
"jest/no-focused-tests": "error",
"jest/no-identical-title": "error",
// React refresh
"react-refresh/only-export-components": ["warn", {
allowConstantExport: true
}],
},
settings: {
react: {
version: "detect",
},
},
}
];

View file

@ -1,4 +1,4 @@
/* eslint-disable no-undef */
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {

View file

@ -1,3 +1,3 @@
/* eslint-disable no-undef */
process.env.VITE_BACKEND_URL = 'http://localhost:4000/';
process.env.VITE_BACKEND_SOCKET_URL = 'https://ets-glitch-backend.glitch.me/';

3002
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"dev": "cross-env MODE=development VITE_BACKEND_URL=http://localhost:4400 vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
@ -12,61 +12,66 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fortawesome/fontawesome-free": "^6.4.2",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mui/icons-material": "^6.4.1",
"@mui/icons-material": "^6.4.6",
"@mui/lab": "^5.0.0-alpha.153",
"@mui/material": "^6.1.0",
"@mui/material": "^6.4.6",
"@types/uuid": "^9.0.7",
"axios": "^1.6.7",
"axios": "^1.8.1",
"dompurify": "^3.2.3",
"esbuild": "^0.23.1",
"esbuild": "^0.25.0",
"gift-pegjs": "^2.0.0-beta.1",
"jest-environment-jsdom": "^29.7.0",
"jwt-decode": "^4.0.0",
"katex": "^0.16.11",
"marked": "^14.1.2",
"nanoid": "^5.0.2",
"nanoid": "^5.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-modal": "^3.16.1",
"react-modal": "^3.16.3",
"react-router-dom": "^6.26.2",
"remark-math": "^6.0.0",
"socket.io-client": "^4.7.2",
"ts-node": "^10.9.1",
"uuid": "^9.0.1",
"vite-plugin-checker": "^0.8.0"
"vite-plugin-checker": "^0.9.0"
},
"devDependencies": {
"@babel/preset-env": "^7.23.3",
"@babel/preset-react": "^7.23.3",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.23.3",
"@eslint/js": "^9.18.0",
"@eslint/js": "^9.21.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.13",
"@types/node": "^22.5.5",
"@types/node": "^22.13.5",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.5.0",
"@typescript-eslint/parser": "^8.5.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"eslint": "^9.18.0",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"cross-env": "^7.0.3",
"eslint": "^9.21.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0-rc-206df66e-20240912",
"eslint-plugin-react-refresh": "^0.4.12",
"eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.6.2",
"typescript-eslint": "^8.19.1",
"vite": "^5.4.5",
"ts-jest": "^29.2.6",
"typescript": "^5.7.3",
"typescript-eslint": "^8.25.0",
"vite": "^6.2.0",
"vite-plugin-environment": "^1.1.3"
}
}

View file

@ -1,6 +1,6 @@
import React from 'react';
// App.tsx
import { Routes, Route } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
// Page main
import Home from './pages/Home/Home';
@ -8,37 +8,55 @@ import Home from './pages/Home/Home';
// Pages espace enseignant
import Dashboard from './pages/Teacher/Dashboard/Dashboard';
import Share from './pages/Teacher/Share/Share';
import Login from './pages/Teacher/Login/Login';
import Register from './pages/Teacher/Register/Register';
import ResetPassword from './pages/Teacher/ResetPassword/ResetPassword';
import Register from './pages/AuthManager/providers/SimpleLogin/Register';
import ResetPassword from './pages/AuthManager/providers/SimpleLogin/ResetPassword';
import ManageRoom from './pages/Teacher/ManageRoom/ManageRoom';
import QuizForm from './pages/Teacher/EditorQuiz/EditorQuiz';
// Pages espace étudiant
import JoinRoom from './pages/Student/JoinRoom/JoinRoom';
// Pages authentification selection
import AuthDrawer from './pages/AuthManager/AuthDrawer';
// Header/Footer import
import Header from './components/Header/Header';
import Footer from './components/Footer/Footer';
import ApiService from './services/ApiService';
import OAuthCallback from './pages/AuthManager/callback/AuthCallback';
const handleLogout = () => {
ApiService.logout();
}
const App: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn());
const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher());
const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null);
const location = useLocation();
const isLoggedIn = () => {
return ApiService.isLoggedIn();
}
// Check login status every time the route changes
useEffect(() => {
const checkLoginStatus = () => {
setIsAuthenticated(ApiService.isLoggedIn());
setIsTeacherAuthenticated(ApiService.isLoggedInTeacher());
};
const fetchAuthenticatedRooms = async () => {
const data = await ApiService.getRoomsRequireAuth();
setRoomsRequireAuth(data);
};
checkLoginStatus();
fetchAuthenticatedRooms();
}, [location]);
const handleLogout = () => {
ApiService.logout();
setIsAuthenticated(false);
setIsTeacherAuthenticated(false);
};
function App() {
return (
<div className="content">
<Header
isLoggedIn={isLoggedIn}
handleLogout={handleLogout}/>
<Header isLoggedIn={isAuthenticated} handleLogout={handleLogout} />
<div className="app">
<main>
<Routes>
@ -46,22 +64,46 @@ function App() {
<Route path="/" element={<Home />} />
{/* Pages espace enseignant */}
<Route path="/teacher/login" element={<Login />} />
<Route path="/teacher/register" element={<Register />} />
<Route path="/teacher/resetPassword" element={<ResetPassword />} />
<Route path="/teacher/dashboard" element={<Dashboard />} />
<Route path="/teacher/share/:id" element={<Share />} />
<Route path="/teacher/editor-quiz/:id" element={<QuizForm />} />
<Route path="/teacher/manage-room/:id" element={<ManageRoom />} />
<Route
path="/teacher/dashboard"
element={isTeacherAuthenticated ? <Dashboard /> : <Navigate to="/login" />}
/>
<Route
path="/teacher/share/:id"
element={isTeacherAuthenticated ? <Share /> : <Navigate to="/login" />}
/>
<Route
path="/teacher/editor-quiz/:id"
element={isTeacherAuthenticated ? <QuizForm /> : <Navigate to="/login" />}
/>
<Route
path="/teacher/manage-room/:quizId/:roomName"
element={isTeacherAuthenticated ? <ManageRoom /> : <Navigate to="/login" />}
/>
{/* Pages espace étudiant */}
<Route path="/student/join-room" element={<JoinRoom />} />
<Route
path="/student/join-room"
element={( !isRoomRequireAuthentication || isAuthenticated ) ? <JoinRoom /> : <Navigate to="/login" />}
/>
{/* Pages authentification */}
<Route path="/login" element={<AuthDrawer />} />
{/* Pages enregistrement */}
<Route path="/register" element={<Register />} />
{/* Pages rest password */}
<Route path="/resetPassword" element={<ResetPassword />} />
{/* Pages authentification sélection */}
<Route path="/auth/callback" element={<OAuthCallback />} />
</Routes>
</main>
</div>
<Footer/>
<Footer />
</div>
);
}
};
export default App;

View file

@ -0,0 +1,6 @@
export interface RoomType {
_id: string;
userId: string;
title: string;
created_at: string;
}

View file

@ -1,5 +1,7 @@
import { AnswerType } from "src/pages/Student/JoinRoom/JoinRoom";
export interface Answer {
answer: string | number | boolean;
answer: AnswerType;
isCorrect: boolean;
idQuestion: number;
}

View file

@ -0,0 +1,17 @@
import { RoomType } from "../../Types/RoomType";
const room: RoomType = {
_id: '123',
userId: '456',
title: 'Test Room',
created_at: '2025-02-21T00:00:00Z'
};
describe('RoomType', () => {
test('creates a room with _id, userId, title, and created_at', () => {
expect(room._id).toBe('123');
expect(room.userId).toBe('456');
expect(room.title).toBe('Test Room');
expect(room.created_at).toBe('2025-02-21T00:00:00Z');
});
});

View file

@ -12,6 +12,6 @@ describe('StudentType', () => {
expect(user.name).toBe('Student');
expect(user.id).toBe('123');
expect(user.answers.length).toBe(0);
expect(user.answers).toHaveLength(0);
});
});

View file

@ -88,10 +88,10 @@ describe('LiveResultsTable', () => {
//50% because only one of the two questions have been answered (getALLByText, because there are a value 50% for the %reussite de la question
// and a second one for the student grade)
const gradeElements = screen.getAllByText('50 %');
expect(gradeElements.length).toBe(2);
expect(gradeElements).toHaveLength(2);
const gradeElements2 = screen.getAllByText('0 %');
expect(gradeElements2.length).toBe(2); });
expect(gradeElements2).toHaveLength(2); });
test('calculates and displays class average', () => {
render(
@ -107,4 +107,4 @@ describe('LiveResultsTable', () => {
//1 good answer out of 4 possible good answers (the second question has not been answered)
expect(screen.getByText('25 %')).toBeInTheDocument();
});
});
});

View file

@ -90,6 +90,6 @@ describe('LiveResultsTableBody', () => {
/>
);
expect(screen.getAllByText('******').length).toBe(2);
expect(screen.getAllByText('******')).toHaveLength(2);
});
});
});

View file

@ -54,10 +54,10 @@ describe('TextType', () => {
format: ''
};
// eslint-disable-next-line no-irregular-whitespace
// warning: there are zero-width spaces "" in the expected output -- you must enable seeing them with an extension such as Gremlins tracker in VSCode
// eslint-disable-next-line no-irregular-whitespace
const expectedOutput = `Inline matrix: <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mo fence="true">(</mo><mtable rowspacing="0.16em"><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>a</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>b</mi></mstyle></mtd></mtr><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>c</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>d</mi></mstyle></mtd></mtr></mtable><mo fence="true">)</mo></mrow> \\begin{pmatrix} a &amp; b \\\\ c &amp; d \\end{pmatrix} </math></span><span aria-hidden="true" class="katex-html"><span class="base"><span style="height:2.4em;vertical-align:-0.95em;" class="strut"></span><span class="minner"><span style="top:0em;" class="mopen delimcenter"><span class="delimsizing size3">(</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">a</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">c</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span><span style="width:0.5em;" class="arraycolsep"></span><span style="width:0.5em;" class="arraycolsep"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">b</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">d</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span></span></span><span style="top:0em;" class="mclose delimcenter"><span class="delimsizing size3">)</span></span></span></span></span></span>`;
expect(FormattedTextTemplate(input)).toContain(expectedOutput);
});

View file

@ -28,7 +28,7 @@ function convertStylesToObject(styles: string): React.CSSProperties {
styles.split(';').forEach((style) => {
const [property, value] = style.split(':');
if (property && value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(styleObject as any)[property.trim()] = value.trim();
}
});

View file

@ -5,6 +5,7 @@ import { act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MultipleChoiceQuestion, parse } from 'gift-pegjs';
import MultipleChoiceQuestionDisplay from 'src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
const questions = parse(
`::Sample Question 1:: Question stem
@ -21,7 +22,7 @@ describe('MultipleChoiceQuestionDisplay', () => {
const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => {
const [showAnswerState, setShowAnswerState] = useState(showAnswer);
const handleOnSubmitAnswer = (answer: string) => {
const handleOnSubmitAnswer = (answer: AnswerType) => {
mockHandleOnSubmitAnswer(answer);
setShowAnswerState(true);
};

View file

@ -5,6 +5,7 @@ import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import TrueFalseQuestionDisplay from 'src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay';
import { parse, TrueFalseQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
describe('TrueFalseQuestion Component', () => {
const mockHandleSubmitAnswer = jest.fn();
@ -16,7 +17,7 @@ describe('TrueFalseQuestion Component', () => {
const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => {
const [showAnswerState, setShowAnswerState] = useState(showAnswer);
const handleOnSubmitAnswer = (answer: boolean) => {
const handleOnSubmitAnswer = (answer: AnswerType) => {
mockHandleSubmitAnswer(answer);
setShowAnswerState(true);
};

View file

@ -10,14 +10,15 @@ describe('StudentWaitPage Component', () => {
{ id: '1', name: 'User1', answers: new Array<Answer>() },
{ id: '2', name: 'User2', answers: new Array<Answer>() },
{ id: '3', name: 'User3', answers: new Array<Answer>() },
];
];
const mockProps = {
const mockProps = {
students: mockUsers,
launchQuiz: jest.fn(),
roomName: 'Test Room',
setQuizMode: jest.fn(),
};
setIsRoomSelectionVisible: jest.fn()
};
test('renders StudentWaitPage with correct content', () => {
render(<StudentWaitPage {...mockProps} />);
@ -28,16 +29,15 @@ describe('StudentWaitPage Component', () => {
expect(launchButton).toBeInTheDocument();
mockUsers.forEach((user) => {
expect(screen.getByText(user.name)).toBeInTheDocument();
expect(screen.getByText(user.name)).toBeInTheDocument();
});
});
});
test('clicking on "Lancer" button opens LaunchQuizDialog', () => {
test('clicking on "Lancer" button opens LaunchQuizDialog', () => {
render(<StudentWaitPage {...mockProps} />);
fireEvent.click(screen.getByRole('button', { name: /Lancer/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
})
});
});

View file

@ -8,6 +8,7 @@ import { QuizType } from 'src/Types/QuizType';
import webSocketService, { AnswerReceptionFromBackendType } from 'src/services/WebsocketService';
import ApiService from 'src/services/ApiService';
import { Socket } from 'socket.io-client';
import { RoomProvider } from 'src/pages/Teacher/ManageRoom/RoomContext';
jest.mock('src/services/WebsocketService');
jest.mock('src/services/ApiService');
@ -16,6 +17,7 @@ jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
useParams: jest.fn(),
}));
jest.mock('src/pages/Teacher/ManageRoom/RoomContext');
const mockSocket = {
on: jest.fn(),
@ -33,7 +35,7 @@ const mockQuiz: QuizType = {
folderName: 'folder-name',
userId: 'user-id',
created_at: new Date(),
updated_at: new Date()
updated_at: new Date(),
};
const mockStudents: StudentType[] = [
@ -51,13 +53,18 @@ const mockAnswerData: AnswerReceptionFromBackendType = {
describe('ManageRoom', () => {
const navigate = jest.fn();
const useParamsMock = useParams as jest.Mock;
const mockSetSelectedRoom = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(navigate);
useParamsMock.mockReturnValue({ id: 'test-quiz-id' });
useParamsMock.mockReturnValue({ quizId: 'test-quiz-id', roomName: 'Test Room' });
(ApiService.getQuiz as jest.Mock).mockResolvedValue(mockQuiz);
(webSocketService.connect as jest.Mock).mockReturnValue(mockSocket);
(RoomProvider as jest.Mock).mockReturnValue({
selectedRoom: { id: '1', title: 'Test Room' },
setSelectedRoom: mockSetSelectedRoom,
});
});
test('prepares to launch quiz and fetches quiz data', async () => {
@ -68,33 +75,36 @@ describe('ManageRoom', () => {
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
await waitFor(() => {
expect(ApiService.getQuiz).toHaveBeenCalledWith('test-quiz-id');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await waitFor(() => {
expect(screen.getByText('Test Quiz')).toBeInTheDocument();
expect(screen.getByText('Salle: test-room-name')).toBeInTheDocument();
const roomHeader = document.querySelector('h1');
expect(roomHeader).toHaveTextContent('Salle : TEST ROOM');
expect(screen.getByText('0/60')).toBeInTheDocument();
expect(screen.getByText('Question 1/2')).toBeInTheDocument();
});
});
test('handles create-success event', async () => {
await act(async () => {
render(
@ -106,11 +116,11 @@ describe('ManageRoom', () => {
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
await waitFor(() => {
expect(screen.getByText('Salle: test-room-name')).toBeInTheDocument();
expect(screen.getByText(/Salle\s*:\s*Test Room/i)).toBeInTheDocument();
});
});
@ -125,7 +135,7 @@ describe('ManageRoom', () => {
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
await act(async () => {
@ -153,47 +163,6 @@ describe('ManageRoom', () => {
});
});
test('handles submit-answer-room event', async () => {
const consoleSpy = jest.spyOn(console, 'log');
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
await act(async () => {
const submitAnswerCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'submit-answer-room')[1];
submitAnswerCallback(mockAnswerData);
});
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Received answer from Student 1 for question 1: Answer1');
});
consoleSpy.mockRestore();
});
test('handles next question', async () => {
await act(async () => {
render(
@ -202,29 +171,30 @@ describe('ManageRoom', () => {
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
const nextQuestionButton = screen.getByText('Prochaine question');
fireEvent.click(screen.getByText('Lancer'));
fireEvent.click(screen.getByText('Rythme du professeur'));
fireEvent.click(screen.getAllByText('Lancer')[1]);
await waitFor(() => {
screen.debug();
});
const nextQuestionButton = await screen.findByRole('button', { name: /Prochaine question/i });
expect(nextQuestionButton).toBeInTheDocument();
fireEvent.click(nextQuestionButton);
await waitFor(() => {
expect(screen.getByText('Question 2/2')).toBeInTheDocument();
});
});
test('handles disconnect', async () => {
await act(async () => {
render(
@ -236,7 +206,7 @@ describe('ManageRoom', () => {
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
createSuccessCallback('Test Room');
});
const disconnectButton = screen.getByText('Quitter');
@ -250,4 +220,77 @@ describe('ManageRoom', () => {
expect(navigate).toHaveBeenCalledWith('/teacher/dashboard');
});
});
});
test('handles submit-answer-room event', async () => {
const consoleSpy = jest.spyOn(console, 'log');
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('test-room-name');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
await act(async () => {
const submitAnswerCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'submit-answer-room')[1];
submitAnswerCallback(mockAnswerData);
});
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Received answer from Student 1 for question 1: Answer1'
);
});
consoleSpy.mockRestore();
});
test('vide la liste des étudiants après déconnexion', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
const disconnectButton = screen.getByText('Quitter');
fireEvent.click(disconnectButton);
const confirmButton = screen.getAllByText('Confirmer');
fireEvent.click(confirmButton[1]);
await waitFor(() => {
expect(screen.queryByText('Student 1')).not.toBeInTheDocument();
});
});
});

View file

@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz';
import { BaseQuestion, parse } from 'gift-pegjs';
import { QuestionType } from 'src/Types/QuestionType';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
@ -15,21 +16,26 @@ const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) =>
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
return { question: newMockQuestion as BaseQuestion };
});
const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn();
beforeEach(() => {
// Clear local storage before each test
// localStorage.clear();
render(
<MemoryRouter>
<StudentModeQuiz
questions={mockQuestions}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
</MemoryRouter>);
</MemoryRouter>
);
});
describe('StudentModeQuiz', () => {
@ -51,6 +57,49 @@ describe('StudentModeQuiz', () => {
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
});
test('handles shows feedback for an already answered question', async () => {
// Answer the first question
act(() => {
fireEvent.click(screen.getByText('Option A'));
});
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
const firstButtonA = screen.getByRole("button", {name: '✅ A Option A'});
expect(firstButtonA).toBeInTheDocument();
expect(firstButtonA.querySelector('.selected')).toBeInTheDocument();
expect(screen.getByRole("button", {name: '❌ B Option B'})).toBeInTheDocument();
expect(screen.queryByText('Répondre')).not.toBeInTheDocument();
// Navigate to the next question
act(() => {
fireEvent.click(screen.getByText('Question suivante'));
});
expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
// Navigate back to the first question
act(() => {
fireEvent.click(screen.getByText('Question précédente'));
});
expect(await screen.findByText('Sample Question 1')).toBeInTheDocument();
// Since answers are mocked, the it doesn't recognize the question as already answered
// TODO these tests are partially faked, need to be fixed if we can mock the answers
// const buttonA = screen.getByRole("button", {name: '✅ A Option A'});
const buttonA = screen.getByRole("button", {name: 'A Option A'});
expect(buttonA).toBeInTheDocument();
// const buttonB = screen.getByRole("button", {name: '❌ B Option B'});
const buttonB = screen.getByRole("button", {name: 'B Option B'});
expect(buttonB).toBeInTheDocument();
// // "Option A" div inside the name of button should have selected class
// expect(buttonA.querySelector('.selected')).toBeInTheDocument();
});
test('handles quit button click', async () => {
act(() => {
fireEvent.click(screen.getByText('Quitter'));
@ -65,16 +114,12 @@ describe('StudentModeQuiz', () => {
});
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
});
act(() => {
fireEvent.click(screen.getByText('Question suivante'));
});
const sampleQuestionElements = screen.queryAllByText(/Sample question 2/i);
expect(sampleQuestionElements.length).toBeGreaterThan(0);
expect(screen.getByText('V')).toBeInTheDocument();
expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
});

View file

@ -3,41 +3,52 @@ import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { screen } from '@testing-library/dom';
import '@testing-library/jest-dom';
import { MultipleChoiceQuestion, parse } from 'gift-pegjs';
import { BaseQuestion, MultipleChoiceQuestion, parse } from 'gift-pegjs';
import TeacherModeQuiz from 'src/components/TeacherModeQuiz/TeacherModeQuiz';
import { MemoryRouter } from 'react-router-dom';
// import { mock } from 'node:test';
import { QuestionType } from 'src/Types/QuestionType';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse(
`::Sample Question:: Sample Question {=Option A ~Option B}`);
describe('TeacherModeQuiz', () => {
it ('renders the initial question as MultipleChoiceQuestion', () => {
expect(mockGiftQuestions[0].type).toBe('MC');
});
`::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
const mockQuestion = mockGiftQuestions[0] as MultipleChoiceQuestion;
::Sample Question 2:: Sample Question 2 {=Option A ~Option B}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
describe('TeacherModeQuiz', () => {
let mockQuestion = mockQuestions[0].question as MultipleChoiceQuestion;
mockQuestion.id = '1';
const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn();
let rerender: (ui: React.ReactElement) => void;
beforeEach(async () => {
render(
const utils = render(
<MemoryRouter>
<TeacherModeQuiz
questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket} />
</MemoryRouter>
);
rerender = utils.rerender;
});
test('renders the initial question', () => {
expect(screen.getByText('Question 1')).toBeInTheDocument();
expect(screen.getByText('Sample Question')).toBeInTheDocument();
expect(screen.getByText('Sample Question 1')).toBeInTheDocument();
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Quitter')).toBeInTheDocument();
@ -53,9 +64,51 @@ describe('TeacherModeQuiz', () => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
expect(screen.getByText('Votre réponse est:')).toBeInTheDocument();
});
test('handles shows feedback for an already answered question', () => {
// Answer the first question
act(() => {
fireEvent.click(screen.getByText('Option A'));
});
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
mockQuestion = mockQuestions[1].question as MultipleChoiceQuestion;
// Navigate to the next question by re-rendering with new props
act(() => {
rerender(
<MemoryRouter>
<TeacherModeQuiz
questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
</MemoryRouter>
);
});
mockQuestion = mockQuestions[0].question as MultipleChoiceQuestion;
act(() => {
rerender(
<MemoryRouter>
<TeacherModeQuiz
questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
</MemoryRouter>
);
});
// Check if the feedback dialog is shown again
expect(screen.getByText('Rétroaction')).toBeInTheDocument();
});
test('handles disconnect button click', () => {
act(() => {
fireEvent.click(screen.getByText('Quitter'));

View file

@ -1,7 +1,9 @@
//WebsocketService.test.tsx
import { BaseQuestion, parse } from 'gift-pegjs';
import WebsocketService from '../../services/WebsocketService';
import { io, Socket } from 'socket.io-client';
import { ENV_VARIABLES } from 'src/constants';
import { QuestionType } from 'src/Types/QuestionType';
jest.mock('socket.io-client');
@ -23,13 +25,13 @@ describe('WebSocketService', () => {
});
test('connect should initialize socket connection', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(io).toHaveBeenCalled();
expect(WebsocketService['socket']).toBe(mockSocket);
});
test('disconnect should terminate socket connection', () => {
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(WebsocketService['socket']).toBeTruthy();
WebsocketService.disconnect();
expect(mockSocket.disconnect).toHaveBeenCalled();
@ -37,17 +39,24 @@ describe('WebSocketService', () => {
});
test('createRoom should emit create-room event', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.createRoom();
expect(mockSocket.emit).toHaveBeenCalledWith('create-room');
const roomName = 'Test Room';
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.createRoom(roomName);
expect(mockSocket.emit).toHaveBeenCalledWith('create-room', roomName);
});
test('nextQuestion should emit next-question event with correct parameters', () => {
const roomName = 'testRoom';
const question = { id: 1, text: 'Sample Question' };
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.nextQuestion(roomName, question);
const mockGiftQuestions = parse('A {T}');
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.nextQuestion({roomName, questions: mockQuestions, questionIndex: 0, isLaunch: false});
const question = mockQuestions[0];
expect(mockSocket.emit).toHaveBeenCalledWith('next-question', { roomName, question });
});
@ -55,7 +64,7 @@ describe('WebSocketService', () => {
const roomName = 'testRoom';
const questions = [{ id: 1, text: 'Sample Question' }];
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.launchStudentModeQuiz(roomName, questions);
expect(mockSocket.emit).toHaveBeenCalledWith('launch-student-mode', {
roomName,
@ -66,7 +75,7 @@ describe('WebSocketService', () => {
test('endQuiz should emit end-quiz event with correct parameters', () => {
const roomName = 'testRoom';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.endQuiz(roomName);
expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName });
});
@ -75,7 +84,7 @@ describe('WebSocketService', () => {
const enteredRoomName = 'testRoom';
const username = 'testUser';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.joinRoom(enteredRoomName, username);
expect(mockSocket.emit).toHaveBeenCalledWith('join-room', { enteredRoomName, username });
});

View file

@ -16,8 +16,8 @@ function formatLatex(text: string): string {
.replace(/\\\((.*?)\\\)/g, (_, inner) =>
katex.renderToString(inner, { displayMode: false })
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
console.log('Error rendering LaTeX (KaTeX):', error);
renderedText = text;
}

View file

@ -1,10 +1,10 @@
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import * as React from 'react';
import './header.css';
import { Button } from '@mui/material';
interface HeaderProps {
isLoggedIn: () => boolean;
isLoggedIn: boolean;
handleLogout: () => void;
}
@ -20,7 +20,7 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
onClick={() => navigate('/')}
/>
{isLoggedIn() && (
{isLoggedIn && (
<Button
variant="outlined"
color="primary"
@ -32,6 +32,14 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
Logout
</Button>
)}
{!isLoggedIn && (
<div className="auth-selection-btn">
<Link to="/login">
<button className="auth-btn">Connexion</button>
</Link>
</div>
)}
</div>
);
};

View file

@ -4,33 +4,44 @@ import '../questionStyle.css';
import { Button } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { MultipleChoiceQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props {
question: MultipleChoiceQuestion;
handleOnSubmitAnswer?: (answer: string) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
passedAnswer?: AnswerType;
}
const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = props;
const [answer, setAnswer] = useState<string>();
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
let disableButton = false;
if(handleOnSubmitAnswer === undefined){
disableButton = true;
}
useEffect(() => {
setAnswer(undefined);
}, [question]);
if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
const handleOnClickAnswer = (choice: string) => {
setAnswer(choice);
};
const alpha = Array.from(Array(26)).map((_e, i) => i + 65);
const alphabet = alpha.map((x) => String.fromCharCode(x));
return (
<div className="question-container">
<div className="question content">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} />
</div>
<div className="choices-wrapper mb-1">
{question.choices.map((choice, i) => {
const selected = answer === choice.formattedText.text ? 'selected' : '';
return (
@ -38,6 +49,7 @@ const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
<Button
variant="text"
className="button-wrapper"
disabled={disableButton}
onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}>
{showAnswer? (<div> {(choice.isCorrect ? '✅' : '❌')}</div>)
:``}
@ -67,9 +79,9 @@ const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
<Button
variant="contained"
onClick={() =>
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
answer !== "" && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
}
disabled={answer === undefined}
disabled={answer === '' || answer === null}
>
Répondre

View file

@ -1,26 +1,32 @@
// NumericalQuestion.tsx
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import '../questionStyle.css';
import { Button, TextField } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { NumericalQuestion, SimpleNumericalAnswer, RangeNumericalAnswer, HighLowNumericalAnswer } from 'gift-pegjs';
import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer, isMultipleNumericalAnswer } from 'gift-pegjs/typeGuards';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props {
question: NumericalQuestion;
handleOnSubmitAnswer?: (answer: number) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
passedAnswer?: AnswerType;
}
const NumericalQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } =
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } =
props;
const [answer, setAnswer] = useState<number>();
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
const correctAnswers = question.choices;
let correctAnswer = '';
useEffect(() => {
if (passedAnswer !== null && passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
//const isSingleAnswer = correctAnswers.length === 1;
if (isSimpleNumericalAnswer(correctAnswers[0])) {
@ -44,10 +50,16 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
</div>
{showAnswer ? (
<>
<div className="correct-answer-text mb-2">{correctAnswer}</div>
<div className="correct-answer-text mb-2">
<strong>La bonne réponse est: </strong>
{correctAnswer}</div>
<span>
<strong>Votre réponse est: </strong>{answer.toString()}
</span>
{question.formattedGlobalFeedback && <div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
</div>}
</>
) : (
<>
@ -75,7 +87,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer)
}
disabled={answer === undefined || isNaN(answer)}
disabled={answer === "" || isNaN(answer as number)}
>
Répondre
</Button>

View file

@ -5,17 +5,21 @@ import TrueFalseQuestionDisplay from './TrueFalseQuestionDisplay/TrueFalseQuesti
import MultipleChoiceQuestionDisplay from './MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import NumericalQuestionDisplay from './NumericalQuestionDisplay/NumericalQuestionDisplay';
import ShortAnswerQuestionDisplay from './ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
// import useCheckMobileScreen from '../../services/useCheckMobileScreen';
interface QuestionProps {
question: Question;
handleOnSubmitAnswer?: (answer: string | number | boolean) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
answer?: AnswerType;
}
const QuestionDisplay: React.FC<QuestionProps> = ({
question,
handleOnSubmitAnswer,
showAnswer,
answer,
}) => {
// const isMobile = useCheckMobileScreen();
// const imgWidth = useMemo(() => {
@ -30,37 +34,32 @@ const QuestionDisplay: React.FC<QuestionProps> = ({
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
passedAnswer={answer}
/>
);
break;
case 'MC':
questionTypeComponent = (
<MultipleChoiceQuestionDisplay
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
passedAnswer={answer}
/>
);
break;
case 'Numerical':
if (question.choices) {
if (!Array.isArray(question.choices)) {
questionTypeComponent = (
<NumericalQuestionDisplay
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
passedAnswer={answer}
/>
);
} else {
questionTypeComponent = ( // TODO fix NumericalQuestion (correctAnswers is borked)
<NumericalQuestionDisplay
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
/>
);
}
}
break;
case 'Short':
@ -69,6 +68,7 @@ const QuestionDisplay: React.FC<QuestionProps> = ({
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
passedAnswer={answer}
/>
);
break;

View file

@ -1,18 +1,29 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import '../questionStyle.css';
import { Button, TextField } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { ShortAnswerQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props {
question: ShortAnswerQuestion;
handleOnSubmitAnswer?: (answer: string) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
passedAnswer?: AnswerType;
}
const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = props;
const [answer, setAnswer] = useState<string>();
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || '');
useEffect(() => {
if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
console.log("Answer" , answer);
return (
<div className="question-wrapper">
@ -22,11 +33,18 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
{showAnswer ? (
<>
<div className="correct-answer-text mb-1">
<span>
<strong>La bonne réponse est: </strong>
{question.choices.map((choice) => (
<div key={choice.text} className="mb-1">
{choice.text}
</div>
))}
</span>
<span>
<strong>Votre réponse est: </strong>{answer}
</span>
</div>
{question.formattedGlobalFeedback && <div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
@ -54,7 +72,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer)
}
disabled={answer === undefined || answer === ''}
disabled={answer === null || answer === ''}
>
Répondre
</Button>

View file

@ -1,24 +1,48 @@
// TrueFalseQuestion.tsx
import React, { useState, useEffect } from 'react';
import React, { useState,useEffect } from 'react';
import '../questionStyle.css';
import { Button } from '@mui/material';
import { TrueFalseQuestion } from 'gift-pegjs';
import { FormattedTextTemplate } from 'src/components/GiftTemplate/templates/TextTypeTemplate';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props {
question: TrueFalseQuestion;
handleOnSubmitAnswer?: (answer: boolean) => void;
handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean;
passedAnswer?: AnswerType;
}
const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } =
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer} =
props;
const [answer, setAnswer] = useState<boolean | undefined>(undefined);
let disableButton = false;
if(handleOnSubmitAnswer === undefined){
disableButton = true;
}
useEffect(() => {
setAnswer(undefined);
}, [question]);
console.log("passedAnswer", answer);
if (passedAnswer === true || passedAnswer === false) {
setAnswer(passedAnswer);
} else {
setAnswer(undefined);
}
}, [passedAnswer, question.id]);
const [answer, setAnswer] = useState<boolean | undefined>(() => {
if (passedAnswer === true || passedAnswer === false) {
return passedAnswer;
}
return undefined;
});
const handleOnClickAnswer = (choice: boolean) => {
setAnswer(choice);
};
const selectedTrue = answer ? 'selected' : '';
const selectedFalse = answer !== undefined && !answer ? 'selected' : '';
@ -30,35 +54,38 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
<div className="choices-wrapper mb-1">
<Button
className="button-wrapper"
onClick={() => !showAnswer && setAnswer(true)}
onClick={() => !showAnswer && handleOnClickAnswer(true)}
fullWidth
disabled={disableButton}
>
{showAnswer? (<div> {(question.isTrue ? '✅' : '❌')}</div>):``}
<div className={`circle ${selectedTrue}`}>V</div>
<div className={`answer-text ${selectedTrue}`}>Vrai</div>
{showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
</Button>
<Button
className="button-wrapper"
onClick={() => !showAnswer && setAnswer(false)}
onClick={() => !showAnswer && handleOnClickAnswer(false)}
fullWidth
disabled={disableButton}
>
{showAnswer? (<div> {(!question.isTrue ? '✅' : '❌')}</div>):``}
<div className={`circle ${selectedFalse}`}>F</div>
<div className={`answer-text ${selectedFalse}`}>Faux</div>
{showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
</Button>
</div>
{/* selected TRUE, show True feedback if it exists */}
{showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
{/* selected FALSE, show False feedback if it exists */}
{showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
{question.formattedGlobalFeedback && showAnswer && (
<div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
@ -69,6 +96,7 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
variant="contained"
onClick={() =>
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
}
disabled={answer === undefined}
>

View file

@ -147,6 +147,25 @@
box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
}
.true-feedback {
position: relative;
padding: 0 1rem;
background-color: hsl(43, 100%, 94%);
color: hsl(43, 95%, 9%);
border: hsl(36, 84%, 93%) 1px solid;
border-radius: 6px;
box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
}
.false-feedback {
position: relative;
padding: 0 1rem;
background-color: hsl(43, 100%, 94%);
color: hsl(43, 95%, 9%);
border: hsl(36, 84%, 93%) 1px solid;
border-radius: 6px;
box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
}
.choices-wrapper {
width: 90%;
}

View file

@ -3,41 +3,47 @@ import React, { useEffect, useState } from 'react';
import QuestionComponent from '../QuestionsDisplay/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType';
// import { QuestionService } from '../../services/QuestionService';
import { Button } from '@mui/material';
//import QuestionNavigation from '../QuestionNavigation/QuestionNavigation';
//import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
import { Question } from 'gift-pegjs';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface StudentModeQuizProps {
questions: QuestionType[];
submitAnswer: (answer: string | number | boolean, idQuestion: number) => void;
answers: AnswerSubmissionToBackendType[];
submitAnswer: (_answer: AnswerType, _idQuestion: number) => void;
disconnectWebSocket: () => void;
}
const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
questions,
answers,
submitAnswer,
disconnectWebSocket
}) => {
//Ajouter type AnswerQuestionType en remplacement de QuestionType
const [questionInfos, setQuestion] = useState<QuestionType>(questions[0]);
const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false);
// const [imageUrl, setImageUrl] = useState('');
// const [answer, setAnswer] = useState<AnswerType>('');
// const previousQuestion = () => {
// setQuestion(questions[Number(questionInfos.question?.id) - 2]);
// setIsAnswerSubmitted(false);
// };
const previousQuestion = () => {
setQuestion(questions[Number(questionInfos.question?.id) - 2]);
};
useEffect(() => {}, [questionInfos]);
useEffect(() => {
const savedAnswer = answers[Number(questionInfos.question.id)-1]?.answer;
console.log(`StudentModeQuiz: useEffect: savedAnswer: ${savedAnswer}`);
setIsAnswerSubmitted(savedAnswer !== undefined);
}, [questionInfos.question, answers]);
const nextQuestion = () => {
setQuestion(questions[Number(questionInfos.question?.id)]);
setIsAnswerSubmitted(false);
};
const handleOnSubmitAnswer = (answer: string | number | boolean) => {
const handleOnSubmitAnswer = (answer: AnswerType) => {
const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion);
setIsAnswerSubmitted(true);
@ -46,11 +52,13 @@ const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
return (
<div className='room'>
<div className='roomHeader'>
<DisconnectButton
onReturn={disconnectWebSocket}
message={`Êtes-vous sûr de vouloir quitter?`} />
</div>
<div >
<b>Question {questionInfos.question.id}/{questions.length}</b>
</div>
<div className="overflow-auto">
<div className="question-component-container">
@ -66,31 +74,30 @@ const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question}
showAnswer={isAnswerSubmitted}
answer={answers[Number(questionInfos.question.id)-1]?.answer}
/>
<div className="center-h-align mt-1/2">
<div className="w-12">
{/* <Button
variant="outlined"
onClick={previousQuestion}
fullWidth
startIcon={<ChevronLeft />}
disabled={Number(questionInfos.question.id) <= 1}
>
Question précédente
</Button> */}
</div>
<div className="w-12">
<Button style={{ display: isAnswerSubmitted ? 'block' : 'none' }}
variant="outlined"
onClick={nextQuestion}
fullWidth
//endIcon={<ChevronRight />}
disabled={Number(questionInfos.question.id) >= questions.length}
>
Question suivante
</Button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: '1rem' }}>
<div>
<Button
variant="outlined"
onClick={previousQuestion}
fullWidth
disabled={Number(questionInfos.question.id) <= 1}
>
Question précédente
</Button>
</div>
<div>
<Button
variant="outlined"
onClick={nextQuestion}
fullWidth
disabled={Number(questionInfos.question.id) >= questions.length}
>
Question suivante
</Button>
</div>
</div>
</div>
</div>
</div>

View file

@ -9,23 +9,27 @@ import './studentWaitPage.css';
interface Props {
students: StudentType[];
launchQuiz: () => void;
setQuizMode: (mode: 'student' | 'teacher') => void;
setQuizMode: (_mode: 'student' | 'teacher') => void;
}
const StudentWaitPage: React.FC<Props> = ({ students, launchQuiz, setQuizMode }) => {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const handleLaunchClick = () => {
setIsDialogOpen(true);
};
return (
<div className="wait">
<div className='button'>
<Button
variant="contained"
onClick={() => setIsDialogOpen(true)}
onClick={handleLaunchClick}
startIcon={<PlayArrow />}
fullWidth
sx={{ fontWeight: 600, fontSize: 20 }}
>
Lancer
Lancer
</Button>
</div>

View file

@ -1,55 +1,59 @@
// TeacherModeQuiz.tsx
import React, { useEffect, useState } from 'react';
import QuestionComponent from '../QuestionsDisplay/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType';
// import { QuestionService } from '../../services/QuestionService';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material';
import { Question } from 'gift-pegjs';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
// import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface TeacherModeQuizProps {
questionInfos: QuestionType;
submitAnswer: (answer: string | number | boolean, idQuestion: number) => void;
answers: AnswerSubmissionToBackendType[];
submitAnswer: (_answer: AnswerType, _idQuestion: number) => void;
disconnectWebSocket: () => void;
}
const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
questionInfos,
answers,
submitAnswer,
disconnectWebSocket
}) => {
const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false);
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [feedbackMessage, setFeedbackMessage] = useState<React.ReactNode>('');
const renderFeedbackMessage = (answer: string) => {
const [answer, setAnswer] = useState<AnswerType>();
if(answer === 'true' || answer === 'false'){
return (<span>
<strong>Votre réponse est: </strong>{answer==="true" ? 'Vrai' : 'Faux'}
</span>)
}
else{
return (
<span>
<strong>Votre réponse est: </strong>{answer.toString()}
</span>
);}
};
// arrive here the first time after waiting for next question
useEffect(() => {
// Close the feedback dialog when the question changes
handleFeedbackDialogClose();
setIsAnswerSubmitted(false);
}, [questionInfos.question]);
console.log(`TeacherModeQuiz: useEffect: answers: ${JSON.stringify(answers)}`);
console.log(`TeacherModeQuiz: useEffect: questionInfos.question.id: ${questionInfos.question.id} answer: ${answer}`);
const oldAnswer = answers[Number(questionInfos.question.id) -1 ]?.answer;
console.log(`TeacherModeQuiz: useEffect: oldAnswer: ${oldAnswer}`);
setAnswer(oldAnswer);
setIsFeedbackDialogOpen(false);
}, [questionInfos.question, answers]);
const handleOnSubmitAnswer = (answer: string | number | boolean) => {
// handle showing the feedback dialog
useEffect(() => {
console.log(`TeacherModeQuiz: useEffect: answer: ${answer}`);
setIsAnswerSubmitted(answer !== undefined);
setIsFeedbackDialogOpen(answer !== undefined);
}, [answer]);
useEffect(() => {
console.log(`TeacherModeQuiz: useEffect: isAnswerSubmitted: ${isAnswerSubmitted}`);
setIsFeedbackDialogOpen(isAnswerSubmitted);
}, [isAnswerSubmitted]);
const handleOnSubmitAnswer = (answer: AnswerType) => {
const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion);
setFeedbackMessage(renderFeedbackMessage(answer.toString()));
// setAnswer(answer);
setIsFeedbackDialogOpen(true);
};
@ -60,21 +64,21 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
return (
<div className='room'>
<div className='roomHeader'>
<div className='roomHeader'>
<DisconnectButton
onReturn={disconnectWebSocket}
message={`Êtes-vous sûr de vouloir quitter?`} />
<div className='centerTitle'>
<div className='title'>Question {questionInfos.question.id}</div>
</div>
<div className='dumb'></div>
<DisconnectButton
onReturn={disconnectWebSocket}
message={`Êtes-vous sûr de vouloir quitter?`} />
<div className='centerTitle'>
<div className='title'>Question {questionInfos.question.id}</div>
</div>
{isAnswerSubmitted ? (
<div className='dumb'></div>
</div>
{isAnswerSubmitted ? (
<div>
En attente pour la prochaine question...
</div>
@ -82,6 +86,7 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
<QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question}
answer={answer}
/>
)}
@ -92,20 +97,21 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
<DialogTitle>Rétroaction</DialogTitle>
<DialogContent>
<div style={{
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
}}>
{feedbackMessage}
<div style={{ textAlign: 'left', fontWeight: 'bold', marginTop: '10px'}}
>Question : </div>
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
}}>
<div style={{ textAlign: 'left', fontWeight: 'bold', marginTop: '10px' }}
>Question : </div>
</div>
<QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question}
showAnswer={true}
<QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question}
showAnswer={true}
answer={answer}
/>
</DialogContent>
<DialogActions>
@ -114,7 +120,7 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
</Button>
</DialogActions>
</Dialog>
</div>
</div>
);
};

View file

@ -1,11 +1,11 @@
// constants.tsx
const ENV_VARIABLES = {
MODE: 'production',
VITE_BACKEND_URL: import.meta.env.VITE_BACKEND_URL || "",
VITE_BACKEND_SOCKET_URL: import.meta.env.VITE_BACKEND_SOCKET_URL || "",
MODE: process.env.MODE || "production",
VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "",
BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}`:''}` : process.env.VITE_BACKEND_URL || '',
FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}`:''}` : ''
};
console.log(`ENV_VARIABLES.VITE_BACKEND_URL=${ENV_VARIABLES.VITE_BACKEND_URL}`);
console.log(`ENV_VARIABLES.VITE_BACKEND_SOCKET_URL=${ENV_VARIABLES.VITE_BACKEND_SOCKET_URL}`);
export { ENV_VARIABLES };

View file

@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import './authDrawer.css';
import SimpleLogin from './providers/SimpleLogin/Login';
import authService from '../../services/AuthService';
import { ENV_VARIABLES } from '../../constants';
import ButtonAuth from './providers/OAuth-Oidc/ButtonAuth';
const AuthSelection: React.FC = () => {
const [authData, setAuthData] = useState<any>(null); // Stocke les données d'auth
const navigate = useNavigate();
ENV_VARIABLES.VITE_BACKEND_URL;
// Récupérer les données d'authentification depuis l'API
useEffect(() => {
const fetchData = async () => {
const data = await authService.fetchAuthData();
setAuthData(data);
};
fetchData();
}, []);
return (
<div className="auth-selection-page">
<h1>Connexion</h1>
{/* Formulaire de connexion Simple Login */}
{authData && authData['simpleauth'] && (
<div className="form-container">
<SimpleLogin />
</div>
)}
{/* Conteneur OAuth/OIDC */}
{authData && Object.keys(authData).some(key => authData[key].type === 'oidc' || authData[key].type === 'oauth') && (
<div className="auth-button-container">
{Object.keys(authData).map((providerKey) => {
const providerType = authData[providerKey].type;
if (providerType === 'oidc' || providerType === 'oauth') {
return (
<ButtonAuth
key={providerKey}
providerName={providerKey}
providerType={providerType}
/>
);
}
return null;
})}
</div>
)}
<div>
<button className="home-button-container" onClick={() => navigate('/')}>Retour à l'accueil</button>
</div>
</div>
);
};
export default AuthSelection;

View file

@ -0,0 +1,49 @@
.auth-selection-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
h1 {
margin-bottom: 20px;
}
.form-container {
border: 1px solid #ccc;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
width: 400px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
input {
margin: 5px 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 10px;
border: none;
border-radius: 4px;
background-color: #5271ff;
color: white;
cursor: pointer;
}
/* This hover was affecting the entire App */
/* button:hover {
background-color: #5271ff;
} */
.home-button-container {
background: none;
color: black;
}
.home-button-container:hover {
background: none;
color: black;
text-decoration: underline;
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import apiService from '../../../services/ApiService';
const OAuthCallback: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const user = searchParams.get('user');
const username = searchParams.get('username');
if (user) {
apiService.saveToken(user);
apiService.saveUsername(username || "");
navigate('/teacher/dashboard');
} else {
navigate('/login');
}
}, []);
return <div>Loading...</div>;
};
export default OAuthCallback;

View file

@ -0,0 +1,27 @@
import React from 'react';
import { ENV_VARIABLES } from '../../../../constants';
import '../css/buttonAuth.css';
interface ButtonAuthContainerProps {
providerName: string;
providerType: 'oauth' | 'oidc';
}
const handleAuthLogin = (provider: string) => {
window.location.href = `${ENV_VARIABLES.BACKEND_URL}/api/auth/${provider}`;
};
const ButtonAuth: React.FC<ButtonAuthContainerProps> = ({ providerName, providerType }) => {
return (
<>
<div className={`${providerName}-${providerType}-container button-container`}>
<h2>Se connecter avec {providerType.toUpperCase()}</h2>
<button key={providerName} className={`provider-btn ${providerType}-btn`} onClick={() => handleAuthLogin(providerName)}>
Continuer avec {providerName}
</button>
</div>
</>
);
};
export default ButtonAuth;

View file

@ -1,81 +1,90 @@
import { useNavigate } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from 'src/components/LoginContainer/LoginContainer'
import ApiService from '../../../services/ApiService';
const Register: React.FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => {
};
}, []);
const register = async () => {
const result = await ApiService.register(email, password);
if (typeof result === 'string') {
setConnectionError(result);
return;
}
navigate("/teacher/login")
};
return (
<LoginContainer
title='Créer un compte'
error={connectionError}>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<TextField
label="Mot de passe"
variant="outlined"
value={password}
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<LoadingButton
loading={isConnecting}
onClick={register}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!email || !password}
>
S&apos;inscrire
</LoadingButton>
</LoginContainer>
);
};
export default Register;
import { Link } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import '../css/simpleLogin.css';
import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer'
import ApiService from '../../../../services/ApiService';
const SimpleLogin: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => {
};
}, []);
const login = async () => {
console.log(`SimpleLogin: login: email: ${email}, password: ${password}`);
const result = await ApiService.login(email, password);
if (result !== true) {
setConnectionError(result);
return;
}
};
return (
<LoginContainer
title=''
error={connectionError}>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Nom d'utilisateur"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<TextField
label="Mot de passe"
variant="outlined"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Nom de la salle"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<LoadingButton
loading={isConnecting}
onClick={login}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!email || !password}
>
Login
</LoadingButton>
<div className="login-links">
{/* <Link to="/resetPassword"> */}
<del>Réinitialiser le mot de passe</del>
{/* </Link> */}
<Link to="/register">
Créer un compte
</Link>
</div>
</LoginContainer>
);
};
export default SimpleLogin;

View file

@ -0,0 +1,114 @@
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import { TextField, FormLabel, RadioGroup, FormControlLabel, Radio, Box } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer';
import ApiService from '../../../../services/ApiService';
const Register: React.FC = () => {
const [name, setName] = useState(''); // State for name
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [roles, setRoles] = useState<string[]>(['teacher']); // Set 'student' as the default role
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => { };
}, []);
const handleRoleChange = (role: string) => {
setRoles([role]); // Update the roles array to contain the selected role
};
const isValidEmail = (email: string) => {
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const register = async () => {
if (!isValidEmail(email)) {
setConnectionError("Veuillez entrer une adresse email valide.");
return;
}
const result = await ApiService.register(name, email, password, roles);
if (result !== true) {
setConnectionError(result);
return;
}
};
return (
<LoginContainer
title="Créer un compte"
error={connectionError}
>
<TextField
label="Nom"
variant="outlined"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
type="email"
error={!!connectionError && !isValidEmail(email)}
helperText={connectionError && !isValidEmail(email) ? "Adresse email invalide." : ""}
/>
<TextField
label="Mot de passe"
variant="outlined"
value={password}
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
<FormLabel component="legend" sx={{ marginRight: '1rem' }}>Choisir votre rôle</FormLabel>
<RadioGroup
row
aria-label="role"
name="role"
value={roles[0]}
onChange={(e) => handleRoleChange(e.target.value)}
>
<FormControlLabel value="student" control={<Radio />} label="Étudiant" />
<FormControlLabel value="teacher" control={<Radio />} label="Professeur" />
</RadioGroup>
</Box>
<LoadingButton
loading={isConnecting}
onClick={register}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!name || !email || !password}
>
S'inscrire
</LoadingButton>
</LoginContainer>
);
};
export default Register;

View file

@ -0,0 +1,68 @@
import { useNavigate } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer'
import ApiService from '../../../../services/ApiService';
const ResetPassword: React.FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => {
};
}, []);
const reset = async () => {
const result = await ApiService.resetPassword(email);
if (!result) {
setConnectionError(result.toString());
return;
}
navigate("/login")
};
return (
<LoginContainer
title='Récupération du compte'
error={connectionError}>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<LoadingButton
loading={isConnecting}
onClick={reset}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!email}
>
Réinitialiser le mot de passe
</LoadingButton>
</LoginContainer>
);
};
export default ResetPassword;

View file

@ -0,0 +1,23 @@
.provider-btn {
background-color: #ffffff;
border: 1px solid #ccc;
color: black;
margin: 4px 0 4px 0;
}
.provider-btn:hover {
background-color: #dbdbdb;
border: 1px solid #ccc;
color: black;
margin: 4px 0 4px 0;
}
.button-container {
border: 1px solid #ccc;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
width: 400px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}

View file

@ -0,0 +1,17 @@
.login-links {
padding-top: 10px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.login-links a {
padding: 4px;
color: #333;
text-decoration: none;
}
.login-links a:hover {
text-decoration: underline;
}

View file

@ -61,6 +61,25 @@
align-items: end;
}
.auth-selection-btn {
position: absolute;
top: 20px;
right: 20px;
}
.auth-btn {
padding: 10px 20px;
background-color: #5271ff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.auth-btn:hover {
background-color: #5976fa;
}
@media only screen and (max-width: 768px) {
.btn-container {
flex-direction: column;

View file

@ -15,14 +15,19 @@ import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from 'src/components/LoginContainer/LoginContainer'
import ApiService from '../../../services/ApiService'
export type AnswerType = string | number | boolean;
const JoinRoom: React.FC = () => {
const [roomName, setRoomName] = useState('');
const [username, setUsername] = useState('');
const [username, setUsername] = useState(ApiService.getUsername());
const [socket, setSocket] = useState<Socket | null>(null);
const [isWaitingForTeacher, setIsWaitingForTeacher] = useState(false);
const [question, setQuestion] = useState<QuestionType>();
const [quizMode, setQuizMode] = useState<string>();
const [questions, setQuestions] = useState<QuestionType[]>([]);
const [answers, setAnswers] = useState<AnswerSubmissionToBackendType[]>([]);
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting, setIsConnecting] = useState<boolean>(false);
@ -33,21 +38,38 @@ const JoinRoom: React.FC = () => {
};
}, []);
const handleCreateSocket = () => {
console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_SOCKET_URL}`);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
useEffect(() => {
// init the answers array, one for each question
setAnswers(Array(questions.length).fill({} as AnswerSubmissionToBackendType));
console.log(`JoinRoom: useEffect: questions: ${JSON.stringify(questions)}`);
}, [questions]);
socket.on('join-success', () => {
const handleCreateSocket = () => {
console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_URL}`);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
socket.on('join-success', (roomJoinedName) => {
setIsWaitingForTeacher(true);
setIsConnecting(false);
console.log('Successfully joined the room.');
console.log(`on(join-success): Successfully joined the room ${roomJoinedName}`);
});
socket.on('next-question', (question: QuestionType) => {
console.log('JoinRoom: on(next-question): Received next-question:', question);
setQuizMode('teacher');
setIsWaitingForTeacher(false);
setQuestion(question);
});
socket.on('launch-teacher-mode', (questions: QuestionType[]) => {
console.log('on(launch-teacher-mode): Received launch-teacher-mode:', questions);
setQuizMode('teacher');
setIsWaitingForTeacher(true);
setQuestions(questions);
// wait for next-question
});
socket.on('launch-student-mode', (questions: QuestionType[]) => {
console.log('on(launch-student-mode): Received launch-student-mode:', questions);
setQuizMode('student');
setIsWaitingForTeacher(false);
setQuestions(questions);
@ -78,6 +100,7 @@ const JoinRoom: React.FC = () => {
};
const disconnect = () => {
// localStorage.clear();
webSocketService.disconnect();
setSocket(null);
setQuestion(undefined);
@ -96,21 +119,37 @@ const JoinRoom: React.FC = () => {
}
if (username && roomName) {
console.log(`Tentative de rejoindre : ${roomName}, utilisateur : ${username}`);
webSocketService.joinRoom(roomName, username);
}
};
const handleOnSubmitAnswer = (answer: string | number | boolean, idQuestion: number) => {
const handleOnSubmitAnswer = (answer: AnswerType, idQuestion: number) => {
console.info(`JoinRoom: handleOnSubmitAnswer: answer: ${answer}, idQuestion: ${idQuestion}`);
const answerData: AnswerSubmissionToBackendType = {
roomName: roomName,
answer: answer,
username: username,
idQuestion: idQuestion
};
// localStorage.setItem(`Answer${idQuestion}`, JSON.stringify(answer));
setAnswers((prevAnswers) => {
console.log(`JoinRoom: handleOnSubmitAnswer: prevAnswers: ${JSON.stringify(prevAnswers)}`);
const newAnswers = [...prevAnswers]; // Create a copy of the previous answers array
newAnswers[idQuestion - 1] = answerData; // Update the specific answer
return newAnswers; // Return the new array
});
console.log(`JoinRoom: handleOnSubmitAnswer: answers: ${JSON.stringify(answers)}`);
webSocketService.submitAnswer(answerData);
};
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && username && roomName) {
handleSocket();
}
};
if (isWaitingForTeacher) {
return (
<div className='room'>
@ -139,6 +178,7 @@ const JoinRoom: React.FC = () => {
return (
<StudentModeQuiz
questions={questions}
answers={answers}
submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect}
/>
@ -148,6 +188,7 @@ const JoinRoom: React.FC = () => {
question && (
<TeacherModeQuiz
questionInfos={question}
answers={answers}
submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect}
/>
@ -160,14 +201,15 @@ const JoinRoom: React.FC = () => {
error={connectionError}>
<TextField
type="number"
label="Numéro de la salle"
type="text"
label="Nom de la salle"
variant="outlined"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="Numéro de la salle"
onChange={(e) => setRoomName(e.target.value.toUpperCase())}
placeholder="Nom de la salle"
sx={{ marginBottom: '1rem' }}
fullWidth
fullWidth={true}
onKeyDown={handleReturnKey}
/>
<TextField
@ -177,7 +219,8 @@ const JoinRoom: React.FC = () => {
onChange={(e) => setUsername(e.target.value)}
placeholder="Nom d'utilisateur"
sx={{ marginBottom: '1rem' }}
fullWidth
fullWidth={true}
onKeyDown={handleReturnKey}
/>
<LoadingButton

View file

@ -12,8 +12,13 @@ import ApiService from '../../../services/ApiService';
import './dashboard.css';
import ImportModal from 'src/components/ImportModal/ImportModal';
//import axios from 'axios';
import { RoomType } from 'src/Types/RoomType';
// import { useRooms } from '../ManageRoom/RoomContext';
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
IconButton,
InputAdornment,
@ -23,6 +28,7 @@ import {
NativeSelect,
CardContent,
styled,
DialogContentText
} from '@mui/material';
import {
Search,
@ -33,7 +39,6 @@ import {
FolderCopy,
ContentCopy,
Edit,
// DriveFileMove
} from '@mui/icons-material';
import ShareQuizModal from 'src/components/ShareQuizModal/ShareQuizModal';
@ -43,7 +48,7 @@ const CustomCard = styled(Card)({
position: 'relative',
margin: '40px 0 20px 0', // Add top margin to make space for the tab
borderRadius: '8px',
paddingTop: '20px', // Ensure content inside the card doesn't overlap with the tab
paddingTop: '20px' // Ensure content inside the card doesn't overlap with the tab
});
const Dashboard: React.FC = () => {
@ -53,6 +58,13 @@ const Dashboard: React.FC = () => {
const [showImportModal, setShowImportModal] = useState<boolean>(false);
const [folders, setFolders] = useState<FolderType[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string>(''); // Selected folder
const [rooms, setRooms] = useState<RoomType[]>([]);
const [openAddRoomDialog, setOpenAddRoomDialog] = useState(false);
const [newRoomTitle, setNewRoomTitle] = useState('');
// const { selectedRoom, selectRoom, createRoom } = useRooms();
const [selectedRoom, selectRoom] = useState<RoomType>(); // menu
const [errorMessage, setErrorMessage] = useState('');
const [showErrorDialog, setShowErrorDialog] = useState(false);
// Filter quizzes based on search term
// const filteredQuizzes = quizzes.filter(quiz =>
@ -65,7 +77,6 @@ const Dashboard: React.FC = () => {
);
}, [quizzes, searchTerm]);
// Group quizzes by folder
const quizzesByFolder = filteredQuizzes.reduce((acc, quiz) => {
if (!acc[quiz.folderName]) {
@ -77,20 +88,73 @@ const Dashboard: React.FC = () => {
useEffect(() => {
const fetchData = async () => {
if (!ApiService.isLoggedIn()) {
navigate("/teacher/login");
const isLoggedIn = await ApiService.isLoggedIn();
console.log(`Dashboard: isLoggedIn: ${isLoggedIn}`);
if (!isLoggedIn) {
navigate('/teacher/login');
return;
}
else {
const userFolders = await ApiService.getUserFolders();
} else {
const userRooms = await ApiService.getUserRooms();
setRooms(userRooms as RoomType[]);
const userFolders = await ApiService.getUserFolders();
setFolders(userFolders as FolderType[]);
}
};
fetchData();
}, []);
useEffect(() => {
if (rooms.length > 0 && !selectedRoom) {
selectRoom(rooms[rooms.length - 1]);
localStorage.setItem('selectedRoomId', rooms[rooms.length - 1]._id);
}
}, [rooms, selectedRoom]);
const handleSelectRoom = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value === 'add-room') {
setOpenAddRoomDialog(true);
} else {
selectRoomByName(event.target.value);
}
};
// Créer une salle
const createRoom = async (title: string) => {
// Créer la salle et récupérer l'objet complet
const newRoom = await ApiService.createRoom(title);
// Mettre à jour la liste des salles
const updatedRooms = await ApiService.getUserRooms();
setRooms(updatedRooms as RoomType[]);
// Sélectionner la nouvelle salle avec son ID
selectRoomByName(newRoom); // Utiliser l'ID de l'objet retourné
};
// Sélectionner une salle
const selectRoomByName = (roomId: string) => {
const room = rooms.find(r => r._id === roomId);
selectRoom(room);
localStorage.setItem('selectedRoomId', roomId);
};
const handleCreateRoom = async () => {
if (newRoomTitle.trim()) {
try {
await createRoom(newRoomTitle);
const userRooms = await ApiService.getUserRooms();
setRooms(userRooms as RoomType[]);
setOpenAddRoomDialog(false);
setNewRoomTitle('');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Erreur inconnue");
setShowErrorDialog(true);
}
}
};
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolderId(event.target.value);
@ -98,7 +162,6 @@ const Dashboard: React.FC = () => {
useEffect(() => {
const fetchQuizzesForFolder = async () => {
if (selectedFolderId == '') {
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
//console.log("show all quizzes")
@ -109,33 +172,29 @@ const Dashboard: React.FC = () => {
//console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
// add the folder.title to the QuizType if the folderQuizzes is an array
addFolderTitleToQuizzes(folderQuizzes, folder.title);
quizzes = quizzes.concat(folderQuizzes as QuizType[])
quizzes = quizzes.concat(folderQuizzes as QuizType[]);
}
setQuizzes(quizzes as QuizType[]);
}
else {
console.log("show some quizzes")
} else {
console.log('show some quizzes');
const folderQuizzes = await ApiService.getFolderContent(selectedFolderId);
console.log("folderQuizzes: ", folderQuizzes);
console.log('folderQuizzes: ', folderQuizzes);
// get the folder title from its id
const folderTitle = folders.find((folder) => folder._id === selectedFolderId)?.title || '';
const folderTitle =
folders.find((folder) => folder._id === selectedFolderId)?.title || '';
addFolderTitleToQuizzes(folderQuizzes, folderTitle);
setQuizzes(folderQuizzes as QuizType[]);
}
};
fetchQuizzesForFolder();
}, [selectedFolderId]);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
};
const handleRemoveQuiz = async (quiz: QuizType) => {
try {
const confirmed = window.confirm('Voulez-vous vraiment supprimer ce quiz?');
@ -149,30 +208,27 @@ const Dashboard: React.FC = () => {
}
};
const handleDuplicateQuiz = async (quiz: QuizType) => {
try {
await ApiService.duplicateQuiz(quiz._id);
if (selectedFolderId == '') {
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
console.log("show all quizzes")
console.log('show all quizzes');
let quizzes: QuizType[] = [];
for (const folder of folders as FolderType[]) {
const folderQuizzes = await ApiService.getFolderContent(folder._id);
console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
console.log('folder: ', folder.title, ' quiz: ', folderQuizzes);
addFolderTitleToQuizzes(folderQuizzes, folder.title);
quizzes = quizzes.concat(folderQuizzes as QuizType[]);
}
setQuizzes(quizzes as QuizType[]);
}
else {
console.log("show some quizzes")
} else {
console.log('show some quizzes');
const folderQuizzes = await ApiService.getFolderContent(selectedFolderId);
addFolderTitleToQuizzes(folderQuizzes, selectedFolderId);
setQuizzes(folderQuizzes as QuizType[]);
}
} catch (error) {
console.error('Error duplicating quiz:', error);
@ -181,7 +237,6 @@ const Dashboard: React.FC = () => {
const handleOnImport = () => {
setShowImportModal(true);
};
const validateQuiz = (questions: string[]) => {
@ -193,11 +248,10 @@ const Dashboard: React.FC = () => {
// Otherwise the quiz is invalid
for (let i = 0; i < questions.length; i++) {
try {
// questions[i] = QuestionService.ignoreImgTags(questions[i]);
const parsedItem = parse(questions[i]);
Template(parsedItem[0]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
console.error('Error parsing question:', error);
return false;
}
}
@ -206,9 +260,8 @@ const Dashboard: React.FC = () => {
};
const downloadTxtFile = async (quiz: QuizType) => {
try {
const selectedQuiz = await ApiService.getQuiz(quiz._id) as QuizType;
const selectedQuiz = (await ApiService.getQuiz(quiz._id)) as QuizType;
//quizzes.find((quiz) => quiz._id === quiz._id);
if (!selectedQuiz) {
@ -216,7 +269,7 @@ const Dashboard: React.FC = () => {
}
//const { title, content } = selectedQuiz;
let quizContent = "";
let quizContent = '';
const title = selectedQuiz.title;
console.log(selectedQuiz.content);
selectedQuiz.content.forEach((question, qIndex) => {
@ -231,7 +284,9 @@ const Dashboard: React.FC = () => {
});
if (!validateQuiz(selectedQuiz.content)) {
window.alert('Attention! Ce quiz contient des questions invalides selon le format GIFT.');
window.alert(
'Attention! Ce quiz contient des questions invalides selon le format GIFT.'
);
}
const blob = new Blob([quizContent], { type: 'text/plain' });
const a = document.createElement('a');
@ -239,8 +294,6 @@ const Dashboard: React.FC = () => {
a.download = `${filename}.gift`;
a.href = window.URL.createObjectURL(blob);
a.click();
} catch (error) {
console.error('Error exporting selected quiz:', error);
}
@ -252,18 +305,16 @@ const Dashboard: React.FC = () => {
if (folderTitle) {
await ApiService.createFolder(folderTitle);
const userFolders = await ApiService.getUserFolders();
setFolders(userFolders as FolderType[]);
setFolders(userFolders as FolderType[]);
const newlyCreatedFolder = userFolders[userFolders.length - 1] as FolderType;
setSelectedFolderId(newlyCreatedFolder._id);
}
} catch (error) {
console.error('Error creating folder:', error);
}
};
const handleDeleteFolder = async () => {
const handleDeleteFolder = async () => {
try {
const confirmed = window.confirm('Voulez-vous vraiment supprimer ce dossier?');
if (confirmed) {
@ -273,18 +324,17 @@ const Dashboard: React.FC = () => {
}
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
console.log("show all quizzes")
console.log('show all quizzes');
let quizzes: QuizType[] = [];
for (const folder of folders as FolderType[]) {
const folderQuizzes = await ApiService.getFolderContent(folder._id);
console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
quizzes = quizzes.concat(folderQuizzes as QuizType[])
console.log('folder: ', folder.title, ' quiz: ', folderQuizzes);
quizzes = quizzes.concat(folderQuizzes as QuizType[]);
}
setQuizzes(quizzes as QuizType[]);
setSelectedFolderId('');
} catch (error) {
console.error('Error deleting folder:', error);
}
@ -294,12 +344,15 @@ const Dashboard: React.FC = () => {
try {
// folderId: string GET THIS FROM CURRENT FOLDER
// currentTitle: string GET THIS FROM CURRENT FOLDER
const newTitle = prompt('Entrée le nouveau nom du fichier', folders.find((folder) => folder._id === selectedFolderId)?.title);
const newTitle = prompt(
'Entrée le nouveau nom du fichier',
folders.find((folder) => folder._id === selectedFolderId)?.title
);
if (newTitle) {
const renamedFolderId = selectedFolderId;
const result = await ApiService.renameFolder(selectedFolderId, newTitle);
if (result !== true ) {
if (result !== true) {
window.alert(`Une erreur est survenue: ${result}`);
return;
}
@ -331,26 +384,72 @@ const Dashboard: React.FC = () => {
};
const handleCreateQuiz = () => {
navigate("/teacher/editor-quiz/new");
}
navigate('/teacher/editor-quiz/new');
};
const handleEditQuiz = (quiz: QuizType) => {
navigate(`/teacher/editor-quiz/${quiz._id}`);
}
};
const handleLancerQuiz = (quiz: QuizType) => {
navigate(`/teacher/manage-room/${quiz._id}`);
}
if (selectedRoom) {
navigate(`/teacher/manage-room/${quiz._id}/${selectedRoom.title}`);
} else {
const randomSixDigit = Math.floor(100000 + Math.random() * 900000);
navigate(`/teacher/manage-room/${quiz._id}/${randomSixDigit}`);
}
};
return (
<div className="dashboard">
<div className="title">Tableau de bord</div>
<div className="roomSelection">
<label htmlFor="select-room">Sélectionner une salle: </label>
<select value={selectedRoom?._id || ''} onChange={(e) => handleSelectRoom(e)}>
<option value="" disabled>
-- Sélectionner une salle --
</option>
{rooms.map((room) => (
<option key={room._id} value={room._id}>
{room.title}
</option>
))}
<option value="add-room">Ajouter salle</option>
</select>
</div>
{selectedRoom && (
<div className="roomTitle">
<h2>Salle sélectionnée: {selectedRoom.title}</h2>
</div>
)}
<Dialog open={openAddRoomDialog} onClose={() => setOpenAddRoomDialog(false)}>
<DialogTitle>Créer une nouvelle salle</DialogTitle>
<DialogContent>
<TextField
value={newRoomTitle}
onChange={(e) => setNewRoomTitle(e.target.value.toUpperCase())}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddRoomDialog(false)}>Annuler</Button>
<Button onClick={handleCreateRoom}>Créer</Button>
</DialogActions>
</Dialog>
<Dialog open={showErrorDialog} onClose={() => setShowErrorDialog(false)}>
<DialogTitle>Erreur</DialogTitle>
<DialogContent>
<DialogContentText>{errorMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowErrorDialog(false)}>Fermer</Button>
</DialogActions>
</Dialog>
<div className="search-bar">
<TextField
onChange={handleSearch}
@ -369,8 +468,8 @@ const Dashboard: React.FC = () => {
/>
</div>
<div className='folder'>
<div className='select'>
<div className="folder">
<div className="select">
<NativeSelect
id="select-folder"
color="primary"
@ -380,48 +479,65 @@ const Dashboard: React.FC = () => {
<option value=""> Tous les dossiers... </option>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option>
<option value={folder._id} key={folder._id}>
{' '}
{folder.title}{' '}
</option>
))}
</NativeSelect>
</div>
<div className='actions'>
<div className="actions">
<Tooltip title="Ajouter dossier" placement="top">
<IconButton
color="primary"
onClick={handleCreateFolder}
> <Add /> </IconButton>
<IconButton color="primary" onClick={handleCreateFolder}>
{' '}
<Add />{' '}
</IconButton>
</Tooltip>
<Tooltip title="Renommer dossier" placement="top">
<div>
<IconButton
color="primary"
onClick={handleRenameFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <Edit /> </IconButton>
>
{' '}
<Edit />{' '}
</IconButton>
</div>
</Tooltip>
<Tooltip title="Dupliquer dossier" placement="top">
<div>
<IconButton
color="primary"
onClick={handleDuplicateFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <FolderCopy /> </IconButton>
>
{' '}
<FolderCopy />{' '}
</IconButton>
</div>
</Tooltip>
<Tooltip title="Supprimer dossier" placement="top">
<div>
<IconButton
aria-label="delete"
color="primary"
onClick={handleDeleteFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <DeleteOutline /> </IconButton>
>
{' '}
<DeleteOutline />{' '}
</IconButton>
</div>
</Tooltip>
</div>
</div>
<div className='ajouter'>
<div className="ajouter">
<Button
variant="outlined"
color="primary"
@ -439,47 +555,59 @@ const Dashboard: React.FC = () => {
>
Import
</Button>
</div>
<div className='list'>
{Object.keys(quizzesByFolder).map(folderName => (
<CustomCard key={folderName} className='folder-card'>
<div className='folder-tab'>{folderName}</div>
<div className="list">
{Object.keys(quizzesByFolder).map((folderName) => (
<CustomCard key={folderName} className="folder-card">
<div className="folder-tab">{folderName}</div>
<CardContent>
{quizzesByFolder[folderName].map((quiz: QuizType) => (
<div className='quiz' key={quiz._id}>
<div className='title'>
<div className="quiz" key={quiz._id}>
<div className="title">
<Tooltip title="Lancer quiz" placement="top">
<Button
variant="outlined"
onClick={() => handleLancerQuiz(quiz)}
disabled={!validateQuiz(quiz.content)}
>
{`${quiz.title} (${quiz.content.length} question${quiz.content.length > 1 ? 's' : ''})`}
</Button>
<div>
<Button
variant="outlined"
onClick={() => handleLancerQuiz(quiz)}
disabled={!validateQuiz(quiz.content)}
>
{`${quiz.title} (${quiz.content.length} question${
quiz.content.length > 1 ? 's' : ''
})`}
</Button>
</div>
</Tooltip>
</div>
<div className='actions'>
<div className="actions">
<Tooltip title="Télécharger quiz" placement="top">
<IconButton
color="primary"
onClick={() => downloadTxtFile(quiz)}
> <FileDownload /> </IconButton>
>
{' '}
<FileDownload />{' '}
</IconButton>
</Tooltip>
<Tooltip title="Modifier quiz" placement="top">
<IconButton
color="primary"
onClick={() => handleEditQuiz(quiz)}
> <Edit /> </IconButton>
>
{' '}
<Edit />{' '}
</IconButton>
</Tooltip>
<Tooltip title="Dupliquer quiz" placement="top">
<IconButton
color="primary"
onClick={() => handleDuplicateQuiz(quiz)}
> <ContentCopy /> </IconButton>
>
{' '}
<ContentCopy />{' '}
</IconButton>
</Tooltip>
<Tooltip title="Supprimer quiz" placement="top">
@ -487,7 +615,10 @@ const Dashboard: React.FC = () => {
aria-label="delete"
color="primary"
onClick={() => handleRemoveQuiz(quiz)}
> <DeleteOutline /> </IconButton>
>
{' '}
<DeleteOutline />{' '}
</IconButton>
</Tooltip>
<div className="quiz-share">
@ -506,7 +637,6 @@ const Dashboard: React.FC = () => {
handleOnImport={handleOnImport}
selectedFolder={selectedFolderId}
/>
</div>
);
};
@ -519,4 +649,3 @@ function addFolderTitleToQuizzes(folderQuizzes: string | QuizType[], folderName:
console.log(`quiz: ${quiz.title} folder: ${quiz.folderName}`);
});
}

View file

@ -194,9 +194,8 @@ const QuizForm: React.FC = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
window.alert(`Une erreur est survenue.\n${error}\nVeuillez réessayer plus tard.`)
}
};

View file

@ -1,6 +1,4 @@
import { useNavigate, Link } from 'react-router-dom';
// JoinRoom.tsx
import { Link, useNavigate } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import './Login.css';
@ -38,6 +36,11 @@ const Login: React.FC = () => {
};
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && email && password) {
login();
}
};
return (
<LoginContainer
@ -51,7 +54,8 @@ const Login: React.FC = () => {
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
fullWidth={true}
onKeyDown={handleReturnKey} // Add this line as well
/>
<TextField
@ -62,7 +66,8 @@ const Login: React.FC = () => {
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
sx={{ marginBottom: '1rem' }}
fullWidth
fullWidth={true}
onKeyDown={handleReturnKey} // Add this line as well
/>
<LoadingButton

View file

@ -1,71 +1,94 @@
// ManageRoom.tsx
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Socket } from 'socket.io-client';
import { ParsedGIFTQuestion, BaseQuestion, parse, Question } from 'gift-pegjs';
import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer } from "gift-pegjs/typeGuards";
import {
isSimpleNumericalAnswer,
isRangeNumericalAnswer,
isHighLowNumericalAnswer
} from 'gift-pegjs/typeGuards';
import LiveResultsComponent from 'src/components/LiveResults/LiveResults';
// import { QuestionService } from '../../../services/QuestionService';
import webSocketService, { AnswerReceptionFromBackendType } from '../../../services/WebsocketService';
import webSocketService, {
AnswerReceptionFromBackendType
} from '../../../services/WebsocketService';
import { QuizType } from '../../../Types/QuizType';
import GroupIcon from '@mui/icons-material/Group';
import './manageRoom.css';
import { ENV_VARIABLES } from 'src/constants';
import { StudentType, Answer } from '../../../Types/StudentType';
import { Button } from '@mui/material';
import LoadingCircle from 'src/components/LoadingCircle/LoadingCircle';
import { Refresh, Error } from '@mui/icons-material';
import StudentWaitPage from 'src/components/StudentWaitPage/StudentWaitPage';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
//import QuestionNavigation from 'src/components/QuestionNavigation/QuestionNavigation';
import QuestionDisplay from 'src/components/QuestionsDisplay/QuestionDisplay';
import ApiService from '../../../services/ApiService';
import { QuestionType } from 'src/Types/QuestionType';
import { Button } from '@mui/material';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
const ManageRoom: React.FC = () => {
const navigate = useNavigate();
const [roomName, setRoomName] = useState<string>('');
const [socket, setSocket] = useState<Socket | null>(null);
const [students, setStudents] = useState<StudentType[]>([]);
const quizId = useParams<{ id: string }>();
const { quizId = '', roomName = '' } = useParams<{ quizId: string, roomName: string }>();
const [quizQuestions, setQuizQuestions] = useState<QuestionType[] | undefined>();
const [quiz, setQuiz] = useState<QuizType | null>(null);
const [quizMode, setQuizMode] = useState<'teacher' | 'student'>('teacher');
const [connectingError, setConnectingError] = useState<string>('');
const [currentQuestion, setCurrentQuestion] = useState<QuestionType | undefined>(undefined);
const [quizStarted, setQuizStarted] = useState(false);
useEffect(() => {
if (quizId.id) {
const fetchquiz = async () => {
const [formattedRoomName, setFormattedRoomName] = useState("");
const quiz = await ApiService.getQuiz(quizId.id as string);
useEffect(() => {
const verifyLogin = async () => {
if (!ApiService.isLoggedIn()) {
navigate('/teacher/login');
return;
}
};
verifyLogin();
}, []);
useEffect(() => {
if (!roomName || !quizId) {
window.alert(
`Une erreur est survenue.\n La salle ou le quiz n'a pas été spécifié.\nVeuillez réessayer plus tard.`
);
console.error(`Room "${roomName}" or Quiz "${quizId}" not found.`);
navigate('/teacher/dashboard');
}
if (roomName && !socket) {
createWebSocketRoom();
}
return () => {
disconnectWebSocket();
};
}, [roomName, navigate]);
useEffect(() => {
if (quizId) {
const fetchQuiz = async () => {
const quiz = await ApiService.getQuiz(quizId);
if (!quiz) {
window.alert(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`)
console.error('Quiz not found for id:', quizId.id);
window.alert(
`Une erreur est survenue.\n Le quiz ${quizId} n'a pas été trouvé\nVeuillez réessayer plus tard`
);
console.error('Quiz not found for id:', quizId);
navigate('/teacher/dashboard');
return;
}
setQuiz(quiz as QuizType);
if (!socket) {
console.log(`no socket in ManageRoom, creating one.`);
createWebSocketRoom();
}
// return () => {
// webSocketService.disconnect();
// };
};
fetchquiz();
fetchQuiz();
} else {
window.alert(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`)
console.error('Quiz not found for id:', quizId.id);
window.alert(
`Une erreur est survenue.\n Le quiz ${quizId} n'a pas été trouvé\nVeuillez réessayer plus tard`
);
console.error('Quiz not found for id:', quizId);
navigate('/teacher/dashboard');
return;
}
@ -73,76 +96,73 @@ const ManageRoom: React.FC = () => {
const disconnectWebSocket = () => {
if (socket) {
webSocketService.endQuiz(roomName);
webSocketService.endQuiz(formattedRoomName);
webSocketService.disconnect();
setSocket(null);
setQuizQuestions(undefined);
setCurrentQuestion(undefined);
setStudents(new Array<StudentType>());
setRoomName('');
}
};
const createWebSocketRoom = () => {
console.log('Creating WebSocket room...');
setConnectingError('');
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
const roomNameUpper = roomName.toUpperCase();
setFormattedRoomName(roomNameUpper);
console.log(`Creating WebSocket room named ${roomNameUpper}`);
socket.on('connect', () => {
webSocketService.createRoom();
webSocketService.createRoom(roomNameUpper);
});
socket.on('connect_error', (error) => {
setConnectingError('Erreur lors de la connexion... Veuillez réessayer');
console.error('ManageRoom: WebSocket connection error:', error);
});
socket.on('create-success', (roomName: string) => {
setRoomName(roomName);
});
socket.on('create-failure', () => {
console.log('Error creating room.');
socket.on('create-success', (createdRoomName: string) => {
console.log(`Room created: ${createdRoomName}`);
});
socket.on('user-joined', (student: StudentType) => {
console.log(`Student joined: name = ${student.name}, id = ${student.id}`);
console.log(`Student joined: name = ${student.name}, id = ${student.id}, quizMode = ${quizMode}, quizStarted = ${quizStarted}`);
setStudents((prevStudents) => [...prevStudents, student]);
// only send nextQuestion if the quiz has started
if (!quizStarted) return;
if (quizMode === 'teacher') {
webSocketService.nextQuestion(roomName, currentQuestion);
webSocketService.nextQuestion(
{roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: Number(currentQuestion?.question.id) - 1,
isLaunch: false});
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
}
});
socket.on('join-failure', (message) => {
setConnectingError(message);
setSocket(null);
});
socket.on('user-disconnected', (userId: string) => {
console.log(`Student left: id = ${userId}`);
setStudents((prevUsers) => prevUsers.filter((user) => user.id !== userId));
});
setSocket(socket);
};
useEffect(() => {
// This is here to make sure the correct value is sent when user join
if (socket) {
console.log(`Listening for user-joined in room ${roomName}`);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
socket.on('user-joined', (_student: StudentType) => {
if (quizMode === 'teacher') {
webSocketService.nextQuestion(roomName, currentQuestion);
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
}
});
}
if (socket) {
// handle the case where user submits an answer
console.log(`Listening for submit-answer-room in room ${roomName}`);
console.log(`Listening for submit-answer-room in room ${formattedRoomName}`);
socket.on('submit-answer-room', (answerData: AnswerReceptionFromBackendType) => {
const { answer, idQuestion, idUser, username } = answerData;
console.log(`Received answer from ${username} for question ${idQuestion}: ${answer}`);
console.log(
`Received answer from ${username} for question ${idQuestion}: ${answer}`
);
if (!quizQuestions) {
console.log('Quiz questions not found (cannot update answers without them).');
return;
@ -150,7 +170,6 @@ const ManageRoom: React.FC = () => {
// Update the students state using the functional form of setStudents
setStudents((prevStudents) => {
// print the list of current student names
console.log('Current students:');
prevStudents.forEach((student) => {
console.log(student.name);
@ -161,17 +180,31 @@ const ManageRoom: React.FC = () => {
console.log(`Comparing ${student.id} to ${idUser}`);
if (student.id === idUser) {
foundStudent = true;
const existingAnswer = student.answers.find((ans) => ans.idQuestion === idQuestion);
const existingAnswer = student.answers.find(
(ans) => ans.idQuestion === idQuestion
);
let updatedAnswers: Answer[] = [];
if (existingAnswer) {
// Update the existing answer
updatedAnswers = student.answers.map((ans) => {
console.log(`Comparing ${ans.idQuestion} to ${idQuestion}`);
return (ans.idQuestion === idQuestion ? { ...ans, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) } : ans);
return ans.idQuestion === idQuestion
? {
...ans,
answer,
isCorrect: checkIfIsCorrect(
answer,
idQuestion,
quizQuestions!
)
}
: ans;
});
} else {
// Add a new answer
const newAnswer = { idQuestion, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) };
const newAnswer = {
idQuestion,
answer,
isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!)
};
updatedAnswers = [...student.answers, newAnswer];
}
return { ...student, answers: updatedAnswers };
@ -186,73 +219,8 @@ const ManageRoom: React.FC = () => {
});
setSocket(socket);
}
}, [socket, currentQuestion, quizQuestions]);
// useEffect(() => {
// if (socket) {
// const submitAnswerHandler = (answerData: answerSubmissionType) => {
// const { answer, idQuestion, username } = answerData;
// console.log(`Received answer from ${username} for question ${idQuestion}: ${answer}`);
// // print the list of current student names
// console.log('Current students:');
// students.forEach((student) => {
// console.log(student.name);
// });
// // Update the students state using the functional form of setStudents
// setStudents((prevStudents) => {
// let foundStudent = false;
// const updatedStudents = prevStudents.map((student) => {
// if (student.id === username) {
// foundStudent = true;
// const updatedAnswers = student.answers.map((ans) => {
// const newAnswer: Answer = { answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!), idQuestion };
// console.log(`Updating answer for ${student.name} for question ${idQuestion} to ${answer}`);
// return (ans.idQuestion === idQuestion ? { ...ans, newAnswer } : ans);
// }
// );
// return { ...student, answers: updatedAnswers };
// }
// return student;
// });
// if (!foundStudent) {
// console.log(`Student ${username} not found in the list of students in LiveResults`);
// }
// return updatedStudents;
// });
// // make a copy of the students array so we can update it
// // const updatedStudents = [...students];
// // const student = updatedStudents.find((student) => student.id === idUser);
// // if (!student) {
// // // this is a bad thing if an answer was submitted but the student isn't in the list
// // console.log(`Student ${idUser} not found in the list of students in LiveResults`);
// // return;
// // }
// // const isCorrect = checkIfIsCorrect(answer, idQuestion);
// // const newAnswer: Answer = { answer, isCorrect, idQuestion };
// // student.answers.push(newAnswer);
// // // print list of answers
// // console.log('Answers:');
// // student.answers.forEach((answer) => {
// // console.log(answer.answer);
// // });
// // setStudents(updatedStudents); // update the state
// };
// socket.on('submit-answer', submitAnswerHandler);
// return () => {
// socket.off('submit-answer');
// };
// }
// }, [socket]);
const nextQuestion = () => {
if (!quizQuestions || !currentQuestion || !quiz?.content) return;
@ -261,7 +229,10 @@ const ManageRoom: React.FC = () => {
if (nextQuestionIndex === undefined || nextQuestionIndex > quizQuestions.length - 1) return;
setCurrentQuestion(quizQuestions[nextQuestionIndex]);
webSocketService.nextQuestion(roomName, quizQuestions[nextQuestionIndex]);
webSocketService.nextQuestion({roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: nextQuestionIndex,
isLaunch: false});
};
const previousQuestion = () => {
@ -271,7 +242,7 @@ const ManageRoom: React.FC = () => {
if (prevQuestionIndex === undefined || prevQuestionIndex < 0) return;
setCurrentQuestion(quizQuestions[prevQuestionIndex]);
webSocketService.nextQuestion(roomName, quizQuestions[prevQuestionIndex]);
webSocketService.nextQuestion({roomName: formattedRoomName, questions: quizQuestions, questionIndex: prevQuestionIndex, isLaunch: false});
};
const initializeQuizQuestion = () => {
@ -299,7 +270,7 @@ const ManageRoom: React.FC = () => {
}
setCurrentQuestion(quizQuestions[0]);
webSocketService.nextQuestion(roomName, quizQuestions[0]);
webSocketService.nextQuestion({roomName: formattedRoomName, questions: quizQuestions, questionIndex: 0, isLaunch: true});
};
const launchStudentMode = () => {
@ -311,13 +282,15 @@ const ManageRoom: React.FC = () => {
return;
}
setQuizQuestions(quizQuestions);
webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
};
const launchQuiz = () => {
if (!socket || !roomName || !quiz?.content || quiz?.content.length === 0) {
if (!socket || !formattedRoomName || !quiz?.content || quiz?.content.length === 0) {
// TODO: This error happens when token expires! Need to handle it properly
console.log(`Error launching quiz. socket: ${socket}, roomName: ${roomName}, quiz: ${quiz}`);
console.log(
`Error launching quiz. socket: ${socket}, roomName: ${formattedRoomName}, quiz: ${quiz}`
);
setQuizStarted(true);
return;
@ -329,16 +302,14 @@ const ManageRoom: React.FC = () => {
case 'teacher':
setQuizStarted(true);
return launchTeacherMode();
}
};
const showSelectedQuestion = (questionIndex: number) => {
if (quiz?.content && quizQuestions) {
setCurrentQuestion(quizQuestions[questionIndex]);
if (quizMode === 'teacher') {
webSocketService.nextQuestion(roomName, quizQuestions[questionIndex]);
webSocketService.nextQuestion({roomName: formattedRoomName, questions: quizQuestions, questionIndex, isLaunch: false});
}
}
};
@ -348,7 +319,11 @@ const ManageRoom: React.FC = () => {
navigate('/teacher/dashboard');
};
function checkIfIsCorrect(answer: string | number | boolean, idQuestion: number, questions: QuestionType[]): boolean {
function checkIfIsCorrect(
answer: AnswerType,
idQuestion: number,
questions: QuestionType[]
): boolean {
const questionInfo = questions.find((q) =>
q.question.id ? q.question.id === idQuestion.toString() : false
) as QuestionType | undefined;
@ -371,8 +346,7 @@ const ManageRoom: React.FC = () => {
const answerNumber = parseFloat(answerText);
if (!isNaN(answerNumber)) {
return (
answerNumber <= choice.numberHigh &&
answerNumber >= choice.numberLow
answerNumber <= choice.numberHigh && answerNumber >= choice.numberLow
);
}
}
@ -402,8 +376,7 @@ const ManageRoom: React.FC = () => {
return false;
}
if (!roomName) {
if (!formattedRoomName) {
return (
<div className="center">
{!connectingError ? (
@ -426,47 +399,51 @@ const ManageRoom: React.FC = () => {
}
return (
<div className='room'>
<div className='roomHeader'>
<div className="room">
<h1>Salle : {formattedRoomName}</h1>
<div className="roomHeader">
<DisconnectButton
onReturn={handleReturn}
askConfirm
message={`Êtes-vous sûr de vouloir quitter?`} />
message={`Êtes-vous sûr de vouloir quitter?`}
/>
<div className='headerContent' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<div className='title'>Salle: {roomName}</div>
</div>
{quizStarted && (
<div className='userCount subtitle smallText' style={{ display: 'flex', alignItems: 'center' }}>
<div
className="headerContent"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}}
>
{(
<div
className="userCount subtitle smallText"
style={{ display: "flex", justifyContent: "flex-end" }}
>
<GroupIcon style={{ marginRight: '5px' }} />
{students.length}/60
</div>
)}
</div>
<div className='dumb'></div>
<div className="dumb"></div>
</div>
{/* the following breaks the css (if 'room' classes are nested) */}
<div className=''>
<div className="">
{quizQuestions ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="title center-h-align mb-2">{quiz?.title}</div>
{!isNaN(Number(currentQuestion?.question.id)) && (
<strong className='number of questions'>
Question {Number(currentQuestion?.question.id)}/{quizQuestions?.length}
<strong className="number of questions">
Question {Number(currentQuestion?.question.id)}/
{quizQuestions?.length}
</strong>
)}
{quizMode === 'teacher' && (
<div className="mb-1">
{/* <QuestionNavigation
currentQuestionId={Number(currentQuestion?.question.id)}
@ -475,16 +452,15 @@ const ManageRoom: React.FC = () => {
nextQuestion={nextQuestion}
/> */}
</div>
)}
<div className="mb-2 flex-column-wrapper">
<div className="preview-and-result-container">
{currentQuestion && (
<QuestionDisplay
showAnswer={false}
question={currentQuestion?.question as Question}
/>
)}
@ -495,42 +471,46 @@ const ManageRoom: React.FC = () => {
showSelectedQuestion={showSelectedQuestion}
students={students}
></LiveResultsComponent>
</div>
</div>
{quizMode === 'teacher' && (
<div className="questionNavigationButtons" style={{ display: 'flex', justifyContent: 'center' }}>
<div
className="questionNavigationButtons"
style={{ display: 'flex', justifyContent: 'center' }}
>
<div className="previousQuestionButton">
<Button onClick={previousQuestion}
<Button
onClick={previousQuestion}
variant="contained"
disabled={Number(currentQuestion?.question.id) <= 1}>
disabled={Number(currentQuestion?.question.id) <= 1}
>
Question précédente
</Button>
</div>
<div className="nextQuestionButton">
<Button onClick={nextQuestion}
<Button
onClick={nextQuestion}
variant="contained"
disabled={Number(currentQuestion?.question.id) >= quizQuestions.length}
disabled={
Number(currentQuestion?.question.id) >=
quizQuestions.length
}
>
Prochaine question
</Button>
</div>
</div>)}
</div>
)}
</div>
) : (
<StudentWaitPage
students={students}
launchQuiz={launchQuiz}
setQuizMode={setQuizMode}
/>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import ApiService from '../../../services/ApiService';
import { RoomType } from 'src/Types/RoomType';
import React from "react";
import { RoomContext } from './useRooms';
export const RoomProvider = ({ children }: { children: React.ReactNode }) => {
const [rooms, setRooms] = useState<RoomType[]>([]);
const [selectedRoom, setSelectedRoom] = useState<RoomType | null>(null);
useEffect(() => {
const loadRooms = async () => {
const userRooms = await ApiService.getUserRooms();
const roomsList = userRooms as RoomType[];
setRooms(roomsList);
const savedRoomId = localStorage.getItem('selectedRoomId');
if (savedRoomId) {
const savedRoom = roomsList.find(r => r._id === savedRoomId);
if (savedRoom) {
setSelectedRoom(savedRoom);
return;
}
}
if (roomsList.length > 0) {
setSelectedRoom(roomsList[0]);
localStorage.setItem('selectedRoomId', roomsList[0]._id);
}
};
loadRooms();
}, []);
// Sélectionner une salle
const selectRoom = (roomId: string) => {
const room = rooms.find(r => r._id === roomId) || null;
setSelectedRoom(room);
localStorage.setItem('selectedRoomId', roomId);
};
// Créer une salle
const createRoom = async (title: string) => {
// Créer la salle et récupérer l'objet complet
const newRoom = await ApiService.createRoom(title);
// Mettre à jour la liste des salles
const updatedRooms = await ApiService.getUserRooms();
setRooms(updatedRooms as RoomType[]);
// Sélectionner la nouvelle salle avec son ID
selectRoom(newRoom); // Utiliser l'ID de l'objet retourné
};
return (
<RoomContext.Provider value={{ rooms, selectedRoom, selectRoom, createRoom }}>
{children}
</RoomContext.Provider>
);
};

View file

@ -0,0 +1,20 @@
import { useContext } from 'react';
import { RoomType } from 'src/Types/RoomType';
import { createContext } from 'react';
//import { RoomContext } from './RoomContext';
type RoomContextType = {
rooms: RoomType[];
selectedRoom: RoomType | null;
selectRoom: (roomId: string) => void;
createRoom: (title: string) => Promise<void>;
};
export const RoomContext = createContext<RoomContextType | undefined>(undefined);
export const useRooms = () => {
const context = useContext(RoomContext);
if (!context) throw new Error('useRooms must be used within a RoomProvider');
return context;
};

View file

@ -33,7 +33,7 @@ const Share: React.FC = () => {
if (!ApiService.isLoggedIn()) {
window.alert(`Vous n'êtes pas connecté.\nVeuillez vous connecter et revenir à ce lien`);
navigate("/teacher/login");
navigate("/login");
return;
}

View file

@ -1,8 +1,10 @@
import axios, { AxiosError, AxiosResponse } from 'axios';
import { jwtDecode } from 'jwt-decode';
import { ENV_VARIABLES } from '../constants';
import { FolderType } from 'src/Types/FolderType';
import { QuizType } from 'src/Types/QuizType';
import { ENV_VARIABLES } from 'src/constants';
import { RoomType } from 'src/Types/RoomType';
type ApiResponse = boolean | string;
@ -34,7 +36,7 @@ class ApiService {
}
// Helpers
private saveToken(token: string): void {
public saveToken(token: string): void {
const now = new Date();
const object = {
@ -72,13 +74,87 @@ class ApiService {
return false;
}
console.log("ApiService: isLoggedIn: Token:", token);
// Update token expiry
this.saveToken(token);
return true;
}
public isLoggedInTeacher(): boolean {
const token = this.getToken();
if (token == null) {
return false;
}
try {
console.log("ApiService: isLoggedInTeacher: Token:", token);
const decodedToken = jwtDecode(token) as { roles: string[] };
/////// REMOVE BELOW
// automatically add teacher role if not present
if (!decodedToken.roles.includes('teacher')) {
decodedToken.roles.push('teacher');
}
////// REMOVE ABOVE
const userRoles = decodedToken.roles;
const requiredRole = 'teacher';
console.log("ApiService: isLoggedInTeacher: UserRoles:", userRoles);
if (!userRoles || !userRoles.includes(requiredRole)) {
return false;
}
// Update token expiry
this.saveToken(token);
return true;
} catch (error) {
console.error("Error decoding token:", error);
return false;
}
}
public saveUsername(username: string): void {
if (!username || username.length === 0) {
return;
}
const object = {
username: username
}
localStorage.setItem("username", JSON.stringify(object));
}
public getUsername(): string {
const objectStr = localStorage.getItem("username");
if (!objectStr) {
return "";
}
const object = JSON.parse(objectStr)
return object.username;
}
// Route to know if rooms need authentication to join
public async getRoomsRequireAuth(): Promise<any> {
const url: string = this.constructRequestUrl(`/auth/getRoomsRequireAuth`);
const result: AxiosResponse = await axios.get(url);
if (result.status == 200) {
return result.data.roomsRequireAuth;
}
return false;
}
public logout(): void {
localStorage.removeItem("username");
return localStorage.removeItem("jwt");
}
@ -88,73 +164,36 @@ class ApiService {
* @returns true if successful
* @returns A error string if unsuccessful,
*/
public async register(email: string, password: string): Promise<ApiResponse> {
public async register(name: string, email: string, password: string, roles: string[]): Promise<any> {
console.log(`ApiService.register: name: ${name}, email: ${email}, password: ${password}, roles: ${roles}`);
try {
if (!email || !password) {
throw new Error(`L'email et le mot de passe sont requis.`);
}
const url: string = this.constructRequestUrl(`/user/register`);
const url: string = this.constructRequestUrl(`/auth/simple-auth/register`);
const headers = this.constructRequestHeaders();
const body = { email, password };
const body = { name, email, password, roles };
const result: AxiosResponse = await axios.post(url, body, { headers: headers });
if (result.status !== 200) {
throw new Error(`L'enregistrement a échoué. Status: ${result.status}`);
console.log(result);
if (result.status == 200) {
//window.location.href = result.request.responseURL;
window.location.href = '/login';
}
return true;
} 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;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`
}
}
/**
* @returns true if successful
* @returns A error string if unsuccessful,
*/
public async login(email: string, password: string): Promise<ApiResponse> {
try {
if (!email || !password) {
throw new Error(`L'email et le mot de passe sont requis.`);
}
const url: string = this.constructRequestUrl(`/user/login`);
const headers = this.constructRequestHeaders();
const body = { email, password };
const result: AxiosResponse = await axios.post(url, body, { headers: headers });
if (result.status !== 200) {
else {
throw new Error(`La connexion a échoué. Status: ${result.status}`);
}
this.saveToken(result.data.token);
return true;
} catch (error) {
console.log("Error details: ", error);
console.log("axios.isAxiosError(error): ", axios.isAxiosError(error));
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
if (err.status === 401) {
return 'Email ou mot de passe incorrect.';
}
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
@ -163,6 +202,59 @@ class ApiService {
}
}
/**
* @returns true if successful
* @returns An error string if unsuccessful
*/
public async login(email: string, password: string): Promise<any> {
console.log(`login: email: ${email}, password: ${password}`);
try {
if (!email || !password) {
throw new Error("L'email et le mot de passe sont requis.");
}
const url: string = this.constructRequestUrl(`/auth/simple-auth/login`);
const headers = this.constructRequestHeaders();
const body = { email, password };
console.log(`login: POST ${url} body: ${JSON.stringify(body)}`);
const result: AxiosResponse = await axios.post(url, body, { headers: headers });
console.log(`login: result: ${result.status}, ${result.data}`);
// If login is successful, redirect the user
if (result.status === 200) {
//window.location.href = result.request.responseURL;
this.saveToken(result.data.token);
this.saveUsername(result.data.username);
window.location.href = '/teacher/dashboard';
return true;
} else {
throw new Error(`La connexion a échoué. Statut: ${result.status}`);
}
} catch (error) {
console.log("Error details:", error);
// Handle Axios-specific errors
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const responseData = err.response?.data as { message?: string } | undefined;
// If there is a message field in the response, print it
if (responseData?.message) {
console.log("Backend error message:", responseData.message);
return responseData.message;
}
// If no message is found, return a fallback message
return "Erreur serveur inconnue lors de la requête.";
}
// Handle other non-Axios errors
return "Une erreur inattendue s'est produite.";
}
}
/**
* @returns true if successful
* @returns A error string if unsuccessful,
@ -174,7 +266,7 @@ class ApiService {
throw new Error(`L'email est requis.`);
}
const url: string = this.constructRequestUrl(`/user/reset-password`);
const url: string = this.constructRequestUrl(`/auth/simple-auth/reset-password`);
const headers = this.constructRequestHeaders();
const body = { email };
@ -210,7 +302,7 @@ class ApiService {
throw new Error(`L'email, l'ancien et le nouveau mot de passe sont requis.`);
}
const url: string = this.constructRequestUrl(`/user/change-password`);
const url: string = this.constructRequestUrl(`/auth/simple-auth/change-password`);
const headers = this.constructRequestHeaders();
const body = { email, oldPassword, newPassword };
@ -840,6 +932,195 @@ class ApiService {
}
}
//ROOM routes
public async getUserRooms(): Promise<RoomType[] | string> {
try {
const url: string = this.constructRequestUrl(`/room/getUserRooms`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.get(url, { headers: headers });
if (result.status !== 200) {
throw new Error(`L'obtention des salles utilisateur a échoué. Status: ${result.status}`);
}
return result.data.data.map((room: RoomType) => ({ _id: room._id, title: room.title }));
} 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 url = err.config?.url || 'URL inconnue';
return data?.error || `Erreur serveur inconnue lors de la requête (${url}).`;
}
return `Une erreur inattendue s'est produite.`
}
}
public async getRoomContent(roomId: string): Promise<RoomType> {
try {
const url = this.constructRequestUrl(`/room/${roomId}`);
const headers = this.constructRequestHeaders();
const response = await axios.get<{ data: RoomType }>(url, { headers });
if (response.status !== 200) {
throw new Error(`Failed to get room: ${response.status}`);
}
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const serverError = error.response?.data?.error;
throw new Error(serverError || 'Erreur serveur inconnue');
}
throw new Error('Erreur réseau');
}
}
public async getRoomTitleByUserId(userId: string): Promise<string[] | string> {
try {
if (!userId) {
throw new Error(`L'ID utilisateur est requis.`);
}
const url: string = this.constructRequestUrl(`/room/getRoomTitleByUserId/${userId}`);
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.titles;
} 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;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async getRoomTitle(roomId: string): Promise<string | string> {
try {
if (!roomId) {
throw new Error(`L'ID de la salle est requis.`);
}
const url: string = this.constructRequestUrl(`/room/getRoomTitle/${roomId}`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.get(url, { headers });
if (result.status !== 200) {
throw new Error(`L'obtention du titre de la salle a échoué. Status: ${result.status}`);
}
return result.data.title;
} 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;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async createRoom(title: string): Promise<string> {
try {
if (!title) {
throw new Error("Le titre de la salle est requis.");
}
const url: string = this.constructRequestUrl(`/room/create`);
const headers = this.constructRequestHeaders();
const body = { title };
const result = await axios.post<{ roomId: string }>(url, body, { headers });
return `Salle créée avec succès. ID de la salle: ${result.data.roomId}`;
} catch (error) {
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const serverMessage = (err.response?.data as { message?: string })?.message
|| (err.response?.data as { error?: string })?.error
|| err.message;
if (err.response?.status === 409) {
throw new Error(serverMessage);
}
throw new Error(serverMessage || "Erreur serveur inconnue");
}
throw error;
}
}
public async deleteRoom(roomId: string): Promise<string | string> {
try {
if (!roomId) {
throw new Error(`L'ID de la salle est requis.`);
}
const url: string = this.constructRequestUrl(`/room/delete/${roomId}`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.delete(url, { headers });
if (result.status !== 200) {
throw new Error(`La suppression de la salle a échoué. Status: ${result.status}`);
}
return `Salle supprimée avec succès.`;
} 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;
return data?.error || 'Erreur serveur inconnue lors de la suppression de la salle.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async renameRoom(roomId: string, newTitle: string): Promise<string | string> {
try {
if (!roomId || !newTitle) {
throw new Error(`L'ID de la salle et le nouveau titre sont requis.`);
}
const url: string = this.constructRequestUrl(`/room/rename`);
const headers = this.constructRequestHeaders();
const body = { roomId, newTitle };
const result: AxiosResponse = await axios.put(url, body, { headers });
if (result.status !== 200) {
throw new Error(`La mise à jour du titre de la salle a échoué. Status: ${result.status}`);
}
return `Titre de la salle mis à jour avec succès.`;
} 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;
return data?.error || 'Erreur serveur inconnue lors de la mise à jour du titre.';
}
return `Une erreur inattendue s'est produite.`;
}
}
// Images Route
/**

View file

@ -0,0 +1,33 @@
import { ENV_VARIABLES } from '../constants';
class AuthService {
private BASE_URL: string;
constructor() {
this.BASE_URL = ENV_VARIABLES.VITE_BACKEND_URL;
}
private constructRequestUrl(endpoint: string): string {
return `${this.BASE_URL}/api${endpoint}`;
}
async fetchAuthData(){
try {
// console.info(`MODE: ${ENV_VARIABLES.MODE}`);
// if (ENV_VARIABLES.MODE === 'development') {
// return { authActive: true };
// }
const response = await fetch(this.constructRequestUrl('/auth/getActiveAuth'));
const data = await response.json();
console.log('Data:', JSON.stringify(data));
return data.authActive;
} catch (error) {
console.error('Erreur lors de la récupération des données d\'auth:', error);
}
};
}
const authService = new AuthService();
export default authService;

View file

@ -1,19 +1,20 @@
// WebSocketService.tsx
import { io, Socket } from 'socket.io-client';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import { QuestionType } from 'src/Types/QuestionType';
// Must (manually) sync these types to server/socket/socket.js
export type AnswerSubmissionToBackendType = {
roomName: string;
username: string;
answer: string | number | boolean;
answer: AnswerType;
idQuestion: number;
};
export type AnswerReceptionFromBackendType = {
idUser: string;
username: string;
answer: string | number | boolean;
answer: AnswerType;
idQuestion: number;
};
@ -46,19 +47,39 @@ class WebSocketService {
}
}
createRoom() {
createRoom(roomName: string) {
if (this.socket) {
this.socket.emit('create-room');
this.socket.emit('create-room', roomName);
}
}
nextQuestion(roomName: string, question: unknown) {
// deleteRoom(roomName: string) {
// console.log('WebsocketService: deleteRoom', roomName);
// if (this.socket) {
// console.log('WebsocketService: emit: delete-room', roomName);
// this.socket.emit('delete-room', roomName);
// }
// }
nextQuestion(args: {roomName: string, questions: QuestionType[] | undefined, questionIndex: number, isLaunch: boolean}) {
// deconstruct args
const { roomName, questions, questionIndex, isLaunch } = args;
console.log('WebsocketService: nextQuestion', roomName, questions, questionIndex, isLaunch);
if (!questions || !questions[questionIndex]) {
throw new Error('WebsocketService: nextQuestion: question is null');
}
if (this.socket) {
if (isLaunch) {
this.socket.emit('launch-teacher-mode', { roomName, questions });
}
const question = questions[questionIndex];
this.socket.emit('next-question', { roomName, question });
}
}
launchStudentModeQuiz(roomName: string, questions: unknown) {
console.log('WebsocketService: launchStudentModeQuiz', roomName, questions, this.socket);
if (this.socket) {
this.socket.emit('launch-student-mode', { roomName, questions });
}
@ -76,21 +97,9 @@ class WebSocketService {
}
}
submitAnswer(answerData: AnswerSubmissionToBackendType
// roomName: string,
// answer: string | number | boolean,
// username: string,
// idQuestion: string
) {
submitAnswer(answerData: AnswerSubmissionToBackendType) {
if (this.socket) {
this.socket?.emit('submit-answer',
// {
// answer: answer,
// roomName: roomName,
// username: username,
// idQuestion: idQuestion
// }
answerData
this.socket?.emit('submit-answer', answerData
);
}
}

96
docker-compose-auth.yaml Normal file
View file

@ -0,0 +1,96 @@
version: '3'
services:
frontend:
build:
context: ./client
dockerfile: Dockerfile
container_name: frontend
ports:
- "5173:5173"
restart: always
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: backend
ports:
- "3000:3000"
environment:
PORT: 3000
MONGO_URI: "mongodb://mongo:27017/evaluetonsavoir"
MONGO_DATABASE: evaluetonsavoir
EMAIL_SERVICE: gmail
SENDER_EMAIL: infoevaluetonsavoir@gmail.com
EMAIL_PSW: 'vvml wmfr dkzb vjzb'
JWT_SECRET: haQdgd2jp09qb897GeBZyJetC8ECSpbFJe
SESSION_Secret: 'lookMomImQuizzing'
SITE_URL: http://localhost
FRONTEND_PORT: 5173
USE_PORTS: false
AUTHENTICATED_ROOMS: false
volumes:
- ./server/auth_config.json:/usr/src/app/serveur/config/auth_config.json
depends_on:
- mongo
- keycloak
restart: always
# Ce conteneur sert de routeur pour assurer le bon fonctionnement de l'application
nginx:
image: fuhrmanator/evaluetonsavoir-routeur:latest
container_name: nginx
ports:
- "80:80"
depends_on:
- backend
- frontend
restart: always
# Ce conteneur est la base de données principale pour l'application
mongo:
image: mongo
container_name: mongo
ports:
- "27017:27017"
tty: true
volumes:
- mongodb_data:/data/db
restart: always
# Ce conteneur assure que l'application est à jour en allant chercher s'il y a des mises à jours à chaque heure
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- TZ=America/Montreal
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_DEBUG=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_SCHEDULE=0 0 5 * * * # At 5 am everyday
restart: always
keycloak:
container_name: keycloak
image: quay.io/keycloak/keycloak:latest
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin123
KC_HEALTH_ENABLED: 'true'
KC_FEATURES: preview
ports:
- "8080:8080"
volumes:
- ./oauth-tester/config.json:/opt/keycloak/data/import/realm-config.json
command:
- start-dev
- --import-realm
- --hostname-strict=false
volumes:
mongodb_data:
external: false

109
docker-compose-local.yaml Normal file
View file

@ -0,0 +1,109 @@
version: '3'
services:
frontend:
build:
context: ./client
dockerfile: Dockerfile
container_name: frontend
ports:
- "5173:5173"
restart: always
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: backend
ports:
- "3000:3000"
environment:
PORT: 3000
MONGO_URI: "mongodb://mongo:27017/evaluetonsavoir"
MONGO_DATABASE: evaluetonsavoir
EMAIL_SERVICE: gmail
SENDER_EMAIL: infoevaluetonsavoir@gmail.com
EMAIL_PSW: 'vvml wmfr dkzb vjzb'
JWT_SECRET: haQdgd2jp09qb897GeBZyJetC8ECSpbFJe
SESSION_Secret: 'lookMomImQuizzing'
SITE_URL: http://localhost
FRONTEND_PORT: 5173
USE_PORTS: false
AUTHENTICATED_ROOMS: false
volumes:
- ./server/auth_config.json:/usr/src/app/serveur/config/auth_config.json
depends_on:
- mongo
- keycloak
restart: always
# Ce conteneur sert de routeur pour assurer le bon fonctionnement de l'application
nginx:
image: fuhrmanator/evaluetonsavoir-routeur:latest
container_name: nginx
ports:
- "80:80"
depends_on:
- backend
- frontend
restart: always
# Ce conteneur est la base de données principale pour l'application
mongo:
image: mongo
container_name: mongo
ports:
- "27017:27017"
tty: true
volumes:
- mongodb_data:/data/db
restart: always
# Ce conteneur cherche des mises à jour à 5h du matin
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- TZ=America/Montreal
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_DEBUG=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_SCHEDULE=0 0 5 * * * # At 5 am everyday
restart: always
watchtower-once:
image: containrrr/watchtower
container_name: watchtower-once
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --run-once
environment:
- TZ=America/Montreal
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_DEBUG=true
- WATCHTOWER_INCLUDE_RESTARTING=true
restart: "no"
keycloak:
container_name: keycloak
image: quay.io/keycloak/keycloak:latest
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin123
KC_HEALTH_ENABLED: 'true'
KC_FEATURES: preview
ports:
- "8080:8080"
volumes:
- ./oauth-tester/config.json:/opt/keycloak/data/import/realm-config.json
command:
- start-dev
- --import-realm
- --hostname-strict=false
volumes:
mongodb_data:
external: false

View file

@ -1,3 +1,5 @@
version: '3'
services:
frontend:
@ -25,9 +27,17 @@ services:
SENDER_EMAIL: infoevaluetonsavoir@gmail.com
EMAIL_PSW: 'vvml wmfr dkzb vjzb'
JWT_SECRET: haQdgd2jp09qb897GeBZyJetC8ECSpbFJe
FRONTEND_URL: "http://localhost:5173"
SESSION_Secret: 'lookMomImQuizzing'
SITE_URL: http://localhost
OIDC_URL: https://evalsa.etsmtl.ca
FRONTEND_PORT: 5173
USE_PORTS: false
AUTHENTICATED_ROOMS: false
volumes:
- /opt/EvalueTonSavoir/auth_config.json:/usr/src/app/serveur/auth_config.json
depends_on:
- mongo
- keycloak
restart: always
# Ce conteneur sert de routeur pour assurer le bon fonctionnement de l'application
@ -79,6 +89,23 @@ services:
- WATCHTOWER_INCLUDE_RESTARTING=true
restart: "no"
keycloak:
container_name: keycloak
image: quay.io/keycloak/keycloak:latest
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin123
KC_HEALTH_ENABLED: 'true'
KC_FEATURES: preview
ports:
- "8080:8080"
volumes:
- /opt/EvalueTonSavoir/oauth-tester/config.json:/opt/keycloak/data/import/realm-config.json
command:
- start-dev
- --import-realm
- --hostname-strict=false
volumes:
mongodb_data:
external: false

96
oauth-tester/config.json Normal file
View file

@ -0,0 +1,96 @@
{
"id": "test-realm",
"realm": "EvalueTonSavoir",
"enabled": true,
"users": [
{
"username": "teacher",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "teacher123",
"temporary": false
}
],
"groups": ["teachers"]
},
{
"username": "student",
"enabled": true,
"credentials": [
{
"type": "password",
"value": "student123",
"temporary": false
}
],
"groups": ["students"]
}
],
"groups": [
{
"name": "teachers",
"attributes": {
"role": ["teacher"]
}
},
{
"name": "students",
"attributes": {
"role": ["student"]
}
}
],
"roles": {
"realm": [
{
"name": "teacher",
"description": "Teacher role"
},
{
"name": "student",
"description": "Student role"
}
]
},
"clients": [
{
"clientId": "evaluetonsavoir-client",
"enabled": true,
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"secret": "your-secret-key-123",
"redirectUris": ["http://localhost:5173/*","http://localhost/*"],
"webOrigins": ["http://localhost:5173","http://localhost/"]
}
],
"clientScopes": [
{
"name": "group",
"description": "Group scope for access control",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true"
},
"protocolMappers": [
{
"name": "group mapper",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "group",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "group",
"jsonType.label": "String"
}
}
]
}
],
"defaultDefaultClientScopes": ["group"]
}

View file

@ -14,4 +14,10 @@ EMAIL_PSW='vvml wmfr dkzb vjzb'
JWT_SECRET=TOKEN!
# Pour creer les liens images
FRONTEND_URL=http://localhost:5173
SESSION_Secret='session_secret'
SITE_URL=http://localhost
FRONTEND_PORT=5173
USE_PORTS=false
AUTHENTICATED_ROOMS=false

1
server/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
auth_config.json

View file

@ -1,8 +1,11 @@
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
super(message);
this.statusCode = statusCode || 500;
Object.setPrototypeOf(this, new.target.prototype);
Error.captureStackTrace(this, this.constructor);
}
}
}
module.exports = AppError;

View file

@ -0,0 +1,246 @@
const AuthConfig = require("../config/auth.js");
const AuthManager = require("../auth/auth-manager.js");
const mockConfig = {
auth: {
passportjs: [
{
provider1: {
type: "oauth",
OAUTH_AUTHORIZATION_URL: "https://www.testurl.com/oauth2/authorize",
OAUTH_TOKEN_URL: "https://www.testurl.com/oauth2/token",
OAUTH_USERINFO_URL: "https://www.testurl.com/oauth2/userinfo/",
OAUTH_CLIENT_ID: "your_oauth_client_id",
OAUTH_CLIENT_SECRET: "your_oauth_client_secret",
OAUTH_ADD_SCOPE: "scopes",
OAUTH_ROLE_TEACHER_VALUE: "teacher-claim-value",
OAUTH_ROLE_STUDENT_VALUE: "student-claim-value",
},
},
{
provider2: {
type: "oidc",
OIDC_CLIENT_ID: "your_oidc_client_id",
OIDC_CLIENT_SECRET: "your_oidc_client_secret",
OIDC_CONFIG_URL: "https://your-issuer.com",
OIDC_ADD_SCOPE: "groups",
OIDC_ROLE_TEACHER_VALUE: "teacher-claim-value",
OIDC_ROLE_STUDENT_VALUE: "student-claim-value",
},
},
],
"simpleauth": {
enabled: true,
name: "provider3",
SESSION_SECRET: "your_session_secret",
},
},
};
// Créez une instance de AuthConfig en utilisant la configuration mockée
describe(
"AuthConfig Class Tests",
() => {
let authConfigInstance;
// Initialisez l'instance avec la configuration mockée
beforeAll(() => {
authConfigInstance = new AuthConfig();
authConfigInstance.loadConfigTest(mockConfig); // On injecte la configuration mockée
});
it("devrait retourner la configuration PassportJS", () => {
const config = authConfigInstance.getPassportJSConfig();
expect(config).toHaveProperty("provider1");
expect(config).toHaveProperty("provider2");
});
it("devrait retourner la configuration Simple Login", () => {
const config = authConfigInstance.getSimpleLoginConfig();
expect(config).toHaveProperty("name", "provider3");
expect(config).toHaveProperty("SESSION_SECRET", "your_session_secret");
});
it("devrait retourner les providers OAuth", () => {
const oauthProviders = authConfigInstance.getOAuthProviders();
expect(Array.isArray(oauthProviders)).toBe(true);
expect(oauthProviders.length).toBe(1); // Il y a un seul provider OAuth
expect(oauthProviders[0]).toHaveProperty("provider1");
});
it("devrait valider la configuration des providers", () => {
expect(() => authConfigInstance.validateProvidersConfig()).not.toThrow();
});
it("devrait lever une erreur si une configuration manque", () => {
const invalidMockConfig = {
auth: {
passportjs: [
{
provider1: {
type: "oauth",
OAUTH_CLIENT_ID: "your_oauth_client_id", // Il manque des champs nécessaires
},
},
],
},
};
const instanceWithInvalidConfig = new AuthConfig();
instanceWithInvalidConfig.loadConfigTest(invalidMockConfig);
// Vérifiez que l'erreur est lancée avec les champs manquants corrects
expect(() => instanceWithInvalidConfig.validateProvidersConfig()).toThrow(
new Error(`Configuration invalide pour les providers suivants : [
{
"provider": "provider1",
"missingFields": [
"OAUTH_AUTHORIZATION_URL",
"OAUTH_TOKEN_URL",
"OAUTH_USERINFO_URL",
"OAUTH_CLIENT_SECRET",
"OAUTH_ROLE_TEACHER_VALUE",
"OAUTH_ROLE_STUDENT_VALUE"
]
}
]`)
);
});
},
describe("Auth Module Registration", () => {
let expressMock = jest.mock("express");
expressMock.use = () => {}
expressMock.get = () => {}
let authConfigInstance;
let authmanagerInstance;
// Initialisez l'instance avec la configuration mockée
beforeAll(() => {
authConfigInstance = new AuthConfig();
});
it("should load valid modules", () => {
const logSpy = jest.spyOn(global.console, "error");
const validModule = {
auth: {
passportjs: [
{
provider1: {
type: "oauth",
OAUTH_AUTHORIZATION_URL:
"https://www.testurl.com/oauth2/authorize",
OAUTH_TOKEN_URL: "https://www.testurl.com/oauth2/token",
OAUTH_USERINFO_URL: "https://www.testurl.com/oauth2/userinfo/",
OAUTH_CLIENT_ID: "your_oauth_client_id",
OAUTH_CLIENT_SECRET: "your_oauth_client_secret",
OAUTH_ADD_SCOPE: "scopes",
OAUTH_ROLE_TEACHER_VALUE: "teacher-claim-value",
OAUTH_ROLE_STUDENT_VALUE: "student-claim-value",
},
provider2: {
type: "oauth",
OAUTH_AUTHORIZATION_URL:
"https://www.testurl.com/oauth2/authorize",
OAUTH_TOKEN_URL: "https://www.testurl.com/oauth2/token",
OAUTH_USERINFO_URL: "https://www.testurl.com/oauth2/userinfo/",
OAUTH_CLIENT_ID: "your_oauth_client_id",
OAUTH_CLIENT_SECRET: "your_oauth_client_secret",
OAUTH_ADD_SCOPE: "scopes",
OAUTH_ROLE_TEACHER_VALUE: "teacher-claim-value",
OAUTH_ROLE_STUDENT_VALUE: "student-claim-value",
},
},
],
},
};
authConfigInstance.loadConfigTest(validModule); // On injecte la configuration mockée
// TODO new AuthManager(...) essaie d'établir une connexion MongoDB et ça laisse un "open handle" dans Jest
authmanagerInstance = new AuthManager(expressMock,authConfigInstance.config);
authmanagerInstance.getUserModel();
expect(logSpy).toHaveBeenCalledTimes(0);
logSpy.mockClear();
});
it("should not load invalid modules", () => {
const logSpy = jest.spyOn(global.console, "error");
const invalidModule = {
auth: {
ModuleX:{}
},
};
authConfigInstance.loadConfigTest(invalidModule); // On injecte la configuration mockée
authmanagerInstance = new AuthManager(expressMock,authConfigInstance.config);
expect(logSpy).toHaveBeenCalledTimes(1);
logSpy.mockClear();
});
it("should not load invalid provider from passport", () => {
const logSpy = jest.spyOn(global.console, "error");
const validModuleInvalidProvider = {
auth: {
passportjs: [
{
provider1: {
type: "x",
OAUTH_AUTHORIZATION_URL:
"https://www.testurl.com/oauth2/authorize",
OAUTH_TOKEN_URL: "https://www.testurl.com/oauth2/token",
OAUTH_USERINFO_URL: "https://www.testurl.com/oauth2/userinfo/",
OAUTH_CLIENT_ID: "your_oauth_client_id",
OAUTH_CLIENT_SECRET: "your_oauth_client_secret",
OAUTH_ADD_SCOPE: "scopes",
OAUTH_ROLE_TEACHER_VALUE: "teacher-claim-value",
OAUTH_ROLE_STUDENT_VALUE: "student-claim-value",
},
},
],
},
};
authConfigInstance.loadConfigTest(validModuleInvalidProvider); // On injecte la configuration mockée
authmanagerInstance = new AuthManager(expressMock,authConfigInstance.config);
expect(logSpy).toHaveBeenCalledTimes(4);
logSpy.mockClear();
});
})
);
describe(
"Rooms requiring authentication", () => {
// Making a copy of env variables to restore them later
const OLD_ENV_VARIABLES = process.env;
let authConfigInstance;
beforeAll(() => {
authConfigInstance = new AuthConfig();
});
// Clearing cache just in case
beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV_VARIABLES };
});
// Resetting the old values
afterAll(() => {
process.env = OLD_ENV_VARIABLES;
});
// tests cases as [environment variable value, expected value]
const cases = [["true", true], ["false", false], ["", false], ["other_than_true_false", false]];
test.each(cases)(
"Given %p as AUTHENTICATED_ROOMS environment variable value, returns %p",
(envVarArg, expectedResult) => {
process.env.AUTHENTICATED_ROOMS = envVarArg;
const isAuthRequired = authConfigInstance.getRoomsRequireAuth();
expect(isAuthRequired).toEqual(expectedResult);
}
);
}
)

View file

@ -0,0 +1,257 @@
jest.mock("../middleware/AppError", () => {
const actualAppError = jest.requireActual("../middleware/AppError");
return jest.fn().mockImplementation((message, statusCode) => {
return new actualAppError(message, statusCode);
});
});
const Rooms = require("../models/room");
const ObjectId = require("mongodb").ObjectId;
describe("Rooms", () => {
let rooms;
let db;
let collection;
beforeEach(() => {
jest.clearAllMocks();
collection = {
findOne: jest.fn(),
insertOne: jest.fn(),
find: jest.fn().mockReturnValue({ toArray: jest.fn() }),
deleteOne: jest.fn(),
deleteMany: jest.fn(),
updateOne: jest.fn(),
};
db = {
connect: jest.fn(),
getConnection: jest.fn().mockReturnThis(),
collection: jest.fn().mockReturnValue(collection),
};
rooms = new Rooms(db);
});
describe("create", () => {
it("should return insertedId on success", async () => {
collection.findOne.mockResolvedValue(null);
collection.insertOne.mockResolvedValue({ insertedId: "abc123" });
const result = await rooms.create("test", "userId");
expect(result).toBe("abc123");
});
it("should throw error when userId is missing", async () => {
await expect(rooms.create("test", undefined)).rejects.toThrowError(
new Error("Missing required parameter(s)", 400)
);
});
it("should throw conflict error when room exists", async () => {
collection.findOne.mockResolvedValue({
_id: "660c72b2f9b1d8b3a4c8e4d3b",
userId: "12345",
title: "existing room",
});
await expect(rooms.create("existing room", "12345")).rejects.toThrowError(
new Error("Room already exists", 409)
);
});
});
describe("getUserRooms", () => {
it("should return all rooms for a user", async () => {
const userId = "12345";
const userRooms = [
{ title: "room 1", userId },
{ title: "room 2", userId },
];
collection.find().toArray.mockResolvedValue(userRooms);
const result = await rooms.getUserRooms(userId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.find).toHaveBeenCalledWith({ userId });
expect(result).toEqual(userRooms);
});
});
describe("getOwner", () => {
it("should return the owner of a room", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
const userId = "12345";
collection.findOne.mockResolvedValue({ userId });
const result = await rooms.getOwner(roomId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.findOne).toHaveBeenCalledWith({
_id: new ObjectId(roomId),
});
expect(result).toBe(userId);
});
});
describe("delete", () => {
it("should delete a room and return true", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
collection.deleteOne.mockResolvedValue({ deletedCount: 1 });
const result = await rooms.delete(roomId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.deleteOne).toHaveBeenCalledWith({
_id: new ObjectId(roomId),
});
expect(result).toBe(true);
});
it("should return false if the room does not exist", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
collection.deleteOne.mockResolvedValue({ deletedCount: 0 });
const result = await rooms.delete(roomId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.deleteOne).toHaveBeenCalledWith({
_id: new ObjectId(roomId),
});
expect(result).toBe(false);
});
});
describe("rename", () => {
it("should rename a room and return true", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
const newTitle = "new room name";
const userId = "12345";
collection.updateOne.mockResolvedValue({ modifiedCount: 1 });
const result = await rooms.rename(roomId, userId, newTitle);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.updateOne).toHaveBeenCalledWith(
{ _id: new ObjectId(roomId), userId: userId },
{ $set: { title: newTitle } }
);
expect(result).toBe(true);
});
it("should return false if the room does not exist", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
const newTitle = "new room name";
const userId = "12345";
collection.updateOne.mockResolvedValue({ modifiedCount: 0 });
const result = await rooms.rename(roomId, userId, newTitle);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.updateOne).toHaveBeenCalledWith(
{ _id: new ObjectId(roomId), userId: userId },
{ $set: { title: newTitle } }
);
expect(result).toBe(false);
});
it("should throw an error if the new title is already in use", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
const newTitle = "existing room";
const userId = "12345";
collection.findOne.mockResolvedValue({ title: newTitle });
collection.updateOne.mockResolvedValue({ modifiedCount: 0 });
await expect(rooms.rename(roomId, userId, newTitle)).rejects.toThrow(
"Room with name 'existing room' already exists."
);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.findOne).toHaveBeenCalledWith({
userId: userId,
title: newTitle,
});
});
});
describe("roomExists", () => {
it("should return true if room exists", async () => {
const title = "TEST ROOM";
const userId = '66fc70bea1b9e87655cf17c9';
collection.findOne.mockResolvedValue({ title, userId });
const result = await rooms.roomExists(title, userId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.findOne).toHaveBeenCalledWith({ title: title.toUpperCase(), userId });
expect(result).toBe(true);
});
it("should return false if room does not exist", async () => {
const title = "NONEXISTENT ROOM";
const userId = '66fc70bea1b9e87655cf17c9';
collection.findOne.mockResolvedValue(null);
const result = await rooms.roomExists(title, userId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith('rooms');
expect(collection.findOne).toHaveBeenCalledWith({ title: title.toUpperCase(), userId });
expect(result).toBe(false);
});
});
describe("getRoomById", () => {
it("should return a room by ID", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
const room = {
_id: new ObjectId(roomId),
title: "test room",
};
collection.findOne.mockResolvedValue(room);
const result = await rooms.getRoomById(roomId);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.findOne).toHaveBeenCalledWith({
_id: new ObjectId(roomId),
});
expect(result).toEqual(room);
});
it("should throw an error if the room does not exist", async () => {
const roomId = "60c72b2f9b1d8b3a4c8e4d3b";
collection.findOne.mockResolvedValue(null);
await expect(rooms.getRoomById(roomId)).rejects.toThrowError(
new Error(`Room ${roomId} not found`, 404)
);
expect(db.connect).toHaveBeenCalled();
expect(db.collection).toHaveBeenCalledWith("rooms");
expect(collection.findOne).toHaveBeenCalledWith({
_id: new ObjectId(roomId),
});
});
});
});

View file

@ -60,45 +60,42 @@ describe("websocket server", () => {
});
test("should create a room", (done) => {
teacherSocket.emit("create-room", "room1");
teacherSocket.on("create-success", (roomName) => {
expect(roomName).toBe("ROOM1");
done();
});
teacherSocket.emit("create-room", "room1");
});
test("should not create a room if it already exists", (done) => {
teacherSocket.emit("create-room", "room1");
teacherSocket.on("create-failure", () => {
done();
});
teacherSocket.emit("create-room", "room1");
});
test("should join a room", (done) => {
studentSocket.emit("join-room", {
enteredRoomName: "ROOM1",
username: "student1",
});
studentSocket.on("join-success", () => {
studentSocket.on("join-success", (roomName) => {
expect(roomName).toBe("ROOM1");
done();
});
studentSocket.emit("join-room", {
enteredRoomName: "room1",
username: "student1",
});
});
test("should not join a room if it does not exist", (done) => {
studentSocket.on("join-failure", () => {
done();
});
studentSocket.emit("join-room", {
enteredRoomName: "ROOM2",
username: "student1",
});
studentSocket.on("join-failure", () => {
done();
});
});
test("should launch student mode", (done) => {
teacherSocket.emit("launch-student-mode", {
roomName: "ROOM1",
questions: [{ question: "question1" }, { question: "question2" }],
});
studentSocket.on("launch-student-mode", (questions) => {
expect(questions).toEqual([
{ question: "question1" },
@ -106,26 +103,36 @@ describe("websocket server", () => {
]);
done();
});
teacherSocket.emit("launch-student-mode", {
roomName: "ROOM1",
questions: [{ question: "question1" }, { question: "question2" }],
});
});
test("should launch teacher mode", (done) => {
studentSocket.on("launch-teacher-mode", (questions) => {
expect(questions).toEqual([
{ question: "question1" },
{ question: "question2" },
]);
done();
});
teacherSocket.emit("launch-teacher-mode", {
roomName: "ROOM1",
questions: [{ question: "question1" }, { question: "question2" }],
});
});
test("should send next question", (done) => {
teacherSocket.emit("next-question", {
roomName: "ROOM1",
question: { question: "question2" },
});
studentSocket.on("next-question", (question) => {
expect(question).toEqual({ question: "question2" });
studentSocket.on("next-question", ( question ) => {
expect(question).toBe("question2");
done();
});
teacherSocket.emit("next-question", { roomName: "ROOM1", question: 'question2'},
);
});
test("should send answer", (done) => {
studentSocket.emit("submit-answer", {
roomName: "ROOM1",
username: "student1",
answer: "answer1",
idQuestion: 1,
});
teacherSocket.on("submit-answer-room", (answer) => {
expect(answer).toEqual({
idUser: studentSocket.id,
@ -135,32 +142,38 @@ describe("websocket server", () => {
});
done();
});
studentSocket.emit("submit-answer", {
roomName: "ROOM1",
username: "student1",
answer: "answer1",
idQuestion: 1,
});
});
test("should not join a room if no room name is provided", (done) => {
studentSocket.on("join-failure", () => {
done();
});
studentSocket.emit("join-room", {
enteredRoomName: "",
username: "student1",
});
studentSocket.on("join-failure", () => {
done();
});
});
test("should not join a room if the username is not provided", (done) => {
studentSocket.emit("join-room", { enteredRoomName: "ROOM2", username: "" });
studentSocket.on("join-failure", () => {
done();
});
studentSocket.emit("join-room", { enteredRoomName: "ROOM2", username: "" });
});
test("should end quiz", (done) => {
teacherSocket.emit("end-quiz", {
roomName: "ROOM1",
});
studentSocket.on("end-quiz", () => {
done();
});
teacherSocket.emit("end-quiz", {
roomName: "ROOM1",
});
});
test("should disconnect", (done) => {

View file

@ -32,7 +32,7 @@ describe('Users', () => {
users = new Users(db, foldersModel);
});
it('should register a new user', async () => {
it.skip('should register a new user', async () => {
db.collection().findOne.mockResolvedValue(null); // No user found
db.collection().insertOne.mockResolvedValue({ insertedId: new ObjectId() });
bcrypt.hash.mockResolvedValue('hashedPassword');

View file

@ -12,6 +12,8 @@ const db = require('./config/db.js');
// instantiate the models
const quiz = require('./models/quiz.js');
const quizModel = new quiz(db);
const room = require('./models/room.js');
const roomModel = new room(db);
const folders = require('./models/folders.js');
const foldersModel = new folders(db, quizModel);
const users = require('./models/users.js');
@ -22,6 +24,8 @@ const imageModel = new images(db);
// instantiate the controllers
const usersController = require('./controllers/users.js');
const usersControllerInstance = new usersController(userModel);
const roomsController = require('./controllers/room.js');
const roomsControllerInstance = new roomsController(roomModel);
const foldersController = require('./controllers/folders.js');
const foldersControllerInstance = new foldersController(foldersModel);
const quizController = require('./controllers/quiz.js');
@ -31,25 +35,35 @@ const imagesControllerInstance = new imagesController(imageModel);
// export the controllers
module.exports.users = usersControllerInstance;
module.exports.rooms = roomsControllerInstance;
module.exports.folders = foldersControllerInstance;
module.exports.quizzes = quizControllerInstance;
module.exports.images = imagesControllerInstance;
//import routers (instantiate controllers as side effect)
const userRouter = require('./routers/users.js');
const roomRouter = require('./routers/room.js');
const folderRouter = require('./routers/folders.js');
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 authRouter = require('./routers/auth.js')
// Setup environment
dotenv.config();
const isDev = process.env.NODE_ENV === 'development';
// Setup urls from configs
const use_ports = (process.env['USE_PORTS'] || 'false').toLowerCase() == "true"
process.env['FRONTEND_URL'] = process.env['SITE_URL'] + (use_ports ? `:${process.env['FRONTEND_PORT']}`:"")
process.env['BACKEND_URL'] = process.env['SITE_URL'] + (use_ports ? `:${process.env['PORT']}`:"")
const errorHandler = require("./middleware/errorHandler.js");
// Start app
const app = express();
const cors = require("cors");
const bodyParser = require('body-parser');
let isDev = process.env.NODE_ENV === 'development';
const configureServer = (httpServer, isDev) => {
console.log(`Configuring server with isDev: ${isDev}`);
@ -81,10 +95,22 @@ app.use(bodyParser.json());
// Create routes
app.use('/api/user', userRouter);
app.use('/api/room', roomRouter);
app.use('/api/folder', folderRouter);
app.use('/api/quiz', quizRouter);
app.use('/api/image', imagesRouter);
app.use('/api/auth', authRouter);
// Add Auths methods
const session = require('express-session');
app.use(session({
secret: process.env['SESSION_Secret'],
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production' }
}));
let _authManager = new AuthManager(app,null,userModel);
app.use(errorHandler);
// Start server

View file

@ -0,0 +1,89 @@
const fs = require('fs');
const AuthConfig = require('../config/auth.js');
const jwt = require('../middleware/jwtToken.js');
const emailer = require('../config/email.js');
const { MISSING_REQUIRED_PARAMETER } = require('../constants/errorCodes.js');
const AppError = require('../middleware/AppError.js');
class AuthManager{
constructor(expressapp,configs=null,userModel){
console.log(`AuthManager: constructor: configs: ${JSON.stringify(configs)}`);
console.log(`AuthManager: constructor: userModel: ${JSON.stringify(userModel)}`);
this.modules = []
this.app = expressapp
this.configs = configs ?? (new AuthConfig()).loadConfig()
this.addModules()
this.simpleregister = userModel;
this.registerAuths()
console.log(`AuthManager: constructor: this.configs: ${JSON.stringify(this.configs)}`);
}
getUserModel(){
return this.simpleregister;
}
async addModules(){
for(const module in this.configs.auth){
this.addModule(module)
}
}
async addModule(name){
const modulePath = `${process.cwd()}/auth/modules/${name}.js`
if(fs.existsSync(modulePath)){
const Module = require(modulePath);
this.modules.push(new Module(this,this.configs.auth[name]));
console.info(`Module d'authentification '${name}' ajouté`)
} else{
console.error(`Le module d'authentification ${name} n'as pas été chargé car il est introuvable`);
}
}
async registerAuths(){
console.log(``);
for(const module of this.modules){
try{
module.registerAuth(this.app, this.simpleregister);
} catch(error){
console.error(`L'enregistrement du module ${module} a échoué.`);
console.error(`Error: ${error} `);
}
}
}
// eslint-disable-next-line no-unused-vars
async login(userInfo,req,res,next){ //passport and simpleauth use next
const tokenToSave = jwt.create(userInfo.email, userInfo._id, userInfo.roles);
res.redirect(`/auth/callback?user=${tokenToSave}&username=${userInfo.name}`);
console.info(`L'utilisateur '${userInfo.name}' vient de se connecter`)
}
// eslint-disable-next-line no-unused-vars
async loginSimple(email,pswd,req,res,next){ //passport and simpleauth use next
console.log(`auth-manager: loginSimple: email: ${email}, pswd: ${pswd}`);
const userInfo = await this.simpleregister.login(email, pswd);
console.log(`auth-manager: loginSimple: userInfo: ${JSON.stringify(userInfo)}`);
userInfo.roles = ['teacher']; // hard coded role
const tokenToSave = jwt.create(userInfo.email, userInfo._id, userInfo.roles);
console.log(`auth-manager: loginSimple: tokenToSave: ${tokenToSave}`);
//res.redirect(`/auth/callback?user=${tokenToSave}&username=${userInfo.email}`);
res.status(200).json({token: tokenToSave});
console.info(`L'utilisateur '${userInfo.email}' vient de se connecter`)
}
async register(userInfos, sendEmail=false){
console.log(userInfos);
if (!userInfos.email || !userInfos.password) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const user = await this.simpleregister.register(userInfos);
if(sendEmail){
emailer.registerConfirmation(user.email);
}
return user
}
}
module.exports = AuthManager;

View file

@ -0,0 +1,99 @@
var OAuth2Strategy = require('passport-oauth2')
var authUserAssoc = require('../../../models/authUserAssociation')
var { hasNestedValue } = require('../../../utils')
class PassportOAuth {
constructor(passportjs, auth_name) {
this.passportjs = passportjs
this.auth_name = auth_name
}
register(app, passport, endpoint, name, provider, userModel) {
const cb_url = `${process.env['OIDC_URL']}${endpoint}/${name}/callback`
const self = this
const scope = 'openid profile email offline_access' + ` ${provider.OAUTH_ADD_SCOPE}`;
passport.use(name, new OAuth2Strategy({
authorizationURL: provider.OAUTH_AUTHORIZATION_URL,
tokenURL: provider.OAUTH_TOKEN_URL,
clientID: provider.OAUTH_CLIENT_ID,
clientSecret: provider.OAUTH_CLIENT_SECRET,
callbackURL: cb_url,
passReqToCallback: true
},
async function (req, accessToken, refreshToken, params, profile, done) {
try {
const userInfoResponse = await fetch(provider.OAUTH_USERINFO_URL, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const userInfo = await userInfoResponse.json();
let received_user = {
auth_id: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
roles: []
};
if (hasNestedValue(userInfo, provider.OAUTH_ROLE_TEACHER_VALUE)) received_user.roles.push('teacher')
if (hasNestedValue(userInfo, provider.OAUTH_ROLE_STUDENT_VALUE)) received_user.roles.push('student')
const user_association = await authUserAssoc.find_user_association(self.auth_name, received_user.auth_id)
let user_account
if (user_association) {
user_account = await userModel.getById(user_association.user_id)
}
else {
let user_id = await userModel.getId(received_user.email)
if (user_id) {
user_account = await userModel.getById(user_id);
} else {
received_user.password = userModel.generatePassword()
user_account = await self.passportjs.register(received_user)
}
await authUserAssoc.link(self.auth_name, received_user.auth_id, user_account._id)
}
user_account.name = received_user.name
user_account.roles = received_user.roles
await userModel.editUser(user_account)
// Store the tokens in the session
req.session.oauth2Tokens = {
accessToken: accessToken,
refreshToken: refreshToken,
expiresIn: params.expires_in
};
return done(null, user_account);
} catch (error) {
console.error(`Erreur dans la strategie OAuth2 '${name}' : ${error}`);
return done(error);
}
}));
app.get(`${endpoint}/${name}`, (req, res, next) => {
passport.authenticate(name, {
scope: scope,
prompt: 'consent'
})(req, res, next);
});
app.get(`${endpoint}/${name}/callback`,
(req, res, next) => {
passport.authenticate(name, { failureRedirect: '/login' })(req, res, next);
},
(req, res) => {
if (req.user) {
self.passportjs.authenticate(req.user, req, res)
} else {
res.status(401).json({ error: "L'authentification a échoué" });
}
}
);
console.info(`Ajout de la connexion : ${name}(OAuth)`)
}
}
module.exports = PassportOAuth;

View file

@ -0,0 +1,127 @@
var OpenIDConnectStrategy = require('passport-openidconnect');
var authUserAssoc = require('../../../models/authUserAssociation');
var { hasNestedValue } = require('../../../utils');
const { MISSING_OIDC_PARAMETER } = require('../../../constants/errorCodes.js');
const AppError = require('../../../middleware/AppError.js');
const expressListEndpoints = require('express-list-endpoints');
class PassportOpenIDConnect {
constructor(passportjs, auth_name) {
this.passportjs = passportjs
this.auth_name = auth_name
}
async getConfigFromConfigURL(name, provider) {
try {
const config = await fetch(provider.OIDC_CONFIG_URL)
return await config.json()
} catch (error) {
console.error(`Error: ${error} `);
throw new AppError(MISSING_OIDC_PARAMETER(name));
}
}
async register(app, passport, endpoint, name, provider, userModel) {
console.log(`oidc.js: register: endpoint: ${endpoint}`);
console.log(`oidc.js: register: name: ${name}`);
console.log(`oidc.js: register: provider: ${JSON.stringify(provider)}`);
console.log(`oidc.js: register: userModel: ${JSON.stringify(userModel)}`);
const config = await this.getConfigFromConfigURL(name, provider);
const cb_url = `${process.env['OIDC_URL']}${endpoint}/${name}/callback`;
const self = this;
const scope = 'openid profile email ' + `${provider.OIDC_ADD_SCOPE}`;
console.log(`oidc.js: register: config: ${JSON.stringify(config)}`);
console.log(`oidc.js: register: cb_url: ${cb_url}`);
console.log(`oidc.js: register: scope: ${scope}`);
passport.use(name, new OpenIDConnectStrategy({
issuer: config.issuer,
authorizationURL: config.authorization_endpoint,
tokenURL: config.token_endpoint,
userInfoURL: config.userinfo_endpoint,
clientID: provider.OIDC_CLIENT_ID,
clientSecret: provider.OIDC_CLIENT_SECRET,
callbackURL: cb_url,
passReqToCallback: true,
scope: scope,
},
// patch pour la librairie permet d'obtenir les groupes, PR en cours mais "morte" : https://github.com/jaredhanson/passport-openidconnect/pull/101
async function (req, issuer, profile, times, tok, done) {
console.log(`oidc.js: register: issuer: ${JSON.stringify(issuer)}`);
console.log(`oidc.js: register: profile: ${JSON.stringify(profile)}`);
try {
const received_user = {
auth_id: profile.id,
email: profile.emails[0].value.toLowerCase(),
name: profile.displayName,
roles: []
};
if (hasNestedValue(profile, provider.OIDC_ROLE_TEACHER_VALUE)) received_user.roles.push('teacher')
if (hasNestedValue(profile, provider.OIDC_ROLE_STUDENT_VALUE)) received_user.roles.push('student')
console.log(`oidc.js: register: received_user: ${JSON.stringify(received_user)}`);
const user_association = await authUserAssoc.find_user_association(self.auth_name, received_user.auth_id);
console.log(`oidc.js: register: user_association: ${JSON.stringify(user_association)}`);
let user_account
if (user_association) {
console.log(`oidc.js: register: user_association: ${JSON.stringify(user_association)}`);
user_account = await userModel.getById(user_association.user_id)
console.log(`oidc.js: register: user_account: ${JSON.stringify(user_account)}`);
}
else {
console.log(`oidc.js: register: user_association: ${JSON.stringify(user_association)}`);
let user_id = await userModel.getId(received_user.email)
console.log(`oidc.js: register: user_id: ${JSON.stringify(user_id)}`);
if (user_id) {
user_account = await userModel.getById(user_id);
console.log(`oidc.js: register: user_account: ${JSON.stringify(user_account)}`);
} else {
received_user.password = userModel.generatePassword()
user_account = await self.passportjs.register(received_user)
console.log(`oidc.js: register: user_account: ${JSON.stringify(user_account)}`);
}
console.log(`oidc.js: register: authUserAssoc.ling.`);
await authUserAssoc.link(self.auth_name, received_user.auth_id, user_account._id)
}
user_account.name = received_user.name
user_account.roles = received_user.roles
console.log(`oidc.js: register: calling userModel.editUser: ${JSON.stringify(user_account)}`);
await userModel.editUser(user_account);
return done(null, user_account);
} catch (error) {
console.error(`Error: ${error} `);
}
}));
app.get(`${endpoint}/${name}`, (req, res, next) => {
passport.authenticate(name, {
scope: scope,
prompt: 'consent'
})(req, res, next);
});
app.get(`${endpoint}/${name}/callback`,
(req, res, next) => {
passport.authenticate(name, { failureRedirect: '/login' })(req, res, next);
},
(req, res) => {
if (req.user) {
self.passportjs.authenticate(req.user, req, res)
} else {
res.status(401).json({ error: "L'authentification a échoué" });
}
}
);
console.info(`Ajout de la connexion : ${name}(OIDC)`);
console.log(expressListEndpoints(app));
}
}
module.exports = PassportOpenIDConnect;

View file

@ -0,0 +1,66 @@
var passport = require('passport')
var authprovider = require('../../models/authProvider')
class PassportJs{
constructor(authmanager,settings){
this.authmanager = authmanager
this.registeredProviders = {}
this.providers = settings
this.endpoint = "/api/auth"
}
async registerAuth(expressapp, userModel){
console.log(`PassportJs: registerAuth: userModel: ${JSON.stringify(userModel)}`);
expressapp.use(passport.initialize());
expressapp.use(passport.session());
for(const p of this.providers){
for(const [name,provider] of Object.entries(p)){
const auth_id = `passportjs_${provider.type}_${name}`
if(!(provider.type in this.registeredProviders)){
this.registerProvider(provider.type,auth_id)
}
try{
this.registeredProviders[provider.type].register(expressapp,passport,this.endpoint,name,provider,userModel)
authprovider.create(auth_id)
} catch(error){
console.error(`La connexion ${name} de type ${provider.type} n'as pu être chargé.`);
console.error(`Error: ${error} `);
}
}
}
passport.serializeUser(function(user, done) {
done(null, user);
});
passport.deserializeUser(function(user, done) {
done(null, user);
});
}
async registerProvider(providerType,auth_id){
try{
const providerPath = `${process.cwd()}/auth/modules/passport-providers/${providerType}.js`
const Provider = require(providerPath);
this.registeredProviders[providerType]= new Provider(this,auth_id)
console.info(`Le type de connexion '${providerType}' a été ajouté dans passportjs.`)
} catch(error){
console.error(`Le type de connexion '${providerType}' n'as pas pu être chargé dans passportjs.`);
console.error(`Error: ${error} `);
}
}
register(userInfos){
return this.authmanager.register(userInfos)
}
authenticate(userInfo,req,res,next){
return this.authmanager.login(userInfo,req,res,next)
}
}
module.exports = PassportJs;

View file

@ -0,0 +1,130 @@
const jwt = require('../../middleware/jwtToken.js');
const emailer = require('../../config/email.js');
const model = require('../../models/users.js');
const AppError = require('../../middleware/AppError.js');
const { MISSING_REQUIRED_PARAMETER, LOGIN_CREDENTIALS_ERROR, GENERATE_PASSWORD_ERROR, UPDATE_PASSWORD_ERROR } = require('../../constants/errorCodes');
const { name } = require('../../models/authProvider.js');
class SimpleAuth {
constructor(authmanager, settings) {
this.authmanager = authmanager
this.providers = settings
this.endpoint = "/api/auth/simple-auth"
}
async registerAuth(expressapp) {
try {
expressapp.post(`${this.endpoint}/register`, (req, res) => this.register(this, req, res));
expressapp.post(`${this.endpoint}/login`, (req, res, next) => this.authenticate(this, req, res, next));
expressapp.post(`${this.endpoint}/reset-password`, (req, res, next) => this.resetPassword(this, req, res, next));
expressapp.post(`${this.endpoint}/change-password`, jwt.authenticate, (req, res, next) => this.changePassword(this, req, res, next));
} catch (error) {
console.error(`La connexion ${name} de type ${this.providers.type} n'as pu être chargé.`);
console.error(`Error: ${error} `);
}
}
async register(self, req, res) {
console.log(`simpleauth.js.register: ${JSON.stringify(req.body)}`);
try {
let userInfos = {
name: req.body.name,
email: req.body.email,
password: req.body.password,
roles: req.body.roles
};
let user = await self.authmanager.register(userInfos, true);
if (user) {
return res.status(200).json({
message: 'User created'
});
}
}
catch (error) {
return res.status(400).json({
message: error.message
});
}
}
async authenticate(self, req, res, next) {
console.log(`authenticate: ${JSON.stringify(req.body)}`);
try {
const { email, password } = req.body;
if (!email || !password) {
const error = new Error("Email or password is missing");
error.statusCode = 400;
throw error;
}
await self.authmanager.loginSimple(email, password, req, res, next);
// return res.status(200).json({ message: 'Logged in' });
} catch (error) {
const statusCode = error.statusCode || 500;
const message = error.message || "An internal server error occurred";
console.error(error);
return res.status(statusCode).json({ message });
}
}
async resetPassword(self, req, res, next) {
try {
const { email } = req.body;
if (!email) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const newPassword = await model.resetPassword(email);
if (!newPassword) {
throw new AppError(GENERATE_PASSWORD_ERROR);
}
emailer.newPasswordConfirmation(email, newPassword);
return res.status(200).json({
message: 'Nouveau mot de passe envoyé par courriel.'
});
}
catch (error) {
return next(error);
}
}
async changePassword(self, req, res, next) {
try {
const { email, oldPassword, newPassword } = req.body;
if (!email || !oldPassword || !newPassword) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
// verify creds first
const user = await model.login(email, oldPassword);
if (!user) {
throw new AppError(LOGIN_CREDENTIALS_ERROR);
}
const password = await model.changePassword(email, newPassword)
if (!password) {
throw new AppError(UPDATE_PASSWORD_ERROR);
}
return res.status(200).json({
message: 'Mot de passe changé avec succès.'
});
}
catch (error) {
return next(error);
}
}
}
module.exports = SimpleAuth;

View file

@ -0,0 +1,9 @@
{
"auth": {
"simpleauth": {
"enabled": true,
"name": "provider3",
"SESSION_SECRET": "your_session_secret"
}
}
}

View file

@ -0,0 +1,26 @@
{
"auth": {
"passportjs":
[
{
"oidc_local": {
"type": "oidc",
"OIDC_CONFIG_URL": "http://localhost:8080/realms/EvalueTonSavoir/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "evaluetonsavoir-client",
"OIDC_CLIENT_SECRET": "your-secret-key-123",
"OIDC_ADD_SCOPE": "group",
"OIDC_ROLE_TEACHER_VALUE": "teachers",
"OIDC_ROLE_STUDENT_VALUE": "students"
}
}
],
"simpleauth": {
"enabled": true,
"name": "provider3",
"SESSION_SECRET": "your_session_secret"
},
"Module X":{
}
}
}

197
server/config/auth.js Normal file
View file

@ -0,0 +1,197 @@
const fs = require('fs');
const path = require('path');
// set pathAuthConfig to './auth_config-development.json' if NODE_ENV is set to development
const pathAuthConfig = process.env.NODE_ENV === 'development' ? './auth_config-development.json' : './auth_config.json';
const configPath = path.join(process.cwd(), pathAuthConfig);
class AuthConfig {
config = null;
// Méthode pour lire le fichier de configuration JSON
loadConfig() {
try {
console.info(`Chargement du fichier de configuration: ${configPath}`);
const configData = fs.readFileSync(configPath, 'utf-8');
this.config = JSON.parse(configData);
} catch (error) {
console.error("Erreur lors de la lecture du fichier de configuration. Ne pas se fier si vous n'avez pas mis de fichier de configuration.");
this.config = {};
throw error;
}
return this.config
}
// Méthode pour load le fichier de test
loadConfigTest(mockConfig) {
this.config = mockConfig;
}
// Méthode pour retourner la configuration des fournisseurs PassportJS
getPassportJSConfig() {
if (this.config && this.config.auth && this.config.auth.passportjs) {
const passportConfig = {};
this.config.auth.passportjs.forEach(provider => {
const providerName = Object.keys(provider)[0];
passportConfig[providerName] = provider[providerName];
});
return passportConfig;
} else {
return { error: "Aucune configuration PassportJS disponible." };
}
}
// Méthode pour retourner la configuration de Simple Login
getSimpleLoginConfig() {
if (this.config && this.config.auth && this.config.auth["simpleauth"]) {
return this.config.auth["simpleauth"];
} else {
return { error: "Aucune configuration Simple Login disponible." };
}
}
// Méthode pour retourner tous les providers de type OAuth
getOAuthProviders() {
if (this.config && this.config.auth && this.config.auth.passportjs) {
const oauthProviders = this.config.auth.passportjs.filter(provider => {
const providerName = Object.keys(provider)[0];
return provider[providerName].type === 'oauth';
});
if (oauthProviders.length > 0) {
return oauthProviders;
} else {
return { error: "Aucun fournisseur OAuth disponible." };
}
} else {
return { error: "Aucune configuration PassportJS disponible." };
}
}
// Méthode pour retourner tous les providers de type OIDC
getOIDCProviders() {
if (this.config && this.config.auth && this.config.auth.passportjs) {
const oidcProviders = this.config.auth.passportjs.filter(provider => {
const providerName = Object.keys(provider)[0];
return provider[providerName].type === 'oidc';
});
if (oidcProviders.length > 0) {
return oidcProviders;
} else {
return { error: "Aucun fournisseur OIDC disponible." };
}
} else {
return { error: "Aucune configuration PassportJS disponible." };
}
}
// Méthode pour vérifier si tous les providers ont les variables nécessaires
validateProvidersConfig() {
const requiredOAuthFields = [
'OAUTH_AUTHORIZATION_URL', 'OAUTH_TOKEN_URL','OAUTH_USERINFO_URL', 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET', 'OAUTH_ROLE_TEACHER_VALUE', 'OAUTH_ROLE_STUDENT_VALUE'
];
const requiredOIDCFields = [
'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_CONFIG_URL', 'OIDC_ROLE_TEACHER_VALUE', 'OIDC_ROLE_STUDENT_VALUE','OIDC_ADD_SCOPE'
];
const missingFieldsReport = [];
if (this.config && this.config.auth && this.config.auth.passportjs) {
this.config.auth.passportjs.forEach(provider => {
const providerName = Object.keys(provider)[0];
const providerConfig = provider[providerName];
let missingFields = [];
// Vérification des providers de type OAuth
if (providerConfig.type === 'oauth') {
missingFields = requiredOAuthFields.filter(field => !(field in providerConfig));
}
// Vérification des providers de type OIDC
else if (providerConfig.type === 'oidc') {
missingFields = requiredOIDCFields.filter(field => !(field in providerConfig));
}
// Si des champs manquent, on les ajoute au rapport
if (missingFields.length > 0) {
missingFieldsReport.push({
provider: providerName,
missingFields: missingFields
});
}
});
// Si des champs manquent, lever une exception
if (missingFieldsReport.length > 0) {
throw new Error(`Configuration invalide pour les providers suivants : ${JSON.stringify(missingFieldsReport, null, 2)}`);
} else {
console.log("Configuration auth_config.json: Tous les providers ont les variables nécessaires.")
return { success: "Tous les providers ont les variables nécessaires." };
}
} else {
throw new Error("Aucune configuration PassportJS disponible.");
}
}
// Méthode pour retourner la configuration des fournisseurs PassportJS pour le frontend
getActiveAuth() {
console.log(`getActiveAuth: this.config: ${JSON.stringify(this.config)}`);
console.log(`getActiveAuth: this.config.auth: ${JSON.stringify(this.config.auth)}`);
if (this.config && this.config.auth) {
const passportConfig = {};
// Gestion des providers PassportJS
if (this.config.auth.passportjs) {
this.config.auth.passportjs.forEach(provider => {
const providerName = Object.keys(provider)[0];
const providerConfig = provider[providerName];
passportConfig[providerName] = {};
if (providerConfig.type === 'oauth') {
passportConfig[providerName] = {
type: providerConfig.type
};
} else if (providerConfig.type === 'oidc') {
passportConfig[providerName] = {
type: providerConfig.type,
};
}
});
}
// Gestion du Simple Login
if (this.config.auth["simpleauth"] && this.config.auth["simpleauth"].enabled) {
passportConfig['simpleauth'] = {
type: "simpleauth",
name: this.config.auth["simpleauth"].name
};
}
return passportConfig;
} else {
return { error: "Aucune configuration d'authentification disponible." };
}
}
// Check if students must be authenticated to join a room
getRoomsRequireAuth() {
const roomRequireAuth = process.env.AUTHENTICATED_ROOMS;
if (!roomRequireAuth || roomRequireAuth !== "true") {
return false;
}
return true;
}
}
module.exports = AuthConfig;

View file

@ -1,129 +1,155 @@
exports.UNAUTHORIZED_NO_TOKEN_GIVEN = {
message: 'Accès refusé. Aucun jeton fourni.',
code: 401
}
};
exports.UNAUTHORIZED_INVALID_TOKEN = {
message: 'Accès refusé. Jeton invalide.',
code: 401
}
};
exports.MISSING_REQUIRED_PARAMETER = {
message: 'Paramètre requis manquant.',
code: 400
};
exports.MISSING_OIDC_PARAMETER = (name) => {
return {
message: `Les informations de connexions de la connexion OIDC ${name} n'ont pu être chargées.`,
code: 400
}
}
exports.USER_ALREADY_EXISTS = {
message: 'L\'utilisateur existe déjà.',
code: 400
}
code: 409
};
exports.LOGIN_CREDENTIALS_ERROR = {
message: 'L\'email et le mot de passe ne correspondent pas.',
code: 401
}
};
exports.GENERATE_PASSWORD_ERROR = {
message: 'Une erreur s\'est produite lors de la création d\'un nouveau mot de passe.',
code: 400
}
code: 500
};
exports.UPDATE_PASSWORD_ERROR = {
message: 'Une erreur s\'est produite lors de la mise à jours du mot de passe.',
code: 400
}
code: 500
};
exports.DELETE_USER_ERROR = {
message: 'Une erreur s\'est produite lors de suppression de l\'utilisateur.',
code: 400
}
code: 500
};
exports.IMAGE_NOT_FOUND = {
message: 'Nous n\'avons pas trouvé l\'image.',
code: 404
}
};
exports.QUIZ_NOT_FOUND = {
message: 'Aucun quiz portant cet identifiant n\'a été trouvé.',
code: 404
}
};
exports.QUIZ_ALREADY_EXISTS = {
message: 'Le quiz existe déjà.',
code: 400
}
code: 409
};
exports.UPDATE_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors de la mise à jour du quiz.',
code: 400
}
code: 500
};
exports.DELETE_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors de la suppression du quiz.',
code: 400
}
code: 500
};
exports.GETTING_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors de la récupération du quiz.',
code: 400
}
code: 500
};
exports.MOVING_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors du déplacement du quiz.',
code: 400
}
code: 500
};
exports.DUPLICATE_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors de la duplication du quiz.',
code: 400
}
code: 500
};
exports.COPY_QUIZ_ERROR = {
message: 'Une erreur s\'est produite lors de la copie du quiz.',
code: 400
}
code: 500
};
exports.FOLDER_NOT_FOUND = {
message: 'Aucun dossier portant cet identifiant n\'a été trouvé.',
code: 404
}
};
exports.FOLDER_ALREADY_EXISTS = {
message: 'Le dossier existe déjà.',
code: 409
}
};
exports.UPDATE_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors de la mise à jour du dossier.',
code: 400
}
code: 500
};
exports.DELETE_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors de la suppression du dossier.',
code: 400
}
code: 500
};
exports.GETTING_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors de la récupération du dossier.',
code: 400
}
code: 500
};
exports.MOVING_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors du déplacement du dossier.',
code: 400
}
code: 500
};
exports.DUPLICATE_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors de la duplication du dossier.',
code: 400
}
code: 500
};
exports.COPY_FOLDER_ERROR = {
message: 'Une erreur s\'est produite lors de la copie du dossier.',
code: 400
}
code: 500
};
exports.ROOM_NOT_FOUND = {
message: "Aucune salle trouvée avec cet identifiant.",
code: 404
};
exports.ROOM_ALREADY_EXISTS = {
message: 'Une salle avec ce nom existe déjà',
code: 409
};
exports.UPDATE_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors de la mise à jour de la salle.',
code: 500
};
exports.DELETE_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors de la suppression de la salle.',
code: 500
};
exports.GETTING_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors de la récupération de la salle.',
code: 500
};
exports.MOVING_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors du déplacement de la salle.',
code: 500
};
exports.DUPLICATE_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors de la duplication de la salle.',
code: 500
};
exports.COPY_ROOM_ERROR = {
message: 'Une erreur s\'est produite lors de la copie de la salle.',
code: 500
};
exports.NOT_IMPLEMENTED = {
message: 'Route not implemented yet!',
code: 400
}
message: "Route non encore implémentée. Fonctionnalité en cours de développement.",
code: 501
};
// static ok(res, results) {200

View file

@ -0,0 +1,36 @@
const AuthConfig = require('../config/auth.js');
class authController {
async getActive(req, res, next) {
try {
const authC = new AuthConfig();
authC.loadConfig();
const authActive = authC.getActiveAuth();
const response = {
authActive
};
return res.json(response);
}
catch (error) {
return next(error); // Gérer l'erreur
}
}
async getRoomsRequireAuth(req, res) {
const authC = new AuthConfig();
const roomsRequireAuth = authC.getRoomsRequireAuth();
const response = {
roomsRequireAuth
}
return res.json(response);
}
}
module.exports = new authController;

219
server/controllers/room.js Normal file
View file

@ -0,0 +1,219 @@
const AppError = require("../middleware/AppError.js");
const {
MISSING_REQUIRED_PARAMETER,
ROOM_NOT_FOUND,
ROOM_ALREADY_EXISTS,
GETTING_ROOM_ERROR,
DELETE_ROOM_ERROR,
UPDATE_ROOM_ERROR,
} = require("../constants/errorCodes");
class RoomsController {
constructor(roomsModel) {
this.rooms = roomsModel;
this.getRoomTitle = this.getRoomTitle.bind(this);
}
create = async (req, res, next) => {
try {
if (!req.user || !req.user.userId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const { title } = req.body;
if (!title) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const normalizedTitle = title.toUpperCase().trim();
const roomExists = await this.rooms.roomExists(normalizedTitle, req.user.userId);
if (roomExists) {
throw new AppError(ROOM_ALREADY_EXISTS);
}
const result = await this.rooms.create(normalizedTitle, req.user.userId);
return res.status(201).json({
message: "Room créée avec succès.",
roomId: result.insertedId,
});
} catch (error) {
next(error);
}
};
getUserRooms = async (req, res, next) => {
try {
const rooms = await this.rooms.getUserRooms(req.user.userId);
if (!rooms) {
throw new AppError(ROOM_NOT_FOUND);
}
return res.status(200).json({
data: rooms,
});
} catch (error) {
return next(error);
}
};
getRoomContent = async (req, res, next) => {
try {
const { roomId } = req.params;
if (!roomId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const content = await this.rooms.getContent(roomId);
if (!content) {
throw new AppError(GETTING_ROOM_ERROR);
}
return res.status(200).json({
data: content,
});
} catch (error) {
return next(error);
}
};
delete = async (req, res, next) => {
try {
const { roomId } = req.params;
if (!roomId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const owner = await this.rooms.getOwner(roomId);
if (owner != req.user.userId) {
throw new AppError(ROOM_NOT_FOUND);
}
const result = await this.rooms.delete(roomId);
if (!result) {
throw new AppError(DELETE_ROOM_ERROR);
}
return res.status(200).json({
message: "Salle supprimé avec succès.",
});
} catch (error) {
return next(error);
}
};
rename = async (req, res, next) => {
try {
const { roomId, newTitle } = req.body;
if (!roomId || !newTitle) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const owner = await this.rooms.getOwner(roomId);
if (owner != req.user.userId) {
throw new AppError(ROOM_NOT_FOUND);
}
const exists = await this.rooms.roomExists(newTitle, req.user.userId);
if (exists) {
throw new AppError(ROOM_ALREADY_EXISTS);
}
const result = await this.rooms.rename(roomId, req.user.userId, newTitle);
if (!result) {
throw new AppError(UPDATE_ROOM_ERROR);
}
return res.status(200).json({
message: "Salle mis <20> jours avec succ<63>s.",
});
} catch (error) {
return next(error);
}
};
getRoomById = async (req, res, next) => {
try {
const { roomId } = req.params;
if (!roomId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
// Is this room mine
const owner = await this.rooms.getOwner(roomId);
if (owner != req.user.userId) {
throw new AppError(ROOM_NOT_FOUND);
}
const room = await this.rooms.getRoomById(roomId);
if (!room) {
throw new AppError(ROOM_NOT_FOUND);
}
return res.status(200).json({
data: room,
});
} catch (error) {
return next(error);
}
};
getRoomTitle = async (req, res, next) => {
try {
const { roomId } = req.params;
if (!roomId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const room = await this.rooms.getRoomById(roomId);
if (room instanceof Error) {
throw new AppError(ROOM_NOT_FOUND);
}
return res.status(200).json({ title: room.title });
} catch (error) {
return next(error);
}
};
getRoomTitleByUserId = async (req, res, next) => {
try {
const { userId } = req.params;
if (!userId) {
throw new AppError(MISSING_REQUIRED_PARAMETER);
}
const rooms = await this.rooms.getUserRooms(userId);
if (!rooms || rooms.length === 0) {
throw new AppError(ROOM_NOT_FOUND);
}
const roomTitles = rooms.map((room) => room.title);
return res.status(200).json({
titles: roomTitles,
});
} catch (error) {
return next(error);
}
};
}
module.exports = RoomsController;

View file

@ -1,9 +1,10 @@
class AppError extends Error {
constructor (errorCode) {
super(errorCode.message)
this.statusCode = errorCode.code;
this.isOperational = true; // Optional: to distinguish operational errors from programming errors
super(errorCode.message);
this.statusCode = errorCode.code;
this.isOperational = true;
}
}
module.exports = AppError;
}
module.exports = AppError;

View file

@ -2,19 +2,20 @@ const AppError = require("./AppError");
const fs = require('fs');
const errorHandler = (error, req, res, _next) => {
res.setHeader('Cache-Control', 'no-store');
if (error instanceof AppError) {
logError(error);
return res.status(error.statusCode).json({
error: error.message
});
return res.status(error.statusCode).json({
message: error.message,
code: error.statusCode
});
}
logError(error.stack);
return res.status(505).send("Oups! We screwed up big time. ┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻");
}
};
const logError = (error) => {
const logError = (error) => {
const time = new Date();
var log_file = fs.createWriteStream(__dirname + '/../debug.log', {flags : 'a'});
log_file.write(time + '\n' + error + '\n\n');

View file

@ -7,8 +7,8 @@ dotenv.config();
class Token {
create(email, userId) {
return jwt.sign({ email, userId }, process.env.JWT_SECRET);
create(email, userId, roles) {
return jwt.sign({ email, userId, roles }, process.env.JWT_SECRET);
}
authenticate(req, res, next) {
@ -25,11 +25,11 @@ class Token {
req.user = payload;
});
} catch (error) {
return next(error);
}
return next();
}
}

View file

@ -0,0 +1,44 @@
const db = require('../config/db.js')
const { ObjectId } = require('mongodb');
class AuthProvider {
constructor(name) {
this._id = new ObjectId();
this.name = name;
}
async getId(name){
await db.connect()
const conn = db.getConnection();
const collection = conn.collection('authprovider');
const existingauth = await collection.findOne({ name:name });
if(existingauth){
return existingauth._id
}
return null
}
async create(name) {
await db.connect()
const conn = db.getConnection();
const collection = conn.collection('authprovider');
const existingauth = await collection.findOne({ name:name });
if(existingauth){
return existingauth._id;
}
const newProvider = {
name:name
}
const result = await collection.insertOne(newProvider);
return result.insertedId;
}
}
module.exports = new AuthProvider;

View file

@ -0,0 +1,59 @@
const authProvider = require('./authProvider.js')
const db = require('../config/db.js')
const { ObjectId } = require('mongodb');
class AuthUserAssociation {
constructor(authProviderId, authId, userId) {
this._id = new ObjectId();
this.authProvider_id = authProviderId;
this.auth_id = authId;
this.user_id = userId;
this.connected = false;
}
async find_user_association(provider_name,auth_id){
await db.connect()
const conn = db.getConnection();
const collection = conn.collection('authUserAssociation');
const provider_id = await authProvider.getId(provider_name)
const userAssociation = await collection.findOne({ authProvider_id: provider_id, auth_id: auth_id });
return userAssociation
}
async link(provider_name,auth_id,user_id){
await db.connect()
const conn = db.getConnection();
const collection = conn.collection('authUserAssociation');
const provider_id = await authProvider.getId(provider_name)
const userAssociation = await collection.findOne({ authProvider_id: provider_id, user_id: user_id });
if(!userAssociation){
return await collection.insertOne({
_id:ObjectId,
authProvider_id:provider_id,
auth_id:auth_id,
user_id:user_id,
})
}
}
async unlink(provider_name,user_id){
await db.connect()
const conn = db.getConnection();
const collection = conn.collection('authUserAssociation');
const provider_id = await authProvider.getId(provider_name)
const userAssociation = await collection.findOne({ authProvider_id: provider_id, user_id: user_id });
if(userAssociation){
return await collection.deleteOne(userAssociation)
} else return null
}
}
module.exports = new AuthUserAssociation;

173
server/models/room.js Normal file
View file

@ -0,0 +1,173 @@
const ObjectId = require("mongodb").ObjectId;
class Rooms
{
constructor(db)
{
this.db = db;
}
async create(title, userId) {
if (!title || !userId) {
throw new Error("Missing required parameter(s)");
}
const exists = await this.roomExists(title, userId);
if (exists) {
throw new Error("Room already exists");
}
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const newRoom = {
userId: userId,
title: title,
created_at: new Date(),
};
const result = await roomsCollection.insertOne(newRoom);
return result.insertedId;
}
async getUserRooms(userId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const result = await roomsCollection.find({ userId: userId }).toArray();
return result;
}
async getOwner(roomId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const room = await roomsCollection.findOne({
_id: ObjectId.createFromHexString(roomId),
});
return room.userId;
}
async getContent(roomId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
if (!ObjectId.isValid(roomId))
{
return null; // Évite d'envoyer une requête invalide
}
const result = await roomsCollection.findOne({ _id: new ObjectId(roomId) });
return result;
}
async delete(roomId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const roomResult = await roomsCollection.deleteOne({
_id: ObjectId.createFromHexString(roomId),
});
if (roomResult.deletedCount != 1) return false;
return true;
}
async rename(roomId, userId, newTitle)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const existingRoom = await roomsCollection.findOne({
title: newTitle,
userId: userId,
});
if (existingRoom)
throw new Error(`Room with name '${newTitle}' already exists.`);
const result = await roomsCollection.updateOne(
{ _id: ObjectId.createFromHexString(roomId), userId: userId },
{ $set: { title: newTitle } }
);
if (result.modifiedCount != 1) return false;
return true;
}
async roomExists(title, userId)
{
try
{
await this.db.connect();
const conn = this.db.getConnection();
const existingRoom = await conn.collection("rooms").findOne({
title: title.toUpperCase(),
userId: userId,
});
return !!existingRoom;
} catch (error)
{
throw new Error(`Database error (${error})`);
}
}
async getRoomById(roomId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const room = await roomsCollection.findOne({
_id: ObjectId.createFromHexString(roomId),
});
if (!room) throw new Error(`Room ${roomId} not found`, 404);
return room;
}
async getRoomWithContent(roomId)
{
const room = await this.getRoomById(roomId);
const content = await this.getContent(roomId);
return {
...room,
content: content,
};
}
async getRoomTitleByUserId(userId)
{
await this.db.connect();
const conn = this.db.getConnection();
const roomsCollection = conn.collection("rooms");
const rooms = await roomsCollection.find({ userId: userId }).toArray();
return rooms.map((room) => room.title);
}
}
module.exports = Rooms;

View file

@ -1,125 +1,181 @@
//user
const bcrypt = require('bcrypt');
const AppError = require('../middleware/AppError.js');
const { USER_ALREADY_EXISTS } = require('../constants/errorCodes');
const bcrypt = require("bcrypt");
const AppError = require("../middleware/AppError.js");
const { USER_ALREADY_EXISTS } = require("../constants/errorCodes");
class Users {
constructor(db, foldersModel) {
// console.log("Users constructor: db", db)
this.db = db;
this.folders = foldersModel;
constructor(db, foldersModel) {
this.db = db;
this.folders = foldersModel;
}
async hashPassword(password) {
return await bcrypt.hash(password, 10);
}
generatePassword() {
return Math.random().toString(36).slice(-8);
}
async verify(password, hash) {
return await bcrypt.compare(password, hash);
}
async register(userInfos) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const existingUser = await userCollection.findOne({ email: userInfos.email });
if (existingUser) {
throw new AppError(USER_ALREADY_EXISTS);
}
let newUser = {
name: userInfos.name ?? userInfos.email,
email: userInfos.email,
password: await this.hashPassword(userInfos.password),
created_at: new Date(),
roles: userInfos.roles
};
let created_user = await userCollection.insertOne(newUser);
let user = await this.getById(created_user.insertedId)
const folderTitle = "Dossier par Défaut";
async hashPassword(password) {
return await bcrypt.hash(password, 10)
const userId = newUser._id ? newUser._id.toString() : 'x';
await this.folders.create(folderTitle, userId);
// TODO: verif if inserted properly...
return user;
}
async login(email, password) {
console.log(`models/users: login: email: ${email}, password: ${password}`);
try {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const user = await userCollection.findOne({ email: email });
if (!user) {
const error = new Error("User not found");
error.statusCode = 404;
throw error;
}
const passwordMatch = await this.verify(password, user.password);
if (!passwordMatch) {
const error = new Error("Password does not match");
error.statusCode = 401;
throw error;
}
console.log(`models/users: login: FOUND user: ${JSON.stringify(user)}`);
return user;
} catch (error) {
console.error(error);
throw error;
}
}
async resetPassword(email) {
const newPassword = this.generatePassword();
return await this.changePassword(email, newPassword);
}
async changePassword(email, newPassword) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const hashedPassword = await this.hashPassword(newPassword);
const result = await userCollection.updateOne(
{ email },
{ $set: { password: hashedPassword } }
);
if (result.modifiedCount != 1) return null;
return newPassword;
}
async delete(email) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const result = await userCollection.deleteOne({ email });
if (result.deletedCount != 1) return false;
return true;
}
async getId(email) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const user = await userCollection.findOne({ email: email });
if (!user) {
return false;
}
generatePassword() {
return Math.random().toString(36).slice(-8);
return user._id;
}
async getById(id) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const user = await userCollection.findOne({ _id: id });
if (!user) {
return false;
}
async verify(password, hash) {
return await bcrypt.compare(password, hash)
return user;
}
async editUser(userInfo) {
await this.db.connect();
const conn = this.db.getConnection();
const userCollection = conn.collection("users");
const user = await userCollection.findOne({ _id: userInfo.id });
if (!user) {
return false;
}
async register(email, password) {
await this.db.connect()
const conn = this.db.getConnection();
const userCollection = conn.collection('users');
const updatedFields = { ...userInfo };
delete updatedFields.id;
const existingUser = await userCollection.findOne({ email: email });
const result = await userCollection.updateOne(
{ _id: userInfo.id },
{ $set: updatedFields }
);
if (existingUser) {
throw new AppError(USER_ALREADY_EXISTS);
}
const newUser = {
email: email,
password: await this.hashPassword(password),
created_at: new Date()
};
const result = await userCollection.insertOne(newUser);
// console.log("userCollection.insertOne() result", result);
const userId = result.insertedId.toString();
const folderTitle = 'Dossier par Défaut';
await this.folders.create(folderTitle, userId);
return result;
}
async login(email, password) {
await this.db.connect()
const conn = this.db.getConnection();
const userCollection = conn.collection('users');
const user = await userCollection.findOne({ email: email });
if (!user) {
return false;
}
const passwordMatch = await this.verify(password, user.password);
if (!passwordMatch) {
return false;
}
return user;
}
async resetPassword(email) {
const newPassword = this.generatePassword();
return await this.changePassword(email, newPassword);
}
async changePassword(email, newPassword) {
await this.db.connect()
const conn = this.db.getConnection();
const userCollection = conn.collection('users');
const hashedPassword = await this.hashPassword(newPassword);
const result = await userCollection.updateOne({ email }, { $set: { password: hashedPassword } });
if (result.modifiedCount != 1) return null;
return newPassword
}
async delete(email) {
await this.db.connect()
const conn = this.db.getConnection();
const userCollection = conn.collection('users');
const result = await userCollection.deleteOne({ email });
if (result.deletedCount != 1) return false;
return true;
}
async getId(email) {
await this.db.connect()
const conn = this.db.getConnection();
const userCollection = conn.collection('users');
const user = await userCollection.findOne({ email: email });
if (!user) {
return false;
}
return user._id;
if (result.modifiedCount === 1) {
return true;
}
return false;
}
}
module.exports = Users;

559
server/package-lock.json generated
View file

@ -7,16 +7,23 @@
"": {
"name": "ets-pfe004-evaluetonsavoir-backend",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.4",
"express": "^4.18.2",
"express-list-endpoints": "^7.1.1",
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.3.0",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.9",
"passport": "^0.7.0",
"passport-oauth2": "^1.8.0",
"passport-openidconnect": "^0.1.2",
"patch-package": "^8.0.0",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2"
},
@ -1618,6 +1625,11 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"dev": true
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -1734,7 +1746,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -1806,6 +1817,14 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@ -1935,6 +1954,14 @@
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -1993,7 +2020,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.1.1"
},
@ -2080,15 +2106,41 @@
}
},
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.1"
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
},
"engines": {
"node": ">= 0.4"
@ -2139,7 +2191,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@ -2155,7 +2206,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -2164,7 +2214,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -2220,7 +2269,6 @@
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"dev": true,
"funding": [
{
"type": "github",
@ -2271,7 +2319,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -2282,8 +2329,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-support": {
"version": "1.1.3",
@ -2452,6 +2498,7 @@
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
@ -2469,7 +2516,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@ -2612,6 +2658,19 @@
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -2756,12 +2815,9 @@
}
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
@ -2774,6 +2830,17 @@
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@ -3182,6 +3249,46 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-list-endpoints": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/express-list-endpoints/-/express-list-endpoints-7.1.1.tgz",
"integrity": "sha512-SA6YHH1r6DrioJ4fFJNqiwu1FweGFqJZO9KBApMzwPosoSGPOX2AW0wiMepOXjojjEXDuP9whIvckomheErbJA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/express-session": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz",
"integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.7",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3234,7 +3341,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@ -3272,6 +3378,14 @@
"node": ">=8"
}
},
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -3338,6 +3452,20 @@
"node": ">= 0.6"
}
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@ -3365,20 +3493,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -3425,15 +3539,20 @@
}
},
"node_modules/get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
"get-proto": "^1.0.0",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@ -3451,6 +3570,18 @@
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@ -3508,11 +3639,11 @@
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"dependencies": {
"get-intrinsic": "^1.1.3"
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -3521,8 +3652,7 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/has-flag": {
"version": "3.0.0",
@ -3544,21 +3674,10 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-proto": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
@ -3572,9 +3691,9 @@
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="
},
"node_modules/hasown": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
"integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
@ -3788,6 +3907,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -3830,7 +3963,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"engines": {
"node": ">=0.12.0"
}
@ -3847,6 +3979,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@ -3855,8 +3998,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
@ -4677,6 +4819,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.2.1.tgz",
"integrity": "sha512-Lp6HbbBgosLmJbjx0pBLbgvx68FaFU1sdkmBuckmhhJ88kL13OA51CDtR2yJB50eCNMH9wRqtQNNiAqQH4YXnA==",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@ -4684,6 +4844,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify/node_modules/isarray": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@ -4696,6 +4861,25 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@ -4751,6 +4935,14 @@
"json-buffer": "3.0.1"
}
},
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@ -4878,6 +5070,14 @@
"tmpl": "1.0.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -4917,7 +5117,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@ -5280,6 +5479,11 @@
"set-blocking": "^2.0.0"
}
},
"node_modules/oauth": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.0.tgz",
"integrity": "sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q=="
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5299,6 +5503,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -5310,6 +5522,14 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -5333,6 +5553,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5351,6 +5586,14 @@
"node": ">= 0.8.0"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -5426,6 +5669,115 @@
"node": ">= 0.8"
}
},
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-openidconnect": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
"dependencies": {
"oauth": "0.10.x",
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/patch-package": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
"integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^9.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"rimraf": "^2.6.3",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.0.33",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -5447,7 +5799,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -5464,6 +5815,11 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@ -5474,7 +5830,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -5613,6 +5968,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -5854,7 +6217,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@ -5866,7 +6228,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -6305,6 +6666,17 @@
"node": ">=8"
}
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@ -6324,7 +6696,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
},
@ -6414,6 +6785,22 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA=="
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -6425,6 +6812,14 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz",
"integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA=="
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -6541,7 +6936,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@ -6655,6 +7049,17 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/yaml": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",

View file

@ -7,7 +7,8 @@
"build": "webpack --config webpack.config.js",
"start": "node app.js",
"dev": "cross-env NODE_ENV=development nodemon app.js",
"test": "jest --colors"
"test": "jest",
"postinstall": "patch-package"
},
"keywords": [],
"author": "",
@ -17,10 +18,16 @@
"cors": "^2.8.5",
"dotenv": "^16.4.4",
"express": "^4.18.2",
"express-list-endpoints": "^7.1.1",
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.3.0",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.9",
"passport": "^0.7.0",
"passport-oauth2": "^1.8.0",
"passport-openidconnect": "^0.1.2",
"patch-package": "^8.0.0",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2"
},

View file

@ -0,0 +1,12 @@
diff --git a/node_modules/passport-openidconnect/lib/profile.js b/node_modules/passport-openidconnect/lib/profile.js
index eeabf4e..8abe391 100644
--- a/node_modules/passport-openidconnect/lib/profile.js
+++ b/node_modules/passport-openidconnect/lib/profile.js
@@ -17,6 +17,7 @@ exports.parse = function(json) {
if (json.middle_name) { profile.name.middleName = json.middle_name; }
}
if (json.email) { profile.emails = [ { value: json.email } ]; }
+ if (json.groups) { profile.groups = [ { value: json.groups } ]; }
return profile;
};

9
server/routers/auth.js Normal file
View file

@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.js')
router.get("/getActiveAuth",authController.getActive);
router.get("/getRoomsRequireAuth", authController.getRoomsRequireAuth);
module.exports = router;

18
server/routers/room.js Normal file
View file

@ -0,0 +1,18 @@
const express = require('express');
const router = express.Router();
const jwt = require('../middleware/jwtToken.js');
const rooms = require('../app.js').rooms;
const asyncHandler = require('./routerUtils.js');
router.post("/create", jwt.authenticate, asyncHandler(rooms.create));
router.post("/roomExists", jwt.authenticate, asyncHandler(rooms.roomExists));
router.get("/getUserRooms", jwt.authenticate, asyncHandler(rooms.getUserRooms));
router.get('/getRoomTitle/:roomId', jwt.authenticate, asyncHandler(rooms.getRoomTitle));
router.get('/getRoomTitleByUserId/:userId', jwt.authenticate, asyncHandler(rooms.getRoomTitleByUserId));
router.get("/getRoomContent/:roomId", jwt.authenticate, asyncHandler(rooms.getRoomContent));
router.delete("/delete/:roomId", jwt.authenticate, asyncHandler(rooms.delete));
router.put("/rename", jwt.authenticate, asyncHandler(rooms.rename));
module.exports = router;
module.exports.rooms = rooms;

View file

@ -3,11 +3,12 @@ const router = express.Router();
const users = require('../app.js').users;
const jwt = require('../middleware/jwtToken.js');
const asyncHandler = require('./routerUtils.js');
const usersController = require('../controllers/users.js')
router.post("/register", asyncHandler(users.register));
router.post("/login", asyncHandler(users.login));
router.post("/reset-password", asyncHandler(users.resetPassword));
router.post("/change-password", jwt.authenticate, asyncHandler(users.changePassword));
router.post("/delete-user", jwt.authenticate, asyncHandler(users.delete));
router.post("/delete-user", jwt.authenticate, usersController);
module.exports = router;

View file

@ -6,7 +6,7 @@ const setupWebsocket = (io) => {
io.on("connection", (socket) => {
if (totalConnections >= MAX_TOTAL_CONNECTIONS) {
console.log("Connection limit reached. Disconnecting client.");
console.log("socket.js: Connection limit reached. Disconnecting client.");
socket.emit(
"join-failure",
"Le nombre maximum de connexions a été atteint"
@ -17,82 +17,99 @@ const setupWebsocket = (io) => {
totalConnections++;
console.log(
"A user connected:",
"socket.js: A user connected:",
socket.id,
"| Total connections:",
totalConnections
);
socket.on("create-room", (sentRoomName) => {
console.log(`socket.js: Demande de création de salle avec le nom : ${sentRoomName}`);
if (sentRoomName) {
const roomName = sentRoomName.toUpperCase();
if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName);
socket.emit("create-success", roomName);
console.log(`socket.js: Salle créée avec succès : ${roomName}`);
} else {
socket.emit("create-failure");
}
} else {
const roomName = generateRoomName();
if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName);
socket.emit("create-success", roomName);
} else {
socket.emit("create-failure");
socket.emit("create-failure", `La salle ${roomName} existe déjà.`);
console.log(`socket.js: Échec de création : ${roomName} existe déjà`);
}
}
reportSalles();
});
function reportSalles() {
console.log("socket.js: Salles existantes :", Array.from(io.sockets.adapter.rooms.keys()));
}
socket.on("join-room", ({ enteredRoomName, username }) => {
if (io.sockets.adapter.rooms.has(enteredRoomName)) {
const clientsInRoom =
io.sockets.adapter.rooms.get(enteredRoomName).size;
const roomToCheck = enteredRoomName.toUpperCase();
console.log(
`socket.js: Requête de connexion : salle="${roomToCheck}", utilisateur="${username}"`
);
reportSalles();
if (io.sockets.adapter.rooms.has(roomToCheck)) {
console.log("socket.js: La salle existe");
const clientsInRoom = io.sockets.adapter.rooms.get(roomToCheck).size;
if (clientsInRoom <= MAX_USERS_PER_ROOM) {
console.log("socket.js: La salle n'est pas pleine avec ", clientsInRoom, " utilisateurs");
const newStudent = {
id: socket.id,
name: username,
answers: [],
};
socket.join(enteredRoomName);
socket
.to(enteredRoomName)
.emit("user-joined", newStudent);
socket.emit("join-success");
socket.join(roomToCheck);
socket.to(roomToCheck).emit("user-joined", newStudent);
socket.emit("join-success", roomToCheck);
} else {
console.log("socket.js: La salle est pleine avec ", clientsInRoom, " utilisateurs");
socket.emit("join-failure", "La salle est remplie");
}
} else {
console.log("socket.js: La salle n'existe pas");
socket.emit("join-failure", "Le nom de la salle n'existe pas");
}
});
socket.on("next-question", ({ roomName, question }) => {
// console.log("next-question", roomName, question);
console.log("socket.js: next-question", roomName, question);
console.log("socket.js: rediffusion de la question", question);
socket.to(roomName).emit("next-question", question);
});
socket.on("launch-teacher-mode", ({ roomName, questions }) => {
socket.to(roomName).emit("launch-teacher-mode", questions);
});
socket.on("launch-student-mode", ({ roomName, questions }) => {
socket.to(roomName).emit("launch-student-mode", questions);
});
socket.on("end-quiz", ({ roomName }) => {
console.log("socket.js: end-quiz", roomName);
socket.to(roomName).emit("end-quiz");
io.sockets.adapter.rooms.delete(roomName);
reportSalles();
});
socket.on("message", (data) => {
console.log("Received message from", socket.id, ":", data);
console.log("socket.js: Received message from", socket.id, ":", data);
});
socket.on("disconnect", () => {
totalConnections--;
console.log(
"A user disconnected:",
"socket.js: A user disconnected:",
socket.id,
"| Total connections:",
totalConnections
);
reportSalles();
for (const [room] of io.sockets.adapter.rooms) {
if (room !== socket.id) {
io.to(room).emit("user-disconnected", socket.id);
@ -109,17 +126,6 @@ const setupWebsocket = (io) => {
});
});
});
const generateRoomName = (length = 6) => {
const characters = "0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return result;
};
};
module.exports = { setupWebsocket };

35
server/utils.js Normal file
View file

@ -0,0 +1,35 @@
function hasNestedValue(obj, path, delimiter = "_") {
const keys = path.split(delimiter);
let current = obj;
for (const key of keys) {
while(Array.isArray(current) && current.length == 1 && current[0]){
current = current[0]
}
while(current['value']){
current = current.value
}
if (current && typeof current === "object") {
if (Array.isArray(current)) {
const index = current.findIndex(x => x == key)
if (index != -1) {
current = current[index];
} else {
return false;
}
} else if (key in current) {
current = current[key];
} else {
return false;
}
} else {
return false;
}
}
return true;
}
module.exports = { hasNestedValue};